From 024de4f8a611ede0ccc411549c18e7360c835c97 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 May 2024 20:17:13 +0200 Subject: [PATCH 001/234] Bump version to 2024.6.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 f5f5b35691c..c4362abb704 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index bd9e801de8c..80c8be0580c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0.dev0" +version = "2024.6.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ad3823764a2d89e484db6a852e585dc1bb7f777a Mon Sep 17 00:00:00 2001 From: swcloudgenie <45437888+swcloudgenie@users.noreply.github.com> Date: Wed, 29 May 2024 15:13:28 -0500 Subject: [PATCH 002/234] New official genie garage integration (#117020) * new official genie garage integration * move api constants into api module * move scan interval constant to cover.py --- .coveragerc | 6 + CODEOWNERS | 4 +- .../components/aladdin_connect/__init__.py | 63 ++-- .../components/aladdin_connect/api.py | 31 ++ .../application_credentials.py | 14 + .../components/aladdin_connect/config_flow.py | 147 ++------- .../components/aladdin_connect/const.py | 22 +- .../components/aladdin_connect/cover.py | 102 +++--- .../components/aladdin_connect/diagnostics.py | 28 -- .../components/aladdin_connect/manifest.json | 7 +- .../components/aladdin_connect/model.py | 22 +- .../components/aladdin_connect/sensor.py | 46 +-- .../components/aladdin_connect/strings.json | 42 +-- .../generated/application_credentials.py | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/aladdin_connect/__init__.py | 2 +- tests/components/aladdin_connect/conftest.py | 48 --- .../snapshots/test_diagnostics.ambr | 20 -- .../aladdin_connect/test_config_flow.py | 312 ++++-------------- .../components/aladdin_connect/test_cover.py | 228 ------------- .../aladdin_connect/test_diagnostics.py | 41 --- tests/components/aladdin_connect/test_init.py | 258 --------------- .../components/aladdin_connect/test_model.py | 19 -- .../components/aladdin_connect/test_sensor.py | 165 --------- 25 files changed, 286 insertions(+), 1354 deletions(-) create mode 100644 homeassistant/components/aladdin_connect/api.py create mode 100644 homeassistant/components/aladdin_connect/application_credentials.py delete mode 100644 homeassistant/components/aladdin_connect/diagnostics.py delete mode 100644 tests/components/aladdin_connect/conftest.py delete mode 100644 tests/components/aladdin_connect/snapshots/test_diagnostics.ambr delete mode 100644 tests/components/aladdin_connect/test_cover.py delete mode 100644 tests/components/aladdin_connect/test_diagnostics.py delete mode 100644 tests/components/aladdin_connect/test_init.py delete mode 100644 tests/components/aladdin_connect/test_model.py delete mode 100644 tests/components/aladdin_connect/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 4e78ea6a3e4..7594d2d2d98 100644 --- a/.coveragerc +++ b/.coveragerc @@ -58,6 +58,12 @@ omit = homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py homeassistant/components/airvisual_pro/sensor.py + homeassistant/components/aladdin_connect/__init__.py + homeassistant/components/aladdin_connect/api.py + homeassistant/components/aladdin_connect/application_credentials.py + homeassistant/components/aladdin_connect/cover.py + homeassistant/components/aladdin_connect/model.py + homeassistant/components/aladdin_connect/sensor.py homeassistant/components/alarmdecoder/__init__.py homeassistant/components/alarmdecoder/alarm_control_panel.py homeassistant/components/alarmdecoder/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index ddd1e424397..32f885f6015 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -80,8 +80,8 @@ build.json @home-assistant/supervisor /tests/components/airzone/ @Noltari /homeassistant/components/airzone_cloud/ @Noltari /tests/components/airzone_cloud/ @Noltari -/homeassistant/components/aladdin_connect/ @mkmer -/tests/components/aladdin_connect/ @mkmer +/homeassistant/components/aladdin_connect/ @swcloudgenie +/tests/components/aladdin_connect/ @swcloudgenie /homeassistant/components/alarm_control_panel/ @home-assistant/core /tests/components/alarm_control_panel/ @home-assistant/core /homeassistant/components/alert/ @home-assistant/core @frenck diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 84710c3f74e..55c4345beb3 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,40 +1,33 @@ -"""The aladdin_connect component.""" +"""The Aladdin Connect Genie integration.""" -import logging -from typing import Final - -from AIOAladdinConnect import AladdinConnectClient -import AIOAladdinConnect.session_manager as Aladdin -from aiohttp import ClientError +from __future__ import annotations from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow -from .const import CLIENT_ID, DOMAIN +from . import api +from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION -_LOGGER: Final = logging.getLogger(__name__) - -PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.COVER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up platform from a ConfigEntry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - acc = AladdinConnectClient( - username, password, async_get_clientsession(hass), CLIENT_ID + """Set up Aladdin Connect Genie from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + # If using an aiohttp-based API lib + entry.runtime_data = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session ) - try: - await acc.login() - except (ClientError, TimeoutError, Aladdin.ConnectionError) as ex: - raise ConfigEntryNotReady("Can not connect to host") from ex - except Aladdin.InvalidPasswordError as ex: - raise ConfigEntryAuthFailed("Incorrect Password") from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = acc await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -42,7 +35,19 @@ 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): - hass.data[DOMAIN].pop(entry.entry_id) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old config.""" + if config_entry.version < CONFIG_FLOW_VERSION: + config_entry.async_start_reauth(hass) + new_data = {**config_entry.data} + hass.config_entries.async_update_entry( + config_entry, + data=new_data, + version=CONFIG_FLOW_VERSION, + minor_version=CONFIG_FLOW_MINOR_VERSION, + ) + + return True diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py new file mode 100644 index 00000000000..8100cd1e4d8 --- /dev/null +++ b/homeassistant/components/aladdin_connect/api.py @@ -0,0 +1,31 @@ +"""API for Aladdin Connect Genie bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +from genie_partner_sdk.auth import Auth + +from homeassistant.helpers import config_entry_oauth2_flow + +API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1" +API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3" + + +class AsyncConfigEntryAuth(Auth): # type: ignore[misc] + """Provide Aladdin Connect Genie authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Aladdin Connect Genie auth.""" + super().__init__( + websession, API_URL, oauth_session.token["access_token"], API_KEY + ) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return str(self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/aladdin_connect/application_credentials.py b/homeassistant/components/aladdin_connect/application_credentials.py new file mode 100644 index 00000000000..e8e959f1fa3 --- /dev/null +++ b/homeassistant/components/aladdin_connect/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Aladdin Connect Genie integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index e960138853a..aa42574a005 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,137 +1,58 @@ -"""Config flow for Aladdin Connect cover integration.""" - -from __future__ import annotations +"""Config flow for Aladdin Connect Genie.""" from collections.abc import Mapping +import logging from typing import Any -from AIOAladdinConnect import AladdinConnectClient -import AIOAladdinConnect.session_manager as Aladdin -from aiohttp.client_exceptions import ClientError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.helpers import config_entry_oauth2_flow -from .const import CLIENT_ID, DOMAIN - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - -REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) +from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Validate the user input allows us to connect. +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Aladdin Connect Genie OAuth2 authentication.""" - Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. - """ - acc = AladdinConnectClient( - data[CONF_USERNAME], - data[CONF_PASSWORD], - async_get_clientsession(hass), - CLIENT_ID, - ) - try: - await acc.login() - except (ClientError, TimeoutError, Aladdin.ConnectionError): - raise + DOMAIN = DOMAIN + VERSION = CONFIG_FLOW_VERSION + MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION - except Aladdin.InvalidPasswordError as ex: - raise InvalidAuth from ex - - -class AladdinConnectConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Aladdin Connect.""" - - VERSION = 1 - entry: ConfigEntry | None + reauth_entry: ConfigEntry | None = None async def async_step_reauth( - self, entry_data: Mapping[str, Any] + self, user_input: Mapping[str, Any] ) -> ConfigFlowResult: - """Handle re-authentication with Aladdin Connect.""" - - self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + """Perform reauth upon API auth error or upgrade from v1 to v2.""" + 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 + self, user_input: Mapping[str, Any] | None = None ) -> ConfigFlowResult: - """Confirm re-authentication with Aladdin Connect.""" - 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 validate_input(self.hass, data) - - except InvalidAuth: - errors["base"] = "invalid_auth" - - except (ClientError, TimeoutError, Aladdin.ConnectionError): - 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: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" + """Dialog that informs the user that reauth is required.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="reauth_confirm", + data_schema=vol.Schema({}), ) + return await self.async_step_user() - errors = {} - - try: - await validate_input(self.hass, user_input) - except InvalidAuth: - errors["base"] = "invalid_auth" - - except (ClientError, TimeoutError, Aladdin.ConnectionError): - errors["base"] = "cannot_connect" - - else: - await self.async_set_unique_id( - user_input["username"].lower(), raise_on_progress=False + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + if self.reauth_entry: + return self.async_update_reload_and_abort( + self.reauth_entry, + data=data, ) - self._abort_if_unique_id_configured() - return self.async_create_entry(title="Aladdin Connect", data=user_input) + return await super().async_oauth_create_entry(data) - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors - ) - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py index bf77c032d1b..5312826469e 100644 --- a/homeassistant/components/aladdin_connect/const.py +++ b/homeassistant/components/aladdin_connect/const.py @@ -1,22 +1,14 @@ -"""Platform for the Aladdin Connect cover component.""" - -from __future__ import annotations +"""Constants for the Aladdin Connect Genie integration.""" from typing import Final from homeassistant.components.cover import CoverEntityFeature -from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING - -NOTIFICATION_ID: Final = "aladdin_notification" -NOTIFICATION_TITLE: Final = "Aladdin Connect Cover Setup" - -STATES_MAP: Final[dict[str, str]] = { - "open": STATE_OPEN, - "opening": STATE_OPENING, - "closed": STATE_CLOSED, - "closing": STATE_CLOSING, -} DOMAIN = "aladdin_connect" +CONFIG_FLOW_VERSION = 2 +CONFIG_FLOW_MINOR_VERSION = 1 + +OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html" +OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token" + SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE -CLIENT_ID = "1000" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 61c8df92eaf..cf31b06cbcd 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,25 +1,23 @@ -"""Platform for the Aladdin Connect cover component.""" - -from __future__ import annotations +"""Cover Entity for Genie Garage Door.""" from datetime import timedelta from typing import Any -from AIOAladdinConnect import AladdinConnectClient, session_manager +from genie_partner_sdk.client import AladdinConnectClient 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 HomeAssistantError, PlatformNotReady +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, STATES_MAP, SUPPORTED_FEATURES -from .model import DoorDevice +from . import api +from .const import DOMAIN, SUPPORTED_FEATURES +from .model import GarageDoor -SCAN_INTERVAL = timedelta(seconds=300) +SCAN_INTERVAL = timedelta(seconds=15) async def async_setup_entry( @@ -28,25 +26,33 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aladdin Connect platform.""" - acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + session: api.AsyncConfigEntryAuth = config_entry.runtime_data + acc = AladdinConnectClient(session) doors = await acc.get_doors() if doors is None: raise PlatformNotReady("Error from Aladdin Connect getting doors") + device_registry = dr.async_get(hass) + doors_to_add = [] + for door in doors: + existing = device_registry.async_get(door.unique_id) + if existing is None: + doors_to_add.append(door) + async_add_entities( - (AladdinDevice(acc, door, config_entry) for door in doors), + (AladdinDevice(acc, door, config_entry) for door in doors_to_add), ) remove_stale_devices(hass, config_entry, doors) def remove_stale_devices( - hass: HomeAssistant, config_entry: ConfigEntry, devices: list[dict] + hass: HomeAssistant, config_entry: ConfigEntry, devices: list[GarageDoor] ) -> None: """Remove stale devices from device registry.""" device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {f"{door['device_id']}-{door['door_number']}" for door in devices} + all_device_ids = {door.unique_id for door in devices} for device_entry in device_entries: device_id: str | None = None @@ -74,74 +80,52 @@ class AladdinDevice(CoverEntity): _attr_name = None def __init__( - self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry + self, acc: AladdinConnectClient, device: GarageDoor, entry: ConfigEntry ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc - self._device_id = device["device_id"] - self._number = device["door_number"] - self._serial = device["serial"] + self._device_id = device.device_id + self._number = device.door_number self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=device["name"], + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, manufacturer="Overhead Door", - model=device["model"], ) - self._attr_unique_id = f"{self._device_id}-{self._number}" - - async def async_added_to_hass(self) -> None: - """Connect Aladdin Connect to the cloud.""" - - self._acc.register_callback( - self.async_write_ha_state, self._serial, self._number - ) - await self._acc.get_doors(self._serial) - - async def async_will_remove_from_hass(self) -> None: - """Close Aladdin Connect before removing.""" - self._acc.unregister_callback(self._serial, self._number) - await self._acc.close() - - async def async_close_cover(self, **kwargs: Any) -> None: - """Issue close command to cover.""" - if not await self._acc.close_door(self._device_id, self._number): - raise HomeAssistantError("Aladdin Connect API failed to close the cover") + self._attr_unique_id = device.unique_id async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - if not await self._acc.open_door(self._device_id, self._number): - raise HomeAssistantError("Aladdin Connect API failed to open the cover") + await self._acc.open_door(self._device_id, self._number) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Issue close command to cover.""" + await self._acc.close_door(self._device_id, self._number) async def async_update(self) -> None: """Update status of cover.""" - try: - await self._acc.get_doors(self._serial) - self._attr_available = True - - except (session_manager.ConnectionError, session_manager.InvalidPasswordError): - self._attr_available = False + await self._acc.update_door(self._device_id, self._number) @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - value = STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + value = self._acc.get_door_status(self._device_id, self._number) if value is None: return None - return value == STATE_CLOSED + return bool(value == "closed") @property - def is_closing(self) -> bool: + def is_closing(self) -> bool | None: """Update is closing attribute.""" - return ( - STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) - == STATE_CLOSING - ) + value = self._acc.get_door_status(self._device_id, self._number) + if value is None: + return None + return bool(value == "closing") @property - def is_opening(self) -> bool: + def is_opening(self) -> bool | None: """Update is opening attribute.""" - return ( - STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) - == STATE_OPENING - ) + value = self._acc.get_door_status(self._device_id, self._number) + if value is None: + return None + return bool(value == "opening") diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py deleted file mode 100644 index 67a31079f14..00000000000 --- a/homeassistant/components/aladdin_connect/diagnostics.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Diagnostics support for Aladdin Connect.""" - -from __future__ import annotations - -from typing import Any - -from AIOAladdinConnect import AladdinConnectClient - -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 = {"serial", "device_id"} - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, - config_entry: ConfigEntry, -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - - acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] - - return { - "doors": async_redact_data(acc.doors, TO_REDACT), - } diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 344c77dcb73..69b38399cce 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -1,11 +1,10 @@ { "domain": "aladdin_connect", "name": "Aladdin Connect", - "codeowners": ["@mkmer"], + "codeowners": ["@swcloudgenie"], "config_flow": true, + "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", - "loggers": ["aladdin_connect"], - "quality_scale": "platinum", - "requirements": ["AIOAladdinConnect==0.1.58"] + "requirements": ["genie-partner-sdk==1.0.2"] } diff --git a/homeassistant/components/aladdin_connect/model.py b/homeassistant/components/aladdin_connect/model.py index 73e445f2f3b..db08cb7b8b8 100644 --- a/homeassistant/components/aladdin_connect/model.py +++ b/homeassistant/components/aladdin_connect/model.py @@ -5,12 +5,26 @@ from __future__ import annotations from typing import TypedDict -class DoorDevice(TypedDict): - """Aladdin door device.""" +class GarageDoorData(TypedDict): + """Aladdin door data.""" device_id: str door_number: int name: str status: str - serial: str - model: str + link_status: str + battery_level: int + + +class GarageDoor: + """Aladdin Garage Door Entity.""" + + def __init__(self, data: GarageDoorData) -> None: + """Create `GarageDoor` from dictionary of data.""" + self.device_id = data["device_id"] + self.door_number = data["door_number"] + self.unique_id = f"{self.device_id}-{self.door_number}" + self.name = data["name"] + self.status = data["status"] + self.link_status = data["link_status"] + self.battery_level = data["battery_level"] diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 22aa9c6faf0..231928656a8 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import cast -from AIOAladdinConnect import AladdinConnectClient +from genie_partner_sdk.client import AladdinConnectClient from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,13 +15,14 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import api from .const import DOMAIN -from .model import DoorDevice +from .model import GarageDoor @dataclass(frozen=True, kw_only=True) @@ -40,24 +41,6 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value_fn=AladdinConnectClient.get_battery_status, ), - AccSensorEntityDescription( - key="rssi", - translation_key="wifi_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_registry_enabled_default=False, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=SensorStateClass.MEASUREMENT, - value_fn=AladdinConnectClient.get_rssi_status, - ), - AccSensorEntityDescription( - key="ble_strength", - translation_key="ble_strength", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - entity_registry_enabled_default=False, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - state_class=SensorStateClass.MEASUREMENT, - value_fn=AladdinConnectClient.get_ble_strength, - ), ) @@ -66,7 +49,8 @@ async def async_setup_entry( ) -> None: """Set up Aladdin Connect sensor devices.""" - acc: AladdinConnectClient = hass.data[DOMAIN][entry.entry_id] + session: api.AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] + acc = AladdinConnectClient(session) entities = [] doors = await acc.get_doors() @@ -88,26 +72,20 @@ class AladdinConnectSensor(SensorEntity): def __init__( self, acc: AladdinConnectClient, - device: DoorDevice, + device: GarageDoor, description: AccSensorEntityDescription, ) -> None: """Initialize a sensor for an Aladdin Connect device.""" - self._device_id = device["device_id"] - self._number = device["door_number"] + self._device_id = device.device_id + self._number = device.door_number self._acc = acc self.entity_description = description - self._attr_unique_id = f"{self._device_id}-{self._number}-{description.key}" + self._attr_unique_id = f"{device.unique_id}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._device_id}-{self._number}")}, - name=device["name"], + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, manufacturer="Overhead Door", - model=device["model"], ) - if device["model"] == "01" and description.key in ( - "battery_level", - "ble_strength", - ): - self._attr_entity_registry_enabled_default = True @property def native_value(self) -> float | None: diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index bfe932b039c..48f9b299a1d 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -1,39 +1,29 @@ { "config": { "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The Aladdin Connect integration needs to re-authenticate your account", - "data": { - "password": "[%key:common::config_flow::data::password%]" - } + "description": "Aladdin Connect needs to re-authenticate your account" } }, - - "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%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - } - }, - "entity": { - "sensor": { - "wifi_strength": { - "name": "Wi-Fi RSSI" - }, - "ble_strength": { - "name": "BLE Strength" - } + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" } } } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index c576f242e30..bc6b29e4c23 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest """ APPLICATION_CREDENTIALS = [ + "aladdin_connect", "electric_kiwi", "fitbit", "geocaching", diff --git a/requirements_all.txt b/requirements_all.txt index 3d297241539..c7ee7ae5623 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,9 +6,6 @@ # homeassistant.components.aemet AEMET-OpenData==0.5.1 -# homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.58 - # homeassistant.components.honeywell AIOSomecomfort==0.0.25 @@ -923,6 +920,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.0.4 +# homeassistant.components.aladdin_connect +genie-partner-sdk==1.0.2 + # homeassistant.components.geniushub geniushub-client==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index faeb0bdfcdb..ccc1ae213ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,9 +6,6 @@ # homeassistant.components.aemet AEMET-OpenData==0.5.1 -# homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.58 - # homeassistant.components.honeywell AIOSomecomfort==0.0.25 @@ -758,6 +755,9 @@ gassist-text==0.0.11 # homeassistant.components.google gcal-sync==6.0.4 +# homeassistant.components.aladdin_connect +genie-partner-sdk==1.0.2 + # homeassistant.components.geocaching geocachingapi==0.2.1 diff --git a/tests/components/aladdin_connect/__init__.py b/tests/components/aladdin_connect/__init__.py index 6e108ed88df..aa5957dc392 100644 --- a/tests/components/aladdin_connect/__init__.py +++ b/tests/components/aladdin_connect/__init__.py @@ -1 +1 @@ -"""The tests for Aladdin Connect platforms.""" +"""Tests for the Aladdin Connect Garage Door integration.""" diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py deleted file mode 100644 index 979c30bdcea..00000000000 --- a/tests/components/aladdin_connect/conftest.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Fixtures for the Aladdin Connect integration tests.""" - -from unittest import mock -from unittest.mock import AsyncMock - -import pytest - -DEVICE_CONFIG_OPEN = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Connected", - "serial": "12345", - "model": "02", - "rssi": -67, - "ble_strength": 0, - "vendor": "GENIE", - "battery_level": 0, -} - - -@pytest.fixture(name="mock_aladdinconnect_api") -def fixture_mock_aladdinconnect_api(): - """Set up aladdin connect API fixture.""" - with mock.patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient" - ) as mock_opener: - mock_opener.login = AsyncMock(return_value=True) - mock_opener.close = AsyncMock(return_value=True) - - mock_opener.async_get_door_status = AsyncMock(return_value="open") - mock_opener.get_door_status.return_value = "open" - mock_opener.async_get_door_link_status = AsyncMock(return_value="connected") - mock_opener.get_door_link_status.return_value = "connected" - mock_opener.async_get_battery_status = AsyncMock(return_value="99") - mock_opener.get_battery_status.return_value = "99" - mock_opener.async_get_rssi_status = AsyncMock(return_value="-55") - mock_opener.get_rssi_status.return_value = "-55" - mock_opener.async_get_ble_strength = AsyncMock(return_value="-45") - mock_opener.get_ble_strength.return_value = "-45" - mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - mock_opener.doors = [DEVICE_CONFIG_OPEN] - mock_opener.register_callback = mock.Mock(return_value=True) - mock_opener.open_door = AsyncMock(return_value=True) - mock_opener.close_door = AsyncMock(return_value=True) - - return mock_opener diff --git a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr deleted file mode 100644 index 8f96567a49f..00000000000 --- a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr +++ /dev/null @@ -1,20 +0,0 @@ -# serializer version: 1 -# name: test_entry_diagnostics - dict({ - 'doors': list([ - dict({ - 'battery_level': 0, - 'ble_strength': 0, - 'device_id': '**REDACTED**', - 'door_number': 1, - 'link_status': 'Connected', - 'model': '02', - 'name': 'home', - 'rssi': -67, - 'serial': '**REDACTED**', - 'status': 'open', - 'vendor': 'GENIE', - }), - ]), - }) -# --- diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index 65b8b24a59d..d460d62625b 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,278 +1,82 @@ -"""Test the Aladdin Connect config flow.""" +"""Test the Aladdin Connect Garage Door config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from AIOAladdinConnect.session_manager import InvalidPasswordError -from aiohttp.client_exceptions import ClientConnectionError +import pytest from homeassistant import config_entries -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.aladdin_connect.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" -async def test_form(hass: HomeAssistant, mock_aladdinconnect_api: MagicMock) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - 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_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Aladdin Connect" - assert result2["data"] == { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_failed_auth( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test we handle failed authentication error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_connection_timeout( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test we handle http timeout error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - mock_aladdinconnect_api.login.side_effect = ClientConnectionError - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_already_configured( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test we handle already configured error.""" - 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": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "already_configured" - - -async def test_reauth_flow( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test a successful reauth flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, - }, - data={"username": "test-username", "password": "new-password"}, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) - assert result["step_id"] == "reauth_confirm" - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - with ( - patch( - "homeassistant.components.aladdin_connect.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() - - assert result2["type"] is 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, mock_aladdinconnect_api: MagicMock +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, ) -> None: - """Test an authorization error reauth flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", - ) - mock_entry.add_to_hass(hass) - + """Check full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", }, - data={"username": "test-username", "password": "new-password"}, ) - assert result["step_id"] == "reauth_confirm" - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with ( - patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ), - patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.cover.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"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_reauth_flow_connnection_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test a connection error reauth flow.""" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-username", "password": "test-password"}, - unique_id="test-username", + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" ) - mock_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "unique_id": mock_entry.unique_id, - "entry_id": mock_entry.entry_id, + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, }, - data={"username": "test-username", "password": "new-password"}, ) - assert result["step_id"] == "reauth_confirm" - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - mock_aladdinconnect_api.login.side_effect = ClientConnectionError - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_PASSWORD: "new-password"}, - ) - await hass.async_block_till_done() + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py deleted file mode 100644 index 082ade75ab9..00000000000 --- a/tests/components/aladdin_connect/test_cover.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Test the Aladdin Connect Cover.""" - -from unittest.mock import AsyncMock, MagicMock, patch - -from AIOAladdinConnect import session_manager -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, - SERVICE_CLOSE_COVER, - SERVICE_OPEN_COVER, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - -YAML_CONFIG = {"username": "test-user", "password": "test-password"} - -DEVICE_CONFIG_OPEN = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_OPENING = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "opening", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_CLOSED = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closed", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_CLOSING = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closing", - "link_status": "Connected", - "serial": "12345", -} - -DEVICE_CONFIG_DISCONNECTED = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", - "link_status": "Disconnected", - "serial": "12345", -} - -DEVICE_CONFIG_BAD = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "open", -} -DEVICE_CONFIG_BAD_NO_DOOR = { - "device_id": 533255, - "door_number": 2, - "name": "home", - "status": "open", - "link_status": "Disconnected", -} - - -async def test_cover_operation( - hass: HomeAssistant, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test Cover Operation states (open,close,opening,closing) cover.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - 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, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert COVER_DOMAIN in hass.config.components - - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - assert hass.states.get("cover.home").state == STATE_OPEN - - mock_aladdinconnect_api.open_door.return_value = False - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - - mock_aladdinconnect_api.open_door.return_value = True - - mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) - mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED - - 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 - - mock_aladdinconnect_api.close_door.return_value = False - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - COVER_DOMAIN, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.home"}, - blocking=True, - ) - - mock_aladdinconnect_api.close_door.return_value = True - - mock_aladdinconnect_api.async_get_door_status = AsyncMock( - return_value=STATE_CLOSING - ) - mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING - - 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( - return_value=STATE_OPENING - ) - mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING - - 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 - - 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 diff --git a/tests/components/aladdin_connect/test_diagnostics.py b/tests/components/aladdin_connect/test_diagnostics.py deleted file mode 100644 index 48741c77cd1..00000000000 --- a/tests/components/aladdin_connect/test_diagnostics.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Test AccuWeather diagnostics.""" - -from unittest.mock import MagicMock, patch - -from syrupy import SnapshotAssertion - -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry -from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.typing import ClientSessionGenerator - -YAML_CONFIG = {"username": "test-user", "password": "test-password"} - - -async def test_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - snapshot: SnapshotAssertion, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test config entry diagnostics.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - with patch( - "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() - - result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - - assert result == snapshot diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py deleted file mode 100644 index bcc32101437..00000000000 --- a/tests/components/aladdin_connect/test_init.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Test for Aladdin Connect init logic.""" - -from unittest.mock import MagicMock, patch - -from AIOAladdinConnect.session_manager import InvalidPasswordError -from aiohttp import ClientConnectionError - -from homeassistant.components.aladdin_connect.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from .conftest import DEVICE_CONFIG_OPEN - -from tests.common import AsyncMock, MockConfigEntry - -CONFIG = {"username": "test-user", "password": "test-password"} -ID = "533255-1" - - -async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: - """Test component setup Get Doors Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with ( - patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), - patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=None, - ), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is True - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - - -async def test_setup_login_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - mock_aladdinconnect_api.login.return_value = False - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_connection_error( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - mock_aladdinconnect_api.login.side_effect = ClientConnectionError - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_component_no_error(hass: HomeAssistant) -> None: - """Test component setup No Error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - with patch( - "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() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_entry_password_fail( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test password fail during entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={"username": "test-user", "password": "test-password"}, - ) - entry.add_to_hass(hass) - mock_aladdinconnect_api.login = AsyncMock(return_value=False) - mock_aladdinconnect_api.login.side_effect = InvalidPasswordError - with patch( - "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 - - -async def test_load_and_unload( - hass: HomeAssistant, mock_aladdinconnect_api: MagicMock -) -> None: - """Test loading and unloading Aladdin Connect entry.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - - with patch( - "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() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_stale_device_removal( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_aladdinconnect_api: MagicMock, -) -> None: - """Test component setup missing door device is removed.""" - DEVICE_CONFIG_DOOR_2 = { - "device_id": 533255, - "door_number": 2, - "name": "home 2", - "status": "open", - "link_status": "Connected", - "serial": "12346", - "model": "02", - } - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id=ID, - ) - config_entry.add_to_hass(hass) - mock_aladdinconnect_api.get_doors = AsyncMock( - return_value=[DEVICE_CONFIG_OPEN, DEVICE_CONFIG_DOOR_2] - ) - config_entry_other = MockConfigEntry( - domain="OtherDomain", - data=CONFIG, - unique_id="unique_id", - ) - config_entry_other.add_to_hass(hass) - - device_entry_other = device_registry.async_get_or_create( - config_entry_id=config_entry_other.entry_id, - identifiers={("OtherDomain", "533255-2")}, - ) - device_registry.async_update_device( - device_entry_other.id, - add_config_entry_id=config_entry.entry_id, - merge_identifiers={(DOMAIN, "533255-2")}, - ) - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - - assert len(device_entries) == 2 - assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) - assert any((DOMAIN, "533255-2") in device.identifiers for device in device_entries) - assert any( - ("OtherDomain", "533255-2") in device.identifiers for device in device_entries - ) - - device_entries_other = dr.async_entries_for_config_entry( - device_registry, config_entry_other.entry_id - ) - assert len(device_entries_other) == 1 - assert any( - (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other - ) - assert any( - ("OtherDomain", "533255-2") in device.identifiers - for device in device_entries_other - ) - - assert await hass.config_entries.async_unload(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED - - mock_aladdinconnect_api.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - assert len(device_entries) == 1 - assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) - assert not any( - (DOMAIN, "533255-2") in device.identifiers for device in device_entries - ) - assert not any( - ("OtherDomain", "533255-2") in device.identifiers for device in device_entries - ) - - device_entries_other = dr.async_entries_for_config_entry( - device_registry, config_entry_other.entry_id - ) - - assert len(device_entries_other) == 1 - assert any( - ("OtherDomain", "533255-2") in device.identifiers - for device in device_entries_other - ) - assert any( - (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other - ) diff --git a/tests/components/aladdin_connect/test_model.py b/tests/components/aladdin_connect/test_model.py deleted file mode 100644 index 84b1c9ae40a..00000000000 --- a/tests/components/aladdin_connect/test_model.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Test the Aladdin Connect model class.""" - -from homeassistant.components.aladdin_connect.model import DoorDevice -from homeassistant.core import HomeAssistant - - -async def test_model(hass: HomeAssistant) -> None: - """Test model for Aladdin Connect Model.""" - test_values = { - "device_id": "1", - "door_number": "2", - "name": "my door", - "status": "good", - } - result2 = DoorDevice(test_values) - assert result2["device_id"] == "1" - assert result2["door_number"] == "2" - assert result2["name"] == "my door" - assert result2["status"] == "good" diff --git a/tests/components/aladdin_connect/test_sensor.py b/tests/components/aladdin_connect/test_sensor.py deleted file mode 100644 index 9c229e2ac5e..00000000000 --- a/tests/components/aladdin_connect/test_sensor.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Test the Aladdin Connect Sensors.""" - -from datetime import timedelta -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 as er -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - -DEVICE_CONFIG_MODEL_01 = { - "device_id": 533255, - "door_number": 1, - "name": "home", - "status": "closed", - "link_status": "Connected", - "serial": "12345", - "model": "01", -} - - -CONFIG = {"username": "test-user", "password": "test-password"} -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( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - await hass.async_block_till_done() - - with patch( - "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() - - entry = entity_registry.async_get("sensor.home_battery") - assert entry - assert entry.disabled - 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() - assert update_entry != entry - assert update_entry.disabled is False - state = hass.states.get("sensor.home_battery") - assert state is None - - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - state = hass.states.get("sensor.home_battery") - assert state - - 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 er.RegistryEntryDisabler.INTEGRATION - update_entry = entity_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("sensor.home_wi_fi_rssi") - assert state is None - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state - - -async def test_sensors_model_01( - hass: HomeAssistant, - mock_aladdinconnect_api: MagicMock, - entity_registry: er.EntityRegistry, -) -> None: - """Test Sensors for AladdinConnect.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - - await hass.async_block_till_done() - - with patch( - "homeassistant.components.aladdin_connect.AladdinConnectClient", - return_value=mock_aladdinconnect_api, - ): - mock_aladdinconnect_api.get_doors = AsyncMock( - return_value=[DEVICE_CONFIG_MODEL_01] - ) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entry = entity_registry.async_get("sensor.home_battery") - assert entry - assert entry.disabled is False - assert entry.disabled_by is None - state = hass.states.get("sensor.home_battery") - assert state - - 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 er.RegistryEntryDisabler.INTEGRATION - update_entry = entity_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("sensor.home_wi_fi_rssi") - assert state is None - - update_entry = entity_registry.async_update_entity( - entry.entity_id, disabled_by=None - ) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - utcnow() + SCAN_INTERVAL, - ) - await hass.async_block_till_done() - - state = hass.states.get("sensor.home_wi_fi_rssi") - assert state - - entry = entity_registry.async_get("sensor.home_ble_strength") - await hass.async_block_till_done() - assert entry - assert entry.disabled is False - assert entry.disabled_by is None - state = hass.states.get("sensor.home_ble_strength") - assert state From 4fb6e59fdca4dd192c8da4a3e2afb534ccd9f0e6 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 May 2024 21:12:47 +0200 Subject: [PATCH 003/234] Add translation strings for Matter Fan presets (#118401) --- homeassistant/components/matter/icons.json | 21 ++++++++++++++++++++ homeassistant/components/matter/strings.json | 16 +++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 homeassistant/components/matter/icons.json diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json new file mode 100644 index 00000000000..94da41931de --- /dev/null +++ b/homeassistant/components/matter/icons.json @@ -0,0 +1,21 @@ +{ + "entity": { + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "default": "mdi:fan", + "state": { + "low": "mdi:fan-speed-1", + "medium": "mdi:fan-speed-2", + "high": "mdi:fan-speed-3", + "auto": "mdi:fan-auto", + "natural_wind": "mdi:tailwind", + "sleep_wind": "mdi:sleep" + } + } + } + } + } + } +} diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c68b38bbb8c..c6c2d779255 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -62,6 +62,22 @@ } } }, + "fan": { + "fan": { + "state_attributes": { + "preset_mode": { + "state": { + "low": "Low", + "medium": "Medium", + "high": "High", + "auto": "Auto", + "natural_wind": "Natural wind", + "sleep_wind": "Sleep wind" + } + } + } + } + }, "sensor": { "flow": { "name": "Flow" From a580d834da9f15b8c27fce297d648839753d5156 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 29 May 2024 21:09:50 +0200 Subject: [PATCH 004/234] Fix light discovery for Matter dimmable plugin unit (#118404) --- homeassistant/components/matter/light.py | 1 + .../fixtures/nodes/dimmable-plugin-unit.json | 502 ++++++++++++++++++ tests/components/matter/test_light.py | 1 + 3 files changed, 504 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index acd85884875..89400c98989 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -435,6 +435,7 @@ DISCOVERY_SCHEMAS = [ device_type=( device_types.ColorTemperatureLight, device_types.DimmableLight, + device_types.DimmablePlugInUnit, device_types.ExtendedColorLight, device_types.OnOffLight, ), diff --git a/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json b/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json new file mode 100644 index 00000000000..5b1e1cfaba6 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json @@ -0,0 +1,502 @@ +{ + "node_id": 36, + "date_commissioned": "2024-05-18T13:06:23.766788", + "last_interview": "2024-05-18T13:06:23.766793", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Matter", + "0/40/2": 4251, + "0/40/3": "Dimmable Plugin Unit", + "0/40/4": 4098, + "0/40/5": "", + "0/40/6": "", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 131365, + "0/40/10": "2.1.25", + "0/40/15": "1000_0030_D228", + "0/40/18": "E2B4285EEDD3A387", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "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": [ + { + "0": "r0", + "1": true, + "2": null, + "3": null, + "4": "AAemN9h0", + "5": ["wKhr7Q=="], + "6": ["/oAAAAAAAAACB6b//jfYdA=="], + "7": 1 + } + ], + "0/51/1": 2, + "0/51/2": 86407, + "0/51/3": 24, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 26, + "1": "Logging~", + "2": 16224, + "3": 4056, + "4": 16352 + }, + { + "0": 26, + "1": "Logging", + "2": 16224, + "3": 4056, + "4": 16352 + }, + { + "0": 34, + "1": "cnR3X3JlY5c=", + "2": 5560, + "3": 862, + "4": 5856 + }, + { + "0": 36, + "1": "rtw_intz", + "2": 832, + "3": 200, + "4": 992 + }, + { + "0": 14, + "1": "interacZ", + "2": 4784, + "3": 1090, + "4": 5088 + }, + { + "0": 37, + "1": "cmd_thr", + "2": 3880, + "3": 718, + "4": 4064 + }, + { + "0": 4, + "1": "LOGUART\u0010", + "2": 3896, + "3": 974, + "4": 4064 + }, + { + "0": 3, + "1": "log_ser\n", + "2": 4968, + "3": 1242, + "4": 5088 + }, + { + "0": 35, + "1": "rtw_xmi\u0014", + "2": 840, + "3": 168, + "4": 992 + }, + { + "0": 49, + "1": "mesh_pr", + "2": 680, + "3": 42, + "4": 992 + }, + { + "0": 47, + "1": "BLE_app", + "2": 4864, + "3": 1112, + "4": 5088 + }, + { + "0": 44, + "1": "trace_t", + "2": 280, + "3": 68, + "4": 480 + }, + { + "0": 45, + "1": "UpperSt", + "2": 2904, + "3": 620, + "4": 3040 + }, + { + "0": 46, + "1": "HCI I/F", + "2": 1800, + "3": 356, + "4": 2016 + }, + { + "0": 8, + "1": "Tmr Svc", + "2": 3940, + "3": 933, + "4": 4076 + }, + { + "0": 38, + "1": "lev_snt", + "2": 3960, + "3": 930, + "4": 4064 + }, + { + "0": 27, + "1": "iot_thr", + "2": 7968, + "3": 1984, + "4": 8160 + }, + { + "0": 28, + "1": "iot_thr", + "2": 7968, + "3": 1984, + "4": 8160 + }, + { + "0": 2, + "1": "lev_hea", + "2": 3824, + "3": 831, + "4": 4064 + }, + { + "0": 23, + "1": "Wifi_Co", + "2": 7872, + "3": 1879, + "4": 8160 + }, + { + "0": 40, + "1": "lev_ota", + "2": 7896, + "3": 1442, + "4": 8160 + }, + { + "0": 39, + "1": "Schedul", + "2": 1696, + "3": 404, + "4": 2016 + }, + { + "0": 29, + "1": "AWS_MQT", + "2": 7832, + "3": 1824, + "4": 8160 + }, + { + "0": 41, + "1": "lev_net", + "2": 7768, + "3": 1788, + "4": 8160 + }, + { + "0": 18, + "1": "Lev_Tim", + "2": 3976, + "3": 948, + "4": 4064 + }, + { + "0": 1, + "1": "WATCHDO", + "2": 888, + "3": 212, + "4": 992 + }, + { + "0": 9, + "1": "TCP_IP", + "2": 3808, + "3": 644, + "4": 3968 + }, + { + "0": 50, + "1": "Bluetoo", + "2": 8000, + "3": 1990, + "4": 8160 + }, + { + "0": 20, + "1": "SHADOW_", + "2": 3736, + "3": 924, + "4": 4064 + }, + { + "0": 17, + "1": "NV_PROP", + "2": 1824, + "3": 446, + "4": 2016 + }, + { + "0": 16, + "1": "DIM_TAS", + "2": 1920, + "3": 460, + "4": 2016 + }, + { + "0": 19, + "1": "Lev_But", + "2": 3872, + "3": 956, + "4": 4064 + }, + { + "0": 7, + "1": "IDLE", + "2": 1944, + "3": 478, + "4": 2040 + }, + { + "0": 51, + "1": "CHIP", + "2": 6840, + "3": 1126, + "4": 8160 + } + ], + "0/52/1": 62880, + "0/52/2": 249440, + "0/52/3": 259456, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -66, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/2": 5, + "0/62/3": 2, + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": 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": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/15": 0, + "1/8/16": 10, + "1/8/17": null, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 15, 16, 17, 20, 16384, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 267, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 775790701d1..2589e041b3b 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -116,6 +116,7 @@ async def test_light_turn_on_off( ("extended-color-light", "light.mock_extended_color_light"), ("color-temperature-light", "light.mock_color_temperature_light"), ("dimmable-light", "light.mock_dimmable_light"), + ("dimmable-plugin-unit", "light.dimmable_plugin_unit"), ], ) async def test_dimmable_light( From 23d9b4b17fd33ecf229b849b70b7c8cb05f2be96 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 29 May 2024 14:18:46 -0500 Subject: [PATCH 005/234] Handle case where timer device id exists but is not registered (delayed command) (#118410) Handle case where device id exists but is not registered --- homeassistant/components/intent/timers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 1dc6b279a61..cddfce55b9f 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -229,7 +229,9 @@ class TimerManager: if (not conversation_command) and (device_id is None): raise ValueError("Conversation command must be set if no device id") - if (device_id is not None) and (not self.is_timer_device(device_id)): + if (not conversation_command) and ( + (device_id is None) or (not self.is_timer_device(device_id)) + ): raise TimersNotSupportedError(device_id) total_seconds = 0 @@ -276,7 +278,7 @@ class TimerManager: name=f"Timer {timer_id}", ) - if timer.device_id is not None: + if timer.device_id in self.handlers: self.handlers[timer.device_id](TimerEventType.STARTED, timer) _LOGGER.debug( "Timer started: id=%s, name=%s, hours=%s, minutes=%s, seconds=%s, device_id=%s", From 7ee2f09fe120e57a7b272a1b07bd33fd33988220 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 09:55:34 -1000 Subject: [PATCH 006/234] Ensure paho.mqtt.client is imported in the executor (#118412) fixes #118405 --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/client.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index f501e7fa89c..ea520e88366 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -244,7 +244,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client) - client.start(mqtt_data) + await client.async_start(mqtt_data) # Restore saved subscriptions if mqtt_data.subscriptions_to_restore: diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 70e6f573266..0871a0419e5 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -39,9 +39,11 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.async_ import create_eager_task from homeassistant.util.collection import chunked_or_all from homeassistant.util.logging import catch_log_exception, log_exception @@ -491,13 +493,13 @@ class MQTT: """Handle HA stop.""" await self.async_disconnect() - def start( + async def async_start( self, mqtt_data: MqttData, ) -> None: """Start Home Assistant MQTT client.""" self._mqtt_data = mqtt_data - self.init_client() + await self.async_init_client() @property def subscriptions(self) -> list[Subscription]: @@ -528,8 +530,11 @@ class MQTT: mqttc.on_socket_open = self._async_on_socket_open mqttc.on_socket_register_write = self._async_on_socket_register_write - def init_client(self) -> None: + async def async_init_client(self) -> None: """Initialize paho client.""" + with async_pause_setup(self.hass, SetupPhases.WAIT_IMPORT_PACKAGES): + await async_import_module(self.hass, "paho.mqtt.client") + mqttc = MqttClientSetup(self.conf).client # on_socket_unregister_write and _async_on_socket_close # are only ever called in the event loop From 1e77a595613a55f966cc8100f5bec8d8e4580e4c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 09:55:22 -1000 Subject: [PATCH 007/234] Fix google_tasks doing blocking I/O in the event loop (#118418) fixes #118407 From 0d4990799feb33d6cda4193cc51a904ebc3101f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 09:55:12 -1000 Subject: [PATCH 008/234] Fix google_mail doing blocking I/O in the event loop (#118421) fixes #118411 --- homeassistant/components/google_tasks/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index ed70f2f6f44..22e5e80229a 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -1,5 +1,6 @@ """API for Google Tasks bound to Home Assistant OAuth.""" +from functools import partial import json import logging from typing import Any @@ -52,7 +53,9 @@ class AsyncConfigEntryAuth: async def _get_service(self) -> Resource: """Get current resource.""" token = await self.async_get_access_token() - return build("tasks", "v1", credentials=Credentials(token=token)) + return await self._hass.async_add_executor_job( + partial(build, "tasks", "v1", credentials=Credentials(token=token)) + ) async def list_task_lists(self) -> list[dict[str, Any]]: """Get all TaskList resources.""" From b75f3d968195bd6301b06273699e3fccca42877c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 10:07:56 -1000 Subject: [PATCH 009/234] Fix workday doing blocking I/O in the event loop (#118422) --- .../components/workday/binary_sensor.py | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 1963359bf0a..205f500746e 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -68,6 +68,32 @@ def validate_dates(holiday_list: list[str]) -> list[str]: return calc_holidays +def _get_obj_holidays( + country: str | None, province: str | None, year: int, language: str | None +) -> HolidayBase: + """Get the object for the requested country and year.""" + if not country: + return HolidayBase() + + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=language, + ) + if (supported_languages := obj_holidays.supported_languages) and language == "en": + for lang in supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years=year, + language=lang, + ) + LOGGER.debug("Changing language from %s to %s", language, lang) + return obj_holidays + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -83,29 +109,9 @@ async def async_setup_entry( language: str | None = entry.options.get(CONF_LANGUAGE) year: int = (dt_util.now() + timedelta(days=days_offset)).year - - if country: - obj_holidays: HolidayBase = country_holidays( - country, - subdiv=province, - years=year, - language=language, - ) - if ( - supported_languages := obj_holidays.supported_languages - ) and language == "en": - for lang in supported_languages: - if lang.startswith("en"): - obj_holidays = country_holidays( - country, - subdiv=province, - years=year, - language=lang, - ) - LOGGER.debug("Changing language from %s to %s", language, lang) - else: - obj_holidays = HolidayBase() - + obj_holidays: HolidayBase = await hass.async_add_executor_job( + _get_obj_holidays, country, province, year, language + ) calc_add_holidays: list[str] = validate_dates(add_holidays) calc_remove_holidays: list[str] = validate_dates(remove_holidays) @@ -198,7 +204,6 @@ async def async_setup_entry( entry.entry_id, ) ], - True, ) From ebf9013569503ee1d2400cc5152e4dc87253117f Mon Sep 17 00:00:00 2001 From: Evgeny <940893+freekode@users.noreply.github.com> Date: Wed, 29 May 2024 23:12:24 +0200 Subject: [PATCH 010/234] Fix OpenWeatherMap migration (#118428) --- homeassistant/components/openweathermap/__init__.py | 7 ++++--- homeassistant/components/openweathermap/const.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 4d6cae86f39..7b21ae89b96 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -72,14 +72,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Migrate old entry.""" config_entries = hass.config_entries data = entry.data + options = entry.options version = entry.version _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) - if version < 3: - new_data = {**data, CONF_MODE: OWM_MODE_V25} + if version < 4: + new_data = {**data, **options, CONF_MODE: OWM_MODE_V25} config_entries.async_update_entry( - entry, data=new_data, version=CONFIG_FLOW_VERSION + entry, data=new_data, options={}, version=CONFIG_FLOW_VERSION ) _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 1e5bfff4697..c074640ebc7 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONFIG_FLOW_VERSION = 3 +CONFIG_FLOW_VERSION = 4 ATTR_API_PRECIPITATION = "precipitation" ATTR_API_PRECIPITATION_KIND = "precipitation_kind" ATTR_API_DATETIME = "datetime" From 9728103de434662bfddafefbd71280870f01b08a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 11:37:24 -1000 Subject: [PATCH 011/234] Fix blocking I/O in the event loop in meteo_france (#118429) --- homeassistant/components/meteo_france/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 9edc557aafc..943d30fccfd 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -200,7 +200,7 @@ class MeteoFranceWeather( break forecast_data.append( { - ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + ATTR_FORECAST_TIME: dt_util.utc_from_timestamp( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( From 27cc97bbeb0f7dc4536f156545f63b3cf28e4184 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 29 May 2024 14:37:36 -0700 Subject: [PATCH 012/234] Bump opower to 0.4.6 (#118434) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 91e4fbc960c..7e16bacdfda 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.4.4"] + "requirements": ["opower==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index c7ee7ae5623..d60aeb4892e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1501,7 +1501,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.4 +opower==0.4.6 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ccc1ae213ca..7321bb6429b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1201,7 +1201,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.4 # homeassistant.components.opower -opower==0.4.4 +opower==0.4.6 # homeassistant.components.oralb oralb-ble==0.17.6 From 5d5210b47d174979e3729420aaa7ab64b58b1485 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 May 2024 12:55:53 -1000 Subject: [PATCH 013/234] Fix google_mail doing blocking i/o in the event loop (take 2) (#118441) --- homeassistant/components/google_mail/__init__.py | 2 +- homeassistant/components/google_mail/api.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 1ac963b430a..441ecd3841f 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -32,7 +32,7 @@ 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) session = OAuth2Session(hass, entry, implementation) - auth = AsyncConfigEntryAuth(session) + auth = AsyncConfigEntryAuth(hass, session) await auth.check_and_refresh_token() hass.data[DOMAIN][entry.entry_id] = auth diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index e824e4b3ddd..485d640a04d 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,5 +1,7 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from functools import partial + from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials @@ -7,6 +9,7 @@ from googleapiclient.discovery import Resource, build from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -20,9 +23,11 @@ class AsyncConfigEntryAuth: def __init__( self, + hass: HomeAssistant, oauth2_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize Google Mail Auth.""" + self._hass = hass self.oauth_session = oauth2_session @property @@ -58,4 +63,6 @@ class AsyncConfigEntryAuth: async def get_resource(self) -> Resource: """Get current resource.""" credentials = Credentials(await self.check_and_refresh_token()) - return build("gmail", "v1", credentials=credentials) + return await self._hass.async_add_executor_job( + partial(build, "gmail", "v1", credentials=credentials) + ) From 8ee1d8865c03a16f4715ab7780349c9329bf3af6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 May 2024 01:19:49 +0200 Subject: [PATCH 014/234] Bump version to 2024.6.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 c4362abb704..4f63aea4e94 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 80c8be0580c..5dfdf35183b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b0" +version = "2024.6.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c6c36718b906b609f8d7e56b6d47ebc3f6aab444 Mon Sep 17 00:00:00 2001 From: Alexey Guseynov Date: Thu, 30 May 2024 11:20:02 +0100 Subject: [PATCH 015/234] Add Total Volatile Organic Compounds (tVOC) matter discovery schema (#116963) --- homeassistant/components/matter/sensor.py | 13 +++++ tests/components/matter/test_sensor.py | 58 +++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index ff5848ef54e..4e2644a1ff7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -219,6 +219,19 @@ DISCOVERY_SCHEMAS = [ clusters.CarbonDioxideConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="TotalVolatileOrganicCompoundsSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 4ee6180ad77..42b13e24c9e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -84,6 +84,16 @@ async def air_quality_sensor_node_fixture( ) +@pytest.fixture(name="air_purifier_node") +async def air_purifier_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an air purifier node.""" + return await setup_integration_with_node_fixture( + hass, "air-purifier", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -333,3 +343,51 @@ async def test_air_quality_sensor( state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_pm10") assert state assert state.state == "50.0" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_air_purifier_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + air_purifier_node: MatterNode, +) -> None: + """Test Air quality sensors are creayted for air purifier device.""" + # Carbon Dioxide + state = hass.states.get("sensor.air_purifier_carbon_dioxide") + assert state + assert state.state == "2.0" + + # PM1 + state = hass.states.get("sensor.air_purifier_pm1") + assert state + assert state.state == "2.0" + + # PM2.5 + state = hass.states.get("sensor.air_purifier_pm2_5") + assert state + assert state.state == "2.0" + + # PM10 + state = hass.states.get("sensor.air_purifier_pm10") + assert state + assert state.state == "2.0" + + # Temperature + state = hass.states.get("sensor.air_purifier_temperature") + assert state + assert state.state == "20.0" + + # Humidity + state = hass.states.get("sensor.air_purifier_humidity") + assert state + assert state.state == "50.0" + + # VOCS + state = hass.states.get("sensor.air_purifier_vocs") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "volatile_organic_compounds_parts" + assert state.attributes["friendly_name"] == "Air Purifier VOCs" From 3e0d9516a9f59bb217bde7249f7b16b0087d9a7a Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 29 May 2024 18:44:33 -0700 Subject: [PATCH 016/234] Improve LLM prompt (#118443) * Improve LLM prompt * test * improvements * improvements --- homeassistant/helpers/llm.py | 4 +++- tests/helpers/test_llm.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5a39bfaa726..d1ce3047e78 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -250,7 +250,9 @@ class AssistAPI(API): prompt = [ ( "Call the intent tools to control Home Assistant. " - "When controlling an area, prefer passing area name and domain." + "When controlling a device, prefer passing just its name and its domain " + "(what comes before the dot in its entity id). " + "When controlling an area, prefer passing just area name and a single domain." ) ] area: ar.AreaEntry | None = None diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index a59b4767196..672b6a6642b 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -423,7 +423,9 @@ async def test_assist_api_prompt( ) first_part_prompt = ( "Call the intent tools to control Home Assistant. " - "When controlling an area, prefer passing area name and domain." + "When controlling a device, prefer passing just its name and its domain " + "(what comes before the dot in its entity id). " + "When controlling an area, prefer passing just area name and a single domain." ) no_timer_prompt = "This device does not support timers." From 48342837c0c435dcf7a030462e5e977d365300df Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 29 May 2024 23:37:45 -0700 Subject: [PATCH 017/234] Instruct LLM to not pass a list to the domain (#118451) --- homeassistant/helpers/llm.py | 3 ++- tests/helpers/test_llm.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index d1ce3047e78..535e2af4d04 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -250,9 +250,10 @@ class AssistAPI(API): prompt = [ ( "Call the intent tools to control Home Assistant. " + "Do not pass the domain to the intent tools as a list. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " - "When controlling an area, prefer passing just area name and a single domain." + "When controlling an area, prefer passing just area name and domain." ) ] area: ar.AreaEntry | None = None diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 672b6a6642b..63c1214dd6d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -423,9 +423,10 @@ async def test_assist_api_prompt( ) first_part_prompt = ( "Call the intent tools to control Home Assistant. " + "Do not pass the domain to the intent tools as a list. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " - "When controlling an area, prefer passing just area name and a single domain." + "When controlling an area, prefer passing just area name and domain." ) no_timer_prompt = "This device does not support timers." From 98d905562ec47075662ac9b6a9fc56024f001d1e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 30 May 2024 11:40:05 +0200 Subject: [PATCH 018/234] Bump deebot-client to 7.3.0 (#118462) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ecovacs/conftest.py | 13 ++++++++----- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index de4181b21b6..66dd07cf431 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==7.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==7.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d60aeb4892e..3dc5a43e9ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -703,7 +703,7 @@ debugpy==1.8.1 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==7.2.0 +deebot-client==7.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7321bb6429b..7d313056e6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -581,7 +581,7 @@ dbus-fast==2.21.3 debugpy==1.8.1 # homeassistant.components.ecovacs -deebot-client==7.2.0 +deebot-client==7.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index d4333f65dc4..f227b6092fd 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,10 +1,11 @@ """Common fixtures for the Ecovacs tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch from deebot_client import const +from deebot_client.command import DeviceCommandResult from deebot_client.device import Device from deebot_client.exceptions import ApiError from deebot_client.models import Credentials @@ -98,7 +99,7 @@ def mock_authenticator_authenticate(mock_authenticator: Mock) -> AsyncMock: @pytest.fixture -def mock_mqtt_client(mock_authenticator: Mock) -> Mock: +def mock_mqtt_client(mock_authenticator: Mock) -> Generator[Mock, None, None]: """Mock the MQTT client.""" with ( patch( @@ -117,10 +118,12 @@ def mock_mqtt_client(mock_authenticator: Mock) -> Mock: @pytest.fixture -def mock_device_execute() -> AsyncMock: +def mock_device_execute() -> Generator[AsyncMock, None, None]: """Mock the device execute function.""" with patch.object( - Device, "_execute_command", return_value=True + Device, + "_execute_command", + return_value=DeviceCommandResult(device_reached=True), ) as mock_device_execute: yield mock_device_execute @@ -139,7 +142,7 @@ async def init_integration( mock_mqtt_client: Mock, mock_device_execute: AsyncMock, platforms: Platform | list[Platform], -) -> MockConfigEntry: +) -> AsyncGenerator[MockConfigEntry, None]: """Set up the Ecovacs integration for testing.""" if not isinstance(platforms, list): platforms = [platforms] From 356374cdc3ed0e13db779508dc4394e9ef8b4dd7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 30 May 2024 11:00:36 +0200 Subject: [PATCH 019/234] Raise `ConfigEntryNotReady` when there is no `_id` in the Tractive data (#118467) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/tractive/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 6c053411329..468f11979e8 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -148,6 +148,13 @@ async def _generate_trackables( tracker.details(), tracker.hw_info(), tracker.pos_report() ) + if not tracker_details.get("_id"): + _LOGGER.info( + "Tractive API returns incomplete data for tracker %s", + trackable["device_id"], + ) + raise ConfigEntryNotReady + return Trackables(tracker, trackable, tracker_details, hw_info, pos_report) From 50acc268127d5655bf78d260a732cc1be7a3d7a2 Mon Sep 17 00:00:00 2001 From: lunmay <28674102+lunmay@users.noreply.github.com> Date: Thu, 30 May 2024 13:24:58 +0200 Subject: [PATCH 020/234] Typo fix in media_extractor (#118473) --- homeassistant/components/media_extractor/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json index 4c3743b5c12..125aa08337a 100644 --- a/homeassistant/components/media_extractor/strings.json +++ b/homeassistant/components/media_extractor/strings.json @@ -23,7 +23,7 @@ }, "extract_media_url": { "name": "Get Media URL", - "description": "Extract media url from a service.", + "description": "Extract media URL from a service.", "fields": { "url": { "name": "Media URL", From 522152e7d2a704b040e6f50166b7c335e2ec9790 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 30 May 2024 14:20:02 +0200 Subject: [PATCH 021/234] Set enity_category to config for airgradient select entities (#118477) --- homeassistant/components/airgradient/select.py | 3 +++ tests/components/airgradient/snapshots/test_select.ambr | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 8dc13fe0eba..41b5a48c686 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -8,6 +8,7 @@ from airgradient.models import ConfigurationControl, TemperatureUnit 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.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,6 +31,7 @@ CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription( key="configuration_control", translation_key="configuration_control", options=[x.value for x in ConfigurationControl], + entity_category=EntityCategory.CONFIG, value_fn=lambda config: config.configuration_control, set_value_fn=lambda client, value: client.set_configuration_control( ConfigurationControl(value) @@ -41,6 +43,7 @@ PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = ( key="display_temperature_unit", translation_key="display_temperature_unit", options=[x.value for x in TemperatureUnit], + entity_category=EntityCategory.CONFIG, value_fn=lambda config: config.temperature_unit, set_value_fn=lambda client, value: client.set_temperature_unit( TemperatureUnit(value) diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr index e32b57758c1..986e3c6ebb8 100644 --- a/tests/components/airgradient/snapshots/test_select.ambr +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -16,7 +16,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.airgradient_configuration_source', 'has_entity_name': True, 'hidden_by': None, @@ -72,7 +72,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.airgradient_display_temperature_unit', 'has_entity_name': True, 'hidden_by': None, @@ -128,7 +128,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'select', - 'entity_category': None, + 'entity_category': , 'entity_id': 'select.airgradient_configuration_source', 'has_entity_name': True, 'hidden_by': None, From e906812fbdcf90e29adf21093a0112d9e6fafa52 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 May 2024 16:59:45 +0200 Subject: [PATCH 022/234] Extend Matter sensor discovery schemas for Air Purifier / Air Quality devices (#118483) Co-authored-by: Franck Nijhof --- homeassistant/components/matter/sensor.py | 93 ++++++++++++++++++++ homeassistant/components/matter/strings.json | 18 ++++ tests/components/matter/test_sensor.py | 59 +++++++++++++ 3 files changed, 170 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 4e2644a1ff7..d91d4d33471 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -37,6 +37,17 @@ from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter from .models import MatterDiscoverySchema +AIR_QUALITY_MAP = { + clusters.AirQuality.Enums.AirQualityEnum.kExtremelyPoor: "extremely_poor", + clusters.AirQuality.Enums.AirQualityEnum.kVeryPoor: "very_poor", + clusters.AirQuality.Enums.AirQualityEnum.kPoor: "poor", + clusters.AirQuality.Enums.AirQualityEnum.kFair: "fair", + clusters.AirQuality.Enums.AirQualityEnum.kGood: "good", + clusters.AirQuality.Enums.AirQualityEnum.kModerate: "moderate", + clusters.AirQuality.Enums.AirQualityEnum.kUnknown: "unknown", + clusters.AirQuality.Enums.AirQualityEnum.kUnknownEnumValue: "unknown", +} + async def async_setup_entry( hass: HomeAssistant, @@ -271,4 +282,86 @@ DISCOVERY_SCHEMAS = [ clusters.Pm10ConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="AirQuality", + translation_key="air_quality", + device_class=SensorDeviceClass.ENUM, + state_class=None, + # convert to set first to remove the duplicate unknown value + options=list(set(AIR_QUALITY_MAP.values())), + measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], + icon="mdi:air-filter", + ), + entity_class=MatterSensor, + required_attributes=(clusters.AirQuality.Attributes.AirQuality,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="CarbonMonoxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.CarbonMonoxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="NitrogenDioxideSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.NitrogenDioxideConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OzoneConcentrationSensor", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.OZONE, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="HepaFilterCondition", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + translation_key="hepa_filter_condition", + icon="mdi:filter-check", + ), + entity_class=MatterSensor, + required_attributes=(clusters.HepaFilterMonitoring.Attributes.Condition,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ActivatedCarbonFilterCondition", + native_unit_of_measurement=PERCENTAGE, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + translation_key="activated_carbon_filter_condition", + icon="mdi:filter-check", + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ActivatedCarbonFilterMonitoring.Attributes.Condition, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c6c2d779255..a3f26a5865a 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -79,8 +79,26 @@ } }, "sensor": { + "activated_carbon_filter_condition": { + "name": "Activated carbon filter condition" + }, + "air_quality": { + "name": "Air quality", + "state": { + "extremely_poor": "Extremely poor", + "very_poor": "Very poor", + "poor": "Poor", + "fair": "Fair", + "good": "Good", + "moderate": "Moderate", + "unknown": "Unknown" + } + }, "flow": { "name": "Flow" + }, + "hepa_filter_condition": { + "name": "Hepa filter condition" } } }, diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 42b13e24c9e..2c9bfae94ce 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -391,3 +391,62 @@ async def test_air_purifier_sensor( assert state.attributes["unit_of_measurement"] == "ppm" assert state.attributes["device_class"] == "volatile_organic_compounds_parts" assert state.attributes["friendly_name"] == "Air Purifier VOCs" + + # Air Quality + state = hass.states.get("sensor.air_purifier_air_quality") + assert state + assert state.state == "good" + expected_options = [ + "extremely_poor", + "very_poor", + "poor", + "fair", + "good", + "moderate", + "unknown", + ] + assert set(state.attributes["options"]) == set(expected_options) + assert state.attributes["device_class"] == "enum" + assert state.attributes["friendly_name"] == "Air Purifier Air quality" + + # Carbon MonoOxide + state = hass.states.get("sensor.air_purifier_carbon_monoxide") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "carbon_monoxide" + assert state.attributes["friendly_name"] == "Air Purifier Carbon monoxide" + + # Nitrogen Dioxide + state = hass.states.get("sensor.air_purifier_nitrogen_dioxide") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "nitrogen_dioxide" + assert state.attributes["friendly_name"] == "Air Purifier Nitrogen dioxide" + + # Ozone Concentration + state = hass.states.get("sensor.air_purifier_ozone") + assert state + assert state.state == "2.0" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "ppm" + assert state.attributes["device_class"] == "ozone" + assert state.attributes["friendly_name"] == "Air Purifier Ozone" + + # Hepa Filter Condition + state = hass.states.get("sensor.air_purifier_hepa_filter_condition") + assert state + assert state.state == "100" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "%" + assert state.attributes["friendly_name"] == "Air Purifier Hepa filter condition" + + # Activated Carbon Filter Condition + state = hass.states.get("sensor.air_purifier_activated_carbon_filter_condition") + assert state + assert state.state == "100" + assert state.attributes["state_class"] == "measurement" + assert state.attributes["unit_of_measurement"] == "%" From 9095941b6236b162d6bc68bf35f95e080fb47e8a Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 30 May 2024 16:39:04 +0200 Subject: [PATCH 023/234] Mark Matter climate dry/fan mode support on Panasonic AC (#118485) --- homeassistant/components/matter/climate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 69a961ebf90..2050a9eb185 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -59,6 +59,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { # The Matter spec is missing a feature flag if the device supports a dry mode. # In the list below specify tuples of (vendorid, productid) of devices that # support dry mode. + (0x0001, 0x0108), (0x1209, 0x8007), } @@ -66,6 +67,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { # The Matter spec is missing a feature flag if the device supports a fan-only mode. # In the list below specify tuples of (vendorid, productid) of devices that # support fan-only mode. + (0x0001, 0x0108), (0x1209, 0x8007), } From 4951b60b1d69c2efc526ca6e300371d0d938122f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 30 May 2024 16:55:49 +0200 Subject: [PATCH 024/234] Update frontend to 20240530.0 (#118489) --- 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 d1177058706..c84a54d2642 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==20240529.0"] + "requirements": ["home-assistant-frontend==20240530.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8b7b7cee138..5f823188423 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.0 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240529.0 +home-assistant-frontend==20240530.0 home-assistant-intents==2024.5.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3dc5a43e9ba..6065c83fba6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240529.0 +home-assistant-frontend==20240530.0 # homeassistant.components.conversation home-assistant-intents==2024.5.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d313056e6a..6d323973dd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240529.0 +home-assistant-frontend==20240530.0 # homeassistant.components.conversation home-assistant-intents==2024.5.28 From 4beb184faf06e3e1ce5d0b5ab56599fc34febd4b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 May 2024 17:02:58 +0200 Subject: [PATCH 025/234] Bump version to 2024.6.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 4f63aea4e94..78fafe5feb8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 5dfdf35183b..e770925d19e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b1" +version = "2024.6.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 486c72db73d6e1c82521179566ec2c8a80b97c7c Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Thu, 30 May 2024 19:18:48 +0200 Subject: [PATCH 026/234] Adjustment of unit of measurement for light (#116695) --- homeassistant/components/fyta/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index c3e90cef28e..3c7ed35746a 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -93,7 +93,7 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ FytaSensorEntityDescription( key="light", translation_key="light", - native_unit_of_measurement="mol/d", + native_unit_of_measurement="μmol/s⋅m²", state_class=SensorStateClass.MEASUREMENT, ), FytaSensorEntityDescription( From e6e017dab7b33d4d7ae10830e946d885a78fb281 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 30 May 2024 19:42:48 +0100 Subject: [PATCH 027/234] Add support for V2C Trydan 2.1.7 (#117147) * Support for firmware 2.1.7 * add device ID as unique_id * add device ID as unique_id * add test device id as unique_id * backward compatibility * move outside try * Sensor return type Co-authored-by: Joost Lekkerkerker * not needed * make slave error enum state * fix enum * Update homeassistant/components/v2c/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/v2c/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/v2c/strings.json Co-authored-by: Joost Lekkerkerker * simplify tests * fix misspellings from upstream library * add sensor tests * just enough coverage for enum sensor * Refactor V2C tests (#117264) * Refactor V2C tests * fix rebase issues * ruff * review * fix https://github.com/home-assistant/core/issues/117296 --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 - homeassistant/components/v2c/__init__.py | 3 + homeassistant/components/v2c/config_flow.py | 7 +- homeassistant/components/v2c/icons.json | 6 + homeassistant/components/v2c/manifest.json | 2 +- homeassistant/components/v2c/sensor.py | 25 +- homeassistant/components/v2c/strings.json | 46 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/v2c/conftest.py | 1 + .../components/v2c/snapshots/test_sensor.ambr | 458 ++++++++++++++++++ tests/components/v2c/test_sensor.py | 40 ++ 12 files changed, 586 insertions(+), 7 deletions(-) diff --git a/.coveragerc b/.coveragerc index 7594d2d2d98..a4215bc0991 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1534,7 +1534,6 @@ omit = homeassistant/components/v2c/coordinator.py homeassistant/components/v2c/entity.py homeassistant/components/v2c/number.py - homeassistant/components/v2c/sensor.py homeassistant/components/v2c/switch.py homeassistant/components/vallox/__init__.py homeassistant/components/vallox/coordinator.py diff --git a/homeassistant/components/v2c/__init__.py b/homeassistant/components/v2c/__init__.py index 75d306b392a..b80163742cb 100644 --- a/homeassistant/components/v2c/__init__.py +++ b/homeassistant/components/v2c/__init__.py @@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + if coordinator.data.ID and entry.unique_id != coordinator.data.ID: + hass.config_entries.async_update_entry(entry, unique_id=coordinator.data.ID) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/v2c/config_flow.py b/homeassistant/components/v2c/config_flow.py index 7a08c34834e..0421d882ee6 100644 --- a/homeassistant/components/v2c/config_flow.py +++ b/homeassistant/components/v2c/config_flow.py @@ -41,13 +41,18 @@ class V2CConfigFlow(ConfigFlow, domain=DOMAIN): ) try: - await evse.get_data() + data = await evse.get_data() + except TrydanError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + if data.ID: + await self.async_set_unique_id(data.ID) + self._abort_if_unique_id_configured() + return self.async_create_entry( title=f"EVSE {user_input[CONF_HOST]}", data=user_input ) diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index 0c0609de347..fa8449135bb 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -15,6 +15,12 @@ }, "fv_power": { "default": "mdi:solar-power-variant" + }, + "slave_error": { + "default": "mdi:alert" + }, + "battery_power": { + "default": "mdi:home-battery" } }, "switch": { diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index fb234d726e8..e26bf80a514 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/v2c", "iot_class": "local_polling", - "requirements": ["pytrydan==0.6.0"] + "requirements": ["pytrydan==0.6.1"] } diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 871dd65aa75..01b89adea4d 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from pytrydan import TrydanData +from pytrydan.models.trydan import SlaveCommunicationState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTime 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 V2CUpdateCoordinator @@ -30,9 +32,11 @@ _LOGGER = logging.getLogger(__name__) class V2CSensorEntityDescription(SensorEntityDescription): """Describes an EVSE Power sensor entity.""" - value_fn: Callable[[TrydanData], float] + value_fn: Callable[[TrydanData], StateType] +_SLAVE_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState] + TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_power", @@ -75,6 +79,23 @@ TRYDAN_SENSORS = ( device_class=SensorDeviceClass.POWER, value_fn=lambda evse_data: evse_data.fv_power, ), + V2CSensorEntityDescription( + key="slave_error", + translation_key="slave_error", + value_fn=lambda evse_data: evse_data.slave_error.name.lower(), + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=_SLAVE_ERROR_OPTIONS, + ), + V2CSensorEntityDescription( + key="battery_power", + translation_key="battery_power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + value_fn=lambda evse_data: evse_data.battery_power, + entity_registry_enabled_default=False, + ), ) @@ -108,6 +129,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): self._attr_unique_id = f"{entry_id}_{description.key}" @property - def native_value(self) -> float | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index a60b61831fd..bafbbe36e0c 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, "step": { "user": { "data": { @@ -47,6 +50,49 @@ }, "fv_power": { "name": "Photovoltaic power" + }, + "battery_power": { + "name": "Battery power" + }, + "slave_error": { + "name": "Slave error", + "state": { + "no_error": "No error", + "communication": "Communication", + "reading": "Reading", + "slave": "Slave", + "waiting_wifi": "Waiting for Wi-Fi", + "waiting_communication": "Waiting communication", + "wrong_ip": "Wrong IP", + "slave_not_found": "Slave not found", + "wrong_slave": "Wrong slave", + "no_response": "No response", + "clamp_not_connected": "Clamp not connected", + "illegal_function": "Illegal function", + "illegal_data_address": "Illegal data address", + "illegal_data_value": "Illegal data value", + "server_device_failure": "Server device failure", + "acknowledge": "Acknowledge", + "server_device_busy": "Server device busy", + "negative_acknowledge": "Negative acknowledge", + "memory_parity_error": "Memory parity error", + "gateway_path_unavailable": "Gateway path unavailable", + "gateway_target_no_resp": "Gateway target no response", + "server_rtu_inactive244_timeout": "Server RTU inactive/timeout", + "invalid_server": "Invalid server", + "crc_error": "CRC error", + "fc_mismatch": "FC mismatch", + "server_id_mismatch": "Server id mismatch", + "packet_length_error": "Packet length error", + "parameter_count_error": "Parameter count error", + "parameter_limit_error": "Parameter limit error", + "request_queue_full": "Request queue full", + "illegal_ip_or_port": "Illegal IP or port", + "ip_connection_failed": "IP connection failed", + "tcp_head_mismatch": "TCP head mismatch", + "empty_message": "Empty message", + "undefined_error": "Undefined error" + } } }, "switch": { diff --git a/requirements_all.txt b/requirements_all.txt index 6065c83fba6..86e0cf509d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2352,7 +2352,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.6.0 +pytrydan==0.6.1 # homeassistant.components.usb pyudev==0.24.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d323973dd0..7591fd0a3c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1831,7 +1831,7 @@ pytradfri[async]==9.0.1 pytrafikverket==0.3.10 # homeassistant.components.v2c -pytrydan==0.6.0 +pytrydan==0.6.1 # homeassistant.components.usb pyudev==0.24.1 diff --git a/tests/components/v2c/conftest.py b/tests/components/v2c/conftest.py index 3508c0596b2..87c11a3ceef 100644 --- a/tests/components/v2c/conftest.py +++ b/tests/components/v2c/conftest.py @@ -48,4 +48,5 @@ def mock_v2c_client() -> Generator[AsyncMock, None, None]: client = mock_client.return_value get_data_json = load_json_object_fixture("get_data.json", DOMAIN) client.get_data.return_value = TrydanData.from_api(get_data_json) + client.firmware_version = get_data_json["FirmwareVersion"] yield client diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 2504aa2e7c8..0ef9bfe8429 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -1,4 +1,340 @@ # serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 Photovoltaic power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_charge_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Charge power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_power', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_power', + '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.evse_1_1_1_1_charge_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge energy', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_energy', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_energy', + '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.evse_1_1_1_1_charge_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge time', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_time', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_time', + '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.evse_1_1_1_1_house_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'House power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'house_power', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_house_power', + '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.evse_1_1_1_1_photovoltaic_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Photovoltaic power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fv_power', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_fv_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'slave', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'slave_not_found', + 'wrong_slave', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_missmatch', + 'server_id_missmatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_missmatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_slave_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Slave error', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slave_error', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_slave_error', + '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': None, + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_battery_power', + 'unit_of_measurement': , + }), + ]) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_battery_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery power', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_power', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_battery_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_battery_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'EVSE 1.1.1.1 Battery power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_battery_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensor[sensor.evse_1_1_1_1_charge_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -255,3 +591,125 @@ 'state': '0.0', }) # --- +# name: test_sensor[sensor.evse_1_1_1_1_slave_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'slave', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'slave_not_found', + 'wrong_slave', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_slave_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Slave error', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'slave_error', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_slave_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_slave_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'EVSE 1.1.1.1 Slave error', + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'slave', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'slave_not_found', + 'wrong_slave', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_slave_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_wifi', + }) +# --- diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index b30dfd436ff..a4a7fe6ca34 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -25,3 +25,43 @@ async def test_sensor( with patch("homeassistant.components.v2c.PLATFORMS", [Platform.SENSOR]): await init_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + from homeassistant.components.v2c.sensor import _SLAVE_ERROR_OPTIONS + + assert [ + "no_error", + "communication", + "reading", + "slave", + "waiting_wifi", + "waiting_communication", + "wrong_ip", + "slave_not_found", + "wrong_slave", + "no_response", + "clamp_not_connected", + "illegal_function", + "illegal_data_address", + "illegal_data_value", + "server_device_failure", + "acknowledge", + "server_device_busy", + "negative_acknowledge", + "memory_parity_error", + "gateway_path_unavailable", + "gateway_target_no_resp", + "server_rtu_inactive244_timeout", + "invalid_server", + "crc_error", + "fc_mismatch", + "server_id_mismatch", + "packet_length_error", + "parameter_count_error", + "parameter_limit_error", + "request_queue_full", + "illegal_ip_or_port", + "ip_connection_failed", + "tcp_head_mismatch", + "empty_message", + "undefined_error", + ] == _SLAVE_ERROR_OPTIONS From d93d7159db187fe7d2d8ca42e8b57d2ce51059e4 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Thu, 30 May 2024 19:27:15 +0300 Subject: [PATCH 028/234] Fix Jewish calendar unique id's (#117985) * Initial commit * Fix updating of unique id * Add testing to check the unique id is being updated correctly * Reload the config entry and confirm the unique id has not been changed * Move updating unique_id to __init__.py as suggested * Change the config_entry variable's name back from config to config_entry * Move the loop into the update_unique_ids method * Move test from test_config_flow to test_init * Try an early optimization to check if we need to update the unique ids * Mention the correct version * Implement suggestions * Ensure all entities are migrated correctly * Just to be sure keep the previous assertion as well --- .../components/jewish_calendar/__init__.py | 41 ++++++++-- .../jewish_calendar/binary_sensor.py | 9 ++- .../components/jewish_calendar/sensor.py | 11 ++- .../jewish_calendar/test_config_flow.py | 1 + tests/components/jewish_calendar/test_init.py | 74 +++++++++++++++++++ 5 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 tests/components/jewish_calendar/test_init.py diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 77a6b8af98c..7c4c0b7f634 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -16,11 +16,13 @@ from homeassistant.const import ( CONF_TIME_ZONE, Platform, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from .binary_sensor import BINARY_SENSORS from .const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -32,6 +34,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -131,18 +134,24 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b timezone=config_entry.data.get(CONF_TIME_ZONE, hass.config.time_zone), ) - prefix = get_unique_prefix( - location, language, candle_lighting_offset, havdalah_offset - ) hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = { CONF_LANGUAGE: language, CONF_DIASPORA: diaspora, CONF_LOCATION: location, CONF_CANDLE_LIGHT_MINUTES: candle_lighting_offset, CONF_HAVDALAH_OFFSET_MINUTES: havdalah_offset, - "prefix": prefix, } + # Update unique ID to be unrelated to user defined options + old_prefix = get_unique_prefix( + location, language, candle_lighting_offset, havdalah_offset + ) + + ent_reg = er.async_get(hass) + entries = er.async_entries_for_config_entry(ent_reg, config_entry.entry_id) + if not entries or any(entry.unique_id.startswith(old_prefix) for entry in entries): + async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -157,3 +166,25 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +@callback +def async_update_unique_ids( + ent_reg: er.EntityRegistry, new_prefix: str, old_prefix: str +) -> None: + """Update unique ID to be unrelated to user defined options. + + Introduced with release 2024.6 + """ + platform_descriptions = { + Platform.BINARY_SENSOR: BINARY_SENSORS, + Platform.SENSOR: (*INFO_SENSORS, *TIME_SENSORS), + } + for platform, descriptions in platform_descriptions.items(): + for description in descriptions: + new_unique_id = f"{new_prefix}-{description.key}" + old_unique_id = f"{old_prefix}_{description.key}" + if entity_id := ent_reg.async_get_entity_id( + platform, DOMAIN, old_unique_id + ): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 4982016ad66..c28dee88cf5 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -70,10 +70,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Jewish Calendar binary sensors.""" + entry = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( - JewishCalendarBinarySensor( - hass.data[DOMAIN][config_entry.entry_id], description - ) + JewishCalendarBinarySensor(config_entry.entry_id, entry, description) for description in BINARY_SENSORS ) @@ -86,13 +86,14 @@ class JewishCalendarBinarySensor(BinarySensorEntity): def __init__( self, + entry_id: str, data: dict[str, Any], description: JewishCalendarBinarySensorEntityDescription, ) -> None: """Initialize the binary sensor.""" self.entity_description = description self._attr_name = f"{DEFAULT_NAME} {description.name}" - self._attr_unique_id = f'{data["prefix"]}_{description.key}' + self._attr_unique_id = f"{entry_id}-{description.key}" self._location = data[CONF_LOCATION] self._hebrew = data[CONF_LANGUAGE] == "hebrew" self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d2fa872936c..90e504fe8fd 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -155,9 +155,13 @@ async def async_setup_entry( ) -> None: """Set up the Jewish calendar sensors .""" entry = hass.data[DOMAIN][config_entry.entry_id] - sensors = [JewishCalendarSensor(entry, description) for description in INFO_SENSORS] + sensors = [ + JewishCalendarSensor(config_entry.entry_id, entry, description) + for description in INFO_SENSORS + ] sensors.extend( - JewishCalendarTimeSensor(entry, description) for description in TIME_SENSORS + JewishCalendarTimeSensor(config_entry.entry_id, entry, description) + for description in TIME_SENSORS ) async_add_entities(sensors) @@ -168,13 +172,14 @@ class JewishCalendarSensor(SensorEntity): def __init__( self, + entry_id: str, data: dict[str, Any], description: SensorEntityDescription, ) -> None: """Initialize the Jewish calendar sensor.""" self.entity_description = description self._attr_name = f"{DEFAULT_NAME} {description.name}" - self._attr_unique_id = f'{data["prefix"]}_{description.key}' + self._attr_unique_id = f"{entry_id}-{description.key}" self._location = data[CONF_LOCATION] self._hebrew = data[CONF_LANGUAGE] == "hebrew" self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index ef16742d8d0..55c2f39b7eb 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -93,6 +93,7 @@ async def test_import_with_options(hass: HomeAssistant) -> None: } } + # Simulate HomeAssistant setting up the component assert await async_setup_component(hass, DOMAIN, conf.copy()) await hass.async_block_till_done() diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py new file mode 100644 index 00000000000..49dad98fa89 --- /dev/null +++ b/tests/components/jewish_calendar/test_init.py @@ -0,0 +1,74 @@ +"""Tests for the Jewish Calendar component's init.""" + +from hdate import Location + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSORS +from homeassistant.components.jewish_calendar import get_unique_prefix +from homeassistant.components.jewish_calendar.const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_DIASPORA, + DEFAULT_LANGUAGE, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_import_unique_id_migration(hass: HomeAssistant) -> None: + """Test unique_id migration.""" + yaml_conf = { + DOMAIN: { + CONF_NAME: "test", + CONF_DIASPORA: DEFAULT_DIASPORA, + CONF_LANGUAGE: DEFAULT_LANGUAGE, + CONF_CANDLE_LIGHT_MINUTES: 20, + CONF_HAVDALAH_OFFSET_MINUTES: 50, + CONF_LATITUDE: 31.76, + CONF_LONGITUDE: 35.235, + } + } + + # Create an entry in the entity registry with the data from conf + ent_reg = er.async_get(hass) + location = Location( + latitude=yaml_conf[DOMAIN][CONF_LATITUDE], + longitude=yaml_conf[DOMAIN][CONF_LONGITUDE], + timezone=hass.config.time_zone, + altitude=hass.config.elevation, + diaspora=DEFAULT_DIASPORA, + ) + old_prefix = get_unique_prefix(location, DEFAULT_LANGUAGE, 20, 50) + sample_entity = ent_reg.async_get_or_create( + BINARY_SENSORS, + DOMAIN, + unique_id=f"{old_prefix}_erev_shabbat_hag", + suggested_object_id=f"{DOMAIN}_erev_shabbat_hag", + ) + # Save the existing unique_id, DEFAULT_LANGUAGE should be part of it + old_unique_id = sample_entity.unique_id + assert DEFAULT_LANGUAGE in old_unique_id + + # Simulate HomeAssistant setting up the component + assert await async_setup_component(hass, DOMAIN, yaml_conf.copy()) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data == yaml_conf[DOMAIN] + + # Assert that the unique_id was updated + new_unique_id = ent_reg.async_get(sample_entity.entity_id).unique_id + assert new_unique_id != old_unique_id + assert DEFAULT_LANGUAGE not in new_unique_id + + # Confirm that when the component is reloaded, the unique_id is not changed + assert ent_reg.async_get(sample_entity.entity_id).unique_id == new_unique_id + + # Confirm that all the unique_ids are prefixed correctly + await hass.config_entries.async_reload(entries[0].entry_id) + er_entries = er.async_entries_for_config_entry(ent_reg, entries[0].entry_id) + assert all(entry.unique_id.startswith(entries[0].entry_id) for entry in er_entries) From 008aec56703832cfee3c976f2659852c70e05ebe Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 31 May 2024 04:17:44 +0200 Subject: [PATCH 029/234] Log aiohttp error in rest_command (#118453) --- homeassistant/components/rest_command/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c43e23cf068..b6945c5ce98 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -200,6 +200,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) from err except aiohttp.ClientError as err: + _LOGGER.error("Error fetching data: %s", err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="client_error", From e3ddbb27687c4d9b776c8b4b5e01d36065a54464 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 30 May 2024 18:23:58 +0100 Subject: [PATCH 030/234] Fix evohome so it doesn't retrieve schedules unnecessarily (#118478) --- homeassistant/components/evohome/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2a664986b74..0b0ef1d1c0d 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -6,7 +6,7 @@ Such systems include evohome, Round Thermostat, and others. from __future__ import annotations from collections.abc import Awaitable -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from http import HTTPStatus import logging import re @@ -452,7 +452,7 @@ class EvoBroker: self.config = client.installation_info[loc_idx][GWS][0][TCS][0] self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] # noqa: SLF001 - self.tcs_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET]) + self.loc_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET]) self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: @@ -685,7 +685,8 @@ class EvoChild(EvoDevice): if not (schedule := self._schedule.get("DailySchedules")): return {} # no scheduled setpoints when {'DailySchedules': []} - day_time = dt_util.now() + # get dt in the same TZ as the TCS location, so we can compare schedule times + day_time = dt_util.now().astimezone(timezone(self._evo_broker.loc_utc_offset)) day_of_week = day_time.weekday() # for evohome, 0 is Monday time_of_day = day_time.strftime("%H:%M:%S") @@ -699,7 +700,7 @@ class EvoChild(EvoDevice): else: break - # Did the current SP start yesterday? Does the next start SP tomorrow? + # Did this setpoint start yesterday? Does the next setpoint start tomorrow? this_sp_day = -1 if sp_idx == -1 else 0 next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 @@ -716,7 +717,7 @@ class EvoChild(EvoDevice): ) assert switchpoint_time_of_day is not None # mypy check dt_aware = _dt_evo_to_aware( - switchpoint_time_of_day, self._evo_broker.tcs_utc_offset + switchpoint_time_of_day, self._evo_broker.loc_utc_offset ) self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() From eb887a707c0fefbb61620c28f77f2ba6154e0a30 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2024 12:53:50 -0400 Subject: [PATCH 031/234] Ignore the toggle intent (#118491) --- homeassistant/helpers/llm.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 535e2af4d04..b749ff23da3 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -206,10 +206,11 @@ class AssistAPI(API): """API exposing Assist API to LLMs.""" IGNORE_INTENTS = { - intent.INTENT_NEVERMIND, - intent.INTENT_GET_STATE, - INTENT_GET_WEATHER, INTENT_GET_TEMPERATURE, + INTENT_GET_WEATHER, + intent.INTENT_GET_STATE, + intent.INTENT_NEVERMIND, + intent.INTENT_TOGGLE, } def __init__(self, hass: HomeAssistant) -> None: From 248c7c33b29391fb77be6d39dbb02dd4336cb4cc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 30 May 2024 19:11:19 +0200 Subject: [PATCH 032/234] Fix blocking call in holiday (#118496) --- homeassistant/components/holiday/calendar.py | 31 +++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 83988502d18..f56f4f90831 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -18,16 +18,10 @@ from homeassistant.util import dt as dt_util from .const import CONF_PROVINCE, DOMAIN -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Holiday Calendar config entry.""" - country: str = config_entry.data[CONF_COUNTRY] - province: str | None = config_entry.data.get(CONF_PROVINCE) - language = hass.config.language - +def _get_obj_holidays_and_language( + country: str, province: str | None, language: str +) -> tuple[HolidayBase, str]: + """Get the object for the requested country and year.""" obj_holidays = country_holidays( country, subdiv=province, @@ -58,6 +52,23 @@ async def async_setup_entry( ) language = default_language + return (obj_holidays, language) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays, language = await hass.async_add_executor_job( + _get_obj_holidays_and_language, country, province, language + ) + async_add_entities( [ HolidayCalendarEntity( From 7646d853f4dba7bbf1da4d2a70808166d5f80b69 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 30 May 2024 19:24:34 +0200 Subject: [PATCH 033/234] Remove not needed hass object from Tag (#118498) --- homeassistant/components/tag/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index ea0c6079e5b..b7c9660ed93 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -255,7 +255,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_add_entities( [ TagEntity( - hass, entity.name or entity.original_name, updated_config[TAG_ID], updated_config.get(LAST_SCANNED), @@ -301,7 +300,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: name = entity.name or entity.original_name entities.append( TagEntity( - hass, name, tag[TAG_ID], tag.get(LAST_SCANNED), @@ -365,14 +363,12 @@ class TagEntity(Entity): def __init__( self, - hass: HomeAssistant, name: str, tag_id: str, last_scanned: str | None, device_id: str | None, ) -> None: """Initialize the Tag event.""" - self.hass = hass self._attr_name = name self._tag_id = tag_id self._attr_unique_id = tag_id From ea44b534e6bd51287ca4189a22dff7924af6746f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 30 May 2024 19:14:54 +0200 Subject: [PATCH 034/234] Fix group platform dependencies (#118499) --- homeassistant/components/group/manifest.json | 9 ++++ tests/components/group/test_init.py | 55 +++++++++++++++++--- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/group/manifest.json b/homeassistant/components/group/manifest.json index 7ead19414af..d86fc4ba622 100644 --- a/homeassistant/components/group/manifest.json +++ b/homeassistant/components/group/manifest.json @@ -1,6 +1,15 @@ { "domain": "group", "name": "Group", + "after_dependencies": [ + "alarm_control_panel", + "climate", + "device_tracker", + "person", + "plant", + "vacuum", + "water_heater" + ], "codeowners": ["@home-assistant/core"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/group", diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 4f928e0a8c2..e2e618002ac 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1487,28 +1487,67 @@ async def test_group_vacuum_on(hass: HomeAssistant) -> None: assert hass.states.get("group.group_zero").state == STATE_ON -async def test_device_tracker_not_home(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("entity_state_list", "group_state"), + [ + ( + { + "device_tracker.one": "not_home", + "device_tracker.two": "not_home", + "device_tracker.three": "not_home", + }, + "not_home", + ), + ( + { + "device_tracker.one": "home", + "device_tracker.two": "not_home", + "device_tracker.three": "not_home", + }, + "home", + ), + ( + { + "device_tracker.one": "home", + "device_tracker.two": "elsewhere", + "device_tracker.three": "not_home", + }, + "home", + ), + ( + { + "device_tracker.one": "not_home", + "device_tracker.two": "elsewhere", + "device_tracker.three": "not_home", + }, + "not_home", + ), + ], +) +async def test_device_tracker_or_person_not_home( + hass: HomeAssistant, + entity_state_list: dict[str, str], + group_state: str, +) -> None: """Test group of device_tracker not_home.""" await async_setup_component(hass, "device_tracker", {}) + await async_setup_component(hass, "person", {}) await hass.async_block_till_done() - hass.states.async_set("device_tracker.one", "not_home") - hass.states.async_set("device_tracker.two", "not_home") - hass.states.async_set("device_tracker.three", "not_home") + for entity_id, state in entity_state_list.items(): + hass.states.async_set(entity_id, state) assert await async_setup_component( hass, "group", { "group": { - "group_zero": { - "entities": "device_tracker.one, device_tracker.two, device_tracker.three" - }, + "group_zero": {"entities": ", ".join(entity_state_list)}, } }, ) await hass.async_block_till_done() - assert hass.states.get("group.group_zero").state == "not_home" + assert hass.states.get("group.group_zero").state == group_state async def test_light_removed(hass: HomeAssistant) -> None: From e95b63bc89e2ec5654756c741b733e3273128995 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 May 2024 12:53:42 -0400 Subject: [PATCH 035/234] Intent script: allow setting description and platforms (#118500) * Add description to intent_script * Allow setting platforms --- .../components/intent_script/__init__.py | 7 +++++- tests/components/intent_script/test_init.py | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 63b37c08950..d6fbb1edd80 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -8,7 +8,7 @@ from typing import Any, TypedDict import voluptuous as vol from homeassistant.components.script import CONF_MODE -from homeassistant.const import CONF_TYPE, SERVICE_RELOAD +from homeassistant.const import CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import ( config_validation as cv, @@ -24,6 +24,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "intent_script" +CONF_PLATFORMS = "platforms" CONF_INTENTS = "intents" CONF_SPEECH = "speech" CONF_REPROMPT = "reprompt" @@ -41,6 +42,8 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: { cv.string: { + vol.Optional(CONF_DESCRIPTION): cv.string, + vol.Optional(CONF_PLATFORMS): vol.All([cv.string], vol.Coerce(set)), vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional( CONF_ASYNC_ACTION, default=DEFAULT_CONF_ASYNC_ACTION @@ -146,6 +149,8 @@ class ScriptIntentHandler(intent.IntentHandler): """Initialize the script intent handler.""" self.intent_type = intent_type self.config = config + self.description = config.get(CONF_DESCRIPTION) + self.platforms = config.get(CONF_PLATFORMS) async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: """Handle the intent.""" diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 14e5dd62d51..5f4c7b97b63 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -22,6 +22,8 @@ async def test_intent_script(hass: HomeAssistant) -> None: { "intent_script": { "HelloWorld": { + "description": "Intent to control a test service.", + "platforms": ["switch"], "action": { "service": "test.service", "data_template": {"hello": "{{ name }}"}, @@ -36,6 +38,17 @@ async def test_intent_script(hass: HomeAssistant) -> None: }, ) + handlers = [ + intent_handler + for intent_handler in intent.async_get(hass) + if intent_handler.intent_type == "HelloWorld" + ] + + assert len(handlers) == 1 + handler = handlers[0] + assert handler.description == "Intent to control a test service." + assert handler.platforms == {"switch"} + response = await intent.async_handle( hass, "test", "HelloWorld", {"name": {"value": "Paulus"}} ) @@ -78,6 +91,16 @@ async def test_intent_script_wait_response(hass: HomeAssistant) -> None: }, ) + handlers = [ + intent_handler + for intent_handler in intent.async_get(hass) + if intent_handler.intent_type == "HelloWorldWaitResponse" + ] + + assert len(handlers) == 1 + handler = handlers[0] + assert handler.platforms is None + response = await intent.async_handle( hass, "test", "HelloWorldWaitResponse", {"name": {"value": "Paulus"}} ) From 38c88c576b5c4e092bf978e48f1dfa03d35049c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 30 May 2024 22:31:02 +0200 Subject: [PATCH 036/234] Fix tado non-string unique id for device trackers (#118505) * Fix tado none string unique id for device trackers * Add comment * Fix comment --- homeassistant/components/tado/device_tracker.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index dea92ae3890..d3996db7faf 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -7,6 +7,7 @@ import logging import voluptuous as vol from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, SourceType, @@ -16,6 +17,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -78,9 +80,20 @@ async def async_setup_entry( ) -> None: """Set up the Tado device scannery entity.""" _LOGGER.debug("Setting up Tado device scanner entity") - tado = hass.data[DOMAIN][entry.entry_id][DATA] + tado: TadoConnector = hass.data[DOMAIN][entry.entry_id][DATA] tracked: set = set() + # Fix non-string unique_id for device trackers + # Can be removed in 2025.1 + entity_registry = er.async_get(hass) + for device_key in tado.data["mobile_device"]: + if entity_id := entity_registry.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, DOMAIN, device_key + ): + entity_registry.async_update_entity( + entity_id, new_unique_id=str(device_key) + ) + @callback def update_devices() -> None: """Update the values of the devices.""" @@ -134,7 +147,7 @@ class TadoDeviceTrackerEntity(TrackerEntity): ) -> None: """Initialize a Tado Device Tracker entity.""" super().__init__() - self._attr_unique_id = device_id + self._attr_unique_id = str(device_id) self._device_id = device_id self._device_name = device_name self._tado = tado From 3fb40deacb25728004db05c2e4140c9a179f20ad Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 30 May 2024 22:35:36 +0200 Subject: [PATCH 037/234] Fix key issue in config entry options in Openweathermap (#118506) --- homeassistant/components/openweathermap/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 7b21ae89b96..44c5179f227 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -101,6 +101,6 @@ async def async_unload_entry( def _get_config_value(config_entry: ConfigEntry, key: str) -> Any: - if config_entry.options: + if config_entry.options and key in config_entry.options: return config_entry.options[key] return config_entry.data[key] From 117a02972de1bc5469ed91006d378da40fbe8e4d Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 May 2024 13:29:13 -0700 Subject: [PATCH 038/234] Ignore deprecated open and close cover intents for LLMs (#118515) --- homeassistant/components/cover/intent.py | 2 ++ homeassistant/helpers/llm.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index dc512795c78..f347c8cc104 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -19,6 +19,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_OPEN_COVER, "Opened {}", + description="Opens a cover", platforms={DOMAIN}, ), ) @@ -29,6 +30,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLOSE_COVER, "Closed {}", + description="Closes a cover", platforms={DOMAIN}, ), ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b749ff23da3..ce539de1fd7 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -14,6 +14,7 @@ from homeassistant.components.conversation.trace import ( ConversationTraceEventType, async_conversation_trace_append, ) +from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.components.intent import async_device_supports_timers from homeassistant.components.weather.intent import INTENT_GET_WEATHER @@ -208,6 +209,8 @@ class AssistAPI(API): IGNORE_INTENTS = { INTENT_GET_TEMPERATURE, INTENT_GET_WEATHER, + INTENT_OPEN_COVER, # deprecated + INTENT_CLOSE_COVER, # deprecated intent.INTENT_GET_STATE, intent.INTENT_NEVERMIND, intent.INTENT_TOGGLE, From f4a876c590667c1e10bfb50e30e74f767445014b Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 May 2024 14:14:11 -0700 Subject: [PATCH 039/234] Fix LLMs asking which area when there is only one device (#118518) * Ignore deprecated open and close cover intents for LLMs * Fix LLMs asking which area when there is only one device * remove unrelated changed * remove unrelated changes --- homeassistant/helpers/llm.py | 2 +- tests/helpers/test_llm.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ce539de1fd7..5591c4a8aba 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -282,7 +282,7 @@ class AssistAPI(API): else: prompt.append( "When a user asks to turn on all devices of a specific type, " - "ask user to specify an area." + "ask user to specify an area, unless there is only one device of that type." ) if not tool_context.device_id or not async_device_supports_timers( diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 63c1214dd6d..1c13d643928 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -432,7 +432,7 @@ async def test_assist_api_prompt( area_prompt = ( "When a user asks to turn on all devices of a specific type, " - "ask user to specify an area." + "ask user to specify an area, unless there is only one device of that type." ) api = await llm.async_get_api(hass, "assist", tool_context) assert api.api_prompt == ( From cea7347ed99d7af72d6d859a4c981c243e20ce68 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 May 2024 19:03:57 -0700 Subject: [PATCH 040/234] Improve LLM prompt (#118520) --- homeassistant/helpers/llm.py | 3 ++- tests/helpers/test_llm.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5591c4a8aba..967b43468c8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -253,8 +253,9 @@ class AssistAPI(API): prompt = [ ( - "Call the intent tools to control Home Assistant. " + "When controlling Home Assistant always call the intent tools. " "Do not pass the domain to the intent tools as a list. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " "When controlling an area, prefer passing just area name and domain." diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1c13d643928..355abf2fe5d 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -422,8 +422,9 @@ async def test_assist_api_prompt( + yaml.dump(exposed_entities) ) first_part_prompt = ( - "Call the intent tools to control Home Assistant. " + "When controlling Home Assistant always call the intent tools. " "Do not pass the domain to the intent tools as a list. " + "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " "When controlling an area, prefer passing just area name and domain." From 7dab255c150a894bbb99ce86a2d143807db9871a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 30 May 2024 16:56:06 -0700 Subject: [PATCH 041/234] Fix unnecessary single quotes escaping in Google AI (#118522) --- .../conversation.py | 35 +++++++++++++------ homeassistant/helpers/llm.py | 2 +- .../test_conversation.py | 18 +++++++--- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index f85cf2530dc..e7aaabb912d 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -8,6 +8,7 @@ import google.ai.generativelanguage as glm from google.api_core.exceptions import GoogleAPICallError import google.generativeai as genai import google.generativeai.types as genai_types +from google.protobuf.json_format import MessageToDict import voluptuous as vol from voluptuous_openapi import convert @@ -105,6 +106,17 @@ def _format_tool(tool: llm.Tool) -> dict[str, Any]: ) +def _adjust_value(value: Any) -> Any: + """Reverse unnecessary single quotes escaping.""" + if isinstance(value, str): + return value.replace("\\'", "'") + if isinstance(value, list): + return [_adjust_value(item) for item in value] + if isinstance(value, dict): + return {k: _adjust_value(v) for k, v in value.items()} + return value + + class GoogleGenerativeAIConversationEntity( conversation.ConversationEntity, conversation.AbstractConversationAgent ): @@ -295,21 +307,22 @@ class GoogleGenerativeAIConversationEntity( response=intent_response, conversation_id=conversation_id ) self.history[conversation_id] = chat.history - tool_calls = [ + function_calls = [ part.function_call for part in chat_response.parts if part.function_call ] - if not tool_calls or not llm_api: + if not function_calls or not llm_api: break tool_responses = [] - for tool_call in tool_calls: - tool_input = llm.ToolInput( - tool_name=tool_call.name, - tool_args=dict(tool_call.args), - ) - LOGGER.debug( - "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args - ) + for function_call in function_calls: + tool_call = MessageToDict(function_call._pb) # noqa: SLF001 + tool_name = tool_call["name"] + tool_args = { + key: _adjust_value(value) + for key, value in tool_call["args"].items() + } + LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) + tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) try: function_response = await llm_api.async_call_tool(tool_input) except (HomeAssistantError, vol.Invalid) as e: @@ -321,7 +334,7 @@ class GoogleGenerativeAIConversationEntity( tool_responses.append( glm.Part( function_response=glm.FunctionResponse( - name=tool_call.name, response=function_response + name=tool_name, response=function_response ) ) ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 967b43468c8..b4b5f9137c4 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -140,7 +140,7 @@ class APIInstance: """Call a LLM tool, validate args and return the response.""" async_conversation_trace_append( ConversationTraceEventType.LLM_TOOL_CALL, - {"tool_name": tool_input.tool_name, "tool_args": str(tool_input.tool_args)}, + {"tool_name": tool_input.tool_name, "tool_args": tool_input.tool_args}, ) for tool in self.tools: diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 4c7f2de5e2e..b282895baef 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from freezegun import freeze_time +from google.ai.generativelanguage_v1beta.types.content import FunctionCall from google.api_core.exceptions import GoogleAPICallError import google.generativeai.types as genai_types import pytest @@ -179,8 +180,13 @@ async def test_function_call( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() - mock_part.function_call.name = "test_tool" - mock_part.function_call.args = {"param1": ["test_value"]} + mock_part.function_call = FunctionCall( + name="test_tool", + args={ + "param1": ["test_value", "param1\\'s value"], + "param2": "param2\\'s value", + }, + ) def tool_call(hass, tool_input, tool_context): mock_part.function_call = None @@ -220,7 +226,10 @@ async def test_function_call( hass, llm.ToolInput( tool_name="test_tool", - tool_args={"param1": ["test_value"]}, + tool_args={ + "param1": ["test_value", "param1's value"], + "param2": "param2's value", + }, ), llm.ToolContext( platform="google_generative_ai_conversation", @@ -279,8 +288,7 @@ async def test_function_exception( chat_response = MagicMock() mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() - mock_part.function_call.name = "test_tool" - mock_part.function_call.args = {"param1": 1} + mock_part.function_call = FunctionCall(name="test_tool", args={"param1": 1}) def tool_call(hass, tool_input, tool_context): mock_part.function_call = None From e5e26de06ffa3c1367f8a96d58c37b99dec3f20d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2024 02:20:10 +0000 Subject: [PATCH 042/234] Bump version to 2024.6.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 78fafe5feb8..3e4b9f7b873 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index e770925d19e..998f581700c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b2" +version = "2024.6.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 17cb25a5b62ebb8b6ac7021c8bb7464d39e9d1d6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2024 11:11:24 -0400 Subject: [PATCH 043/234] Rename llm.ToolContext to llm.LLMContext (#118566) --- .../conversation.py | 2 +- .../openai_conversation/conversation.py | 2 +- homeassistant/helpers/llm.py | 56 +++++++++--------- .../test_conversation.py | 4 +- .../openai_conversation/test_conversation.py | 4 +- tests/helpers/test_llm.py | 58 +++++++++---------- 6 files changed, 62 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index e7aaabb912d..d722403a0be 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -169,7 +169,7 @@ class GoogleGenerativeAIConversationEntity( llm_api = await llm.async_get_api( self.hass, self.entry.options[CONF_LLM_HASS_API], - llm.ToolContext( + llm.LLMContext( platform=DOMAIN, context=user_input.context, user_prompt=user_input.text, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index f4652a1f820..58b2f9c39c3 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -107,7 +107,7 @@ class OpenAIConversationEntity( llm_api = await llm.async_get_api( self.hass, options[CONF_LLM_HASS_API], - llm.ToolContext( + llm.LLMContext( platform=DOMAIN, context=user_input.context, user_prompt=user_input.text, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b4b5f9137c4..dd380795227 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -71,7 +71,7 @@ def async_register_api(hass: HomeAssistant, api: API) -> None: async def async_get_api( - hass: HomeAssistant, api_id: str, tool_context: ToolContext + hass: HomeAssistant, api_id: str, llm_context: LLMContext ) -> APIInstance: """Get an API.""" apis = _async_get_apis(hass) @@ -79,7 +79,7 @@ async def async_get_api( if api_id not in apis: raise HomeAssistantError(f"API {api_id} not found") - return await apis[api_id].async_get_api_instance(tool_context) + return await apis[api_id].async_get_api_instance(llm_context) @callback @@ -89,7 +89,7 @@ def async_get_apis(hass: HomeAssistant) -> list[API]: @dataclass(slots=True) -class ToolContext: +class LLMContext: """Tool input to be processed.""" platform: str @@ -117,7 +117,7 @@ class Tool: @abstractmethod async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, tool_context: ToolContext + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: """Call the tool.""" raise NotImplementedError @@ -133,7 +133,7 @@ class APIInstance: api: API api_prompt: str - tool_context: ToolContext + llm_context: LLMContext tools: list[Tool] async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: @@ -149,7 +149,7 @@ class APIInstance: else: raise HomeAssistantError(f'Tool "{tool_input.tool_name}" not found') - return await tool.async_call(self.api.hass, tool_input, self.tool_context) + return await tool.async_call(self.api.hass, tool_input, self.llm_context) @dataclass(slots=True, kw_only=True) @@ -161,7 +161,7 @@ class API(ABC): name: str @abstractmethod - async def async_get_api_instance(self, tool_context: ToolContext) -> APIInstance: + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: """Return the instance of the API.""" raise NotImplementedError @@ -182,20 +182,20 @@ class IntentTool(Tool): self.parameters = vol.Schema(slot_schema) async def async_call( - self, hass: HomeAssistant, tool_input: ToolInput, tool_context: ToolContext + self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: """Handle the intent.""" slots = {key: {"value": val} for key, val in tool_input.tool_args.items()} intent_response = await intent.async_handle( hass=hass, - platform=tool_context.platform, + platform=llm_context.platform, intent_type=self.name, slots=slots, - text_input=tool_context.user_prompt, - context=tool_context.context, - language=tool_context.language, - assistant=tool_context.assistant, - device_id=tool_context.device_id, + text_input=llm_context.user_prompt, + context=llm_context.context, + language=llm_context.language, + assistant=llm_context.assistant, + device_id=llm_context.device_id, ) response = intent_response.as_dict() del response["language"] @@ -224,25 +224,25 @@ class AssistAPI(API): name="Assist", ) - async def async_get_api_instance(self, tool_context: ToolContext) -> APIInstance: + async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: """Return the instance of the API.""" - if tool_context.assistant: + if llm_context.assistant: exposed_entities: dict | None = _get_exposed_entities( - self.hass, tool_context.assistant + self.hass, llm_context.assistant ) else: exposed_entities = None return APIInstance( api=self, - api_prompt=self._async_get_api_prompt(tool_context, exposed_entities), - tool_context=tool_context, - tools=self._async_get_tools(tool_context, exposed_entities), + api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), + llm_context=llm_context, + tools=self._async_get_tools(llm_context, exposed_entities), ) @callback def _async_get_api_prompt( - self, tool_context: ToolContext, exposed_entities: dict | None + self, llm_context: LLMContext, exposed_entities: dict | None ) -> str: """Return the prompt for the API.""" if not exposed_entities: @@ -263,9 +263,9 @@ class AssistAPI(API): ] area: ar.AreaEntry | None = None floor: fr.FloorEntry | None = None - if tool_context.device_id: + if llm_context.device_id: device_reg = dr.async_get(self.hass) - device = device_reg.async_get(tool_context.device_id) + device = device_reg.async_get(llm_context.device_id) if device: area_reg = ar.async_get(self.hass) @@ -286,8 +286,8 @@ class AssistAPI(API): "ask user to specify an area, unless there is only one device of that type." ) - if not tool_context.device_id or not async_device_supports_timers( - self.hass, tool_context.device_id + if not llm_context.device_id or not async_device_supports_timers( + self.hass, llm_context.device_id ): prompt.append("This device does not support timers.") @@ -301,12 +301,12 @@ class AssistAPI(API): @callback def _async_get_tools( - self, tool_context: ToolContext, exposed_entities: dict | None + self, llm_context: LLMContext, exposed_entities: dict | None ) -> list[Tool]: """Return a list of LLM tools.""" ignore_intents = self.IGNORE_INTENTS - if not tool_context.device_id or not async_device_supports_timers( - self.hass, tool_context.device_id + if not llm_context.device_id or not async_device_supports_timers( + self.hass, llm_context.device_id ): ignore_intents = ignore_intents | { intent.INTENT_START_TIMER, diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index b282895baef..19a855aa17f 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -231,7 +231,7 @@ async def test_function_call( "param2": "param2's value", }, ), - llm.ToolContext( + llm.LLMContext( platform="google_generative_ai_conversation", context=context, user_prompt="Please call the test function", @@ -330,7 +330,7 @@ async def test_function_exception( tool_name="test_tool", tool_args={"param1": 1}, ), - llm.ToolContext( + llm.LLMContext( platform="google_generative_ai_conversation", context=context, user_prompt="Please call the test function", diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 0eec14395e5..25a195bf754 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -193,7 +193,7 @@ async def test_function_call( tool_name="test_tool", tool_args={"param1": "test_value"}, ), - llm.ToolContext( + llm.LLMContext( platform="openai_conversation", context=context, user_prompt="Please call the test function", @@ -326,7 +326,7 @@ async def test_function_exception( tool_name="test_tool", tool_args={"param1": "test_value"}, ), - llm.ToolContext( + llm.LLMContext( platform="openai_conversation", context=context, user_prompt="Please call the test function", diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 355abf2fe5d..9c07295dec7 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -24,9 +24,9 @@ from tests.common import MockConfigEntry @pytest.fixture -def tool_input_context() -> llm.ToolContext: +def llm_context() -> llm.LLMContext: """Return tool input context.""" - return llm.ToolContext( + return llm.LLMContext( platform="", context=None, user_prompt=None, @@ -37,29 +37,27 @@ def tool_input_context() -> llm.ToolContext: async def test_get_api_no_existing( - hass: HomeAssistant, tool_input_context: llm.ToolContext + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: """Test getting an llm api where no config exists.""" with pytest.raises(HomeAssistantError): - await llm.async_get_api(hass, "non-existing", tool_input_context) + await llm.async_get_api(hass, "non-existing", llm_context) -async def test_register_api( - hass: HomeAssistant, tool_input_context: llm.ToolContext -) -> None: +async def test_register_api(hass: HomeAssistant, llm_context: llm.LLMContext) -> None: """Test registering an llm api.""" class MyAPI(llm.API): async def async_get_api_instance( - self, tool_input: llm.ToolInput + self, tool_context: llm.ToolInput ) -> llm.APIInstance: """Return a list of tools.""" - return llm.APIInstance(self, "", [], tool_input_context) + return llm.APIInstance(self, "", [], llm_context) api = MyAPI(hass=hass, id="test", name="Test") llm.async_register_api(hass, api) - instance = await llm.async_get_api(hass, "test", tool_input_context) + instance = await llm.async_get_api(hass, "test", llm_context) assert instance.api is api assert api in llm.async_get_apis(hass) @@ -68,10 +66,10 @@ async def test_register_api( async def test_call_tool_no_existing( - hass: HomeAssistant, tool_input_context: llm.ToolContext + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: """Test calling an llm tool where no config exists.""" - instance = await llm.async_get_api(hass, "assist", tool_input_context) + instance = await llm.async_get_api(hass, "assist", llm_context) with pytest.raises(HomeAssistantError): await instance.async_call_tool( llm.ToolInput("test_tool", {}), @@ -93,7 +91,7 @@ async def test_assist_api( ).write_unavailable_state(hass) test_context = Context() - tool_context = llm.ToolContext( + llm_context = llm.LLMContext( platform="test_platform", context=test_context, user_prompt="test_text", @@ -116,19 +114,19 @@ async def test_assist_api( intent.async_register(hass, intent_handler) assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert len(api.tools) == 0 # Match all intent_handler.platforms = None - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert len(api.tools) == 1 # Match specific domain intent_handler.platforms = {"light"} - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert len(api.tools) == 1 tool = api.tools[0] assert tool.name == "test_intent" @@ -176,25 +174,25 @@ async def test_assist_api( async def test_assist_api_get_timer_tools( - hass: HomeAssistant, tool_input_context: llm.ToolContext + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: """Test getting timer tools with Assist API.""" assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) - api = await llm.async_get_api(hass, "assist", tool_input_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert "HassStartTimer" not in [tool.name for tool in api.tools] - tool_input_context.device_id = "test_device" + llm_context.device_id = "test_device" async_register_timer_handler(hass, "test_device", lambda *args: None) - api = await llm.async_get_api(hass, "assist", tool_input_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert "HassStartTimer" in [tool.name for tool in api.tools] async def test_assist_api_description( - hass: HomeAssistant, tool_input_context: llm.ToolContext + hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: """Test intent description with Assist API.""" @@ -205,7 +203,7 @@ async def test_assist_api_description( intent.async_register(hass, MyIntentHandler()) assert len(llm.async_get_apis(hass)) == 1 - api = await llm.async_get_api(hass, "assist", tool_input_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert len(api.tools) == 1 tool = api.tools[0] assert tool.name == "test_intent" @@ -223,7 +221,7 @@ async def test_assist_api_prompt( assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "intent", {}) context = Context() - tool_context = llm.ToolContext( + llm_context = llm.LLMContext( platform="test_platform", context=context, user_prompt="test_text", @@ -231,7 +229,7 @@ async def test_assist_api_prompt( assistant="conversation", device_id=None, ) - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( "Only if the user wants to control a device, tell them to expose entities to their " "voice assistant in Home Assistant." @@ -360,7 +358,7 @@ async def test_assist_api_prompt( ) ) - exposed_entities = llm._get_exposed_entities(hass, tool_context.assistant) + exposed_entities = llm._get_exposed_entities(hass, llm_context.assistant) assert exposed_entities == { "light.1": { "areas": "Test Area 2", @@ -435,7 +433,7 @@ async def test_assist_api_prompt( "When a user asks to turn on all devices of a specific type, " "ask user to specify an area, unless there is only one device of that type." ) - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} @@ -444,12 +442,12 @@ async def test_assist_api_prompt( ) # Fake that request is made from a specific device ID with an area - tool_context.device_id = device.id + llm_context.device_id = device.id area_prompt = ( "You are in area Test Area and all generic commands like 'turn on the lights' " "should target this area." ) - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} @@ -464,7 +462,7 @@ async def test_assist_api_prompt( "You are in area Test Area (floor 2) and all generic commands like 'turn on the lights' " "should target this area." ) - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) assert api.api_prompt == ( f"""{first_part_prompt} {area_prompt} @@ -475,7 +473,7 @@ async def test_assist_api_prompt( # Register device for timers async_register_timer_handler(hass, device.id, lambda *args: None) - api = await llm.async_get_api(hass, "assist", tool_context) + api = await llm.async_get_api(hass, "assist", llm_context) # The no_timer_prompt is gone assert api.api_prompt == ( f"""{first_part_prompt} From 2e45d678b8b26d6fe1208335fd0b5b539b2caca6 Mon Sep 17 00:00:00 2001 From: Bas Brussee <68892092+basbruss@users.noreply.github.com> Date: Fri, 31 May 2024 16:50:22 +0200 Subject: [PATCH 044/234] Revert "Fix Tibber sensors state class" (#118409) Revert "Fix Tibber sensors state class (#117085)" This reverts commit 658c1f3d97a8a8eb0d91150e09b36c995a4863c5. --- homeassistant/components/tibber/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index f0131173403..8d036157494 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -118,7 +118,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = ( translation_key="accumulated_consumption", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedConsumptionLastHour", @@ -138,7 +138,7 @@ RT_SENSORS: tuple[SensorEntityDescription, ...] = ( translation_key="accumulated_production", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="accumulatedProductionLastHour", From 395e1ae31e9a64096383eac94b2f0e34494836bc Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 1 Jun 2024 03:28:44 +0800 Subject: [PATCH 045/234] Add Google Generative AI Conversation system prompt `user_name` and `llm_context` variables (#118510) * Google Generative AI Conversation: Add variables to the system prompt * User name and llm_context * test for template variables * test for template variables --------- Co-authored-by: Paulus Schoutsen --- .../conversation.py | 29 ++++++++---- .../test_conversation.py | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d722403a0be..12b1e44b3df 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -163,20 +163,22 @@ class GoogleGenerativeAIConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None tools: list[dict[str, Any]] | None = None + user_name: str | None = None + llm_context = llm.LLMContext( + platform=DOMAIN, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=conversation.DOMAIN, + device_id=user_input.device_id, + ) if self.entry.options.get(CONF_LLM_HASS_API): try: llm_api = await llm.async_get_api( self.hass, self.entry.options[CONF_LLM_HASS_API], - llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ), + llm_context, ) except HomeAssistantError as err: LOGGER.error("Error getting LLM API: %s", err) @@ -225,6 +227,15 @@ class GoogleGenerativeAIConversationEntity( conversation_id = ulid.ulid_now() messages = [{}, {}] + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + try: if llm_api: api_prompt = llm_api.api_prompt @@ -241,6 +252,8 @@ class GoogleGenerativeAIConversationEntity( ).async_render( { "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, }, parse_result=False, ), diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 19a855aa17f..13e7bd0c8fb 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -449,6 +449,51 @@ async def test_template_error( assert result.response.error_code == "unknown", result +async def test_template_variables( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template variables work.""" + context = Context(user_id="12345") + mock_user = MagicMock() + mock_user.id = "12345" + mock_user.name = "Test User" + + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": ( + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + ), + }, + ) + with ( + patch("google.generativeai.GenerativeModel") as mock_model, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + mock_chat = AsyncMock() + mock_model.return_value.start_chat.return_value = mock_chat + chat_response = MagicMock() + mock_chat.send_message_async.return_value = chat_response + mock_part = MagicMock() + mock_part.text = "Model response" + chat_response.parts = [mock_part] + result = await conversation.async_converse( + hass, "hello", None, context, agent_id=mock_config_entry.entry_id + ) + + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert ( + "The user name is Test User." + in mock_model.mock_calls[1][2]["history"][0]["parts"] + ) + assert "The user id is 12345." in mock_model.mock_calls[1][2]["history"][0]["parts"] + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From c441f689bf87c8abfbc4ac79d16a090ef9e14987 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 31 May 2024 04:13:18 +0200 Subject: [PATCH 046/234] Add typing for OpenAI client and fallout (#118514) * typing for client and consequences * Update homeassistant/components/openai_conversation/conversation.py --------- Co-authored-by: Paulus Schoutsen --- .../openai_conversation/conversation.py | 75 ++++++++++++++----- .../openai_conversation/test_conversation.py | 2 - 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 58b2f9c39c3..26acfda979d 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,9 +1,22 @@ """Conversation support for OpenAI.""" import json -from typing import Any, Literal +from typing import Literal import openai +from openai._types import NOT_GIVEN +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessage, + ChatCompletionMessageParam, + ChatCompletionMessageToolCallParam, + ChatCompletionSystemMessageParam, + ChatCompletionToolMessageParam, + ChatCompletionToolParam, + ChatCompletionUserMessageParam, +) +from openai.types.chat.chat_completion_message_tool_call_param import Function +from openai.types.shared_params import FunctionDefinition import voluptuous as vol from voluptuous_openapi import convert @@ -45,13 +58,12 @@ async def async_setup_entry( async_add_entities([agent]) -def _format_tool(tool: llm.Tool) -> dict[str, Any]: +def _format_tool(tool: llm.Tool) -> ChatCompletionToolParam: """Format tool specification.""" - tool_spec = {"name": tool.name} + tool_spec = FunctionDefinition(name=tool.name, parameters=convert(tool.parameters)) if tool.description: tool_spec["description"] = tool.description - tool_spec["parameters"] = convert(tool.parameters) - return {"type": "function", "function": tool_spec} + return ChatCompletionToolParam(type="function", function=tool_spec) class OpenAIConversationEntity( @@ -65,7 +77,7 @@ class OpenAIConversationEntity( def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" self.entry = entry - self.history: dict[str, list[dict]] = {} + self.history: dict[str, list[ChatCompletionMessageParam]] = {} self._attr_unique_id = entry.entry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, @@ -100,7 +112,7 @@ class OpenAIConversationEntity( options = self.entry.options intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None - tools: list[dict[str, Any]] | None = None + tools: list[ChatCompletionToolParam] | None = None if options.get(CONF_LLM_HASS_API): try: @@ -164,16 +176,18 @@ class OpenAIConversationEntity( response=intent_response, conversation_id=conversation_id ) - messages = [{"role": "system", "content": prompt}] + messages = [ChatCompletionSystemMessageParam(role="system", content=prompt)] - messages.append({"role": "user", "content": user_input.text}) + messages.append( + ChatCompletionUserMessageParam(role="user", content=user_input.text) + ) LOGGER.debug("Prompt: %s", messages) trace.async_conversation_trace_append( trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages} ) - client = self.hass.data[DOMAIN][self.entry.entry_id] + client: openai.AsyncClient = self.hass.data[DOMAIN][self.entry.entry_id] # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): @@ -181,7 +195,7 @@ class OpenAIConversationEntity( result = await client.chat.completions.create( model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), messages=messages, - tools=tools or None, + tools=tools or NOT_GIVEN, max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), @@ -199,7 +213,31 @@ class OpenAIConversationEntity( LOGGER.debug("Response %s", result) response = result.choices[0].message - messages.append(response) + + def message_convert( + message: ChatCompletionMessage, + ) -> ChatCompletionMessageParam: + """Convert from class to TypedDict.""" + tool_calls: list[ChatCompletionMessageToolCallParam] = [] + if message.tool_calls: + tool_calls = [ + ChatCompletionMessageToolCallParam( + id=tool_call.id, + function=Function( + arguments=tool_call.function.arguments, + name=tool_call.function.name, + ), + type=tool_call.type, + ) + for tool_call in message.tool_calls + ] + return ChatCompletionAssistantMessageParam( + role=message.role, + tool_calls=tool_calls, + content=message.content, + ) + + messages.append(message_convert(response)) tool_calls = response.tool_calls if not tool_calls or not llm_api: @@ -223,18 +261,17 @@ class OpenAIConversationEntity( LOGGER.debug("Tool response: %s", tool_response) messages.append( - { - "role": "tool", - "tool_call_id": tool_call.id, - "name": tool_call.function.name, - "content": json.dumps(tool_response), - } + ChatCompletionToolMessageParam( + role="tool", + tool_call_id=tool_call.id, + content=json.dumps(tool_response), + ) ) self.history[conversation_id] = messages intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response.content) + intent_response.async_set_speech(response.content or "") return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id ) diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 25a195bf754..10829db7575 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -184,7 +184,6 @@ async def test_function_call( assert mock_create.mock_calls[1][2]["messages"][3] == { "role": "tool", "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "name": "test_tool", "content": '"Test response"', } mock_tool.async_call.assert_awaited_once_with( @@ -317,7 +316,6 @@ async def test_function_exception( assert mock_create.mock_calls[1][2]["messages"][3] == { "role": "tool", "tool_call_id": "call_AbCdEfGhIjKlMnOpQrStUvWx", - "name": "test_tool", "content": '{"error": "HomeAssistantError", "error_text": "Test tool exception"}', } mock_tool.async_call.assert_awaited_once_with( From c09bc726d1dd28e5bcf89623681225311222e51b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 1 Jun 2024 03:28:23 +0800 Subject: [PATCH 047/234] Add OpenAI Conversation system prompt `user_name` and `llm_context` variables (#118512) * OpenAI Conversation: Add variables to the system prompt * User name and llm_context * test for user name * test for user id --------- Co-authored-by: Paulus Schoutsen --- .../openai_conversation/conversation.py | 32 ++++++++--- .../openai_conversation/test_conversation.py | 53 ++++++++++++++++++- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 26acfda979d..8de146e0851 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -113,20 +113,22 @@ class OpenAIConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None tools: list[ChatCompletionToolParam] | None = None + user_name: str | None = None + llm_context = llm.LLMContext( + platform=DOMAIN, + context=user_input.context, + user_prompt=user_input.text, + language=user_input.language, + assistant=conversation.DOMAIN, + device_id=user_input.device_id, + ) if options.get(CONF_LLM_HASS_API): try: llm_api = await llm.async_get_api( self.hass, options[CONF_LLM_HASS_API], - llm.LLMContext( - platform=DOMAIN, - context=user_input.context, - user_prompt=user_input.text, - language=user_input.language, - assistant=conversation.DOMAIN, - device_id=user_input.device_id, - ), + llm_context, ) except HomeAssistantError as err: LOGGER.error("Error getting LLM API: %s", err) @@ -144,6 +146,18 @@ class OpenAIConversationEntity( messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() + + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user( + user_input.context.user_id + ) + ) + ): + user_name = user.name + try: if llm_api: api_prompt = llm_api.api_prompt @@ -158,6 +172,8 @@ class OpenAIConversationEntity( ).async_render( { "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, }, parse_result=False, ), diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 10829db7575..05d62ffd61b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,6 +1,6 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from httpx import Response from openai import RateLimitError @@ -73,6 +73,53 @@ async def test_template_error( assert result.response.error_code == "unknown", result +async def test_template_variables( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template variables work.""" + context = Context(user_id="12345") + mock_user = Mock() + mock_user.id = "12345" + mock_user.name = "Test User" + + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": ( + "The user name is {{ user_name }}. " + "The user id is {{ llm_context.context.user_id }}." + ), + }, + ) + with ( + patch( + "openai.resources.models.AsyncModels.list", + ), + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ) as mock_create, + patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse( + hass, "hello", None, context, agent_id=mock_config_entry.entry_id + ) + + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert ( + "The user name is Test User." + in mock_create.mock_calls[0][2]["messages"][0]["content"] + ) + assert ( + "The user id is 12345." + in mock_create.mock_calls[0][2]["messages"][0]["content"] + ) + + async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -382,7 +429,9 @@ async def test_assist_api_tools_conversion( ), ), ) as mock_create: - await conversation.async_converse(hass, "hello", None, None, agent_id=agent_id) + await conversation.async_converse( + hass, "hello", None, Context(), agent_id=agent_id + ) tools = mock_create.mock_calls[0][2]["tools"] assert tools From ba769f4d9ff23d8a7e31a05b31a8d1e62adb5465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 May 2024 02:44:28 -1000 Subject: [PATCH 048/234] Fix snmp doing blocking I/O in the event loop (#118521) --- homeassistant/components/snmp/__init__.py | 4 + .../components/snmp/device_tracker.py | 54 +++++------ homeassistant/components/snmp/sensor.py | 42 +++------ homeassistant/components/snmp/switch.py | 89 +++++++------------ homeassistant/components/snmp/util.py | 76 ++++++++++++++++ tests/components/snmp/test_init.py | 22 +++++ 6 files changed, 176 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/snmp/util.py create mode 100644 tests/components/snmp/test_init.py diff --git a/homeassistant/components/snmp/__init__.py b/homeassistant/components/snmp/__init__.py index a4c922877f3..4a049ee1553 100644 --- a/homeassistant/components/snmp/__init__.py +++ b/homeassistant/components/snmp/__init__.py @@ -1 +1,5 @@ """The snmp component.""" + +from .util import async_get_snmp_engine + +__all__ = ["async_get_snmp_engine"] diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 5d4f9e5e0d9..d336838117f 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -4,14 +4,11 @@ from __future__ import annotations import binascii import logging +from typing import TYPE_CHECKING from pysnmp.error import PySnmpError from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, Udp6TransportTarget, UdpTransportTarget, UsmUserData, @@ -43,6 +40,7 @@ from .const import ( DEFAULT_VERSION, SNMP_VERSIONS, ) +from .util import RequestArgsType, async_create_request_cmd_args _LOGGER = logging.getLogger(__name__) @@ -62,7 +60,7 @@ async def async_get_scanner( ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) - await scanner.async_init() + await scanner.async_init(hass) return scanner if scanner.success_init else None @@ -99,33 +97,29 @@ class SnmpScanner(DeviceScanner): if not privkey: privproto = "none" - request_args = [ - SnmpEngine(), - UsmUserData( - community, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=authproto, - privProtocol=privproto, - ), - target, - ContextData(), - ] + self._auth_data = UsmUserData( + community, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=authproto, + privProtocol=privproto, + ) else: - request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION]), - target, - ContextData(), - ] + self._auth_data = CommunityData( + community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION] + ) - self.request_args = request_args + self._target = target + self.request_args: RequestArgsType | None = None self.baseoid = baseoid self.last_results = [] self.success_init = False - async def async_init(self): + async def async_init(self, hass: HomeAssistant) -> None: """Make a one-off read to check if the target device is reachable and readable.""" + self.request_args = await async_create_request_cmd_args( + hass, self._auth_data, self._target, self.baseoid + ) data = await self.async_get_snmp_data() self.success_init = data is not None @@ -156,12 +150,18 @@ class SnmpScanner(DeviceScanner): async def async_get_snmp_data(self): """Fetch MAC addresses from access point via SNMP.""" devices = [] + if TYPE_CHECKING: + assert self.request_args is not None + engine, auth_data, target, context_data, object_type = self.request_args walker = bulkWalkCmd( - *self.request_args, + engine, + auth_data, + target, + context_data, 0, 50, - ObjectType(ObjectIdentity(self.baseoid)), + object_type, lexicographicMode=False, ) async for errindication, errstatus, errindex, res in walker: diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 939cb13ae35..0e5b215dcd4 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -11,10 +11,6 @@ from pysnmp.error import PySnmpError import pysnmp.hlapi.asyncio as hlapi from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, Udp6TransportTarget, UdpTransportTarget, UsmUserData, @@ -71,6 +67,7 @@ from .const import ( MAP_PRIV_PROTOCOLS, SNMP_VERSIONS, ) +from .util import async_create_request_cmd_args _LOGGER = logging.getLogger(__name__) @@ -119,7 +116,7 @@ async def async_setup_platform( host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) - baseoid = config.get(CONF_BASEOID) + baseoid: str = config[CONF_BASEOID] version = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) @@ -145,27 +142,18 @@ async def async_setup_platform( authproto = "none" if not privkey: privproto = "none" - - request_args = [ - SnmpEngine(), - UsmUserData( - username, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), - privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), - ), - target, - ContextData(), - ] + auth_data = UsmUserData( + username, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), + privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), + ) else: - request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[version]), - target, - ContextData(), - ] - get_result = await getCmd(*request_args, ObjectType(ObjectIdentity(baseoid))) + auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) + + request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid) + get_result = await getCmd(*request_args) errindication, _, _, _ = get_result if errindication and not accept_errors: @@ -244,9 +232,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - get_result = await getCmd( - *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) - ) + get_result = await getCmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index a447cdc8e9c..40083ed4213 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -8,10 +8,6 @@ from typing import Any import pysnmp.hlapi.asyncio as hlapi from pysnmp.hlapi.asyncio import ( CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, UdpTransportTarget, UsmUserData, getCmd, @@ -67,6 +63,7 @@ from .const import ( MAP_PRIV_PROTOCOLS, SNMP_VERSIONS, ) +from .util import RequestArgsType, async_create_request_cmd_args _LOGGER = logging.getLogger(__name__) @@ -132,40 +129,54 @@ async def async_setup_platform( host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) - baseoid = config.get(CONF_BASEOID) + baseoid: str = config[CONF_BASEOID] command_oid = config.get(CONF_COMMAND_OID) command_payload_on = config.get(CONF_COMMAND_PAYLOAD_ON) command_payload_off = config.get(CONF_COMMAND_PAYLOAD_OFF) - version = config.get(CONF_VERSION) + version: str = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) - authproto = config.get(CONF_AUTH_PROTOCOL) + authproto: str = config[CONF_AUTH_PROTOCOL] privkey = config.get(CONF_PRIV_KEY) - privproto = config.get(CONF_PRIV_PROTOCOL) + privproto: str = config[CONF_PRIV_PROTOCOL] payload_on = config.get(CONF_PAYLOAD_ON) payload_off = config.get(CONF_PAYLOAD_OFF) vartype = config.get(CONF_VARTYPE) + if version == "3": + if not authkey: + authproto = "none" + if not privkey: + privproto = "none" + + auth_data = UsmUserData( + username, + authKey=authkey or None, + privKey=privkey or None, + authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), + privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), + ) + else: + auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) + + request_args = await async_create_request_cmd_args( + hass, auth_data, UdpTransportTarget((host, port)), baseoid + ) + async_add_entities( [ SnmpSwitch( name, host, port, - community, baseoid, command_oid, - version, - username, - authkey, - authproto, - privkey, - privproto, payload_on, payload_off, command_payload_on, command_payload_off, vartype, + request_args, ) ], True, @@ -180,21 +191,15 @@ class SnmpSwitch(SwitchEntity): name, host, port, - community, baseoid, commandoid, - version, - username, - authkey, - authproto, - privkey, - privproto, payload_on, payload_off, command_payload_on, command_payload_off, vartype, - ): + request_args, + ) -> None: """Initialize the switch.""" self._name = name @@ -206,35 +211,11 @@ class SnmpSwitch(SwitchEntity): self._command_payload_on = command_payload_on or payload_on self._command_payload_off = command_payload_off or payload_off - self._state = None + self._state: bool | None = None self._payload_on = payload_on self._payload_off = payload_off - - if version == "3": - if not authkey: - authproto = "none" - if not privkey: - privproto = "none" - - self._request_args = [ - SnmpEngine(), - UsmUserData( - username, - authKey=authkey or None, - privKey=privkey or None, - authProtocol=getattr(hlapi, MAP_AUTH_PROTOCOLS[authproto]), - privProtocol=getattr(hlapi, MAP_PRIV_PROTOCOLS[privproto]), - ), - UdpTransportTarget((host, port)), - ContextData(), - ] - else: - self._request_args = [ - SnmpEngine(), - CommunityData(community, mpModel=SNMP_VERSIONS[version]), - UdpTransportTarget((host, port)), - ContextData(), - ] + self._target = UdpTransportTarget((host, port)) + self._request_args: RequestArgsType = request_args async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" @@ -259,9 +240,7 @@ class SnmpSwitch(SwitchEntity): async def async_update(self) -> None: """Update the state.""" - get_result = await getCmd( - *self._request_args, ObjectType(ObjectIdentity(self._baseoid)) - ) + get_result = await getCmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication: @@ -296,6 +275,4 @@ class SnmpSwitch(SwitchEntity): return self._state async def _set(self, value): - await setCmd( - *self._request_args, ObjectType(ObjectIdentity(self._commandoid), value) - ) + await setCmd(*self._request_args, value) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py new file mode 100644 index 00000000000..23adbdf0b90 --- /dev/null +++ b/homeassistant/components/snmp/util.py @@ -0,0 +1,76 @@ +"""Support for displaying collected data over SNMP.""" + +from __future__ import annotations + +import logging + +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, +) +from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor +from pysnmp.smi.builder import MibBuilder + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.singleton import singleton + +DATA_SNMP_ENGINE = "snmp_engine" + +_LOGGER = logging.getLogger(__name__) + +type RequestArgsType = tuple[ + SnmpEngine, + UsmUserData | CommunityData, + UdpTransportTarget | Udp6TransportTarget, + ContextData, + ObjectType, +] + + +async def async_create_request_cmd_args( + hass: HomeAssistant, + auth_data: UsmUserData | CommunityData, + target: UdpTransportTarget | Udp6TransportTarget, + object_id: str, +) -> RequestArgsType: + """Create request arguments.""" + return ( + await async_get_snmp_engine(hass), + auth_data, + target, + ContextData(), + ObjectType(ObjectIdentity(object_id)), + ) + + +@singleton(DATA_SNMP_ENGINE) +async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: + """Get the SNMP engine.""" + engine = await hass.async_add_executor_job(_get_snmp_engine) + + @callback + def _async_shutdown_listener(ev: Event) -> None: + _LOGGER.debug("Unconfiguring SNMP engine") + lcd.unconfigure(engine, None) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener) + return engine + + +def _get_snmp_engine() -> SnmpEngine: + """Return a cached instance of SnmpEngine.""" + engine = SnmpEngine() + mib_controller = vbProcessor.getMibViewController(engine) + # Actually load the MIBs from disk so we do + # not do it in the event loop + builder: MibBuilder = mib_controller.mibBuilder + if "PYSNMP-MIB" not in builder.mibSymbols: + builder.loadModules() + return engine diff --git a/tests/components/snmp/test_init.py b/tests/components/snmp/test_init.py new file mode 100644 index 00000000000..0aa97dcc475 --- /dev/null +++ b/tests/components/snmp/test_init.py @@ -0,0 +1,22 @@ +"""SNMP tests.""" + +from unittest.mock import patch + +from pysnmp.hlapi.asyncio import SnmpEngine +from pysnmp.hlapi.asyncio.cmdgen import lcd + +from homeassistant.components import snmp +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + + +async def test_async_get_snmp_engine(hass: HomeAssistant) -> None: + """Test async_get_snmp_engine.""" + engine = await snmp.async_get_snmp_engine(hass) + assert isinstance(engine, SnmpEngine) + engine2 = await snmp.async_get_snmp_engine(hass) + assert engine is engine2 + with patch.object(lcd, "unconfigure") as mock_unconfigure: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert mock_unconfigure.called From 267228cae0307fa6c1b8d56daa81a0352daf44b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 31 May 2024 09:51:38 -0500 Subject: [PATCH 049/234] Fix openweathermap config entry migration (#118526) * Fix openweathermap config entry migration The options keys were accidentally migrated to data so they could no longer be changed in the options flow * more fixes * adjust * reduce * fix * adjust --- .../components/openweathermap/__init__.py | 22 +++++++++---------- .../components/openweathermap/config_flow.py | 5 +++-- .../components/openweathermap/const.py | 2 +- .../components/openweathermap/utils.py | 20 +++++++++++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 44c5179f227..7aea6aafe20 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass import logging -from typing import Any from pyopenweathermap import OWMClient @@ -22,6 +21,7 @@ from homeassistant.core import HomeAssistant from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS from .coordinator import WeatherUpdateCoordinator from .repairs import async_create_issue, async_delete_issue +from .utils import build_data_and_options _LOGGER = logging.getLogger(__name__) @@ -44,8 +44,8 @@ async def async_setup_entry( api_key = entry.data[CONF_API_KEY] latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) - language = _get_config_value(entry, CONF_LANGUAGE) - mode = _get_config_value(entry, CONF_MODE) + language = entry.options[CONF_LANGUAGE] + mode = entry.options[CONF_MODE] if mode == OWM_MODE_V25: async_create_issue(hass, entry.entry_id) @@ -77,10 +77,14 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version) - if version < 4: - new_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + if version < 5: + combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25} + new_data, new_options = build_data_and_options(combined_data) config_entries.async_update_entry( - entry, data=new_data, options={}, version=CONFIG_FLOW_VERSION + entry, + data=new_data, + options=new_options, + version=CONFIG_FLOW_VERSION, ) _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) @@ -98,9 +102,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def _get_config_value(config_entry: ConfigEntry, key: str) -> Any: - if config_entry.options and key in config_entry.options: - return config_entry.options[key] - return config_entry.data[key] diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 3090af94979..5fe06ea2dcd 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -30,7 +30,7 @@ from .const import ( LANGUAGES, OWM_MODES, ) -from .utils import validate_api_key +from .utils import build_data_and_options, validate_api_key class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): @@ -64,8 +64,9 @@ class OpenWeatherMapConfigFlow(ConfigFlow, domain=DOMAIN): ) if not errors: + data, options = build_data_and_options(user_input) return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=user_input[CONF_NAME], data=data, options=options ) schema = vol.Schema( diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index c074640ebc7..456ec05b038 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -25,7 +25,7 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONFIG_FLOW_VERSION = 4 +CONFIG_FLOW_VERSION = 5 ATTR_API_PRECIPITATION = "precipitation" ATTR_API_PRECIPITATION_KIND = "precipitation_kind" ATTR_API_DATETIME = "datetime" diff --git a/homeassistant/components/openweathermap/utils.py b/homeassistant/components/openweathermap/utils.py index cbdd1eab815..7f2391b21a1 100644 --- a/homeassistant/components/openweathermap/utils.py +++ b/homeassistant/components/openweathermap/utils.py @@ -1,7 +1,15 @@ """Util functions for OpenWeatherMap.""" +from typing import Any + from pyopenweathermap import OWMClient, RequestError +from homeassistant.const import CONF_LANGUAGE, CONF_MODE + +from .const import DEFAULT_LANGUAGE, DEFAULT_OWM_MODE + +OPTION_DEFAULTS = {CONF_LANGUAGE: DEFAULT_LANGUAGE, CONF_MODE: DEFAULT_OWM_MODE} + async def validate_api_key(api_key, mode): """Validate API key.""" @@ -18,3 +26,15 @@ async def validate_api_key(api_key, mode): errors["base"] = "invalid_api_key" return errors, description_placeholders + + +def build_data_and_options( + combined_data: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any]]: + """Split combined data and options.""" + data = {k: v for k, v in combined_data.items() if k not in OPTION_DEFAULTS} + options = { + option: combined_data.get(option, default) + for option, default in OPTION_DEFAULTS.items() + } + return (data, options) From a2cdb349f43d31750362fef24555cf87a05defe9 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Fri, 31 May 2024 14:45:52 +0200 Subject: [PATCH 050/234] Fix telegram doing blocking I/O in the event loop (#118531) --- homeassistant/components/telegram_bot/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7a056665ed4..df5bebb47d4 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -284,6 +284,12 @@ SERVICE_MAP = { } +def _read_file_as_bytesio(file_path: str) -> io.BytesIO: + """Read a file and return it as a BytesIO object.""" + with open(file_path, "rb") as file: + return io.BytesIO(file.read()) + + async def load_data( hass, url=None, @@ -342,7 +348,9 @@ async def load_data( ) elif filepath is not None: if hass.config.is_allowed_path(filepath): - return open(filepath, "rb") + return await hass.async_add_executor_job( + _read_file_as_bytesio, filepath + ) _LOGGER.warning("'%s' are not secure to load data from!", filepath) else: From a59c890779e5fcea3ff2c28533aa775592dd7065 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 31 May 2024 22:54:40 +1000 Subject: [PATCH 051/234] Fix off_grid_vehicle_charging_reserve_percent in Teselemetry (#118532) --- homeassistant/components/teslemetry/number.py | 2 +- homeassistant/components/teslemetry/strings.json | 2 +- tests/components/teslemetry/snapshots/test_number.ambr | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index 7551529006b..592c20c3e4a 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -82,7 +82,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = requires="components_battery", ), TeslemetryNumberBatteryEntityDescription( - key="off_grid_vehicle_charging_reserve", + key="off_grid_vehicle_charging_reserve_percent", func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), requires="components_off_grid_vehicle_charging_reserve_supported", ), diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 98b1f7f1932..b1b794404f4 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -254,7 +254,7 @@ "charge_state_charge_limit_soc": { "name": "Charge limit" }, - "off_grid_vehicle_charging_reserve": { + "off_grid_vehicle_charging_reserve_percent": { "name": "Off grid reserve" } }, diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr index 7ead67a1e95..f33b5e15d30 100644 --- a/tests/components/teslemetry/snapshots/test_number.ambr +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -90,8 +90,8 @@ 'platform': 'teslemetry', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'off_grid_vehicle_charging_reserve', - 'unique_id': '123456-off_grid_vehicle_charging_reserve', + 'translation_key': 'off_grid_vehicle_charging_reserve_percent', + 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', 'unit_of_measurement': '%', }) # --- From 4998fe5e6d4cc88a67a098d236eb99319e9eafaf Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 31 May 2024 17:16:39 +0200 Subject: [PATCH 052/234] Migrate openai_conversation to `entry.runtime_data` (#118535) * switch to entry.runtime_data * check for missing config entry * Update homeassistant/components/openai_conversation/__init__.py --------- Co-authored-by: Paulus Schoutsen --- .../openai_conversation/__init__.py | 37 ++++++++++++++----- .../openai_conversation/conversation.py | 8 ++-- .../openai_conversation/strings.json | 5 +++ .../openai_conversation/test_init.py | 24 +++++++++++- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 2a91f1b1b38..0ba7b53795b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal, cast + import openai import voluptuous as vol @@ -13,7 +15,11 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + ServiceValidationError, +) from homeassistant.helpers import ( config_validation as cv, issue_registry as ir, @@ -27,13 +33,25 @@ SERVICE_GENERATE_IMAGE = "generate_image" PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" - client = hass.data[DOMAIN][call.data["config_entry"]] + entry_id = call.data["config_entry"] + entry = hass.config_entries.async_get_entry(entry_id) + + if entry is None or entry.domain != DOMAIN: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={"config_entry": entry_id}, + ) + + client: openai.AsyncClient = entry.runtime_data if call.data["size"] in ("256", "512", "1024"): ir.async_create_issue( @@ -51,6 +69,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: size = call.data["size"] + size = cast( + Literal["256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"], + size, + ) # size is selector, so no need to check further + try: response = await client.images.generate( model="dall-e-3", @@ -90,7 +113,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) try: @@ -101,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except openai.OpenAIError as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -110,8 +133,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload OpenAI.""" - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False - - hass.data[DOMAIN].pop(entry.entry_id) - return True + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 8de146e0851..29228ba8e3b 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -22,7 +22,6 @@ from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation from homeassistant.components.conversation import trace -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError @@ -30,6 +29,7 @@ from homeassistant.helpers import device_registry as dr, intent, llm, template from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import ulid +from . import OpenAIConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, @@ -50,7 +50,7 @@ MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenAIConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up conversation entities.""" @@ -74,7 +74,7 @@ class OpenAIConversationEntity( _attr_has_entity_name = True _attr_name = None - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OpenAIConfigEntry) -> None: """Initialize the agent.""" self.entry = entry self.history: dict[str, list[ChatCompletionMessageParam]] = {} @@ -203,7 +203,7 @@ class OpenAIConversationEntity( trace.ConversationTraceEventType.AGENT_DETAIL, {"messages": messages} ) - client: openai.AsyncClient = self.hass.data[DOMAIN][self.entry.entry_id] + client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 1e93c60b6a9..c5d42eb9521 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -60,6 +60,11 @@ } } }, + "exceptions": { + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + } + }, "issues": { "image_size_deprecated_format": { "title": "Deprecated size format for image generation service", diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index f03013556c7..c9431aa1083 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -14,7 +14,7 @@ from openai.types.images_response import ImagesResponse import pytest from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -160,6 +160,28 @@ async def test_generate_image_service_error( ) +async def test_invalid_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Assert exception when invalid config entry is provided.""" + service_data = { + "prompt": "Picture of a dog", + "config_entry": "invalid_entry", + } + with pytest.raises( + ServiceValidationError, match="Invalid config entry provided. Got invalid_entry" + ): + await hass.services.async_call( + "openai_conversation", + "generate_image", + service_data, + blocking=True, + return_response=True, + ) + + @pytest.mark.parametrize( ("side_effect", "error"), [ From 9b6377906312eb3e96a1687ff6ce1f6bcb560c77 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 31 May 2024 14:11:59 +0200 Subject: [PATCH 053/234] Fix typo in OWM strings (#118538) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/openweathermap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 916e1e0a713..46b5feab75c 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -38,7 +38,7 @@ "step": { "migrate": { "title": "OpenWeatherMap API V2.5 deprecated", - "description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integration to mode v3.0.\n\nBefore the migration, you must have active subscription (be aware subscripiton activation take up to 2h). After your subscription is activated click **Submit** to migrate the integration to API V3.0. Read documentation for more information." + "description": "OWM API v2.5 will be closed in June 2024.\nYou need to migrate all your OpenWeatherMap integrations to v3.0.\n\nBefore the migration, you must have an active subscription (be aware that subscription activation can take up to 2h). After your subscription is activated, select **Submit** to migrate the integration to API V3.0. Read the documentation for more information." } }, "error": { From 3f6df28ef38cb5e5c886d555600dc2a7064fe2b0 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Fri, 31 May 2024 13:35:40 +0300 Subject: [PATCH 054/234] Fix YAML deprecation breaking version in jewish calendar and media extractor (#118546) * Fix YAML deprecation breaking version * Update * fix media extractor deprecation as well * Add issue_domain --- homeassistant/components/jewish_calendar/__init__.py | 3 ++- homeassistant/components/media_extractor/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 7c4c0b7f634..d4edcadf6f7 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -96,7 +96,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", is_fixable=False, - breaks_in_ha_version="2024.10.0", + issue_domain=DOMAIN, + breaks_in_ha_version="2024.12.0", severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", translation_placeholders={ diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 479cdf90aaf..b8bb5f98cd0 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.11.0", + breaks_in_ha_version="2024.12.0", is_fixable=False, issue_domain=DOMAIN, severity=IssueSeverity.WARNING, From e401a0da7f2bb6779bba8e062af10ec9cfc0fffc Mon Sep 17 00:00:00 2001 From: Michael Chisholm Date: Sat, 1 Jun 2024 00:52:19 +1000 Subject: [PATCH 055/234] Fix KeyError in dlna_dmr SSDP config flow when checking existing config entries (#118549) Fix KeyError checking existing dlna_dmr config entries --- homeassistant/components/dlna_dmr/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 7d9efc4096c..6b551f0e999 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -149,7 +149,7 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): # case the device doesn't have a static and unique UDN (breaking the # UPnP spec). for entry in self._async_current_entries(include_ignore=True): - if self._location == entry.data[CONF_URL]: + if self._location == entry.data.get(CONF_URL): return self.async_abort(reason="already_configured") if self._mac and self._mac == entry.data.get(CONF_MAC): return self.async_abort(reason="already_configured") From d823e5665959017a70a4a845c1b06be605e47cd1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 31 May 2024 14:52:43 +0200 Subject: [PATCH 056/234] In Brother integration use SnmpEngine from SNMP integration (#118554) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> Co-authored-by: J. Nick Koston --- homeassistant/components/brother/__init__.py | 22 +++---------- .../components/brother/config_flow.py | 6 ++-- homeassistant/components/brother/const.py | 2 -- .../components/brother/manifest.json | 1 + homeassistant/components/brother/utils.py | 33 ------------------- tests/components/brother/test_init.py | 24 ++------------ 6 files changed, 10 insertions(+), 78 deletions(-) delete mode 100644 homeassistant/components/brother/utils.py diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 68255d66566..e828d35f9c7 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -3,16 +3,14 @@ from __future__ import annotations from brother import Brother, SnmpError -from pysnmp.hlapi.asyncio.cmdgen import lcd -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.components.snmp import async_get_snmp_engine +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN, SNMP_ENGINE from .coordinator import BrotherDataUpdateCoordinator -from .utils import get_snmp_engine PLATFORMS = [Platform.SENSOR] @@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] - snmp_engine = get_snmp_engine(hass) + snmp_engine = await async_get_snmp_engine(hass) try: brother = await Brother.create( host, printer_type=printer_type, snmp_engine=snmp_engine @@ -44,16 +42,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - # We only want to remove the SNMP engine when unloading the last config entry - if unload_ok and len(loaded_entries) == 1: - lcd.unconfigure(hass.data[SNMP_ENGINE], None) - hass.data.pop(SNMP_ENGINE) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index ca2f1ae5a39..2b711186fff 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -8,13 +8,13 @@ from brother import Brother, SnmpError, UnsupportedModelError import voluptuous as vol from homeassistant.components import zeroconf +from homeassistant.components.snmp import async_get_snmp_engine from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.exceptions import HomeAssistantError from homeassistant.util.network import is_host_valid from .const import DOMAIN, PRINTER_TYPES -from .utils import get_snmp_engine DATA_SCHEMA = vol.Schema( { @@ -45,7 +45,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): if not is_host_valid(user_input[CONF_HOST]): raise InvalidHost - snmp_engine = get_snmp_engine(self.hass) + snmp_engine = await async_get_snmp_engine(self.hass) brother = await Brother.create( user_input[CONF_HOST], snmp_engine=snmp_engine @@ -79,7 +79,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN): # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) - snmp_engine = get_snmp_engine(self.hass) + snmp_engine = await async_get_snmp_engine(self.hass) model = discovery_info.properties.get("product") try: diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 1b949e1fa52..c0ae7cf60b0 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -9,6 +9,4 @@ DOMAIN: Final = "brother" PRINTER_TYPES: Final = ["laser", "ink"] -SNMP_ENGINE: Final = "snmp_engine" - UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 3bbaf40f686..6d4912db4cb 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -1,6 +1,7 @@ { "domain": "brother", "name": "Brother Printer", + "after_dependencies": ["snmp"], "codeowners": ["@bieniu"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/brother", diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py deleted file mode 100644 index 0d11f7d2e82..00000000000 --- a/homeassistant/components/brother/utils.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Brother helpers functions.""" - -from __future__ import annotations - -import logging - -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio.cmdgen import lcd - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import singleton - -from .const import SNMP_ENGINE - -_LOGGER = logging.getLogger(__name__) - - -@singleton.singleton(SNMP_ENGINE) -def get_snmp_engine(hass: HomeAssistant) -> hlapi.SnmpEngine: - """Get SNMP engine.""" - _LOGGER.debug("Creating SNMP engine") - snmp_engine = hlapi.SnmpEngine() - - @callback - def shutdown_listener(ev: Event) -> None: - if hass.data.get(SNMP_ENGINE): - _LOGGER.debug("Unconfiguring SNMP engine") - lcd.unconfigure(hass.data[SNMP_ENGINE], None) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) - - return snmp_engine diff --git a/tests/components/brother/test_init.py b/tests/components/brother/test_init.py index 2b366348b03..1a2c6bf23f2 100644 --- a/tests/components/brother/test_init.py +++ b/tests/components/brother/test_init.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.brother.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from . import init_integration @@ -64,27 +63,8 @@ async def test_unload_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert mock_config_entry.state is ConfigEntryState.LOADED - with patch("homeassistant.components.brother.lcd.unconfigure") as mock_unconfigure: - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_unconfigure.called + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) - - -async def test_unconfigure_snmp_engine_on_ha_stop( - hass: HomeAssistant, - mock_brother_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that the SNMP engine is unconfigured when HA stops.""" - await init_integration(hass, mock_config_entry) - - with patch( - "homeassistant.components.brother.utils.lcd.unconfigure" - ) as mock_unconfigure: - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - await hass.async_block_till_done() - - assert mock_unconfigure.called From b459559c8b94de9bcb3cee3f8e7e4631f9f12ed5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 May 2024 21:31:44 +0200 Subject: [PATCH 057/234] Add ability to replace connections in DeviceRegistry (#118555) * Add ability to replace connections in DeviceRegistry * Add more tests * Improve coverage * Apply suggestion Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/device_registry.py | 8 ++ tests/helpers/test_device_registry.py | 110 ++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 75fcda18eac..1f147a1884d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -798,6 +798,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): model: str | None | UndefinedType = UNDEFINED, name_by_user: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, serial_number: str | None | UndefinedType = UNDEFINED, @@ -813,6 +814,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): config_entries = old.config_entries + if merge_connections is not UNDEFINED and new_connections is not UNDEFINED: + raise HomeAssistantError("Cannot define both merge_connections and new_connections") + if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED: raise HomeAssistantError @@ -873,6 +877,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): new_values[attr_name] = old_value | setvalue old_values[attr_name] = old_value + if new_connections is not UNDEFINED: + new_values["connections"] = _normalize_connections(new_connections) + old_values["connections"] = old.connections + if new_identifiers is not UNDEFINED: new_values["identifiers"] = new_identifiers old_values["identifiers"] = old.identifiers diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index e40b3ca0356..da99f176a3c 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1257,6 +1257,7 @@ async def test_update( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, identifiers={("hue", "456"), ("bla", "123")}, ) + new_connections = {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} new_identifiers = {("hue", "654"), ("bla", "321")} assert not entry.area_id assert not entry.labels @@ -1275,6 +1276,7 @@ async def test_update( model="Test Model", name_by_user="Test Friendly Name", name="name", + new_connections=new_connections, new_identifiers=new_identifiers, serial_number="serial_no", suggested_area="suggested_area", @@ -1288,7 +1290,7 @@ async def test_update( area_id="12345A", config_entries={mock_config_entry.entry_id}, configuration_url="https://example.com/config", - connections={("mac", "12:34:56:ab:cd:ef")}, + connections={("mac", "65:43:21:fe:dc:ba")}, disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", @@ -1319,6 +1321,12 @@ async def test_update( device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} ) + is None + ) + assert ( + device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")} + ) == updated_entry ) @@ -1336,6 +1344,7 @@ async def test_update( "device_id": entry.id, "changes": { "area_id": None, + "connections": {("mac", "12:34:56:ab:cd:ef")}, "configuration_url": None, "disabled_by": None, "entry_type": None, @@ -1352,6 +1361,105 @@ async def test_update( "via_device_id": None, }, } + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_connections=new_connections, + new_connections=new_connections, + ) + + with pytest.raises(HomeAssistantError): + device_registry.async_update_device( + entry.id, + merge_identifiers=new_identifiers, + new_identifiers=new_identifiers, + ) + + +@pytest.mark.parametrize( + ("initial_connections", "new_connections", "updated_connections"), + [ + ( # No connection -> single connection + None, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + ), + ( # No connection -> double connection + None, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, + ), + ( # single connection -> no connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + set(), + set(), + ), + ( # single connection -> single connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + {(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + ), + ( # single connection -> double connection + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef"), + }, + ), + ( # Double connection -> None + { + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + }, + set(), + set(), + ), + ( # Double connection -> single connection + { + (dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA"), + (dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF"), + }, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:FE:DC:BA")}, + {(dr.CONNECTION_NETWORK_MAC, "65:43:21:fe:dc:ba")}, + ), + ], +) +async def test_update_connection( + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + initial_connections: set[tuple[str, str]] | None, + new_connections: set[tuple[str, str]] | None, + updated_connections: set[tuple[str, str]] | None, +) -> None: + """Verify that we can update some attributes of a device.""" + entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections=initial_connections, + identifiers={("hue", "456"), ("bla", "123")}, + ) + + with patch.object(device_registry, "async_schedule_save") as mock_save: + updated_entry = device_registry.async_update_device( + entry.id, + new_connections=new_connections, + ) + + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.connections == updated_connections + assert ( + device_registry.async_get_device(identifiers={("bla", "123")}) == updated_entry + ) async def test_update_remove_config_entries( From c01c155037408bb208dba94ffcf1b111736fea90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2024 13:28:52 -0400 Subject: [PATCH 058/234] Fix openAI tool calls (#118577) --- .../components/openai_conversation/conversation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 29228ba8e3b..7cf4d18cce5 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -247,11 +247,13 @@ class OpenAIConversationEntity( ) for tool_call in message.tool_calls ] - return ChatCompletionAssistantMessageParam( + param = ChatCompletionAssistantMessageParam( role=message.role, - tool_calls=tool_calls, content=message.content, ) + if tool_calls: + param["tool_calls"] = tool_calls + return param messages.append(message_convert(response)) tool_calls = response.tool_calls From b39d7b39e1b14fbf4521462c16063a6fc1089a5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 31 May 2024 19:34:58 +0000 Subject: [PATCH 059/234] Bump version to 2024.6.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 3e4b9f7b873..a4f2227f676 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 998f581700c..2dba4928b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b3" +version = "2024.6.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c52fabcf77b2efcb21ec13c550fc7bec5e36d16f Mon Sep 17 00:00:00 2001 From: Thomas Ytterdal Date: Sat, 1 Jun 2024 11:27:03 +0200 Subject: [PATCH 060/234] Ignore myuplink sensors without a description that provide non-numeric values (#115525) Ignore sensors without a description that provide non-numeric values Co-authored-by: Jan-Philipp Benecke --- homeassistant/components/myuplink/sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 6cde6b6b071..45a4590a843 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -160,6 +160,11 @@ async def async_setup_entry( if find_matching_platform(device_point) == Platform.SENSOR: description = get_description(device_point) entity_class = MyUplinkDevicePointSensor + # Ignore sensors without a description that provide non-numeric values + if description is None and not isinstance( + device_point.value, (int, float) + ): + continue if ( description is not None and description.device_class == SensorDeviceClass.ENUM From bfc1c62a49a6e11d51bd795c1d094308fdaacde8 Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Sun, 2 Jun 2024 15:41:44 +0200 Subject: [PATCH 061/234] Bump pyads to 3.4.0 (#116934) Co-authored-by: J. Nick Koston --- homeassistant/components/ads/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json index e5adb593755..0a2cd118a19 100644 --- a/homeassistant/components/ads/manifest.json +++ b/homeassistant/components/ads/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/ads", "iot_class": "local_push", "loggers": ["pyads"], - "requirements": ["pyads==3.2.2"] + "requirements": ["pyads==3.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86e0cf509d2..3cdb44c99d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1694,7 +1694,7 @@ pyW215==0.7.0 pyW800rf32==0.4 # homeassistant.components.ads -pyads==3.2.2 +pyads==3.4.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From 4b06c5d2fb6b383fa5acdc3a3e7d5c0ea785b19b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 31 May 2024 23:07:51 +0200 Subject: [PATCH 062/234] Update device connections in samsungtv (#118556) --- homeassistant/components/samsungtv/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index fbae0d5552a..f49ae276665 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -301,9 +301,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> for device in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ): - for connection in device.connections: - if connection == (dr.CONNECTION_NETWORK_MAC, "none"): - dev_reg.async_remove_device(device.id) + new_connections = device.connections.copy() + new_connections.discard((dr.CONNECTION_NETWORK_MAC, "none")) + if new_connections != device.connections: + dev_reg.async_update_device( + device.id, new_connections=new_connections + ) minor_version = 2 hass.config_entries.async_update_entry(config_entry, minor_version=2) From 6ba9e7d5fd53a8f059e30dde694c7a0d1224e8bd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 31 May 2024 22:22:48 +0200 Subject: [PATCH 063/234] Run ruff format for device registry (#118582) --- homeassistant/helpers/device_registry.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 1f147a1884d..cb336d1455b 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -815,7 +815,9 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): config_entries = old.config_entries if merge_connections is not UNDEFINED and new_connections is not UNDEFINED: - raise HomeAssistantError("Cannot define both merge_connections and new_connections") + raise HomeAssistantError( + "Cannot define both merge_connections and new_connections" + ) if merge_identifiers is not UNDEFINED and new_identifiers is not UNDEFINED: raise HomeAssistantError From 1a588760b9882d80d280518160a216a5f9bc7f25 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 1 Jun 2024 23:51:17 +0200 Subject: [PATCH 064/234] Avoid future exception during setup of Synology DSM (#118583) * avoid future exception during integration setup * clear future flag during setup * always clear the flag (with comment) --- homeassistant/components/synology_dsm/common.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 98a57319f93..e2023aa91a1 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -104,6 +104,11 @@ class SynoApi: except BaseException as err: if not self._login_future.done(): self._login_future.set_exception(err) + with suppress(BaseException): + # Clear the flag as its normal that nothing + # will wait for this future to be resolved + # if there are no concurrent login attempts + await self._login_future raise finally: self._login_future = None From 4df3d43e4595c899c48848baf4864a7f59a41af0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 31 May 2024 17:21:18 -0700 Subject: [PATCH 065/234] Stop instructing LLM to not pass the domain as a list (#118590) --- homeassistant/helpers/llm.py | 1 - tests/helpers/test_llm.py | 1 - 2 files changed, 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index dd380795227..fc00c4ebac6 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -254,7 +254,6 @@ class AssistAPI(API): prompt = [ ( "When controlling Home Assistant always call the intent tools. " - "Do not pass the domain to the intent tools as a list. " "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9c07295dec7..9ad58441277 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -421,7 +421,6 @@ async def test_assist_api_prompt( ) first_part_prompt = ( "When controlling Home Assistant always call the intent tools. " - "Do not pass the domain to the intent tools as a list. " "Use HassTurnOn to lock and HassTurnOff to unlock a lock. " "When controlling a device, prefer passing just its name and its domain " "(what comes before the dot in its entity id). " From 20159d027738a534fee0da9a7750d51935955676 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 1 Jun 2024 00:21:37 -0400 Subject: [PATCH 066/234] Add base prompt for LLMs (#118592) --- .../conversation.py | 3 ++- .../openai_conversation/conversation.py | 3 ++- homeassistant/helpers/llm.py | 7 +++++-- .../snapshots/test_conversation.ambr | 18 ++++++------------ 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 12b1e44b3df..3e289fbe16d 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -245,7 +245,8 @@ class GoogleGenerativeAIConversationEntity( prompt = "\n".join( ( template.Template( - self.entry.options.get( + llm.BASE_PROMPT + + self.entry.options.get( CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT ), self.hass, diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 7cf4d18cce5..306e4134b9e 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -167,7 +167,8 @@ class OpenAIConversationEntity( prompt = "\n".join( ( template.Template( - options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), + llm.BASE_PROMPT + + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), self.hass, ).async_render( { diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index fc00c4ebac6..ec1bfb7dbc4 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -34,10 +34,13 @@ from .singleton import singleton LLM_API_ASSIST = "assist" +BASE_PROMPT = ( + 'Current time is {{ now().strftime("%X") }}. ' + 'Today\'s date is {{ now().strftime("%x") }}.\n' +) + DEFAULT_INSTRUCTIONS_PROMPT = """You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. -The current time is {{ now().strftime("%X") }}. -Today's date is {{ now().strftime("%x") }}. """ diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 40ff556af1c..587586cff17 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -30,10 +30,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. ''', 'role': 'user', @@ -82,10 +81,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. ''', 'role': 'user', @@ -146,10 +144,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. ''', 'role': 'user', @@ -202,10 +199,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. ''', 'role': 'user', @@ -258,10 +254,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. ''', 'role': 'user', @@ -314,10 +309,9 @@ 'history': list([ dict({ 'parts': ''' + Current time is 05:00:00. Today's date is 05/24/24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. - The current time is 05:00:00. - Today's date is 05/24/24. ''', 'role': 'user', From 1afbfd687f8adbf031a82eeb5595aadb3aae002c Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 31 May 2024 21:55:52 -0700 Subject: [PATCH 067/234] Strip Google AI text responses (#118593) * Strip Google AI test responses * strip each part --- .../google_generative_ai_conversation/conversation.py | 2 +- .../google_generative_ai_conversation/test_conversation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 3e289fbe16d..2c0b37a1216 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -355,7 +355,7 @@ class GoogleGenerativeAIConversationEntity( chat_request = glm.Content(parts=tool_responses) intent_response.async_set_speech( - " ".join([part.text for part in chat_response.parts if part.text]) + " ".join([part.text.strip() for part in chat_response.parts if part.text]) ) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 13e7bd0c8fb..901216d262f 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -80,7 +80,7 @@ async def test_default_prompt( mock_chat.send_message_async.return_value = chat_response mock_part = MagicMock() mock_part.function_call = None - mock_part.text = "Hi there!" + mock_part.text = "Hi there!\n" chat_response.parts = [mock_part] result = await conversation.async_converse( hass, From 236b19c5b31a8f13cbcd2d14e5092e015ade711e Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 31 May 2024 21:57:14 -0700 Subject: [PATCH 068/234] Use gemini-1.5-flash-latest in google_generative_ai_conversation.generate_content (#118594) --- .../components/google_generative_ai_conversation/__init__.py | 3 +-- .../snapshots/test_init.ambr | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index b2723f82030..523198355d1 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -66,8 +66,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ) - model_name = "gemini-pro-vision" if image_filenames else "gemini-pro" - model = genai.GenerativeModel(model_name=model_name) + model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL) try: response = await model.generate_content_async(prompt_parts) diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index aba3f35eb19..f68f4c6bf14 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -6,7 +6,7 @@ tuple( ), dict({ - 'model_name': 'gemini-pro-vision', + 'model_name': 'models/gemini-1.5-flash-latest', }), ), tuple( @@ -32,7 +32,7 @@ tuple( ), dict({ - 'model_name': 'gemini-pro', + 'model_name': 'models/gemini-1.5-flash-latest', }), ), tuple( From 1d1af7ec112d819e593d9c71e938f141cbc18cb7 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sun, 2 Jun 2024 08:32:24 +0200 Subject: [PATCH 069/234] Fix telegram bot send_document (#118616) --- homeassistant/components/telegram_bot/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index df5bebb47d4..06c15da5f70 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -287,7 +287,9 @@ SERVICE_MAP = { def _read_file_as_bytesio(file_path: str) -> io.BytesIO: """Read a file and return it as a BytesIO object.""" with open(file_path, "rb") as file: - return io.BytesIO(file.read()) + data = io.BytesIO(file.read()) + data.name = file_path + return data async def load_data( From 9366a4e69b4f0d01cb8c4791738ac5505912d316 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 2 Jun 2024 05:36:25 -0500 Subject: [PATCH 070/234] Include a traceback for non-strict event loop blocking detection (#118620) --- homeassistant/helpers/frame.py | 8 ++++---- homeassistant/util/loop.py | 13 ++++++++----- tests/common.py | 2 ++ tests/helpers/test_frame.py | 6 +++--- tests/test_loader.py | 4 ++-- tests/util/test_loop.py | 11 +++++++++++ 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 3046b718489..e8ba6ba0c07 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -31,17 +31,17 @@ class IntegrationFrame: integration: str module: str | None relative_filename: str - _frame: FrameType + frame: FrameType @cached_property def line_number(self) -> int: """Return the line number of the frame.""" - return self._frame.f_lineno + return self.frame.f_lineno @cached_property def filename(self) -> str: """Return the filename of the frame.""" - return self._frame.f_code.co_filename + return self.frame.f_code.co_filename @cached_property def line(self) -> str: @@ -119,7 +119,7 @@ def get_integration_frame(exclude_integrations: set | None = None) -> Integratio integration=integration, module=found_module, relative_filename=found_frame.f_code.co_filename[index:], - _frame=found_frame, + frame=found_frame, ) diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index cba9f7c3900..64be00cfe35 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -7,6 +7,7 @@ import functools import linecache import logging import threading +import traceback from typing import Any from homeassistant.core import async_get_hass_or_none @@ -54,12 +55,14 @@ def raise_for_blocking_call( if not strict_core: _LOGGER.warning( "Detected blocking call to %s with args %s in %s, " - "line %s: %s inside the event loop", + "line %s: %s inside the event loop\n" + "Traceback (most recent call last):\n%s", func.__name__, mapped_args.get("args"), offender_filename, offender_lineno, offender_line, + "".join(traceback.format_stack(f=offender_frame)), ) return @@ -79,10 +82,9 @@ def raise_for_blocking_call( ) _LOGGER.warning( - ( - "Detected blocking call to %s inside the event loop by %sintegration '%s' " - "at %s, line %s: %s (offender: %s, line %s: %s), please %s" - ), + "Detected blocking call to %s inside the event loop by %sintegration '%s' " + "at %s, line %s: %s (offender: %s, line %s: %s), please %s\n" + "Traceback (most recent call last):\n%s", func.__name__, "custom " if integration_frame.custom_integration else "", integration_frame.integration, @@ -93,6 +95,7 @@ def raise_for_blocking_call( offender_lineno, offender_line, report_issue, + "".join(traceback.format_stack(f=integration_frame.frame)), ) if strict: diff --git a/tests/common.py b/tests/common.py index 6e7cf1b21f3..897a28fbffd 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1689,8 +1689,10 @@ def help_test_all(module: ModuleType) -> None: def extract_stack_to_frame(extract_stack: list[Mock]) -> FrameType: """Convert an extract stack to a frame list.""" stack = list(extract_stack) + _globals = globals() for frame in stack: frame.f_back = None + frame.f_globals = _globals frame.f_code.co_filename = frame.filename frame.f_lineno = int(frame.lineno) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 904bed965c8..e6251963d36 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -17,7 +17,7 @@ async def test_extract_frame_integration( integration_frame = frame.get_integration_frame() assert integration_frame == frame.IntegrationFrame( custom_integration=False, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="hue", module=None, relative_filename="homeassistant/components/hue/light.py", @@ -42,7 +42,7 @@ async def test_extract_frame_resolve_module( assert integration_frame == frame.IntegrationFrame( custom_integration=True, - _frame=ANY, + frame=ANY, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", @@ -98,7 +98,7 @@ async def test_extract_frame_integration_with_excluded_integration( assert integration_frame == frame.IntegrationFrame( custom_integration=False, - _frame=correct_frame, + frame=correct_frame, integration="mdns", module=None, relative_filename="homeassistant/components/mdns/light.py", diff --git a/tests/test_loader.py b/tests/test_loader.py index b2ca8cbd397..fa4a3a14cef 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1271,7 +1271,7 @@ async def test_hass_components_use_reported( ) integration_frame = frame.IntegrationFrame( custom_integration=True, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", @@ -1969,7 +1969,7 @@ async def test_hass_helpers_use_reported( """Test that use of hass.components is reported.""" integration_frame = frame.IntegrationFrame( custom_integration=True, - _frame=mock_integration_frame, + frame=mock_integration_frame, integration="test_integration_frame", module="custom_components.test_integration_frame", relative_filename="custom_components/test_integration_frame/__init__.py", diff --git a/tests/util/test_loop.py b/tests/util/test_loop.py index c3cfb3d0f06..506614d7631 100644 --- a/tests/util/test_loop.py +++ b/tests/util/test_loop.py @@ -27,6 +27,7 @@ async def test_raise_for_blocking_call_async_non_strict_core( """Test non_strict_core raise_for_blocking_call detects from event loop without integration context.""" haloop.raise_for_blocking_call(banned_function, strict_core=False) assert "Detected blocking call to banned_function" in caplog.text + assert "Traceback (most recent call last)" in caplog.text async def test_raise_for_blocking_call_async_integration( @@ -130,6 +131,11 @@ async def test_raise_for_blocking_call_async_integration_non_strict( "please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) + assert "Traceback (most recent call last)" in caplog.text + assert ( + 'File "/home/paulus/homeassistant/components/hue/light.py", line 23' + in caplog.text + ) async def test_raise_for_blocking_call_async_custom( @@ -182,6 +188,11 @@ async def test_raise_for_blocking_call_async_custom( "please create a bug report at https://github.com/home-assistant/core/issues?" "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text + assert "Traceback (most recent call last)" in caplog.text + assert ( + 'File "/home/paulus/config/custom_components/hue/light.py", line 23' + in caplog.text + ) async def test_raise_for_blocking_call_sync( From 3653a512885f75e48d7c819ab70e35932f4d7892 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 2 Jun 2024 21:25:05 +0200 Subject: [PATCH 071/234] Fix handling undecoded mqtt sensor payloads (#118633) --- homeassistant/components/mqtt/sensor.py | 24 ++++++++++------- tests/components/mqtt/test_sensor.py | 36 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 12de26b2358..043bc9a5c0e 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -237,28 +237,32 @@ class MqttSensor(MqttEntity, RestoreSensor): payload = msg.payload if payload is PayloadSentinel.DEFAULT: return - new_value = str(payload) + if not isinstance(payload, str): + _LOGGER.warning( + "Invalid undecoded state message '%s' received from '%s'", + payload, + msg.topic, + ) + return if self._numeric_state_expected: - if new_value == "": + if payload == "": _LOGGER.debug("Ignore empty state from '%s'", msg.topic) - elif new_value == PAYLOAD_NONE: + elif payload == PAYLOAD_NONE: self._attr_native_value = None else: - self._attr_native_value = new_value + self._attr_native_value = payload return if self.device_class in { None, SensorDeviceClass.ENUM, - } and not check_state_too_long(_LOGGER, new_value, self.entity_id, msg): - self._attr_native_value = new_value + } and not check_state_too_long(_LOGGER, payload, self.entity_id, msg): + self._attr_native_value = payload return try: - if (payload_datetime := dt_util.parse_datetime(new_value)) is None: + if (payload_datetime := dt_util.parse_datetime(payload)) is None: raise ValueError except ValueError: - _LOGGER.warning( - "Invalid state message '%s' from '%s'", msg.payload, msg.topic - ) + _LOGGER.warning("Invalid state message '%s' from '%s'", payload, msg.topic) self._attr_native_value = None return if self.device_class == SensorDeviceClass.DATE: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index b8270277161..bde85abf3fb 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -110,6 +110,42 @@ async def test_setting_sensor_value_via_mqtt_message( assert state.attributes.get("unit_of_measurement") == "fav unit" +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "unit_of_measurement": "%", + "device_class": "battery", + "encoding": "", + } + } + } + ], +) +async def test_handling_undecoded_sensor_value( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the setting of the value via MQTT.""" + await mqtt_mock_entry() + + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + + async_fire_mqtt_message(hass, "test-topic", b"88") + state = hass.states.get("sensor.test") + assert state.state == STATE_UNKNOWN + assert ( + "Invalid undecoded state message 'b'88'' received from 'test-topic'" + in caplog.text + ) + + @pytest.mark.parametrize( "hass_config", [ From 4d2dc9a40ec85edbb9e08dec090614f3f3a6f684 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 2 Jun 2024 20:15:35 +0200 Subject: [PATCH 072/234] Fix incorrect placeholder in SharkIQ (#118640) Update strings.json --- homeassistant/components/sharkiq/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json index c1648332975..63d4f6af48b 100644 --- a/homeassistant/components/sharkiq/strings.json +++ b/homeassistant/components/sharkiq/strings.json @@ -43,7 +43,7 @@ }, "exceptions": { "invalid_room": { - "message": "The room { room } is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." + "message": "The room {room} is unavailable to your vacuum. Make sure all rooms match the Shark App, including capitalization." } }, "services": { From 3c012c497b622ef84fac6999c9927484764a0da4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 2 Jun 2024 16:55:48 -0400 Subject: [PATCH 073/234] Bump ZHA dependencies (#118658) * Bump bellows to 0.39.0 * Do not create a backup if there is no active ZHA gateway object * Bump universal-silabs-flasher as well --- homeassistant/components/zha/backup.py | 8 +++++++- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/zha/test_backup.py | 9 ++++++++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/backup.py b/homeassistant/components/zha/backup.py index 25d5a83b6a4..e31ae09eeb6 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -13,7 +13,13 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.debug("Performing coordinator backup") - zha_gateway = get_zha_gateway(hass) + try: + zha_gateway = get_zha_gateway(hass) + except ValueError: + # If ZHA config is in `configuration.yaml` and ZHA is not set up, do nothing + _LOGGER.warning("No ZHA gateway exists, skipping coordinator backup") + return + await zha_gateway.application_controller.backups.create_backup(load_devices=True) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 1a01ca88fd5..8caf296674c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.4", + "bellows==0.39.0", "pyserial==3.5", "zha-quirks==0.0.116", "zigpy-deconz==0.23.1", @@ -29,7 +29,7 @@ "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", - "universal-silabs-flasher==0.0.18", + "universal-silabs-flasher==0.0.20", "pyserial-asyncio-fast==0.11" ], "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index 3cdb44c99d5..5bf92675f53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -547,7 +547,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.38.4 +bellows==0.39.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.3 @@ -2794,7 +2794,7 @@ unifi_ap==0.0.1 unifiled==0.11 # homeassistant.components.zha -universal-silabs-flasher==0.0.18 +universal-silabs-flasher==0.0.20 # homeassistant.components.upb upb-lib==0.5.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7591fd0a3c2..ee74a9a431d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.38.4 +bellows==0.39.0 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.3 @@ -2162,7 +2162,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.1.8 # homeassistant.components.zha -universal-silabs-flasher==0.0.18 +universal-silabs-flasher==0.0.20 # homeassistant.components.upb upb-lib==0.5.6 diff --git a/tests/components/zha/test_backup.py b/tests/components/zha/test_backup.py index 9cf88df1707..dc6c5dc29cb 100644 --- a/tests/components/zha/test_backup.py +++ b/tests/components/zha/test_backup.py @@ -1,6 +1,6 @@ """Unit tests for ZHA backup platform.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from zigpy.application import ControllerApplication @@ -22,6 +22,13 @@ async def test_pre_backup( ) +@patch("homeassistant.components.zha.backup.get_zha_gateway", side_effect=ValueError()) +async def test_pre_backup_no_gateway(hass: HomeAssistant, setup_zha) -> None: + """Test graceful backup failure when no gateway exists.""" + await setup_zha() + await async_pre_backup(hass) + + async def test_post_backup(hass: HomeAssistant, setup_zha) -> None: """Test no-op `async_post_backup`.""" await setup_zha() From 1708b60ecfcf4763b22e5e88e4a04d5e2fe2f690 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 3 Jun 2024 02:41:16 +0200 Subject: [PATCH 074/234] Fix entity state dispatching for Tag entities (#118662) --- homeassistant/components/tag/__init__.py | 4 ++-- tests/components/tag/__init__.py | 2 ++ tests/components/tag/test_init.py | 22 +++++++++++++++++----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index b7c9660ed93..afea86baa93 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -267,7 +267,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # When tags are changed or updated in storage async_dispatcher_send( hass, - SIGNAL_TAG_CHANGED, + f"{SIGNAL_TAG_CHANGED}-{updated_config[TAG_ID]}", updated_config.get(DEVICE_ID), updated_config.get(LAST_SCANNED), ) @@ -414,7 +414,7 @@ class TagEntity(Entity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TAG_CHANGED, + f"{SIGNAL_TAG_CHANGED}-{self._tag_id}", self.async_handle_event, ) ) diff --git a/tests/components/tag/__init__.py b/tests/components/tag/__init__.py index 66b23073d3e..5c701af5d0a 100644 --- a/tests/components/tag/__init__.py +++ b/tests/components/tag/__init__.py @@ -1,5 +1,7 @@ """Tests for the Tag integration.""" TEST_TAG_ID = "test tag id" +TEST_TAG_ID_2 = "test tag id 2" TEST_TAG_NAME = "test tag name" +TEST_TAG_NAME_2 = "test tag name 2" TEST_DEVICE_ID = "device id" diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 914719c8c1a..ff3cef873e7 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -13,7 +13,7 @@ from homeassistant.helpers import collection, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import TEST_DEVICE_ID, TEST_TAG_ID, TEST_TAG_NAME +from . import TEST_DEVICE_ID, TEST_TAG_ID, TEST_TAG_ID_2, TEST_TAG_NAME, TEST_TAG_NAME_2 from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -34,7 +34,11 @@ def storage_setup(hass: HomeAssistant, hass_storage): { "id": TEST_TAG_ID, "tag_id": TEST_TAG_ID, - } + }, + { + "id": TEST_TAG_ID_2, + "tag_id": TEST_TAG_ID_2, + }, ] }, } @@ -42,6 +46,7 @@ def storage_setup(hass: HomeAssistant, hass_storage): hass_storage[DOMAIN] = items entity_registry = er.async_get(hass) _create_entry(entity_registry, TEST_TAG_ID, TEST_TAG_NAME) + _create_entry(entity_registry, TEST_TAG_ID_2, TEST_TAG_NAME_2) config = {DOMAIN: {}} return await async_setup_component(hass, DOMAIN, config) @@ -131,7 +136,8 @@ async def test_ws_list( resp = await client.receive_json() assert resp["success"] assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID} + {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, ] @@ -175,7 +181,8 @@ async def test_tag_scanned( result = {item["id"]: item for item in resp["result"]} assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID} + {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, ] now = dt_util.utcnow() @@ -188,9 +195,10 @@ async def test_tag_scanned( result = {item["id"]: item for item in resp["result"]} - assert len(result) == 2 + assert len(result) == 3 assert resp["result"] == [ {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, { "device_id": "some_scanner", "id": "new tag", @@ -256,6 +264,10 @@ async def test_entity( "friendly_name": "test tag name", } + entity = hass.states.get("tag.test_tag_name_2") + assert entity + assert entity.state == STATE_UNKNOWN + async def test_entity_created_and_removed( caplog: pytest.LogCaptureFixture, From b5783e6f5cdbc30882b555e7e14f653e3e78f75c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2024 01:10:10 +0000 Subject: [PATCH 075/234] Bump version to 2024.6.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 a4f2227f676..842615d4fa6 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 2dba4928b77..675492a27c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b4" +version = "2024.6.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From aff5da5762216224ef642554b74e1214273f7f4d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 2 Jun 2024 10:42:42 +0200 Subject: [PATCH 076/234] Address late review comment in samsungtv (#118539) Address late comment in samsungtv --- homeassistant/components/samsungtv/bridge.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 0b8a5d4a268..059c6682857 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -325,6 +325,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): """Try to gather infos of this device.""" return None + def _notify_reauth_callback(self) -> None: + """Notify access denied callback.""" + if self._reauth_callback is not None: + self.hass.loop.call_soon_threadsafe(self._reauth_callback) + def _get_remote(self) -> Remote: """Create or return a remote control instance.""" if self._remote is None: From b436fe94ae726f9d824ff71d81d9d0db50399137 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 3 Jun 2024 13:29:20 -0400 Subject: [PATCH 077/234] Bump pydrawise to 2024.6.2 (#118608) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 8a0d52d550c..0426b8bf2cc 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.4.1"] + "requirements": ["pydrawise==2024.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bf92675f53..5670c22bb6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1794,7 +1794,7 @@ pydiscovergy==3.0.1 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.4.1 +pydrawise==2024.6.2 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee74a9a431d..e38f741af98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1405,7 +1405,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.1 # homeassistant.components.hydrawise -pydrawise==2024.4.1 +pydrawise==2024.6.2 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 84f9bb1d639963615f0f85fc60cc5684dd6612c1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2024 10:36:41 -0400 Subject: [PATCH 078/234] Automatically fill in slots based on LLM context (#118619) * Automatically fill in slots from LLM context * Add tests * Apply suggestions from code review Co-authored-by: Allen Porter --------- Co-authored-by: Allen Porter --- homeassistant/helpers/llm.py | 38 +++++++++++++++++++-- tests/helpers/test_llm.py | 65 +++++++++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index ec1bfb7dbc4..37233b0d407 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -181,14 +181,48 @@ class IntentTool(Tool): self.description = ( intent_handler.description or f"Execute Home Assistant {self.name} intent" ) - if slot_schema := intent_handler.slot_schema: - self.parameters = vol.Schema(slot_schema) + self.extra_slots = None + if not (slot_schema := intent_handler.slot_schema): + return + + slot_schema = {**slot_schema} + extra_slots = set() + + for field in ("preferred_area_id", "preferred_floor_id"): + if field in slot_schema: + extra_slots.add(field) + del slot_schema[field] + + self.parameters = vol.Schema(slot_schema) + if extra_slots: + self.extra_slots = extra_slots async def async_call( self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext ) -> JsonObjectType: """Handle the intent.""" slots = {key: {"value": val} for key, val in tool_input.tool_args.items()} + + if self.extra_slots and llm_context.device_id: + device_reg = dr.async_get(hass) + device = device_reg.async_get(llm_context.device_id) + + area: ar.AreaEntry | None = None + floor: fr.FloorEntry | None = None + if device: + area_reg = ar.async_get(hass) + if device.area_id and (area := area_reg.async_get_area(device.area_id)): + if area.floor_id: + floor_reg = fr.async_get(hass) + floor = floor_reg.async_get_floor(area.floor_id) + + for slot_name, slot_value in ( + ("preferred_area_id", area.id if area else None), + ("preferred_floor_id", floor.floor_id if floor else None), + ): + if slot_value and slot_name in self.extra_slots: + slots[slot_name] = {"value": slot_value} + intent_response = await intent.async_handle( hass=hass, platform=llm_context.platform, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 9ad58441277..6c9451bc843 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -77,7 +77,11 @@ async def test_call_tool_no_existing( async def test_assist_api( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Test Assist API.""" assert await async_setup_component(hass, "homeassistant", {}) @@ -97,11 +101,13 @@ async def test_assist_api( user_prompt="test_text", language="*", assistant="conversation", - device_id="test_device", + device_id=None, ) schema = { vol.Optional("area"): cv.string, vol.Optional("floor"): cv.string, + vol.Optional("preferred_area_id"): cv.string, + vol.Optional("preferred_floor_id"): cv.string, } class MyIntentHandler(intent.IntentHandler): @@ -131,7 +137,13 @@ async def test_assist_api( tool = api.tools[0] assert tool.name == "test_intent" assert tool.description == "Execute Home Assistant test_intent intent" - assert tool.parameters == vol.Schema(intent_handler.slot_schema) + assert tool.parameters == vol.Schema( + { + vol.Optional("area"): cv.string, + vol.Optional("floor"): cv.string, + # No preferred_area_id, preferred_floor_id + } + ) assert str(tool) == "" assert test_context.json_fragment # To reproduce an error case in tracing @@ -160,7 +172,52 @@ async def test_assist_api( context=test_context, language="*", assistant="conversation", - device_id="test_device", + device_id=None, + ) + assert response == { + "data": { + "failed": [], + "success": [], + "targets": [], + }, + "response_type": "action_done", + "speech": {}, + } + + # Call with a device/area/floor + entry = MockConfigEntry(title=None) + entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={("test", "1234")}, + suggested_area="Test Area", + ) + area = area_registry.async_get_area_by_name("Test Area") + floor = floor_registry.async_create("2") + area_registry.async_update(area.id, floor_id=floor.floor_id) + llm_context.device_id = device.id + + with patch( + "homeassistant.helpers.intent.async_handle", return_value=intent_response + ) as mock_intent_handle: + response = await api.async_call_tool(tool_input) + + mock_intent_handle.assert_awaited_once_with( + hass=hass, + platform="test_platform", + intent_type="test_intent", + slots={ + "area": {"value": "kitchen"}, + "floor": {"value": "ground_floor"}, + "preferred_area_id": {"value": area.id}, + "preferred_floor_id": {"value": floor.floor_id}, + }, + text_input="test_text", + context=test_context, + language="*", + assistant="conversation", + device_id=device.id, ) assert response == { "data": { From e0232510d7b826abc84c2d04afac1cc4470f678f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 3 Jun 2024 03:11:24 -0500 Subject: [PATCH 079/234] Revert "Add websocket API to get list of recorded entities (#92640)" (#118644) Co-authored-by: Paulus Schoutsen --- .../components/recorder/websocket_api.py | 46 +----------- .../components/recorder/test_websocket_api.py | 71 +------------------ 2 files changed, 3 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index b0874d9ea2a..58c362df62e 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime as dt -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import Any, Literal, cast import voluptuous as vol @@ -44,11 +44,7 @@ from .statistics import ( statistics_during_period, validate_statistics, ) -from .util import PERIOD_SCHEMA, get_instance, resolve_period, session_scope - -if TYPE_CHECKING: - from .core import Recorder - +from .util import PERIOD_SCHEMA, get_instance, resolve_period UNIT_SCHEMA = vol.Schema( { @@ -85,7 +81,6 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_info) websocket_api.async_register_command(hass, ws_update_statistics_metadata) websocket_api.async_register_command(hass, ws_validate_statistics) - websocket_api.async_register_command(hass, ws_get_recorded_entities) def _ws_get_statistic_during_period( @@ -518,40 +513,3 @@ def ws_info( "thread_running": is_running, } connection.send_result(msg["id"], recorder_info) - - -def _get_recorded_entities( - hass: HomeAssistant, msg_id: int, instance: Recorder -) -> bytes: - """Get the list of entities being recorded.""" - with session_scope(hass=hass, read_only=True) as session: - return json_bytes( - messages.result_message( - msg_id, - { - "entity_ids": list( - instance.states_meta_manager.get_metadata_id_to_entity_id( - session - ).values() - ) - }, - ) - ) - - -@websocket_api.websocket_command( - { - vol.Required("type"): "recorder/recorded_entities", - } -) -@websocket_api.async_response -async def ws_get_recorded_entities( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] -) -> None: - """Get the list of entities being recorded.""" - instance = get_instance(hass) - return connection.send_message( - await instance.async_add_executor_job( - _get_recorded_entities, hass, msg["id"], instance - ) - ) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 9cb06003415..9c8e0a9203a 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -23,7 +23,6 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA from homeassistant.components.sensor import UNIT_CONVERTERS -from homeassistant.const import CONF_DOMAINS, CONF_EXCLUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component @@ -39,7 +38,7 @@ from .common import ( ) from tests.common import async_fire_time_changed -from tests.typing import RecorderInstanceGenerator, WebSocketGenerator +from tests.typing import WebSocketGenerator DISTANCE_SENSOR_FT_ATTRIBUTES = { "device_class": "distance", @@ -133,13 +132,6 @@ VOLUME_SENSOR_M3_ATTRIBUTES_TOTAL = { } -@pytest.fixture -async def mock_recorder_before_hass( - async_setup_recorder_instance: RecorderInstanceGenerator, -) -> None: - """Set up recorder.""" - - def test_converters_align_with_sensor() -> None: """Ensure UNIT_SCHEMA is aligned with sensor UNIT_CONVERTERS.""" for converter in UNIT_CONVERTERS.values(): @@ -3185,64 +3177,3 @@ async def test_adjust_sum_statistics_errors( stats = statistics_during_period(hass, zero, period="hour") assert stats != previous_stats previous_stats = stats - - -async def test_recorder_recorded_entities_no_filter( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - async_setup_recorder_instance: RecorderInstanceGenerator, -) -> None: - """Test getting the list of recorded entities without a filter.""" - await async_setup_recorder_instance(hass, {recorder.CONF_COMMIT_INTERVAL: 0}) - client = await hass_ws_client() - - await client.send_json({"id": 1, "type": "recorder/recorded_entities"}) - response = await client.receive_json() - assert response["result"] == {"entity_ids": []} - assert response["id"] == 1 - assert response["success"] - assert response["type"] == "result" - - hass.states.async_set("sensor.test", 10) - await async_wait_recording_done(hass) - - await client.send_json({"id": 2, "type": "recorder/recorded_entities"}) - response = await client.receive_json() - assert response["result"] == {"entity_ids": ["sensor.test"]} - assert response["id"] == 2 - assert response["success"] - assert response["type"] == "result" - - -async def test_recorder_recorded_entities_with_filter( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - async_setup_recorder_instance: RecorderInstanceGenerator, -) -> None: - """Test getting the list of recorded entities with a filter.""" - await async_setup_recorder_instance( - hass, - { - recorder.CONF_COMMIT_INTERVAL: 0, - CONF_EXCLUDE: {CONF_DOMAINS: ["sensor"]}, - }, - ) - client = await hass_ws_client() - - await client.send_json({"id": 1, "type": "recorder/recorded_entities"}) - response = await client.receive_json() - assert response["result"] == {"entity_ids": []} - assert response["id"] == 1 - assert response["success"] - assert response["type"] == "result" - - hass.states.async_set("switch.test", 10) - hass.states.async_set("sensor.test", 10) - await async_wait_recording_done(hass) - - await client.send_json({"id": 2, "type": "recorder/recorded_entities"}) - response = await client.receive_json() - assert response["result"] == {"entity_ids": ["switch.test"]} - assert response["id"] == 2 - assert response["success"] - assert response["type"] == "result" From 7e71975358b3e41f93e07a23db12d27ae97f4ef3 Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 3 Jun 2024 21:56:42 +0800 Subject: [PATCH 080/234] Fixing device model compatibility issues. (#118686) --- homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/switch.py | 36 +++++++++++++++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 110b9cb9810..e829fe08d32 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -16,3 +16,4 @@ YOLINK_EVENT = f"{DOMAIN}_event" YOLINK_OFFLINE_TIME = 32400 DEV_MODEL_WATER_METER_YS5007 = "YS5007" +DEV_MODEL_MULTI_OUTLET_YS6801 = "YS6801" diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 7a24ec1bd13..2e31100bf3c 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -25,7 +25,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -35,7 +35,7 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - plug_index: int | None = None + plug_index_fn: Callable[[YoLinkDevice], int | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( @@ -61,36 +61,43 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( key="multi_outlet_usb_ports", translation_key="usb_ports", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=0, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 0, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_1", translation_key="plug_1", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=1, + plug_index_fn=lambda device: 1 + if device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801) + else 0, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_2", translation_key="plug_2", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=2, + plug_index_fn=lambda device: 2 + if device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801) + else 1, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_3", translation_key="plug_3", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=3, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 3, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_4", translation_key="plug_4", device_class=SwitchDeviceClass.OUTLET, - exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, - plug_index=4, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET + and device.device_model_name.startswith(DEV_MODEL_MULTI_OUTLET_YS6801), + plug_index_fn=lambda _: 4, ), ) @@ -152,7 +159,8 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): def update_entity_state(self, state: dict[str, str | list[str]]) -> None: """Update HA Entity State.""" self._attr_is_on = self._get_state( - state.get("state"), self.entity_description.plug_index + state.get("state"), + self.entity_description.plug_index_fn(self.coordinator.device), ) self.async_write_ha_state() @@ -164,12 +172,14 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): ATTR_DEVICE_MULTI_OUTLET, ]: client_request = OutletRequestBuilder.set_state_request( - state, self.entity_description.plug_index + state, self.entity_description.plug_index_fn(self.coordinator.device) ) else: client_request = ClientRequest("setState", {"state": state}) await self.call_device(client_request) - self._attr_is_on = self._get_state(state, self.entity_description.plug_index) + self._attr_is_on = self._get_state( + state, self.entity_description.plug_index_fn(self.coordinator.device) + ) self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: From 7b43b587a7ca8af21c6670f21bb55d1cc34c2b3b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 3 Jun 2024 15:39:50 +0200 Subject: [PATCH 081/234] Bump python-roborock to 2.2.2 (#118697) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 8b46fb4c001..69dea8d0c25 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.1.1", + "python-roborock==2.2.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 5670c22bb6d..58f809d9508 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.1.1 +python-roborock==2.2.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e38f741af98..6d94d335cab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1794,7 +1794,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.1.1 +python-roborock==2.2.2 # homeassistant.components.smarttub python-smarttub==0.0.36 From 54425b756e1ebec9759cdd2301e77979dd4bf758 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Jun 2024 19:23:07 +0200 Subject: [PATCH 082/234] Configure device in airgradient config flow (#118699) --- .../components/airgradient/config_flow.py | 20 +++++-- .../components/airgradient/strings.json | 3 +- .../airgradient/test_config_flow.py | 56 ++++++++++++++++++- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index c02ec2a469f..c7b617de272 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -2,7 +2,7 @@ from typing import Any -from airgradient import AirGradientClient, AirGradientError +from airgradient import AirGradientClient, AirGradientError, ConfigurationControl import voluptuous as vol from homeassistant.components import zeroconf @@ -19,6 +19,14 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self.data: dict[str, Any] = {} + self.client: AirGradientClient | None = None + + async def set_configuration_source(self) -> None: + """Set configuration source to local if it hasn't been set yet.""" + assert self.client + config = await self.client.get_config() + if config.configuration_control is ConfigurationControl.BOTH: + await self.client.set_configuration_control(ConfigurationControl.LOCAL) async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo @@ -31,8 +39,8 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(updates={CONF_HOST: host}) session = async_get_clientsession(self.hass) - air_gradient = AirGradientClient(host, session=session) - await air_gradient.get_current_measures() + self.client = AirGradientClient(host, session=session) + await self.client.get_current_measures() self.context["title_placeholders"] = { "model": self.data[CONF_MODEL], @@ -44,6 +52,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Confirm discovery.""" if user_input is not None: + await self.set_configuration_source() return self.async_create_entry( title=self.data[CONF_MODEL], data={CONF_HOST: self.data[CONF_HOST]}, @@ -64,14 +73,15 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: session = async_get_clientsession(self.hass) - air_gradient = AirGradientClient(user_input[CONF_HOST], session=session) + self.client = AirGradientClient(user_input[CONF_HOST], session=session) try: - current_measures = await air_gradient.get_current_measures() + current_measures = await self.client.get_current_measures() except AirGradientError: errors["base"] = "cannot_connect" else: await self.async_set_unique_id(current_measures.serial_number) self._abort_if_unique_id_configured() + await self.set_configuration_source() return self.async_create_entry( title=current_measures.model, data={CONF_HOST: user_input[CONF_HOST]}, diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index f4441a66209..9deaf17d0e4 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -28,8 +28,7 @@ "name": "Configuration source", "state": { "cloud": "Cloud", - "local": "Local", - "both": "Both" + "local": "Local" } }, "display_temperature_unit": { diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 022a250ebef..6bb951f2e26 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -3,7 +3,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock -from airgradient import AirGradientConnectionError +from airgradient import AirGradientConnectionError, ConfigurationControl from homeassistant.components.airgradient import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -32,7 +32,7 @@ ZEROCONF_DISCOVERY = ZeroconfServiceInfo( async def test_full_flow( hass: HomeAssistant, - mock_airgradient_client: AsyncMock, + mock_new_airgradient_client: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: """Test full flow.""" @@ -55,6 +55,31 @@ async def test_full_flow( CONF_HOST: "10.0.0.131", } assert result["result"].unique_id == "84fce612f5b8" + mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with( + ConfigurationControl.LOCAL + ) + + +async def test_flow_with_registered_device( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we don't revert the cloud setting.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "84fce612f5b8" + mock_cloud_airgradient_client.set_configuration_control.assert_not_called() async def test_flow_errors( @@ -123,7 +148,7 @@ async def test_duplicate( async def test_zeroconf_flow( hass: HomeAssistant, - mock_airgradient_client: AsyncMock, + mock_new_airgradient_client: AsyncMock, mock_setup_entry: AsyncMock, ) -> None: """Test zeroconf flow.""" @@ -147,3 +172,28 @@ async def test_zeroconf_flow( CONF_HOST: "10.0.0.131", } assert result["result"].unique_id == "84fce612f5b8" + mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with( + ConfigurationControl.LOCAL + ) + + +async def test_zeroconf_flow_cloud_device( + hass: HomeAssistant, + mock_cloud_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow doesn't revert the cloud setting.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + mock_cloud_airgradient_client.set_configuration_control.assert_not_called() From ea85ed6992b4896953ff19c318330fec22bd4b0f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Jun 2024 15:49:51 +0200 Subject: [PATCH 083/234] Disable both option in Airgradient select (#118702) --- .../components/airgradient/select.py | 10 ++++---- tests/components/airgradient/conftest.py | 24 ++++++++++++++++++- .../fixtures/get_config_cloud.json | 13 ++++++++++ .../fixtures/get_config_local.json | 13 ++++++++++ .../airgradient/snapshots/test_select.ambr | 8 ++----- tests/components/airgradient/test_select.py | 12 ++++------ 6 files changed, 61 insertions(+), 19 deletions(-) create mode 100644 tests/components/airgradient/fixtures/get_config_cloud.json create mode 100644 tests/components/airgradient/fixtures/get_config_local.json diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 41b5a48c686..5e13ee1d0bb 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -22,7 +22,7 @@ from .entity import AirGradientEntity class AirGradientSelectEntityDescription(SelectEntityDescription): """Describes AirGradient select entity.""" - value_fn: Callable[[Config], str] + value_fn: Callable[[Config], str | None] set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]] requires_display: bool = False @@ -30,9 +30,11 @@ class AirGradientSelectEntityDescription(SelectEntityDescription): CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription( key="configuration_control", translation_key="configuration_control", - options=[x.value for x in ConfigurationControl], + options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value], entity_category=EntityCategory.CONFIG, - value_fn=lambda config: config.configuration_control, + value_fn=lambda config: config.configuration_control + if config.configuration_control is not ConfigurationControl.BOTH + else None, set_value_fn=lambda client, value: client.set_configuration_control( ConfigurationControl(value) ), @@ -96,7 +98,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity): self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" @property - def current_option(self) -> str: + def current_option(self) -> str | None: """Return the state of the select.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py index aa2c1e783a4..d2495c11a79 100644 --- a/tests/components/airgradient/conftest.py +++ b/tests/components/airgradient/conftest.py @@ -42,11 +42,33 @@ def mock_airgradient_client() -> Generator[AsyncMock, None, None]: load_fixture("current_measures.json", DOMAIN) ) client.get_config.return_value = Config.from_json( - load_fixture("get_config.json", DOMAIN) + load_fixture("get_config_local.json", DOMAIN) ) yield client +@pytest.fixture +def mock_new_airgradient_client( + mock_airgradient_client: AsyncMock, +) -> Generator[AsyncMock, None, None]: + """Mock a new AirGradient client.""" + mock_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config.json", DOMAIN) + ) + return mock_airgradient_client + + +@pytest.fixture +def mock_cloud_airgradient_client( + mock_airgradient_client: AsyncMock, +) -> Generator[AsyncMock, None, None]: + """Mock a new AirGradient client.""" + mock_airgradient_client.get_config.return_value = Config.from_json( + load_fixture("get_config_cloud.json", DOMAIN) + ) + return mock_airgradient_client + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Mock a config entry.""" diff --git a/tests/components/airgradient/fixtures/get_config_cloud.json b/tests/components/airgradient/fixtures/get_config_cloud.json new file mode 100644 index 00000000000..a5f27957e04 --- /dev/null +++ b/tests/components/airgradient/fixtures/get_config_cloud.json @@ -0,0 +1,13 @@ +{ + "country": "DE", + "pmStandard": "ugm3", + "ledBarMode": "co2", + "displayMode": "on", + "abcDays": 8, + "tvocLearningOffset": 12, + "noxLearningOffset": 12, + "mqttBrokerUrl": "", + "temperatureUnit": "c", + "configurationControl": "cloud", + "postDataToAirGradient": true +} diff --git a/tests/components/airgradient/fixtures/get_config_local.json b/tests/components/airgradient/fixtures/get_config_local.json new file mode 100644 index 00000000000..09e0e982053 --- /dev/null +++ b/tests/components/airgradient/fixtures/get_config_local.json @@ -0,0 +1,13 @@ +{ + "country": "DE", + "pmStandard": "ugm3", + "ledBarMode": "co2", + "displayMode": "on", + "abcDays": 8, + "tvocLearningOffset": 12, + "noxLearningOffset": 12, + "mqttBrokerUrl": "", + "temperatureUnit": "c", + "configurationControl": "local", + "postDataToAirGradient": true +} diff --git a/tests/components/airgradient/snapshots/test_select.ambr b/tests/components/airgradient/snapshots/test_select.ambr index 986e3c6ebb8..fb201b88204 100644 --- a/tests/components/airgradient/snapshots/test_select.ambr +++ b/tests/components/airgradient/snapshots/test_select.ambr @@ -8,7 +8,6 @@ 'options': list([ 'cloud', 'local', - 'both', ]), }), 'config_entry_id': , @@ -45,7 +44,6 @@ 'options': list([ 'cloud', 'local', - 'both', ]), }), 'context': , @@ -53,7 +51,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'both', + 'state': 'local', }) # --- # name: test_all_entities[select.airgradient_display_temperature_unit-entry] @@ -120,7 +118,6 @@ 'options': list([ 'cloud', 'local', - 'both', ]), }), 'config_entry_id': , @@ -157,7 +154,6 @@ 'options': list([ 'cloud', 'local', - 'both', ]), }), 'context': , @@ -165,6 +161,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'both', + 'state': 'local', }) # --- diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index 2988a5918ad..986295bd245 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -77,16 +77,12 @@ async def test_setting_value( async def test_setting_protected_value( hass: HomeAssistant, - mock_airgradient_client: AsyncMock, + mock_cloud_airgradient_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test setting protected value.""" await setup_integration(hass, mock_config_entry) - mock_airgradient_client.get_config.return_value.configuration_control = ( - ConfigurationControl.CLOUD - ) - with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, @@ -97,9 +93,9 @@ async def test_setting_protected_value( }, blocking=True, ) - mock_airgradient_client.set_temperature_unit.assert_not_called() + mock_cloud_airgradient_client.set_temperature_unit.assert_not_called() - mock_airgradient_client.get_config.return_value.configuration_control = ( + mock_cloud_airgradient_client.get_config.return_value.configuration_control = ( ConfigurationControl.LOCAL ) @@ -112,4 +108,4 @@ async def test_setting_protected_value( }, blocking=True, ) - mock_airgradient_client.set_temperature_unit.assert_called_once_with("c") + mock_cloud_airgradient_client.set_temperature_unit.assert_called_once_with("c") From f805df8390011b373107547701e24bbb50664db9 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 3 Jun 2024 11:43:40 +0200 Subject: [PATCH 084/234] Bump pyoverkiz to 1.13.11 (#118703) --- 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 dc2f0df4783..a78eb160a28 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -19,7 +19,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.10"], + "requirements": ["pyoverkiz==1.13.11"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 58f809d9508..c588e8a5dea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2060,7 +2060,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.10 +pyoverkiz==1.13.11 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d94d335cab..ae0a77fe05f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1617,7 +1617,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.10 +pyoverkiz==1.13.11 # homeassistant.components.onewire pyownet==0.10.0.post1 From 8a516207e92e8f13dbaefe4af3fc3240efb6c2d0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 3 Jun 2024 10:48:50 -0700 Subject: [PATCH 085/234] Use ISO format when passing date to LLMs (#118705) --- homeassistant/helpers/llm.py | 4 ++-- .../snapshots/test_conversation.ambr | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 37233b0d407..31e3c791630 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -35,8 +35,8 @@ from .singleton import singleton LLM_API_ASSIST = "assist" BASE_PROMPT = ( - 'Current time is {{ now().strftime("%X") }}. ' - 'Today\'s date is {{ now().strftime("%x") }}.\n' + 'Current time is {{ now().strftime("%H:%M:%S") }}. ' + 'Today\'s date is {{ now().strftime("%Y-%m-%d") }}.\n' ) DEFAULT_INSTRUCTIONS_PROMPT = """You are a voice assistant for Home Assistant. diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr index 587586cff17..70db5d11868 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_conversation.ambr @@ -30,7 +30,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. @@ -81,7 +81,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. Only if the user wants to control a device, tell them to edit the AI configuration and allow access to Home Assistant. @@ -144,7 +144,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. @@ -199,7 +199,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. @@ -254,7 +254,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. @@ -309,7 +309,7 @@ 'history': list([ dict({ 'parts': ''' - Current time is 05:00:00. Today's date is 05/24/24. + Current time is 05:00:00. Today's date is 2024-05-24. You are a voice assistant for Home Assistant. Answer in plain text. Keep it simple and to the point. From cc83443ad1b23e09b91be501d82dc512186e93c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Jun 2024 13:11:00 +0200 Subject: [PATCH 086/234] Don't store tag_id in tag storage (#118707) --- homeassistant/components/tag/__init__.py | 30 ++++++++++--------- tests/components/tag/snapshots/test_init.ambr | 2 -- tests/components/tag/test_init.py | 18 +++++------ 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index afea86baa93..ca0d53be6d0 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -9,7 +9,7 @@ import uuid import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_ID, CONF_NAME from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, entity_registry as er @@ -107,7 +107,7 @@ class TagStore(Store[collection.SerializedStorageCollection]): # Version 1.2 moves name to entity registry for tag in data["items"]: # Copy name in tag store to the entity registry - _create_entry(entity_registry, tag[TAG_ID], tag.get(CONF_NAME)) + _create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME)) tag["migrated"] = True if old_major_version > 1: @@ -136,24 +136,26 @@ class TagStorageCollection(collection.DictStorageCollection): data = self.CREATE_SCHEMA(data) if not data[TAG_ID]: data[TAG_ID] = str(uuid.uuid4()) + # Move tag id to id + data[CONF_ID] = data.pop(TAG_ID) # make last_scanned JSON serializeable if LAST_SCANNED in data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() # Create entity in entity_registry when creating the tag # This is done early to store name only once in entity registry - _create_entry(self.entity_registry, data[TAG_ID], data.get(CONF_NAME)) + _create_entry(self.entity_registry, data[CONF_ID], data.get(CONF_NAME)) return data @callback def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" - return info[TAG_ID] + return info[CONF_ID] async def _update_data(self, item: dict, update_data: dict) -> dict: """Return a new updated data object.""" data = {**item, **self.UPDATE_SCHEMA(update_data)} - tag_id = data[TAG_ID] + tag_id = item[CONF_ID] # make last_scanned JSON serializeable if LAST_SCANNED in update_data: data[LAST_SCANNED] = data[LAST_SCANNED].isoformat() @@ -211,7 +213,7 @@ class TagDictStorageCollectionWebsocket( item = {k: v for k, v in item.items() if k != "migrated"} if ( entity_id := self.entity_registry.async_get_entity_id( - DOMAIN, DOMAIN, item[TAG_ID] + DOMAIN, DOMAIN, item[CONF_ID] ) ) and (entity := self.entity_registry.async_get(entity_id)): item[CONF_NAME] = entity.name or entity.original_name @@ -249,14 +251,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if change_type == collection.CHANGE_ADDED: # When tags are added to storage - entity = _create_entry(entity_registry, updated_config[TAG_ID], None) + entity = _create_entry(entity_registry, updated_config[CONF_ID], None) if TYPE_CHECKING: assert entity.original_name await component.async_add_entities( [ TagEntity( entity.name or entity.original_name, - updated_config[TAG_ID], + updated_config[CONF_ID], updated_config.get(LAST_SCANNED), updated_config.get(DEVICE_ID), ) @@ -267,7 +269,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # When tags are changed or updated in storage async_dispatcher_send( hass, - f"{SIGNAL_TAG_CHANGED}-{updated_config[TAG_ID]}", + f"{SIGNAL_TAG_CHANGED}-{updated_config[CONF_ID]}", updated_config.get(DEVICE_ID), updated_config.get(LAST_SCANNED), ) @@ -276,7 +278,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif change_type == collection.CHANGE_REMOVED: # When tags are removed from storage entity_id = entity_registry.async_get_entity_id( - DOMAIN, DOMAIN, updated_config[TAG_ID] + DOMAIN, DOMAIN, updated_config[CONF_ID] ) if entity_id: entity_registry.async_remove(entity_id) @@ -287,13 +289,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for tag in storage_collection.async_items(): if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug("Adding tag: %s", tag) - entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[TAG_ID]) + entity_id = entity_registry.async_get_entity_id(DOMAIN, DOMAIN, tag[CONF_ID]) if entity_id := entity_registry.async_get_entity_id( - DOMAIN, DOMAIN, tag[TAG_ID] + DOMAIN, DOMAIN, tag[CONF_ID] ): entity = entity_registry.async_get(entity_id) else: - entity = _create_entry(entity_registry, tag[TAG_ID], None) + entity = _create_entry(entity_registry, tag[CONF_ID], None) if TYPE_CHECKING: assert entity assert entity.original_name @@ -301,7 +303,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entities.append( TagEntity( name, - tag[TAG_ID], + tag[CONF_ID], tag.get(LAST_SCANNED), tag.get(DEVICE_ID), ) diff --git a/tests/components/tag/snapshots/test_init.ambr b/tests/components/tag/snapshots/test_init.ambr index 8a17079e16d..bfa80d8462e 100644 --- a/tests/components/tag/snapshots/test_init.ambr +++ b/tests/components/tag/snapshots/test_init.ambr @@ -13,11 +13,9 @@ 'device_id': 'some_scanner', 'id': 'new tag', 'last_scanned': '2024-02-29T13:00:00+00:00', - 'tag_id': 'new tag', }), dict({ 'id': '1234567890', - 'tag_id': '1234567890', }), ]), }), diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index ff3cef873e7..295f286159e 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -33,11 +33,9 @@ def storage_setup(hass: HomeAssistant, hass_storage): "items": [ { "id": TEST_TAG_ID, - "tag_id": TEST_TAG_ID, }, { "id": TEST_TAG_ID_2, - "tag_id": TEST_TAG_ID_2, }, ] }, @@ -116,6 +114,7 @@ async def test_migration( ) resp = await client.receive_json() assert resp["success"] + assert resp["result"] == {"id": "1234567890", "name": "Kitchen tag"} # Trigger store freezer.tick(11) @@ -136,8 +135,8 @@ async def test_ws_list( resp = await client.receive_json() assert resp["success"] assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, - {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, ] @@ -160,7 +159,7 @@ async def test_ws_update( resp = await client.receive_json() assert resp["success"] item = resp["result"] - assert item == {"id": TEST_TAG_ID, "name": "New name", "tag_id": TEST_TAG_ID} + assert item == {"id": TEST_TAG_ID, "name": "New name"} async def test_tag_scanned( @@ -181,8 +180,8 @@ async def test_tag_scanned( result = {item["id"]: item for item in resp["result"]} assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, - {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, ] now = dt_util.utcnow() @@ -197,14 +196,13 @@ async def test_tag_scanned( assert len(result) == 3 assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID}, - {"id": TEST_TAG_ID_2, "name": "test tag name 2", "tag_id": TEST_TAG_ID_2}, + {"id": TEST_TAG_ID, "name": "test tag name"}, + {"id": TEST_TAG_ID_2, "name": "test tag name 2"}, { "device_id": "some_scanner", "id": "new tag", "last_scanned": now.isoformat(), "name": "Tag new tag", - "tag_id": "new tag", }, ] From 85982d2b87fef2391d2414c2565c76117bcc4243 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 3 Jun 2024 13:13:18 -0400 Subject: [PATCH 087/234] Remove unintended translation key from blink (#118712) --- homeassistant/components/blink/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 8a743e98401..8f94f8c9543 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -85,7 +85,7 @@ }, "save_recent_clips": { "name": "Save recent clips", - "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", + "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_[camera name].mp4\".", "fields": { "file_path": { "name": "Output directory", From f3d1157bc4d43d42bec1f42f5c672067f9bc3bfe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Jun 2024 17:15:57 +0200 Subject: [PATCH 088/234] Remove tag_id from tag store (#118713) --- homeassistant/components/tag/__init__.py | 8 +++++++- tests/components/tag/snapshots/test_init.ambr | 3 +-- tests/components/tag/test_init.py | 4 +--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index ca0d53be6d0..45266652a47 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -34,7 +34,7 @@ LAST_SCANNED = "last_scanned" LAST_SCANNED_BY_DEVICE_ID = "last_scanned_by_device_id" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 2 +STORAGE_VERSION_MINOR = 3 TAG_DATA: HassKey[TagStorageCollection] = HassKey(DOMAIN) SIGNAL_TAG_CHANGED = "signal_tag_changed" @@ -109,6 +109,12 @@ class TagStore(Store[collection.SerializedStorageCollection]): # Copy name in tag store to the entity registry _create_entry(entity_registry, tag[CONF_ID], tag.get(CONF_NAME)) tag["migrated"] = True + if old_major_version == 1 and old_minor_version < 3: + # Version 1.3 removes tag_id from the store + for tag in data["items"]: + if TAG_ID not in tag: + continue + del tag[TAG_ID] if old_major_version > 1: raise NotImplementedError diff --git a/tests/components/tag/snapshots/test_init.ambr b/tests/components/tag/snapshots/test_init.ambr index bfa80d8462e..29a9a2665b8 100644 --- a/tests/components/tag/snapshots/test_init.ambr +++ b/tests/components/tag/snapshots/test_init.ambr @@ -7,7 +7,6 @@ 'id': 'test tag id', 'migrated': True, 'name': 'test tag name', - 'tag_id': 'test tag id', }), dict({ 'device_id': 'some_scanner', @@ -20,7 +19,7 @@ ]), }), 'key': 'tag', - 'minor_version': 2, + 'minor_version': 3, 'version': 1, }) # --- diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 295f286159e..bc9602fd1cb 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -97,9 +97,7 @@ async def test_migration( await client.send_json_auto_id({"type": f"{DOMAIN}/list"}) resp = await client.receive_json() assert resp["success"] - assert resp["result"] == [ - {"id": TEST_TAG_ID, "name": "test tag name", "tag_id": TEST_TAG_ID} - ] + assert resp["result"] == [{"id": TEST_TAG_ID, "name": "test tag name"}] # Scan a new tag await async_scan_tag(hass, "new tag", "some_scanner") From f064f44a09cca7f046b763b0917cf0c231befe27 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 3 Jun 2024 18:22:00 +0100 Subject: [PATCH 089/234] Address reviews comments in #117147 (#118714) --- homeassistant/components/v2c/sensor.py | 8 +- homeassistant/components/v2c/strings.json | 10 +- .../components/v2c/snapshots/test_sensor.ambr | 529 ++++-------------- tests/components/v2c/test_sensor.py | 4 +- 4 files changed, 133 insertions(+), 418 deletions(-) diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 01b89adea4d..799d6c3d03c 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -35,7 +35,7 @@ class V2CSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[TrydanData], StateType] -_SLAVE_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState] +_METER_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState] TRYDAN_SENSORS = ( V2CSensorEntityDescription( @@ -80,12 +80,12 @@ TRYDAN_SENSORS = ( value_fn=lambda evse_data: evse_data.fv_power, ), V2CSensorEntityDescription( - key="slave_error", - translation_key="slave_error", + key="meter_error", + translation_key="meter_error", value_fn=lambda evse_data: evse_data.slave_error.name.lower(), entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=_SLAVE_ERROR_OPTIONS, + options=_METER_ERROR_OPTIONS, ), V2CSensorEntityDescription( key="battery_power", diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index bafbbe36e0c..bc0d870b635 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -54,18 +54,18 @@ "battery_power": { "name": "Battery power" }, - "slave_error": { - "name": "Slave error", + "meter_error": { + "name": "Meter error", "state": { "no_error": "No error", "communication": "Communication", "reading": "Reading", - "slave": "Slave", + "slave": "Meter", "waiting_wifi": "Waiting for Wi-Fi", "waiting_communication": "Waiting communication", "wrong_ip": "Wrong IP", - "slave_not_found": "Slave not found", - "wrong_slave": "Wrong slave", + "slave_not_found": "Meter not found", + "wrong_slave": "Wrong Meter", "no_response": "No response", "clamp_not_connected": "Clamp not connected", "illegal_function": "Illegal function", diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 0ef9bfe8429..859e5f83e15 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -1,289 +1,4 @@ # serializer version: 1 -# name: test_sensor - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'EVSE 1.1.1.1 Photovoltaic power', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.evse_1_1_1_1_photovoltaic_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) - list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.evse_1_1_1_1_charge_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:ev-station', - 'original_name': 'Charge power', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_power', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_power', - '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.evse_1_1_1_1_charge_energy', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charge energy', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_energy', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_energy', - '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.evse_1_1_1_1_charge_time', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Charge time', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'charge_time', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_charge_time', - '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.evse_1_1_1_1_house_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'House power', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'house_power', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_house_power', - '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.evse_1_1_1_1_photovoltaic_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Photovoltaic power', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fv_power', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_fv_power', - 'unit_of_measurement': , - }), - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'no_error', - 'communication', - 'reading', - 'slave', - 'waiting_wifi', - 'waiting_communication', - 'wrong_ip', - 'slave_not_found', - 'wrong_slave', - 'no_response', - 'clamp_not_connected', - 'illegal_function', - 'illegal_data_address', - 'illegal_data_value', - 'server_device_failure', - 'acknowledge', - 'server_device_busy', - 'negative_acknowledge', - 'memory_parity_error', - 'gateway_path_unavailable', - 'gateway_target_no_resp', - 'server_rtu_inactive244_timeout', - 'invalid_server', - 'crc_error', - 'fc_missmatch', - 'server_id_missmatch', - 'packet_length_error', - 'parameter_count_error', - 'parameter_limit_error', - 'request_queue_full', - 'illegal_ip_or_port', - 'ip_connection_failed', - 'tcp_head_missmatch', - 'empty_message', - 'undefined_error', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': , - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.evse_1_1_1_1_slave_error', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Slave error', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'slave_error', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_slave_error', - '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': None, - 'entity_id': 'sensor.evse_1_1_1_1_battery_power', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Battery power', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'battery_power', - 'unique_id': '45a36e55aaddb2137c5f6602e0c38e72_battery_power', - 'unit_of_measurement': , - }), - ]) -# --- # name: test_sensor[sensor.evse_1_1_1_1_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -540,6 +255,128 @@ 'state': '0.0', }) # --- +# name: test_sensor[sensor.evse_1_1_1_1_meter_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'slave', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'slave_not_found', + 'wrong_slave', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.evse_1_1_1_1_meter_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Meter error', + 'platform': 'v2c', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_error', + 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_meter_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.evse_1_1_1_1_meter_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'EVSE 1.1.1.1 Meter error', + 'options': list([ + 'no_error', + 'communication', + 'reading', + 'slave', + 'waiting_wifi', + 'waiting_communication', + 'wrong_ip', + 'slave_not_found', + 'wrong_slave', + 'no_response', + 'clamp_not_connected', + 'illegal_function', + 'illegal_data_address', + 'illegal_data_value', + 'server_device_failure', + 'acknowledge', + 'server_device_busy', + 'negative_acknowledge', + 'memory_parity_error', + 'gateway_path_unavailable', + 'gateway_target_no_resp', + 'server_rtu_inactive244_timeout', + 'invalid_server', + 'crc_error', + 'fc_mismatch', + 'server_id_mismatch', + 'packet_length_error', + 'parameter_count_error', + 'parameter_limit_error', + 'request_queue_full', + 'illegal_ip_or_port', + 'ip_connection_failed', + 'tcp_head_mismatch', + 'empty_message', + 'undefined_error', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_1_1_1_1_meter_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'waiting_wifi', + }) +# --- # name: test_sensor[sensor.evse_1_1_1_1_photovoltaic_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -591,125 +428,3 @@ 'state': '0.0', }) # --- -# name: test_sensor[sensor.evse_1_1_1_1_slave_error-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'options': list([ - 'no_error', - 'communication', - 'reading', - 'slave', - 'waiting_wifi', - 'waiting_communication', - 'wrong_ip', - 'slave_not_found', - 'wrong_slave', - 'no_response', - 'clamp_not_connected', - 'illegal_function', - 'illegal_data_address', - 'illegal_data_value', - 'server_device_failure', - 'acknowledge', - 'server_device_busy', - 'negative_acknowledge', - 'memory_parity_error', - 'gateway_path_unavailable', - 'gateway_target_no_resp', - 'server_rtu_inactive244_timeout', - 'invalid_server', - 'crc_error', - 'fc_mismatch', - 'server_id_mismatch', - 'packet_length_error', - 'parameter_count_error', - 'parameter_limit_error', - 'request_queue_full', - 'illegal_ip_or_port', - 'ip_connection_failed', - 'tcp_head_mismatch', - 'empty_message', - 'undefined_error', - ]), - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.evse_1_1_1_1_slave_error', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Slave error', - 'platform': 'v2c', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'slave_error', - 'unique_id': 'da58ee91f38c2406c2a36d0a1a7f8569_slave_error', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensor[sensor.evse_1_1_1_1_slave_error-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'enum', - 'friendly_name': 'EVSE 1.1.1.1 Slave error', - 'options': list([ - 'no_error', - 'communication', - 'reading', - 'slave', - 'waiting_wifi', - 'waiting_communication', - 'wrong_ip', - 'slave_not_found', - 'wrong_slave', - 'no_response', - 'clamp_not_connected', - 'illegal_function', - 'illegal_data_address', - 'illegal_data_value', - 'server_device_failure', - 'acknowledge', - 'server_device_busy', - 'negative_acknowledge', - 'memory_parity_error', - 'gateway_path_unavailable', - 'gateway_target_no_resp', - 'server_rtu_inactive244_timeout', - 'invalid_server', - 'crc_error', - 'fc_mismatch', - 'server_id_mismatch', - 'packet_length_error', - 'parameter_count_error', - 'parameter_limit_error', - 'request_queue_full', - 'illegal_ip_or_port', - 'ip_connection_failed', - 'tcp_head_mismatch', - 'empty_message', - 'undefined_error', - ]), - }), - 'context': , - 'entity_id': 'sensor.evse_1_1_1_1_slave_error', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'waiting_wifi', - }) -# --- diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index a4a7fe6ca34..93f7e36327c 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -26,7 +26,7 @@ async def test_sensor( await init_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - from homeassistant.components.v2c.sensor import _SLAVE_ERROR_OPTIONS + from homeassistant.components.v2c.sensor import _METER_ERROR_OPTIONS assert [ "no_error", @@ -64,4 +64,4 @@ async def test_sensor( "tcp_head_mismatch", "empty_message", "undefined_error", - ] == _SLAVE_ERROR_OPTIONS + ] == _METER_ERROR_OPTIONS From fd9ea2f224c030ae4a70929b99d166dbd01c5dea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 3 Jun 2024 15:53:23 +0200 Subject: [PATCH 090/234] Bump renault-api to 0.2.3 (#118718) --- homeassistant/components/renault/binary_sensor.py | 2 +- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/renault/fixtures/hvac_status.1.json | 2 +- tests/components/renault/fixtures/hvac_status.2.json | 2 +- tests/components/renault/snapshots/test_diagnostics.ambr | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 2041499b711..7ebc77b8e77 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -81,7 +81,7 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( key="hvac_status", coordinator="hvac_status", on_key="hvacStatus", - on_value="on", + on_value=2, translation_key="hvac_status", ), RenaultBinarySensorEntityDescription( diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9891c838950..8407893011c 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.2"] + "requirements": ["renault-api==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c588e8a5dea..f3fe164dbcf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2448,7 +2448,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.2 +renault-api==0.2.3 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae0a77fe05f..dbde2c9dfe0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1906,7 +1906,7 @@ refoss-ha==1.2.0 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.2 +renault-api==0.2.3 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/tests/components/renault/fixtures/hvac_status.1.json b/tests/components/renault/fixtures/hvac_status.1.json index f48cbae68ae..7cbd7a9fe37 100644 --- a/tests/components/renault/fixtures/hvac_status.1.json +++ b/tests/components/renault/fixtures/hvac_status.1.json @@ -2,6 +2,6 @@ "data": { "type": "Car", "id": "VF1AAAAA555777999", - "attributes": { "externalTemperature": 8.0, "hvacStatus": "off" } + "attributes": { "externalTemperature": 8.0, "hvacStatus": 1 } } } diff --git a/tests/components/renault/fixtures/hvac_status.2.json b/tests/components/renault/fixtures/hvac_status.2.json index a2ca08a71e9..8bb4f941e06 100644 --- a/tests/components/renault/fixtures/hvac_status.2.json +++ b/tests/components/renault/fixtures/hvac_status.2.json @@ -4,7 +4,7 @@ "id": "VF1AAAAA555777999", "attributes": { "socThreshold": 30.0, - "hvacStatus": "off", + "hvacStatus": 1, "lastUpdateTime": "2020-12-03T00:00:00Z" } } diff --git a/tests/components/renault/snapshots/test_diagnostics.ambr b/tests/components/renault/snapshots/test_diagnostics.ambr index a2921dff35e..ae90115fcb6 100644 --- a/tests/components/renault/snapshots/test_diagnostics.ambr +++ b/tests/components/renault/snapshots/test_diagnostics.ambr @@ -22,7 +22,7 @@ }), 'hvac_status': dict({ 'externalTemperature': 8.0, - 'hvacStatus': 'off', + 'hvacStatus': 1, }), 'res_state': dict({ }), @@ -227,7 +227,7 @@ }), 'hvac_status': dict({ 'externalTemperature': 8.0, - 'hvacStatus': 'off', + 'hvacStatus': 1, }), 'res_state': dict({ }), From 8cc3c147fe3e7bde54893ac75b67799f264b0e74 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 3 Jun 2024 17:13:48 +0200 Subject: [PATCH 091/234] Tweak light service schema (#118720) --- homeassistant/components/light/services.yaml | 34 ++++++++++++++++++-- homeassistant/components/light/strings.json | 8 +++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index fb7a1539944..0e75380a40c 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -23,6 +23,7 @@ turn_on: - light.ColorMode.RGB - light.ColorMode.RGBW - light.ColorMode.RGBWW + example: "[255, 100, 100]" selector: color_rgb: rgbw_color: @@ -250,6 +251,7 @@ turn_on: - light.ColorMode.RGB - light.ColorMode.RGBW - light.ColorMode.RGBWW + advanced: true selector: color_temp: unit: "mired" @@ -265,7 +267,6 @@ turn_on: - light.ColorMode.RGB - light.ColorMode.RGBW - light.ColorMode.RGBWW - advanced: true selector: color_temp: unit: "kelvin" @@ -419,10 +420,35 @@ toggle: - light.ColorMode.RGB - light.ColorMode.RGBW - light.ColorMode.RGBWW - advanced: true example: "[255, 100, 100]" selector: color_rgb: + rgbw_color: + 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: + object: + rgbww_color: + 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: + object: color_name: filter: attribute: @@ -625,6 +651,9 @@ toggle: advanced: true selector: color_temp: + unit: "mired" + min: 153 + max: 500 kelvin: filter: attribute: @@ -635,7 +664,6 @@ toggle: - light.ColorMode.RGB - light.ColorMode.RGBW - light.ColorMode.RGBWW - advanced: true selector: color_temp: unit: "kelvin" diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 8be954f4653..fbabaff4584 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -342,6 +342,14 @@ "name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]", "description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]" }, + "rgbw_color": { + "name": "[%key:component::light::services::turn_on::fields::rgbw_color::name%]", + "description": "[%key:component::light::services::turn_on::fields::rgbw_color::description%]" + }, + "rgbww_color": { + "name": "[%key:component::light::services::turn_on::fields::rgbww_color::name%]", + "description": "[%key:component::light::services::turn_on::fields::rgbww_color::description%]" + }, "color_name": { "name": "[%key:component::light::services::turn_on::fields::color_name::name%]", "description": "[%key:component::light::services::turn_on::fields::color_name::description%]" From 11b2f201f367777a91028a1c9f12c87482522b80 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 3 Jun 2024 17:30:17 +0200 Subject: [PATCH 092/234] Rename Discovergy to inexogy (#118724) --- homeassistant/components/discovergy/const.py | 2 +- homeassistant/components/discovergy/manifest.json | 2 +- homeassistant/components/discovergy/strings.json | 2 +- homeassistant/generated/integrations.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py index 39ff7a7cd4b..80c3c23a8fa 100644 --- a/homeassistant/components/discovergy/const.py +++ b/homeassistant/components/discovergy/const.py @@ -3,4 +3,4 @@ from __future__ import annotations DOMAIN = "discovergy" -MANUFACTURER = "Discovergy" +MANUFACTURER = "inexogy" diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index f4cf7894eda..1061766a64c 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -1,6 +1,6 @@ { "domain": "discovergy", - "name": "Discovergy", + "name": "inexogy", "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/discovergy", diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 5147440e1b7..34c21bc1cfe 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -26,7 +26,7 @@ }, "system_health": { "info": { - "api_endpoint_reachable": "Discovergy API endpoint reachable" + "api_endpoint_reachable": "inexogy API endpoint reachable" } }, "entity": { diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 881e001cf12..70995bb3d63 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1240,7 +1240,7 @@ "iot_class": "cloud_push" }, "discovergy": { - "name": "Discovergy", + "name": "inexogy", "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" From f977b5431279fa9987e8a74aaa9fe37e85d88f36 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 3 Jun 2024 13:29:26 -0500 Subject: [PATCH 093/234] Resolve areas/floors to ids in intent_script (#118734) --- homeassistant/components/conversation/default_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2366722e929..d5454883292 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -871,7 +871,7 @@ class DefaultAgent(ConversationEntity): if device_area is None: return None - return {"area": {"value": device_area.id, "text": device_area.name}} + return {"area": {"value": device_area.name, "text": device_area.name}} def _get_error_text( self, From b5f557ad737046c20d0d5cc29a5ff4f320229095 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 3 Jun 2024 19:25:01 +0200 Subject: [PATCH 094/234] Update frontend to 20240603.0 (#118736) --- 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 c84a54d2642..dd112f5094a 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==20240530.0"] + "requirements": ["home-assistant-frontend==20240603.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5f823188423..3ccd21d8110 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.0 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240530.0 +home-assistant-frontend==20240603.0 home-assistant-intents==2024.5.28 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f3fe164dbcf..261d6d3e4dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240530.0 +home-assistant-frontend==20240603.0 # homeassistant.components.conversation home-assistant-intents==2024.5.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbde2c9dfe0..9ec7c519744 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240530.0 +home-assistant-frontend==20240603.0 # homeassistant.components.conversation home-assistant-intents==2024.5.28 From 8072a268a16ac5e417366ec910d186ea4e198c8e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 3 Jun 2024 21:08:28 +0200 Subject: [PATCH 095/234] Require firmware version 3.1.1 for airgradient (#118744) --- .../components/airgradient/config_flow.py | 9 +++ .../components/airgradient/strings.json | 3 +- tests/components/airgradient/conftest.py | 2 +- .../airgradient/test_config_flow.py | 57 ++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index c7b617de272..fff2615365e 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -3,6 +3,8 @@ from typing import Any from airgradient import AirGradientClient, AirGradientError, ConfigurationControl +from awesomeversion import AwesomeVersion +from mashumaro import MissingField import voluptuous as vol from homeassistant.components import zeroconf @@ -12,6 +14,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +MIN_VERSION = AwesomeVersion("3.1.1") + class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): """AirGradient config flow.""" @@ -38,6 +42,9 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.properties["serialno"]) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + if AwesomeVersion(discovery_info.properties["fw_ver"]) < MIN_VERSION: + return self.async_abort(reason="invalid_version") + session = async_get_clientsession(self.hass) self.client = AirGradientClient(host, session=session) await self.client.get_current_measures() @@ -78,6 +85,8 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): current_measures = await self.client.get_current_measures() except AirGradientError: errors["base"] = "cannot_connect" + except MissingField: + return self.async_abort(reason="invalid_version") else: await self.async_set_unique_id(current_measures.serial_number) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 9deaf17d0e4..3b1e9f9ee41 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -15,7 +15,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_version": "This firmware version is unsupported. Please upgrade the firmware of the device to at least version 3.1.1." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py index d2495c11a79..d5857fdc46a 100644 --- a/tests/components/airgradient/conftest.py +++ b/tests/components/airgradient/conftest.py @@ -62,7 +62,7 @@ def mock_new_airgradient_client( def mock_cloud_airgradient_client( mock_airgradient_client: AsyncMock, ) -> Generator[AsyncMock, None, None]: - """Mock a new AirGradient client.""" + """Mock a cloud AirGradient client.""" mock_airgradient_client.get_config.return_value = Config.from_json( load_fixture("get_config_cloud.json", DOMAIN) ) diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 6bb951f2e26..217d2ac0e8c 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -4,6 +4,7 @@ from ipaddress import ip_address from unittest.mock import AsyncMock from airgradient import AirGradientConnectionError, ConfigurationControl +from mashumaro import MissingField from homeassistant.components.airgradient import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -14,7 +15,7 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -ZEROCONF_DISCOVERY = ZeroconfServiceInfo( +OLD_ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("10.0.0.131"), ip_addresses=[ip_address("10.0.0.131")], hostname="airgradient_84fce612f5b8.local.", @@ -29,6 +30,21 @@ ZEROCONF_DISCOVERY = ZeroconfServiceInfo( }, ) +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("10.0.0.131"), + ip_addresses=[ip_address("10.0.0.131")], + hostname="airgradient_84fce612f5b8.local.", + name="airgradient_84fce612f5b8._airgradient._tcp.local.", + port=80, + type="_airgradient._tcp.local.", + properties={ + "vendor": "AirGradient", + "fw_ver": "3.1.1", + "serialno": "84fce612f5b8", + "model": "I-9PSL", + }, +) + async def test_full_flow( hass: HomeAssistant, @@ -119,6 +135,34 @@ async def test_flow_errors( assert result["type"] is FlowResultType.CREATE_ENTRY +async def test_flow_old_firmware_version( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow with old firmware version.""" + mock_airgradient_client.get_current_measures.side_effect = MissingField( + "", object, object + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_version" + + async def test_duplicate( hass: HomeAssistant, mock_airgradient_client: AsyncMock, @@ -197,3 +241,14 @@ async def test_zeroconf_flow_cloud_device( ) assert result["type"] is FlowResultType.CREATE_ENTRY mock_cloud_airgradient_client.set_configuration_control.assert_not_called() + + +async def test_zeroconf_flow_abort_old_firmware(hass: HomeAssistant) -> None: + """Test zeroconf flow aborts with old firmware.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=OLD_ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_version" From 294010400898e4e8fdcdf4061c92561bde23bba6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2024 13:39:40 -0400 Subject: [PATCH 096/234] Remove dispatcher from Tag entity (#118671) * Remove dispatcher from Tag entity * type * Don't use helper * Del is faster than pop * Use id in update --------- Co-authored-by: G Johansson --- homeassistant/components/tag/__init__.py | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 45266652a47..1613601e23a 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, final import uuid @@ -14,10 +15,6 @@ from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, entity_registry as er import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.storage import Store @@ -245,6 +242,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ).async_setup(hass) entity_registry = er.async_get(hass) + entity_update_handlers: dict[str, Callable[[str | None, str | None], None]] = {} async def tag_change_listener( change_type: str, item_id: str, updated_config: dict @@ -263,6 +261,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_add_entities( [ TagEntity( + entity_update_handlers, entity.name or entity.original_name, updated_config[CONF_ID], updated_config.get(LAST_SCANNED), @@ -273,12 +272,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif change_type == collection.CHANGE_UPDATED: # When tags are changed or updated in storage - async_dispatcher_send( - hass, - f"{SIGNAL_TAG_CHANGED}-{updated_config[CONF_ID]}", - updated_config.get(DEVICE_ID), - updated_config.get(LAST_SCANNED), - ) + if handler := entity_update_handlers.get(updated_config[CONF_ID]): + handler( + updated_config.get(DEVICE_ID), + updated_config.get(LAST_SCANNED), + ) # Deleted tags elif change_type == collection.CHANGE_REMOVED: @@ -308,6 +306,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: name = entity.name or entity.original_name entities.append( TagEntity( + entity_update_handlers, name, tag[CONF_ID], tag.get(LAST_SCANNED), @@ -371,12 +370,14 @@ class TagEntity(Entity): def __init__( self, + entity_update_handlers: dict[str, Callable[[str | None, str | None], None]], name: str, tag_id: str, last_scanned: str | None, device_id: str | None, ) -> None: """Initialize the Tag event.""" + self._entity_update_handlers = entity_update_handlers self._attr_name = name self._tag_id = tag_id self._attr_unique_id = tag_id @@ -419,10 +420,9 @@ class TagEntity(Entity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_TAG_CHANGED}-{self._tag_id}", - self.async_handle_event, - ) - ) + self._entity_update_handlers[self._tag_id] = self.async_handle_event + + async def async_will_remove_from_hass(self) -> None: + """Handle entity being removed.""" + await super().async_will_remove_from_hass() + del self._entity_update_handlers[self._tag_id] From 26344ffd748fcef7457ef04d52609c189723eb82 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 3 Jun 2024 21:27:31 +0200 Subject: [PATCH 097/234] Bump version to 2024.6.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 842615d4fa6..bc19054193f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 675492a27c2..6d3a3ac5a5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b5" +version = "2024.6.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From eb1a9eda60fad75b06c0bc72f9612a6409db4c97 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 3 Jun 2024 20:48:48 +0100 Subject: [PATCH 098/234] Harden evohome against failures to retrieve zone schedules (#118517) --- homeassistant/components/evohome/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 0b0ef1d1c0d..72e4dd5d83b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -741,16 +741,18 @@ class EvoChild(EvoDevice): assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check try: - self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] + schedule = await self._evo_broker.call_client_api( self._evo_device.get_schedule(), update_state=False ) except evo.InvalidSchedule as err: _LOGGER.warning( - "%s: Unable to retrieve the schedule: %s", + "%s: Unable to retrieve a valid schedule: %s", self._evo_device, err, ) self._schedule = {} + else: + self._schedule = schedule or {} _LOGGER.debug("Schedule['%s'] = %s", self.name, self._schedule) From 9cf6e9b21a70741f3d01d0e0b2c8cad78952c3bd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 4 Jun 2024 08:00:40 +0200 Subject: [PATCH 099/234] Bump reolink-aio to 0.9.1 (#118655) Co-authored-by: J. Nick Koston --- homeassistant/components/reolink/entity.py | 31 ++++++++++++++++--- homeassistant/components/reolink/host.py | 23 ++++++++++++-- .../components/reolink/manifest.json | 2 +- homeassistant/components/reolink/select.py | 8 +++-- homeassistant/components/reolink/strings.json | 4 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 56 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 29c1e95be81..53a81f2b162 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -89,11 +89,17 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() - if ( - self.entity_description.cmd_key is not None - and self.entity_description.cmd_key not in self._host.update_cmd_list - ): - self._host.update_cmd_list.append(self.entity_description.cmd_key) + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_register_update_cmd(cmd_key) + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_unregister_update_cmd(cmd_key) + + await super().async_will_remove_from_hass() class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): @@ -128,3 +134,18 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): sw_version=self._host.api.camera_sw_version(dev_ch), configuration_url=self._conf_url, ) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_register_update_cmd(cmd_key, self._channel) + + async def async_will_remove_from_hass(self) -> None: + """Entity removed.""" + cmd_key = self.entity_description.cmd_key + if cmd_key is not None: + self._host.async_unregister_update_cmd(cmd_key, self._channel) + + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index fe8b1596e74..b1a1a9adf0f 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections import defaultdict from collections.abc import Mapping import logging from typing import Any, Literal @@ -21,7 +22,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -67,7 +68,9 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) - self.update_cmd_list: list[str] = [] + self._update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( + lambda: defaultdict(int) + ) self.webhook_id: str | None = None self._onvif_push_supported: bool = True @@ -84,6 +87,20 @@ class ReolinkHost: self._long_poll_task: asyncio.Task | None = None self._lost_subscription: bool = False + @callback + def async_register_update_cmd(self, cmd: str, channel: int | None = None) -> None: + """Register the command to update the state.""" + self._update_cmd[cmd][channel] += 1 + + @callback + def async_unregister_update_cmd(self, cmd: str, channel: int | None = None) -> None: + """Unregister the command to update the state.""" + self._update_cmd[cmd][channel] -= 1 + if not self._update_cmd[cmd][channel]: + del self._update_cmd[cmd][channel] + if not self._update_cmd[cmd]: + del self._update_cmd[cmd] + @property def unique_id(self) -> str: """Create the unique ID, base for all entities.""" @@ -320,7 +337,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states(cmd_list=self.update_cmd_list) + await self._api.get_states(cmd_list=self._update_cmd) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index f9050ee73c4..36bc8731925 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.8.11"] + "requirements": ["reolink-aio==0.9.1"] } diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 13757e7bb22..907cc90b8af 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -109,12 +109,14 @@ SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="status_led", cmd_key="GetPowerLed", - translation_key="status_led", + translation_key="doorbell_led", entity_category=EntityCategory.CONFIG, - get_options=[state.name for state in StatusLedEnum], + get_options=lambda api, ch: api.doorbell_led_list(ch), supported=lambda api, ch: api.supported(ch, "doorbell_led"), value=lambda api, ch: StatusLedEnum(api.doorbell_led(ch)).name, - method=lambda api, ch, name: api.set_status_led(ch, StatusLedEnum[name].value), + method=lambda api, ch, name: ( + api.set_status_led(ch, StatusLedEnum[name].value, doorbell=True) + ), ), ) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 26d2bb82f0c..dc2b9a1bbaf 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -383,8 +383,8 @@ "pantiltfirst": "Pan/tilt first" } }, - "status_led": { - "name": "Status LED", + "doorbell_led": { + "name": "Doorbell LED", "state": { "stayoff": "Stay off", "auto": "Auto", diff --git a/requirements_all.txt b/requirements_all.txt index 261d6d3e4dc..f4170192e4f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2454,7 +2454,7 @@ renault-api==0.2.3 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.11 +reolink-aio==0.9.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ec7c519744..658e34322f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1912,7 +1912,7 @@ renault-api==0.2.3 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.11 +reolink-aio==0.9.1 # homeassistant.components.rflink rflink==0.0.66 From ebaec6380f13c052d9ba9cf7342da5b54358f99d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2024 16:29:50 -0400 Subject: [PATCH 100/234] Google Gen AI: Copy messages to avoid changing the trace data (#118745) --- .../google_generative_ai_conversation/conversation.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 2c0b37a1216..6b2f3c11dcc 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -225,7 +225,7 @@ class GoogleGenerativeAIConversationEntity( messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() - messages = [{}, {}] + messages = [{}, {"role": "model", "parts": "Ok"}] if ( user_input.context @@ -272,8 +272,11 @@ class GoogleGenerativeAIConversationEntity( response=intent_response, conversation_id=conversation_id ) - messages[0] = {"role": "user", "parts": prompt} - messages[1] = {"role": "model", "parts": "Ok"} + # Make a copy, because we attach it to the trace event. + messages = [ + {"role": "user", "parts": prompt}, + *messages[1:], + ] LOGGER.debug("Input: '%s' with history: %s", user_input.text, messages) trace.async_conversation_trace_append( From 69bdefb02da44a58a55b96929daf860ca4828753 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 3 Jun 2024 22:30:37 +0200 Subject: [PATCH 101/234] Revert "Allow MQTT device based auto discovery" (#118746) Revert "Allow MQTT device based auto discovery (#109030)" This reverts commit 585892f0678dc054819eb5a0a375077cd9b604b8. --- .../components/mqtt/abbreviations.py | 1 - homeassistant/components/mqtt/const.py | 1 - homeassistant/components/mqtt/discovery.py | 360 +++------ homeassistant/components/mqtt/mixins.py | 35 - homeassistant/components/mqtt/models.py | 10 - homeassistant/components/mqtt/schemas.py | 51 +- tests/components/mqtt/conftest.py | 9 +- tests/components/mqtt/test_device_trigger.py | 38 +- tests/components/mqtt/test_discovery.py | 760 ++---------------- tests/components/mqtt/test_init.py | 2 + tests/components/mqtt/test_tag.py | 10 +- 11 files changed, 171 insertions(+), 1106 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index af08fb5218e..c3efe5667ad 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -33,7 +33,6 @@ ABBREVIATIONS = { "cmd_on_tpl": "command_on_template", "cmd_t": "command_topic", "cmd_tpl": "command_template", - "cmp": "components", "cod_arm_req": "code_arm_required", "cod_dis_req": "code_disarm_required", "cod_form": "code_format", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 2d7b4ecf9e2..9a8e6ae22df 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -86,7 +86,6 @@ CONF_TEMP_MIN = "min_temp" CONF_CERTIFICATE = "certificate" CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" -CONF_COMPONENTS = "components" CONF_TLS_INSECURE = "tls_insecure" # Device and integration info options diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 2893a270be3..2cdd900690c 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -10,8 +10,6 @@ import re import time from typing import TYPE_CHECKING, Any -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HassJobType, HomeAssistant, callback @@ -21,7 +19,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.service_info.mqtt import MqttServiceInfo, ReceivePayloadType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_mqtt from homeassistant.util.json import json_loads_object @@ -34,21 +32,15 @@ from .const import ( ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, - CONF_COMPONENTS, CONF_ORIGIN, CONF_TOPIC, DOMAIN, SUPPORTED_COMPONENTS, ) -from .models import DATA_MQTT, MqttComponentConfig, MqttOriginInfo, ReceiveMessage -from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS +from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage +from .schemas import MQTT_ORIGIN_INFO_SCHEMA from .util import async_forward_entry_setup_and_setup_discovery -ABBREVIATIONS_SET = set(ABBREVIATIONS) -DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS) -ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS) - - _LOGGER = logging.getLogger(__name__) TOPIC_MATCHER = re.compile( @@ -72,7 +64,6 @@ TOPIC_BASE = "~" class MQTTDiscoveryPayload(dict[str, Any]): """Class to hold and MQTT discovery payload and discovery data.""" - device_discovery: bool = False discovery_data: DiscoveryInfoType @@ -91,13 +82,6 @@ def async_log_discovery_origin_info( message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO ) -> None: """Log information about the discovery and origin.""" - # We only log origin info once per device discovery - if not _LOGGER.isEnabledFor(level): - # bail early if logging is disabled - return - if discovery_payload.device_discovery: - _LOGGER.log(level, message) - return if CONF_ORIGIN not in discovery_payload: _LOGGER.log(level, message) return @@ -118,151 +102,6 @@ def async_log_discovery_origin_info( ) -@callback -def _replace_abbreviations( - payload: Any | dict[str, Any], - abbreviations: dict[str, str], - abbreviations_set: set[str], -) -> None: - """Replace abbreviations in an MQTT discovery payload.""" - if not isinstance(payload, dict): - return - for key in abbreviations_set.intersection(payload): - payload[abbreviations[key]] = payload.pop(key) - - -@callback -def _replace_all_abbreviations(discovery_payload: Any | dict[str, Any]) -> None: - """Replace all abbreviations in an MQTT discovery payload.""" - - _replace_abbreviations(discovery_payload, ABBREVIATIONS, ABBREVIATIONS_SET) - - if CONF_ORIGIN in discovery_payload: - _replace_abbreviations( - discovery_payload[CONF_ORIGIN], - ORIGIN_ABBREVIATIONS, - ORIGIN_ABBREVIATIONS_SET, - ) - - if CONF_DEVICE in discovery_payload: - _replace_abbreviations( - discovery_payload[CONF_DEVICE], - DEVICE_ABBREVIATIONS, - DEVICE_ABBREVIATIONS_SET, - ) - - if CONF_AVAILABILITY in discovery_payload: - for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): - _replace_abbreviations(availability_conf, ABBREVIATIONS, ABBREVIATIONS_SET) - - -@callback -def _replace_topic_base(discovery_payload: dict[str, Any]) -> None: - """Replace topic base in MQTT discovery data.""" - base = discovery_payload.pop(TOPIC_BASE) - for key, value in discovery_payload.items(): - if isinstance(value, str) and value: - if value[0] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{base}{value[1:]}" - if value[-1] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{value[:-1]}{base}" - if discovery_payload.get(CONF_AVAILABILITY): - for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): - if not isinstance(availability_conf, dict): - continue - if topic := str(availability_conf.get(CONF_TOPIC)): - if topic[0] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" - if topic[-1] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" - - -@callback -def _generate_device_cleanup_config( - hass: HomeAssistant, object_id: str, node_id: str | None -) -> dict[str, Any]: - """Generate a cleanup message on device cleanup.""" - mqtt_data = hass.data[DATA_MQTT] - device_node_id: str = f"{node_id} {object_id}" if node_id else object_id - config: dict[str, Any] = {CONF_DEVICE: {}, CONF_COMPONENTS: {}} - comp_config = config[CONF_COMPONENTS] - for platform, discover_id in mqtt_data.discovery_already_discovered: - ids = discover_id.split(" ") - component_node_id = ids.pop(0) - component_object_id = " ".join(ids) - if not ids: - continue - if device_node_id == component_node_id: - comp_config[component_object_id] = {CONF_PLATFORM: platform} - - return config if comp_config else {} - - -@callback -def _parse_device_payload( - hass: HomeAssistant, - payload: ReceivePayloadType, - object_id: str, - node_id: str | None, -) -> dict[str, Any]: - """Parse a device discovery payload.""" - device_payload: dict[str, Any] = {} - if payload == "": - if not ( - device_payload := _generate_device_cleanup_config(hass, object_id, node_id) - ): - _LOGGER.warning( - "No device components to cleanup for %s, node_id '%s'", - object_id, - node_id, - ) - return device_payload - try: - device_payload = MQTTDiscoveryPayload(json_loads_object(payload)) - except ValueError: - _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) - return {} - _replace_all_abbreviations(device_payload) - try: - DEVICE_DISCOVERY_SCHEMA(device_payload) - except vol.Invalid as exc: - _LOGGER.warning( - "Invalid MQTT device discovery payload for %s, %s: '%s'", - object_id, - exc, - payload, - ) - return {} - return device_payload - - -@callback -def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool: - """Parse and validate origin info from a single component discovery payload.""" - if CONF_ORIGIN not in discovery_payload: - return True - try: - MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) - except Exception as exc: # noqa:BLE001 - _LOGGER.warning( - "Unable to parse origin information from discovery message: %s, got %s", - exc, - discovery_payload[CONF_ORIGIN], - ) - return False - return True - - -@callback -def _merge_common_options( - component_config: MQTTDiscoveryPayload, device_config: dict[str, Any] -) -> None: - """Merge common options with the component config options.""" - for option in SHARED_OPTIONS: - if option in device_config and option not in component_config: - component_config[option] = device_config.get(option) - - async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: @@ -306,7 +145,8 @@ async def async_start( # noqa: C901 _LOGGER.warning( ( "Received message on illegal discovery topic '%s'. The topic" - " contains not allowed characters. For more information see " + " contains " + "not allowed characters. For more information see " "https://www.home-assistant.io/integrations/mqtt/#discovery-topic" ), topic, @@ -315,114 +155,108 @@ async def async_start( # noqa: C901 component, node_id, object_id = match.groups() - discovered_components: list[MqttComponentConfig] = [] - if component == CONF_DEVICE: - # Process device based discovery message - # and regenate cleanup config. - device_discovery_payload = _parse_device_payload( - hass, payload, object_id, node_id - ) - if not device_discovery_payload: - return - device_config: dict[str, Any] - origin_config: dict[str, Any] | None - component_configs: dict[str, dict[str, Any]] - device_config = device_discovery_payload[CONF_DEVICE] - origin_config = device_discovery_payload.get(CONF_ORIGIN) - component_configs = device_discovery_payload[CONF_COMPONENTS] - for component_id, config in component_configs.items(): - component = config.pop(CONF_PLATFORM) - # The object_id in the device discovery topic is the unique identifier. - # It is used as node_id for the components it contains. - component_node_id = object_id - # The component_id in the discovery playload is used as object_id - # If we have an additional node_id in the discovery topic, - # we extend the component_id with it. - component_object_id = ( - f"{node_id} {component_id}" if node_id else component_id - ) - _replace_all_abbreviations(config) - # We add wrapper to the discovery payload with the discovery data. - # If the dict is empty after removing the platform, the payload is - # assumed to remove the existing config and we do not want to add - # device or orig or shared availability attributes. - if discovery_payload := MQTTDiscoveryPayload(config): - discovery_payload.device_discovery = True - discovery_payload[CONF_DEVICE] = device_config - discovery_payload[CONF_ORIGIN] = origin_config - # Only assign shared config options - # when they are not set at entity level - _merge_common_options(discovery_payload, device_discovery_payload) - discovered_components.append( - MqttComponentConfig( - component, - component_object_id, - component_node_id, - discovery_payload, - ) - ) - _LOGGER.debug( - "Process device discovery payload %s", device_discovery_payload - ) - device_discovery_id = f"{node_id} {object_id}" if node_id else object_id - message = f"Processing device discovery for '{device_discovery_id}'" - async_log_discovery_origin_info( - message, MQTTDiscoveryPayload(device_discovery_payload) - ) + if component not in SUPPORTED_COMPONENTS: + _LOGGER.warning("Integration %s is not supported", component) + return - else: - # Process component based discovery message + if payload: try: - discovery_payload = MQTTDiscoveryPayload( - json_loads_object(payload) if payload else {} - ) + discovery_payload = MQTTDiscoveryPayload(json_loads_object(payload)) except ValueError: _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return - _replace_all_abbreviations(discovery_payload) - if not _valid_origin_info(discovery_payload): - return - discovered_components.append( - MqttComponentConfig(component, object_id, node_id, discovery_payload) - ) + else: + discovery_payload = MQTTDiscoveryPayload({}) - discovery_pending_discovered = mqtt_data.discovery_pending_discovered - for component_config in discovered_components: - component = component_config.component - node_id = component_config.node_id - object_id = component_config.object_id - discovery_payload = component_config.discovery_payload - if component not in SUPPORTED_COMPONENTS: - _LOGGER.warning("Integration %s is not supported", component) - return + for key in list(discovery_payload): + abbreviated_key = key + key = ABBREVIATIONS.get(key, key) + discovery_payload[key] = discovery_payload.pop(abbreviated_key) - if TOPIC_BASE in discovery_payload: - _replace_topic_base(discovery_payload) + if CONF_DEVICE in discovery_payload: + device = discovery_payload[CONF_DEVICE] + for key in list(device): + abbreviated_key = key + key = DEVICE_ABBREVIATIONS.get(key, key) + device[key] = device.pop(abbreviated_key) - # If present, the node_id will be included in the discovery_id. - discovery_id = f"{node_id} {object_id}" if node_id else object_id - discovery_hash = (component, discovery_id) - - if discovery_payload: - # Attach MQTT topic to the payload, used for debug prints - discovery_data = { - ATTR_DISCOVERY_HASH: discovery_hash, - ATTR_DISCOVERY_PAYLOAD: discovery_payload, - ATTR_DISCOVERY_TOPIC: topic, - } - setattr(discovery_payload, "discovery_data", discovery_data) - - if discovery_hash in discovery_pending_discovered: - pending = discovery_pending_discovered[discovery_hash]["pending"] - pending.appendleft(discovery_payload) - _LOGGER.debug( - "Component has already been discovered: %s %s, queuing update", - component, - discovery_id, + if CONF_ORIGIN in discovery_payload: + origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] + try: + for key in list(origin_info): + abbreviated_key = key + key = ORIGIN_ABBREVIATIONS.get(key, key) + origin_info[key] = origin_info.pop(abbreviated_key) + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception: # noqa: BLE001 + _LOGGER.warning( + "Unable to parse origin information " + "from discovery message, got %s", + discovery_payload[CONF_ORIGIN], ) return - async_process_discovery_payload(component, discovery_id, discovery_payload) + if CONF_AVAILABILITY in discovery_payload: + for availability_conf in cv.ensure_list( + discovery_payload[CONF_AVAILABILITY] + ): + if isinstance(availability_conf, dict): + for key in list(availability_conf): + abbreviated_key = key + key = ABBREVIATIONS.get(key, key) + availability_conf[key] = availability_conf.pop(abbreviated_key) + + if TOPIC_BASE in discovery_payload: + base = discovery_payload.pop(TOPIC_BASE) + for key, value in discovery_payload.items(): + if isinstance(value, str) and value: + if value[0] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{base}{value[1:]}" + if value[-1] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{value[:-1]}{base}" + if discovery_payload.get(CONF_AVAILABILITY): + for availability_conf in cv.ensure_list( + discovery_payload[CONF_AVAILABILITY] + ): + if not isinstance(availability_conf, dict): + continue + if topic := str(availability_conf.get(CONF_TOPIC)): + if topic[0] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" + if topic[-1] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" + + # If present, the node_id will be included in the discovered object id + discovery_id = f"{node_id} {object_id}" if node_id else object_id + discovery_hash = (component, discovery_id) + + if discovery_payload: + # Attach MQTT topic to the payload, used for debug prints + setattr( + discovery_payload, + "__configuration_source__", + f"MQTT (topic: '{topic}')", + ) + discovery_data = { + ATTR_DISCOVERY_HASH: discovery_hash, + ATTR_DISCOVERY_PAYLOAD: discovery_payload, + ATTR_DISCOVERY_TOPIC: topic, + } + setattr(discovery_payload, "discovery_data", discovery_data) + + discovery_payload[CONF_PLATFORM] = "mqtt" + + if discovery_hash in mqtt_data.discovery_pending_discovered: + pending = mqtt_data.discovery_pending_discovered[discovery_hash]["pending"] + pending.appendleft(discovery_payload) + _LOGGER.debug( + "Component has already been discovered: %s %s, queuing update", + component, + discovery_id, + ) + return + + async_process_discovery_payload(component, discovery_id, discovery_payload) @callback def async_process_discovery_payload( @@ -430,7 +264,7 @@ async def async_start( # noqa: C901 ) -> None: """Process the payload of a new discovery.""" - _LOGGER.debug("Process component discovery payload %s", payload) + _LOGGER.debug("Process discovery payload %s", payload) discovery_hash = (component, discovery_id) already_discovered = discovery_hash in mqtt_data.discovery_already_discovered diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 4ade2f260d4..55b76337db0 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -682,7 +682,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC): self._config_entry = config_entry self._config_entry_id = config_entry.entry_id self._skip_device_removal: bool = False - self._migrate_discovery: str | None = None discovery_hash = get_discovery_hash(discovery_data) self._remove_discovery_updated = async_dispatcher_connect( @@ -721,24 +720,6 @@ class MqttDiscoveryDeviceUpdateMixin(ABC): discovery_hash, discovery_payload, ) - if not discovery_payload and self._migrate_discovery is not None: - # Ignore empty update from migrated and removed discovery config. - self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery - self._migrate_discovery = None - _LOGGER.info("Component successfully migrated: %s", discovery_hash) - send_discovery_done(self.hass, self._discovery_data) - return - - if discovery_payload and ( - (discovery_topic := discovery_payload.discovery_data[ATTR_DISCOVERY_TOPIC]) - != self._discovery_data[ATTR_DISCOVERY_TOPIC] - ): - # Make sure the migrated discovery topic is removed. - self._migrate_discovery = discovery_topic - _LOGGER.debug("Migrating component: %s", discovery_hash) - self.hass.async_create_task( - async_remove_discovery_payload(self.hass, self._discovery_data) - ) if ( discovery_payload and discovery_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD] @@ -835,7 +816,6 @@ class MqttDiscoveryUpdateMixin(Entity): mqtt_data = hass.data[DATA_MQTT] self._registry_hooks = mqtt_data.discovery_registry_hooks discovery_hash: tuple[str, str] = discovery_data[ATTR_DISCOVERY_HASH] - self._migrate_discovery: str | None = None if discovery_hash in self._registry_hooks: self._registry_hooks.pop(discovery_hash)() @@ -918,27 +898,12 @@ class MqttDiscoveryUpdateMixin(Entity): old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) if not payload: - if self._migrate_discovery is not None: - # Ignore empty update of the migrated and removed discovery config. - self._discovery_data[ATTR_DISCOVERY_TOPIC] = self._migrate_discovery - self._migrate_discovery = None - _LOGGER.info("Component successfully migrated: %s", self.entity_id) - send_discovery_done(self.hass, self._discovery_data) - return # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) self.hass.async_create_task( self._async_process_discovery_update_and_remove() ) elif self._discovery_update: - discovery_topic = payload.discovery_data[ATTR_DISCOVERY_TOPIC] - if discovery_topic != self._discovery_data[ATTR_DISCOVERY_TOPIC]: - # Make sure the migrated discovery topic is removed. - self._migrate_discovery = discovery_topic - _LOGGER.debug("Migrating component: %s", self.entity_id) - self.hass.async_create_task( - async_remove_discovery_payload(self.hass, self._discovery_data) - ) if old_payload != payload: # Non-empty, changed payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 35276eeb946..f26ed196663 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -424,15 +424,5 @@ class MqttData: tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) -@dataclass(slots=True) -class MqttComponentConfig: - """(component, object_id, node_id, discovery_payload).""" - - component: str - object_id: str - node_id: str | None - discovery_payload: MQTTDiscoveryPayload - - DATA_MQTT: HassKey[MqttData] = HassKey("mqtt") DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available") diff --git a/homeassistant/components/mqtt/schemas.py b/homeassistant/components/mqtt/schemas.py index 587d4f1e154..bbc0194a1a5 100644 --- a/homeassistant/components/mqtt/schemas.py +++ b/homeassistant/components/mqtt/schemas.py @@ -2,8 +2,6 @@ from __future__ import annotations -import logging - import voluptuous as vol from homeassistant.const import ( @@ -12,7 +10,6 @@ from homeassistant.const import ( CONF_ICON, CONF_MODEL, CONF_NAME, - CONF_PLATFORM, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) @@ -27,13 +24,10 @@ from .const import ( CONF_AVAILABILITY_MODE, CONF_AVAILABILITY_TEMPLATE, CONF_AVAILABILITY_TOPIC, - CONF_COMMAND_TOPIC, - CONF_COMPONENTS, CONF_CONFIGURATION_URL, CONF_CONNECTIONS, CONF_DEPRECATED_VIA_HUB, CONF_ENABLED_BY_DEFAULT, - CONF_ENCODING, CONF_HW_VERSION, CONF_IDENTIFIERS, CONF_JSON_ATTRS_TEMPLATE, @@ -43,9 +37,7 @@ from .const import ( CONF_ORIGIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_SERIAL_NUMBER, - CONF_STATE_TOPIC, CONF_SUGGESTED_AREA, CONF_SUPPORT_URL, CONF_SW_VERSION, @@ -53,33 +45,8 @@ from .const import ( CONF_VIA_DEVICE, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, - SUPPORTED_COMPONENTS, -) -from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic - -_LOGGER = logging.getLogger(__name__) - -# Device discovery options that are also available at entity component level -SHARED_OPTIONS = [ - CONF_AVAILABILITY, - CONF_AVAILABILITY_MODE, - CONF_AVAILABILITY_TEMPLATE, - CONF_AVAILABILITY_TOPIC, - CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, - CONF_PAYLOAD_NOT_AVAILABLE, - CONF_STATE_TOPIC, -] - -MQTT_ORIGIN_INFO_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_SW_VERSION): cv.string, - vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, - } - ), ) +from .util import valid_subscribe_topic MQTT_AVAILABILITY_SINGLE_SCHEMA = vol.Schema( { @@ -181,19 +148,3 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( vol.Optional(CONF_UNIQUE_ID): cv.string, } ) - -COMPONENT_CONFIG_SCHEMA = vol.Schema( - {vol.Required(CONF_PLATFORM): vol.In(SUPPORTED_COMPONENTS)} -).extend({}, extra=True) - -DEVICE_DISCOVERY_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, - vol.Required(CONF_COMPONENTS): vol.Schema({str: COMPONENT_CONFIG_SCHEMA}), - vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, - vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_QOS): valid_qos_schema, - vol.Optional(CONF_ENCODING): cv.string, - } -) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 9e82bbbbf7e..91ece381f6d 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator from random import getrandbits -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest @@ -29,10 +29,3 @@ def mock_temp_dir(temp_dir_prefix: str) -> Generator[None, None, str]: f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", ) as mocked_temp_dir: yield mocked_temp_dir - - -@pytest.fixture -def tag_mock() -> Generator[AsyncMock, None, None]: - """Fixture to mock tag.""" - with patch("homeassistant.components.tag.async_scan_tag") as mock_tag: - yield mock_tag diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 1971ad70547..9e75ea5168b 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -35,42 +35,22 @@ def calls(hass: HomeAssistant) -> list[ServiceCall]: return async_mock_service(hass, "test", "automation") -@pytest.mark.parametrize( - ("discovery_topic", "data"), - [ - ( - "homeassistant/device_automation/0AFFD2/bla/config", - '{ "automation_type":"trigger",' - ' "device":{"identifiers":["0AFFD2"]},' - ' "payload": "short_press",' - ' "topic": "foobar/triggers/button1",' - ' "type": "button_short_press",' - ' "subtype": "button_1" }', - ), - ( - "homeassistant/device/0AFFD2/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"}, "cmp": ' - '{ "bla": {' - ' "automation_type":"trigger", ' - ' "payload": "short_press",' - ' "topic": "foobar/triggers/button1",' - ' "type": "button_short_press",' - ' "subtype": "button_1",' - ' "platform":"device_automation"}}}', - ), - ], -) async def test_get_triggers( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - discovery_topic: str, - data: str, ) -> None: """Test we get the expected triggers from a discovered mqtt device.""" await mqtt_mock_entry() - async_fire_mqtt_message(hass, discovery_topic, data) + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 3404190d871..2e1f78c1bd4 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -5,14 +5,12 @@ import copy import json from pathlib import Path import re -from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, call, patch import pytest from homeassistant import config_entries from homeassistant.components import mqtt -from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.abbreviations import ( ABBREVIATIONS, DEVICE_ABBREVIATIONS, @@ -43,13 +41,11 @@ from homeassistant.setup import async_setup_component from homeassistant.util.signal_type import SignalTypeFormat from .test_common import help_all_subscribe_calls, help_test_unload_config_entry -from .test_tag import DEFAULT_TAG_ID, DEFAULT_TAG_SCAN from tests.common import ( MockConfigEntry, async_capture_events, async_fire_mqtt_message, - async_get_device_automations, mock_config_flow, mock_platform, ) @@ -89,8 +85,6 @@ async def test_subscribing_config_topic( [ ("homeassistant/binary_sensor/bla/not_config", False), ("homeassistant/binary_sensor/rörkrökare/config", True), - ("homeassistant/device/bla/not_config", False), - ("homeassistant/device/rörkrökare/config", True), ], ) async def test_invalid_topic( @@ -119,15 +113,10 @@ async def test_invalid_topic( caplog.clear() -@pytest.mark.parametrize( - "discovery_topic", - ["homeassistant/binary_sensor/bla/config", "homeassistant/device/bla/config"], -) async def test_invalid_json( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - discovery_topic: str, ) -> None: """Test sending in invalid JSON.""" await mqtt_mock_entry() @@ -136,7 +125,9 @@ async def test_invalid_json( ) as mock_dispatcher_send: mock_dispatcher_send = AsyncMock(return_value=None) - async_fire_mqtt_message(hass, discovery_topic, "not json") + async_fire_mqtt_message( + hass, "homeassistant/binary_sensor/bla/config", "not json" + ) await hass.async_block_till_done() assert "Unable to parse JSON" in caplog.text assert not mock_dispatcher_send.called @@ -185,43 +176,6 @@ async def test_invalid_config( assert "Error 'expected int for dictionary value @ data['qos']'" in caplog.text -async def test_invalid_device_discovery_config( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test sending in JSON that violates the discovery schema if device or platform key is missing.""" - await mqtt_mock_entry() - async_fire_mqtt_message( - hass, - "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "cmp": ' - '{ "acp1": {"name": "abc", "state_topic": "home/alarm", ' - '"command_topic": "home/alarm/set", ' - '"platform":"alarm_control_panel"}}}', - ) - await hass.async_block_till_done() - assert ( - "Invalid MQTT device discovery payload for bla, " - "required key not provided @ data['device']" in caplog.text - ) - - caplog.clear() - async_fire_mqtt_message( - hass, - "homeassistant/device/bla/config", - '{ "o": {"name": "foobar"}, "dev": {"identifiers": ["ABDE03"]}, ' - '"cmp": { "acp1": {"name": "abc", "state_topic": "home/alarm", ' - '"command_topic": "home/alarm/set" }}}', - ) - await hass.async_block_till_done() - assert ( - "Invalid MQTT device discovery payload for bla, " - "required key not provided @ data['components']['acp1']['platform']" - in caplog.text - ) - - async def test_only_valid_components( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, @@ -267,51 +221,17 @@ async def test_correct_config_discovery( assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered -@pytest.mark.parametrize( - ("discovery_topic", "payloads", "discovery_id"), - [ - ( - "homeassistant/binary_sensor/bla/config", - ( - '{"name":"Beer","state_topic": "test-topic",' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - '{"name":"Milk","state_topic": "test-topic",' - '"o":{"name":"bla2mqtt","sw":"1.1",' - '"url":"https://bla2mqtt.example.com/support"},' - '"dev":{"identifiers":["bla"]}}', - ), - "bla", - ), - ( - "homeassistant/device/bla/config", - ( - '{"cmp":{"bin_sens1":{"platform":"binary_sensor",' - '"name":"Beer","state_topic": "test-topic"}},' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - '{"cmp":{"bin_sens1":{"platform":"binary_sensor",' - '"name":"Milk","state_topic": "test-topic"}},' - '"o":{"name":"bla2mqtt","sw":"1.1",' - '"url":"https://bla2mqtt.example.com/support"},' - '"dev":{"identifiers":["bla"]}}', - ), - "bla bin_sens1", - ), - ], -) async def test_discovery_integration_info( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - discovery_topic: str, - payloads: tuple[str, str], - discovery_id: str, ) -> None: - """Test discovery of integration info.""" + """Test logging discovery of new and updated items.""" await mqtt_mock_entry() async_fire_mqtt_message( hass, - discovery_topic, - payloads[0], + "homeassistant/binary_sensor/bla/config", + '{ "name": "Beer", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.0" } }', ) await hass.async_block_till_done() @@ -321,10 +241,7 @@ async def test_discovery_integration_info( assert state.name == "Beer" assert ( - "Processing device discovery for 'bla' from external " - "application bla2mqtt, version: 1.0" - in caplog.text - or f"Found new component: binary_sensor {discovery_id} from external application bla2mqtt, version: 1.0" + "Found new component: binary_sensor bla from external application bla2mqtt, version: 1.0" in caplog.text ) caplog.clear() @@ -332,8 +249,8 @@ async def test_discovery_integration_info( # Send an update and add support url async_fire_mqtt_message( hass, - discovery_topic, - payloads[1], + "homeassistant/binary_sensor/bla/config", + '{ "name": "Milk", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.1", "url": "https://bla2mqtt.example.com/support" } }', ) await hass.async_block_till_done() state = hass.states.get("binary_sensor.beer") @@ -342,343 +259,31 @@ async def test_discovery_integration_info( assert state.name == "Milk" assert ( - f"Component has already been discovered: binary_sensor {discovery_id}" + "Component has already been discovered: binary_sensor bla, sending update from external application bla2mqtt, version: 1.1, support URL: https://bla2mqtt.example.com/support" in caplog.text ) @pytest.mark.parametrize( - ("single_configs", "device_discovery_topic", "device_config"), + "config_message", [ - ( - [ - ( - "homeassistant/device_automation/0AFFD2/bla1/config", - { - "device": {"identifiers": ["0AFFD2"]}, - "automation_type": "trigger", - "payload": "short_press", - "topic": "foobar/triggers/button1", - "type": "button_short_press", - "subtype": "button_1", - }, - ), - ( - "homeassistant/sensor/0AFFD2/bla2/config", - { - "device": {"identifiers": ["0AFFD2"]}, - "state_topic": "foobar/sensors/bla2/state", - }, - ), - ( - "homeassistant/tag/0AFFD2/bla3/config", - { - "device": {"identifiers": ["0AFFD2"]}, - "topic": "foobar/tags/bla3/see", - }, - ), - ], - "homeassistant/device/0AFFD2/config", - { - "device": {"identifiers": ["0AFFD2"]}, - "o": {"name": "foobar"}, - "cmp": { - "bla1": { - "platform": "device_automation", - "automation_type": "trigger", - "payload": "short_press", - "topic": "foobar/triggers/button1", - "type": "button_short_press", - "subtype": "button_1", - }, - "bla2": { - "platform": "sensor", - "state_topic": "foobar/sensors/bla2/state", - }, - "bla3": { - "platform": "tag", - "topic": "foobar/tags/bla3/see", - }, - }, - }, - ) - ], -) -async def test_discovery_migration( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock: AsyncMock, - single_configs: list[tuple[str, dict[str, Any]]], - device_discovery_topic: str, - device_config: dict[str, Any], -) -> None: - """Test the migration of single discovery to device discovery.""" - mock_mqtt = await mqtt_mock_entry() - publish_mock: MagicMock = mock_mqtt._mqttc.publish - - # Discovery single config schema - for discovery_topic, config in single_configs: - payload = json.dumps(config) - async_fire_mqtt_message( - hass, - discovery_topic, - payload, - ) - await hass.async_block_till_done() - await hass.async_block_till_done() - - async def check_discovered_items(): - # Check the device_trigger was discovered - device_entry = device_registry.async_get_device( - identifiers={("mqtt", "0AFFD2")} - ) - assert device_entry is not None - triggers = await async_get_device_automations( - hass, DeviceAutomationType.TRIGGER, device_entry.id - ) - assert len(triggers) == 1 - # Check the sensor was discovered - state = hass.states.get("sensor.mqtt_sensor") - assert state is not None - - # Check the tag works - async_fire_mqtt_message(hass, "foobar/tags/bla3/see", DEFAULT_TAG_SCAN) - await hass.async_block_till_done() - tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) - tag_mock.reset_mock() - - await check_discovered_items() - - # Migrate to device based discovery - payload = json.dumps(device_config) - async_fire_mqtt_message( - hass, - device_discovery_topic, - payload, - ) - await hass.async_block_till_done() - # Test the single discovery topics are reset and `None` is published - await check_discovered_items() - assert len(publish_mock.mock_calls) == len(single_configs) - published_topics = {call[1][0] for call in publish_mock.mock_calls} - expected_topics = {item[0] for item in single_configs} - assert published_topics == expected_topics - published_payloads = [call[1][1] for call in publish_mock.mock_calls] - assert published_payloads == [None, None, None] - - -@pytest.mark.parametrize( - ("discovery_topic", "payload", "discovery_id"), - [ - ( - "homeassistant/binary_sensor/bla/config", - '{"name":"Beer","state_topic": "test-topic",' - '"avty": {"topic": "avty-topic"},' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - "bla", - ), - ( - "homeassistant/device/bla/config", - '{"cmp":{"bin_sens1":{"platform":"binary_sensor",' - '"name":"Beer","state_topic": "test-topic"}},' - '"avty": {"topic": "avty-topic"},' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - "bin_sens1 bla", - ), - ], -) -async def test_discovery_availability( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - discovery_topic: str, - payload: str, - discovery_id: str, -) -> None: - """Test device discovery with shared availability mapping.""" - await mqtt_mock_entry() - async_fire_mqtt_message( - hass, - discovery_topic, - payload, - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.name == "Beer" - assert state.state == STATE_UNAVAILABLE - - async_fire_mqtt_message( - hass, - "avty-topic", - "online", - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.state == STATE_UNKNOWN - - async_fire_mqtt_message( - hass, - "test-topic", - "ON", - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.state == STATE_ON - - -@pytest.mark.parametrize( - ("discovery_topic", "payload", "discovery_id"), - [ - ( - "homeassistant/device/bla/config", - '{"cmp":{"bin_sens1":{"platform":"binary_sensor",' - '"avty": {"topic": "avty-topic-component"},' - '"name":"Beer","state_topic": "test-topic"}},' - '"avty": {"topic": "avty-topic-device"},' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - "bin_sens1 bla", - ), - ( - "homeassistant/device/bla/config", - '{"cmp":{"bin_sens1":{"platform":"binary_sensor",' - '"availability_topic": "avty-topic-component",' - '"name":"Beer","state_topic": "test-topic"}},' - '"availability_topic": "avty-topic-device",' - '"o":{"name":"bla2mqtt","sw":"1.0"},"dev":{"identifiers":["bla"]}}', - "bin_sens1 bla", - ), - ], -) -async def test_discovery_component_availability_overridden( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, - discovery_topic: str, - payload: str, - discovery_id: str, -) -> None: - """Test device discovery with overridden shared availability mapping.""" - await mqtt_mock_entry() - async_fire_mqtt_message( - hass, - discovery_topic, - payload, - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.name == "Beer" - assert state.state == STATE_UNAVAILABLE - - async_fire_mqtt_message( - hass, - "avty-topic-device", - "online", - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.state == STATE_UNAVAILABLE - - async_fire_mqtt_message( - hass, - "avty-topic-component", - "online", - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.state == STATE_UNKNOWN - - async_fire_mqtt_message( - hass, - "test-topic", - "ON", - ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.state == STATE_ON - - -@pytest.mark.parametrize( - ("discovery_topic", "config_message", "error_message"), - [ - ( - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic", "o": "bla2mqtt" }', - "Unable to parse origin information from discovery message", - ), - ( - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic", "o": 2.0 }', - "Unable to parse origin information from discovery message", - ), - ( - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic", "o": null }', - "Unable to parse origin information from discovery message", - ), - ( - "homeassistant/binary_sensor/bla/config", - '{ "name": "Beer", "state_topic": "test-topic", "o": {"sw": "bla2mqtt"} }', - "Unable to parse origin information from discovery message", - ), - ( - "homeassistant/device/bla/config", - '{"dev":{"identifiers":["bs1"]},"cmp":{"bs1":' - '{"platform":"binary_sensor","name":"Beer","state_topic":"test-topic"}' - '},"o": "bla2mqtt"' - "}", - "Invalid MQTT device discovery payload for bla, " - "expected a dictionary for dictionary value @ data['origin']", - ), - ( - "homeassistant/device/bla/config", - '{"dev":{"identifiers":["bs1"]},"cmp":{"bs1":' - '{"platform":"binary_sensor","name":"Beer","state_topic":"test-topic"}' - '},"o": 2.0' - "}", - "Invalid MQTT device discovery payload for bla, " - "expected a dictionary for dictionary value @ data['origin']", - ), - ( - "homeassistant/device/bla/config", - '{"dev":{"identifiers":["bs1"]},"cmp":{"bs1":' - '{"platform":"binary_sensor","name":"Beer","state_topic":"test-topic"}' - '},"o": null' - "}", - "Invalid MQTT device discovery payload for bla, " - "expected a dictionary for dictionary value @ data['origin']", - ), - ( - "homeassistant/device/bla/config", - '{"dev":{"identifiers":["bs1"]},"cmp":{"bs1":' - '{"platform":"binary_sensor","name":"Beer","state_topic":"test-topic"}' - '},"o": {"sw": "bla2mqtt"}' - "}", - "Invalid MQTT device discovery payload for bla, " - "required key not provided @ data['origin']['name']", - ), + '{ "name": "Beer", "state_topic": "test-topic", "o": "bla2mqtt" }', + '{ "name": "Beer", "state_topic": "test-topic", "o": 2.0 }', + '{ "name": "Beer", "state_topic": "test-topic", "o": null }', + '{ "name": "Beer", "state_topic": "test-topic", "o": {"sw": "bla2mqtt"} }', ], ) async def test_discovery_with_invalid_integration_info( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, - discovery_topic: str, config_message: str, - error_message: str, ) -> None: """Test sending in correct JSON.""" await mqtt_mock_entry() async_fire_mqtt_message( hass, - discovery_topic, + "homeassistant/binary_sensor/bla/config", config_message, ) await hass.async_block_till_done() @@ -686,7 +291,9 @@ async def test_discovery_with_invalid_integration_info( state = hass.states.get("binary_sensor.beer") assert state is None - assert error_message in caplog.text + assert ( + "Unable to parse origin information from discovery message, got" in caplog.text + ) async def test_discover_fan( @@ -1215,63 +822,35 @@ async def test_duplicate_removal( assert "Component has already been discovered: binary_sensor bla" not in caplog.text -@pytest.mark.parametrize( - ("discovery_topic", "discovery_payload", "entity_ids"), - [ - ( - "homeassistant/sensor/bla/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique" }', - ["sensor.none_mqtt_sensor"], - ), - ( - "homeassistant/device/bla/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "cmp": {"sens1": {' - ' "platform": "sensor",' - ' "name": "sensor1",' - ' "state_topic": "foobar/sensor1",' - ' "unique_id": "unique1"' - ' },"sens2": {' - ' "platform": "sensor",' - ' "name": "sensor2",' - ' "state_topic": "foobar/sensor2",' - ' "unique_id": "unique2"' - "}}}", - ["sensor.none_sensor1", "sensor.none_sensor2"], - ), - ], -) async def test_cleanup_device( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - discovery_topic: str, - discovery_payload: str, - entity_ids: list[str], ) -> None: """Test discovered device is cleaned up when entry removed from device.""" mqtt_mock = await mqtt_mock_entry() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - async_fire_mqtt_message(hass, discovery_topic, discovery_payload) + data = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() # Verify device and registry entries are created device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") + assert entity_entry is not None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is not None - - state = hass.states.get(entity_id) - assert state is not None + state = hass.states.get("sensor.none_mqtt_sensor") + assert state is not None # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] @@ -1289,221 +868,60 @@ async def test_cleanup_device( assert entity_entry is None # Verify state is removed - for entity_id in entity_ids: - state = hass.states.get(entity_id) - assert state is None - await hass.async_block_till_done() + state = hass.states.get("sensor.none_mqtt_sensor") + assert state is None + await hass.async_block_till_done() # Verify retained discovery topic has been cleared - mqtt_mock.async_publish.assert_called_with(discovery_topic, None, 0, True) + mqtt_mock.async_publish.assert_called_once_with( + "homeassistant/sensor/bla/config", None, 0, True + ) -@pytest.mark.parametrize( - ("discovery_topic", "discovery_payload", "entity_ids"), - [ - ( - "homeassistant/sensor/bla/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique" }', - ["sensor.none_mqtt_sensor"], - ), - ( - "homeassistant/device/bla/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "cmp": {"sens1": {' - ' "platform": "sensor",' - ' "name": "sensor1",' - ' "state_topic": "foobar/sensor1",' - ' "unique_id": "unique1"' - ' },"sens2": {' - ' "platform": "sensor",' - ' "name": "sensor2",' - ' "state_topic": "foobar/sensor2",' - ' "unique_id": "unique2"' - "}}}", - ["sensor.none_sensor1", "sensor.none_sensor2"], - ), - ], -) async def test_cleanup_device_mqtt( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - discovery_topic: str, - discovery_payload: str, - entity_ids: list[str], ) -> None: - """Test discovered device is cleaned up when removed through MQTT.""" + """Test discvered device is cleaned up when removed through MQTT.""" mqtt_mock = await mqtt_mock_entry() - - # set up an existing sensor first data = ( - '{ "device":{"identifiers":["0AFFD3"]},' - ' "name": "sensor_base",' + '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' - ' "unique_id": "unique_base" }' + ' "unique_id": "unique" }' ) - base_discovery_topic = "homeassistant/sensor/bla_base/config" - base_entity_id = "sensor.none_sensor_base" - async_fire_mqtt_message(hass, base_discovery_topic, data) - await hass.async_block_till_done() - # Verify the base entity has been created and it has a state - base_device_entry = device_registry.async_get_device( - identifiers={("mqtt", "0AFFD3")} - ) - assert base_device_entry is not None - entity_entry = entity_registry.async_get(base_entity_id) - assert entity_entry is not None - state = hass.states.get(base_entity_id) - assert state is not None - - async_fire_mqtt_message(hass, discovery_topic, discovery_payload) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() # Verify device and registry entries are created device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is not None + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") + assert entity_entry is not None - state = hass.states.get(entity_id) - assert state is not None + state = hass.states.get("sensor.none_mqtt_sensor") + assert state is not None - async_fire_mqtt_message(hass, discovery_topic, "") + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", "") await hass.async_block_till_done() await hass.async_block_till_done() # Verify device and registry entries are cleared device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") + assert entity_entry is None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is None - - # Verify state is removed - state = hass.states.get(entity_id) - assert state is None - await hass.async_block_till_done() + # Verify state is removed + state = hass.states.get("sensor.none_mqtt_sensor") + assert state is None + await hass.async_block_till_done() # Verify retained discovery topics have not been cleared again mqtt_mock.async_publish.assert_not_called() - # Verify the base entity still exists and it has a state - base_device_entry = device_registry.async_get_device( - identifiers={("mqtt", "0AFFD3")} - ) - assert base_device_entry is not None - entity_entry = entity_registry.async_get(base_entity_id) - assert entity_entry is not None - state = hass.states.get(base_entity_id) - assert state is not None - - -async def test_cleanup_device_mqtt_device_discovery( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test discovered device is cleaned up partly when removed through MQTT.""" - await mqtt_mock_entry() - - discovery_topic = "homeassistant/device/bla/config" - discovery_payload = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "cmp": {"sens1": {' - ' "platform": "sensor",' - ' "name": "sensor1",' - ' "state_topic": "foobar/sensor1",' - ' "unique_id": "unique1"' - ' },"sens2": {' - ' "platform": "sensor",' - ' "name": "sensor2",' - ' "state_topic": "foobar/sensor2",' - ' "unique_id": "unique2"' - "}}}" - ) - entity_ids = ["sensor.none_sensor1", "sensor.none_sensor2"] - async_fire_mqtt_message(hass, discovery_topic, discovery_payload) - await hass.async_block_till_done() - - # Verify device and registry entries are created - device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) - assert device_entry is not None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is not None - - state = hass.states.get(entity_id) - assert state is not None - - # Do update and remove sensor 2 from device - discovery_payload_update1 = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "cmp": {"sens1": {' - ' "platform": "sensor",' - ' "name": "sensor1",' - ' "state_topic": "foobar/sensor1",' - ' "unique_id": "unique1"' - ' },"sens2": {' - ' "platform": "sensor"' - "}}}" - ) - async_fire_mqtt_message(hass, discovery_topic, discovery_payload_update1) - await hass.async_block_till_done() - state = hass.states.get(entity_ids[0]) - assert state is not None - state = hass.states.get(entity_ids[1]) - assert state is None - - # Repeating the update - async_fire_mqtt_message(hass, discovery_topic, discovery_payload_update1) - await hass.async_block_till_done() - state = hass.states.get(entity_ids[0]) - assert state is not None - state = hass.states.get(entity_ids[1]) - assert state is None - - # Removing last sensor - discovery_payload_update2 = ( - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "cmp": {"sens1": {' - ' "platform": "sensor"' - ' },"sens2": {' - ' "platform": "sensor"' - "}}}" - ) - async_fire_mqtt_message(hass, discovery_topic, discovery_payload_update2) - await hass.async_block_till_done() - device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) - # Verify the device entry was removed with the last sensor - assert device_entry is None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is None - - state = hass.states.get(entity_id) - assert state is None - - # Repeating the update - async_fire_mqtt_message(hass, discovery_topic, discovery_payload_update2) - await hass.async_block_till_done() - - # Clear the empty discovery payload and verify there was nothing to cleanup - async_fire_mqtt_message(hass, discovery_topic, "") - await hass.async_block_till_done() - assert "No device components to cleanup" in caplog.text - async def test_cleanup_device_multiple_config_entries( hass: HomeAssistant, @@ -2388,77 +1806,3 @@ async def test_discovery_dispatcher_signal_type_messages( assert len(calls) == 1 assert calls[0] == test_data unsub() - - -@pytest.mark.parametrize( - ("discovery_topic", "discovery_payload", "entity_ids"), - [ - ( - "homeassistant/device/bla/config", - '{ "device":{"identifiers":["0AFFD2"]},' - ' "o": {"name": "foobar"},' - ' "state_topic": "foobar/sensor-shared",' - ' "cmp": {"sens1": {' - ' "platform": "sensor",' - ' "name": "sensor1",' - ' "unique_id": "unique1"' - ' },"sens2": {' - ' "platform": "sensor",' - ' "name": "sensor2",' - ' "unique_id": "unique2"' - ' },"sens3": {' - ' "platform": "sensor",' - ' "name": "sensor3",' - ' "state_topic": "foobar/sensor3",' - ' "unique_id": "unique3"' - "}}}", - ["sensor.none_sensor1", "sensor.none_sensor2", "sensor.none_sensor3"], - ), - ], -) -async def test_shared_state_topic( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - mqtt_mock_entry: MqttMockHAClientGenerator, - discovery_topic: str, - discovery_payload: str, - entity_ids: list[str], -) -> None: - """Test a shared state_topic can be used.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, discovery_topic, discovery_payload) - await hass.async_block_till_done() - - # Verify device and registry entries are created - device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) - assert device_entry is not None - for entity_id in entity_ids: - entity_entry = entity_registry.async_get(entity_id) - assert entity_entry is not None - - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_UNKNOWN - - async_fire_mqtt_message(hass, "foobar/sensor-shared", "New state") - - entity_id = entity_ids[0] - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "New state" - entity_id = entity_ids[1] - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "New state" - entity_id = entity_ids[2] - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_UNKNOWN - - async_fire_mqtt_message(hass, "foobar/sensor3", "New state3") - entity_id = entity_ids[2] - state = hass.states.get(entity_id) - assert state is not None - assert state.state == "New state3" diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8c3bd99c562..50b22e986b0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3162,6 +3162,7 @@ async def test_mqtt_ws_get_device_debug_info( } data_sensor = json.dumps(config_sensor) data_trigger = json.dumps(config_trigger) + config_sensor["platform"] = config_trigger["platform"] = mqtt.DOMAIN async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data_sensor) async_fire_mqtt_message( @@ -3218,6 +3219,7 @@ async def test_mqtt_ws_get_device_debug_info_binary( "unique_id": "unique", } data = json.dumps(config) + config["platform"] = mqtt.DOMAIN async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data) await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 60c02b9ad4b..1575684e164 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -1,8 +1,9 @@ """The tests for MQTT tag scanner.""" +from collections.abc import Generator import copy import json -from unittest.mock import ANY, AsyncMock +from unittest.mock import ANY, AsyncMock, patch import pytest @@ -45,6 +46,13 @@ DEFAULT_TAG_SCAN_JSON = ( ) +@pytest.fixture +def tag_mock() -> Generator[AsyncMock, None, None]: + """Fixture to mock tag.""" + with patch("homeassistant.components.tag.async_scan_tag") as mock_tag: + yield mock_tag + + @pytest.mark.no_fail_on_log_exception async def test_discover_bad_tag( hass: HomeAssistant, From d68d87105406c2455231cfe2b5d80aa5e8f44cfe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 3 Jun 2024 16:27:05 -0400 Subject: [PATCH 102/234] Update OpenAI prompt on each interaction (#118747) --- .../openai_conversation/conversation.py | 96 +++++++++---------- .../openai_conversation/test_conversation.py | 50 +++++++++- 2 files changed, 93 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 306e4134b9e..d5e566678f1 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -146,58 +146,58 @@ class OpenAIConversationEntity( messages = self.history[conversation_id] else: conversation_id = ulid.ulid_now() + messages = [] - if ( - user_input.context - and user_input.context.user_id - and ( - user := await self.hass.auth.async_get_user( - user_input.context.user_id - ) + if ( + user_input.context + and user_input.context.user_id + and ( + user := await self.hass.auth.async_get_user(user_input.context.user_id) + ) + ): + user_name = user.name + + try: + if llm_api: + api_prompt = llm_api.api_prompt + else: + api_prompt = llm.async_render_no_api_prompt(self.hass) + + prompt = "\n".join( + ( + template.Template( + llm.BASE_PROMPT + + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), + self.hass, + ).async_render( + { + "ha_name": self.hass.config.location_name, + "user_name": user_name, + "llm_context": llm_context, + }, + parse_result=False, + ), + api_prompt, ) - ): - user_name = user.name + ) - try: - if llm_api: - api_prompt = llm_api.api_prompt - else: - api_prompt = llm.async_render_no_api_prompt(self.hass) + except TemplateError as err: + LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem with my template: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) - prompt = "\n".join( - ( - template.Template( - llm.BASE_PROMPT - + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT), - self.hass, - ).async_render( - { - "ha_name": self.hass.config.location_name, - "user_name": user_name, - "llm_context": llm_context, - }, - parse_result=False, - ), - api_prompt, - ) - ) - - except TemplateError as err: - LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - messages = [ChatCompletionSystemMessageParam(role="system", content=prompt)] - - messages.append( - ChatCompletionUserMessageParam(role="user", content=user_input.text) - ) + # Create a copy of the variable because we attach it to the trace + messages = [ + ChatCompletionSystemMessageParam(role="system", content=prompt), + *messages[1:], + ChatCompletionUserMessageParam(role="user", content=user_input.text), + ] LOGGER.debug("Prompt: %s", messages) trace.async_conversation_trace_append( diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 05d62ffd61b..002b2df186b 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time from httpx import Response from openai import RateLimitError from openai.types.chat.chat_completion import ChatCompletion, Choice @@ -214,11 +215,14 @@ async def test_function_call( ), ) - with patch( - "openai.resources.chat.completions.AsyncCompletions.create", - new_callable=AsyncMock, - side_effect=completion_result, - ) as mock_create: + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-03 23:00:00"), + ): result = await conversation.async_converse( hass, "Please call the test function", @@ -227,6 +231,11 @@ async def test_function_call( agent_id=agent_id, ) + assert ( + "Today's date is 2024-06-03." + in mock_create.mock_calls[1][2]["messages"][0]["content"] + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert mock_create.mock_calls[1][2]["messages"][3] == { "role": "tool", @@ -262,6 +271,37 @@ async def test_function_call( # AGENT_DETAIL event contains the raw prompt passed to the model detail_event = trace_events[1] assert "Answer in plain text" in detail_event["data"]["messages"][0]["content"] + assert ( + "Today's date is 2024-06-03." + in trace_events[1]["data"]["messages"][0]["content"] + ) + + # Call it again, make sure we have updated prompt + with ( + patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=completion_result, + ) as mock_create, + freeze_time("2024-06-04 23:00:00"), + ): + result = await conversation.async_converse( + hass, + "Please call the test function", + None, + context, + agent_id=agent_id, + ) + + assert ( + "Today's date is 2024-06-04." + in mock_create.mock_calls[1][2]["messages"][0]["content"] + ) + # Test old assert message not updated + assert ( + "Today's date is 2024-06-03." + in trace_events[1]["data"]["messages"][0]["content"] + ) @patch( From 7bbfb1a22b6daa9c0c427529db47bd0a5d377f1e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 3 Jun 2024 21:47:09 -0500 Subject: [PATCH 103/234] Bump intents to 2024.6.3 (#118748) --- 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 d69a65b9c6e..6873e47e647 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.5.28"] + "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3ccd21d8110..c3d30e6e09d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ hass-nabucasa==0.81.0 hassil==1.7.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240603.0 -home-assistant-intents==2024.5.28 +home-assistant-intents==2024.6.3 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index f4170192e4f..d91a45a4b33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1090,7 +1090,7 @@ holidays==0.49 home-assistant-frontend==20240603.0 # homeassistant.components.conversation -home-assistant-intents==2024.5.28 +home-assistant-intents==2024.6.3 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 658e34322f8..93161a76c78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ holidays==0.49 home-assistant-frontend==20240603.0 # homeassistant.components.conversation -home-assistant-intents==2024.5.28 +home-assistant-intents==2024.6.3 # homeassistant.components.home_connect homeconnect==0.7.2 From 70d7cedf0804414fffec233571bbba0363ab4937 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 3 Jun 2024 23:38:31 +0200 Subject: [PATCH 104/234] Do not log mqtt origin info if the log level does not allow it (#118752) --- homeassistant/components/mqtt/discovery.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 2cdd900690c..e8a3ed9a8cb 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -82,6 +82,9 @@ def async_log_discovery_origin_info( message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO ) -> None: """Log information about the discovery and origin.""" + if not _LOGGER.isEnabledFor(level): + # bail early if logging is disabled + return if CONF_ORIGIN not in discovery_payload: _LOGGER.log(level, message) return From 4b4b5362d9d83dc8f5082766c33ef9812ce91267 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 3 Jun 2024 21:26:40 -0500 Subject: [PATCH 105/234] Clean up exposed domains (#118753) * Remove lock and script * Add media player * Fix tests --- .../homeassistant/exposed_entities.py | 3 +-- .../conversation/test_default_agent.py | 20 ++++++++++++------- .../homeassistant/test_exposed_entities.py | 16 ++++++++++++--- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py index d40105324c4..82848b0e273 100644 --- a/homeassistant/components/homeassistant/exposed_entities.py +++ b/homeassistant/components/homeassistant/exposed_entities.py @@ -35,9 +35,8 @@ DEFAULT_EXPOSED_DOMAINS = { "fan", "humidifier", "light", - "lock", + "media_player", "scene", - "script", "switch", "todo", "vacuum", diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 659ee8794b8..511967e3a9c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -72,15 +72,23 @@ async def test_hidden_entities_skipped( async def test_exposed_domains(hass: HomeAssistant, init_components) -> None: """Test that we can't interact with entities that aren't exposed.""" hass.states.async_set( - "media_player.test", "off", attributes={ATTR_FRIENDLY_NAME: "Test Media Player"} + "lock.front_door", "off", attributes={ATTR_FRIENDLY_NAME: "Front Door"} ) + hass.states.async_set( + "script.my_script", "off", attributes={ATTR_FRIENDLY_NAME: "My Script"} + ) + + # These are match failures instead of handle failures because the domains + # aren't exposed by default. + result = await conversation.async_converse( + hass, "unlock front door", None, Context(), None + ) + assert result.response.response_type == intent.IntentResponseType.ERROR + assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS result = await conversation.async_converse( - hass, "turn on test media player", None, Context(), None + hass, "run my script", None, Context(), None ) - - # This is a match failure instead of a handle failure because the media - # player domain is not exposed. assert result.response.response_type == intent.IntentResponseType.ERROR assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS @@ -806,7 +814,6 @@ async def test_error_wrong_state(hass: HomeAssistant, init_components) -> None: media_player.STATE_IDLE, {ATTR_FRIENDLY_NAME: "test player"}, ) - expose_entity(hass, "media_player.test_player", True) result = await conversation.async_converse( hass, "pause test player", None, Context(), None @@ -829,7 +836,6 @@ async def test_error_feature_not_supported( {ATTR_FRIENDLY_NAME: "test player"}, # missing VOLUME_SET feature ) - expose_entity(hass, "media_player.test_player", True) result = await conversation.async_converse( hass, "set test player volume to 100%", None, Context(), None diff --git a/tests/components/homeassistant/test_exposed_entities.py b/tests/components/homeassistant/test_exposed_entities.py index 9a14198b1ef..b3ff6594509 100644 --- a/tests/components/homeassistant/test_exposed_entities.py +++ b/tests/components/homeassistant/test_exposed_entities.py @@ -57,9 +57,12 @@ def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: entry_sensor_temperature = entity_registry.async_get_or_create( "sensor", "test", - "unique2", + "unique3", original_device_class="temperature", ) + entry_media_player = entity_registry.async_get_or_create( + "media_player", "test", "unique4", original_device_class="media_player" + ) return { "blocked": entry_blocked.entity_id, "lock": entry_lock.entity_id, @@ -67,6 +70,7 @@ def entities_unique_id(entity_registry: er.EntityRegistry) -> dict[str, str]: "door_sensor": entry_binary_sensor_door.entity_id, "sensor": entry_sensor.entity_id, "temperature_sensor": entry_sensor_temperature.entity_id, + "media_player": entry_media_player.entity_id, } @@ -78,10 +82,12 @@ def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: door_sensor = "binary_sensor.door" sensor = "sensor.test" sensor_temperature = "sensor.temperature" + media_player = "media_player.test" hass.states.async_set(binary_sensor, "on", {}) hass.states.async_set(door_sensor, "on", {"device_class": "door"}) hass.states.async_set(sensor, "on", {}) hass.states.async_set(sensor_temperature, "on", {"device_class": "temperature"}) + hass.states.async_set(media_player, "idle", {}) return { "blocked": blocked, "lock": lock, @@ -89,6 +95,7 @@ def entities_no_unique_id(hass: HomeAssistant) -> dict[str, str]: "door_sensor": door_sensor, "sensor": sensor, "temperature_sensor": sensor_temperature, + "media_player": media_player, } @@ -409,8 +416,8 @@ async def test_should_expose( # Blocked entity is not exposed assert async_should_expose(hass, "cloud.alexa", entities["blocked"]) is False - # Lock is exposed - assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is True + # Lock is not exposed + assert async_should_expose(hass, "cloud.alexa", entities["lock"]) is False # Binary sensor without device class is not exposed assert async_should_expose(hass, "cloud.alexa", entities["binary_sensor"]) is False @@ -426,6 +433,9 @@ async def test_should_expose( async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True ) + # Media player is exposed + assert async_should_expose(hass, "cloud.alexa", entities["media_player"]) is True + # The second time we check, it should load it from storage assert ( async_should_expose(hass, "cloud.alexa", entities["temperature_sensor"]) is True From 01c4ca27499a011bfe6d74380613d5fc9044b923 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 4 Jun 2024 06:20:18 +0200 Subject: [PATCH 106/234] Recover mqtt abbrevations optimizations (#118762) Co-authored-by: J. Nick Koston --- homeassistant/components/mqtt/discovery.py | 143 ++++++++++++--------- tests/components/mqtt/test_discovery.py | 4 +- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index e8a3ed9a8cb..0d93af26a57 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -41,6 +41,10 @@ from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage from .schemas import MQTT_ORIGIN_INFO_SCHEMA from .util import async_forward_entry_setup_and_setup_discovery +ABBREVIATIONS_SET = set(ABBREVIATIONS) +DEVICE_ABBREVIATIONS_SET = set(DEVICE_ABBREVIATIONS) +ORIGIN_ABBREVIATIONS_SET = set(ORIGIN_ABBREVIATIONS) + _LOGGER = logging.getLogger(__name__) TOPIC_MATCHER = re.compile( @@ -105,6 +109,82 @@ def async_log_discovery_origin_info( ) +@callback +def _replace_abbreviations( + payload: Any | dict[str, Any], + abbreviations: dict[str, str], + abbreviations_set: set[str], +) -> None: + """Replace abbreviations in an MQTT discovery payload.""" + if not isinstance(payload, dict): + return + for key in abbreviations_set.intersection(payload): + payload[abbreviations[key]] = payload.pop(key) + + +@callback +def _replace_all_abbreviations(discovery_payload: Any | dict[str, Any]) -> None: + """Replace all abbreviations in an MQTT discovery payload.""" + + _replace_abbreviations(discovery_payload, ABBREVIATIONS, ABBREVIATIONS_SET) + + if CONF_ORIGIN in discovery_payload: + _replace_abbreviations( + discovery_payload[CONF_ORIGIN], + ORIGIN_ABBREVIATIONS, + ORIGIN_ABBREVIATIONS_SET, + ) + + if CONF_DEVICE in discovery_payload: + _replace_abbreviations( + discovery_payload[CONF_DEVICE], + DEVICE_ABBREVIATIONS, + DEVICE_ABBREVIATIONS_SET, + ) + + if CONF_AVAILABILITY in discovery_payload: + for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): + _replace_abbreviations(availability_conf, ABBREVIATIONS, ABBREVIATIONS_SET) + + +@callback +def _replace_topic_base(discovery_payload: dict[str, Any]) -> None: + """Replace topic base in MQTT discovery data.""" + base = discovery_payload.pop(TOPIC_BASE) + for key, value in discovery_payload.items(): + if isinstance(value, str) and value: + if value[0] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{base}{value[1:]}" + if value[-1] == TOPIC_BASE and key.endswith("topic"): + discovery_payload[key] = f"{value[:-1]}{base}" + if discovery_payload.get(CONF_AVAILABILITY): + for availability_conf in cv.ensure_list(discovery_payload[CONF_AVAILABILITY]): + if not isinstance(availability_conf, dict): + continue + if topic := str(availability_conf.get(CONF_TOPIC)): + if topic[0] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" + if topic[-1] == TOPIC_BASE: + availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" + + +@callback +def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool: + """Parse and validate origin info from a single component discovery payload.""" + if CONF_ORIGIN not in discovery_payload: + return True + try: + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception as exc: # noqa:BLE001 + _LOGGER.warning( + "Unable to parse origin information from discovery message: %s, got %s", + exc, + discovery_payload[CONF_ORIGIN], + ) + return False + return True + + async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: @@ -168,67 +248,14 @@ async def async_start( # noqa: C901 except ValueError: _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return + _replace_all_abbreviations(discovery_payload) + if not _valid_origin_info(discovery_payload): + return + if TOPIC_BASE in discovery_payload: + _replace_topic_base(discovery_payload) else: discovery_payload = MQTTDiscoveryPayload({}) - for key in list(discovery_payload): - abbreviated_key = key - key = ABBREVIATIONS.get(key, key) - discovery_payload[key] = discovery_payload.pop(abbreviated_key) - - if CONF_DEVICE in discovery_payload: - device = discovery_payload[CONF_DEVICE] - for key in list(device): - abbreviated_key = key - key = DEVICE_ABBREVIATIONS.get(key, key) - device[key] = device.pop(abbreviated_key) - - if CONF_ORIGIN in discovery_payload: - origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] - try: - for key in list(origin_info): - abbreviated_key = key - key = ORIGIN_ABBREVIATIONS.get(key, key) - origin_info[key] = origin_info.pop(abbreviated_key) - MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) - except Exception: # noqa: BLE001 - _LOGGER.warning( - "Unable to parse origin information " - "from discovery message, got %s", - discovery_payload[CONF_ORIGIN], - ) - return - - if CONF_AVAILABILITY in discovery_payload: - for availability_conf in cv.ensure_list( - discovery_payload[CONF_AVAILABILITY] - ): - if isinstance(availability_conf, dict): - for key in list(availability_conf): - abbreviated_key = key - key = ABBREVIATIONS.get(key, key) - availability_conf[key] = availability_conf.pop(abbreviated_key) - - if TOPIC_BASE in discovery_payload: - base = discovery_payload.pop(TOPIC_BASE) - for key, value in discovery_payload.items(): - if isinstance(value, str) and value: - if value[0] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{base}{value[1:]}" - if value[-1] == TOPIC_BASE and key.endswith("topic"): - discovery_payload[key] = f"{value[:-1]}{base}" - if discovery_payload.get(CONF_AVAILABILITY): - for availability_conf in cv.ensure_list( - discovery_payload[CONF_AVAILABILITY] - ): - if not isinstance(availability_conf, dict): - continue - if topic := str(availability_conf.get(CONF_TOPIC)): - if topic[0] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{base}{topic[1:]}" - if topic[-1] == TOPIC_BASE: - availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}" - # If present, the node_id will be included in the discovered object id discovery_id = f"{node_id} {object_id}" if node_id else object_id discovery_hash = (component, discovery_id) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 2e1f78c1bd4..020ab4a09a9 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -291,9 +291,7 @@ async def test_discovery_with_invalid_integration_info( state = hass.states.get("binary_sensor.beer") assert state is None - assert ( - "Unable to parse origin information from discovery message, got" in caplog.text - ) + assert "Unable to parse origin information from discovery message" in caplog.text async def test_discover_fan( From 8c332ddbdb395edddf50f91675de696746a6abf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 4 Jun 2024 06:27:54 +0200 Subject: [PATCH 107/234] Update hass-nabucasa to version 0.81.1 (#118768) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index f30b6b14f67..529f4fb9be9 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.81.0"] + "requirements": ["hass-nabucasa==0.81.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c3d30e6e09d..379adb18cc0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.2.0 habluetooth==3.1.1 -hass-nabucasa==0.81.0 +hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240603.0 diff --git a/pyproject.toml b/pyproject.toml index 6d3a3ac5a5a..a045c2969fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "fnv-hash-fast==0.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.81.0", + "hass-nabucasa==0.81.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.0", diff --git a/requirements.txt b/requirements.txt index d77962d64d7..7e2107a4490 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ bcrypt==4.1.2 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==0.5.0 -hass-nabucasa==0.81.0 +hass-nabucasa==0.81.1 httpx==0.27.0 home-assistant-bluetooth==1.12.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d91a45a4b33..e6bbc56b8d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1047,7 +1047,7 @@ habitipy==0.3.1 habluetooth==3.1.1 # homeassistant.components.cloud -hass-nabucasa==0.81.0 +hass-nabucasa==0.81.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93161a76c78..657de6baea5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -861,7 +861,7 @@ habitipy==0.3.1 habluetooth==3.1.1 # homeassistant.components.cloud -hass-nabucasa==0.81.0 +hass-nabucasa==0.81.1 # homeassistant.components.conversation hassil==1.7.1 From 954e8ff9b3dc372bd5b8be6ea7fc477f7b0afe72 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 10:53:16 +0200 Subject: [PATCH 108/234] Bump airgradient to 0.4.3 (#118776) --- homeassistant/components/airgradient/config_flow.py | 2 +- homeassistant/components/airgradient/manifest.json | 2 +- homeassistant/components/airgradient/select.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py index fff2615365e..6fc12cf7397 100644 --- a/homeassistant/components/airgradient/config_flow.py +++ b/homeassistant/components/airgradient/config_flow.py @@ -29,7 +29,7 @@ class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): """Set configuration source to local if it hasn't been set yet.""" assert self.client config = await self.client.get_config() - if config.configuration_control is ConfigurationControl.BOTH: + if config.configuration_control is ConfigurationControl.NOT_INITIALIZED: await self.client.set_configuration_control(ConfigurationControl.LOCAL) async def async_step_zeroconf( diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index 474031ccfe1..c30d7a4c42f 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["airgradient==0.4.2"], + "requirements": ["airgradient==0.4.3"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 5e13ee1d0bb..7a82d3b8a46 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -33,7 +33,7 @@ CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription( options=[ConfigurationControl.CLOUD.value, ConfigurationControl.LOCAL.value], entity_category=EntityCategory.CONFIG, value_fn=lambda config: config.configuration_control - if config.configuration_control is not ConfigurationControl.BOTH + if config.configuration_control is not ConfigurationControl.NOT_INITIALIZED else None, set_value_fn=lambda client, value: client.set_configuration_control( ConfigurationControl(value) diff --git a/requirements_all.txt b/requirements_all.txt index e6bbc56b8d6..7e473e33634 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -404,7 +404,7 @@ aiowithings==2.1.0 aioymaps==1.2.2 # homeassistant.components.airgradient -airgradient==0.4.2 +airgradient==0.4.3 # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 657de6baea5..24021b642ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -377,7 +377,7 @@ aiowithings==2.1.0 aioymaps==1.2.2 # homeassistant.components.airgradient -airgradient==0.4.2 +airgradient==0.4.3 # homeassistant.components.airly airly==1.1.0 From c76b7a48d36f9b7d4cc9f4e7c0c9fbe9d73a6d93 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 10:13:31 +0200 Subject: [PATCH 109/234] Initial cleanup for Aladdin connect (#118777) --- .../components/aladdin_connect/__init__.py | 44 ++++++++++--------- .../components/aladdin_connect/api.py | 11 ++--- .../components/aladdin_connect/config_flow.py | 14 +++--- .../components/aladdin_connect/const.py | 8 ---- .../components/aladdin_connect/cover.py | 10 +++-- 5 files changed, 42 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 55c4345beb3..dcd26c6cd04 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -5,49 +5,51 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) -from . import api -from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION +from .api import AsyncConfigEntryAuth PLATFORMS: list[Platform] = [Platform.COVER] +type AladdinConnectConfigEntry = ConfigEntry[AsyncConfigEntryAuth] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: AladdinConnectConfigEntry +) -> bool: """Set up Aladdin Connect Genie from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) + implementation = await async_get_config_entry_implementation(hass, entry) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + session = OAuth2Session(hass, entry, implementation) - # If using an aiohttp-based API lib - entry.runtime_data = api.AsyncConfigEntryAuth( - aiohttp_client.async_get_clientsession(hass), session - ) + entry.runtime_data = AsyncConfigEntryAuth(async_get_clientsession(hass), session) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AladdinConnectConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry +) -> bool: """Migrate old config.""" - if config_entry.version < CONFIG_FLOW_VERSION: + if config_entry.version < 2: config_entry.async_start_reauth(hass) - new_data = {**config_entry.data} hass.config_entries.async_update_entry( config_entry, - data=new_data, - version=CONFIG_FLOW_VERSION, - minor_version=CONFIG_FLOW_MINOR_VERSION, + version=2, + minor_version=1, ) return True diff --git a/homeassistant/components/aladdin_connect/api.py b/homeassistant/components/aladdin_connect/api.py index 8100cd1e4d8..c4a19ef0081 100644 --- a/homeassistant/components/aladdin_connect/api.py +++ b/homeassistant/components/aladdin_connect/api.py @@ -1,9 +1,11 @@ """API for Aladdin Connect Genie bound to Home Assistant OAuth.""" +from typing import cast + from aiohttp import ClientSession from genie_partner_sdk.auth import Auth -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session API_URL = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1" API_KEY = "k6QaiQmcTm2zfaNns5L1Z8duBtJmhDOW8JawlCC3" @@ -15,7 +17,7 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc] def __init__( self, websession: ClientSession, - oauth_session: config_entry_oauth2_flow.OAuth2Session, + oauth_session: OAuth2Session, ) -> None: """Initialize Aladdin Connect Genie auth.""" super().__init__( @@ -25,7 +27,6 @@ class AsyncConfigEntryAuth(Auth): # type: ignore[misc] async def async_get_access_token(self) -> str: """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() + await self._oauth_session.async_ensure_token_valid() - return str(self._oauth_session.token["access_token"]) + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index aa42574a005..e1a7b44830d 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -7,19 +7,17 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlowResult -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler -from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN +from .const import DOMAIN -class OAuth2FlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): +class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Config flow to handle Aladdin Connect Genie OAuth2 authentication.""" DOMAIN = DOMAIN - VERSION = CONFIG_FLOW_VERSION - MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION + VERSION = 2 + MINOR_VERSION = 1 reauth_entry: ConfigEntry | None = None @@ -43,7 +41,7 @@ class OAuth2FlowHandler( ) return await self.async_step_user() - async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" if self.reauth_entry: return self.async_update_reload_and_abort( diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py index 5312826469e..0fe60724154 100644 --- a/homeassistant/components/aladdin_connect/const.py +++ b/homeassistant/components/aladdin_connect/const.py @@ -1,14 +1,6 @@ """Constants for the Aladdin Connect Genie integration.""" -from typing import Final - -from homeassistant.components.cover import CoverEntityFeature - DOMAIN = "aladdin_connect" -CONFIG_FLOW_VERSION = 2 -CONFIG_FLOW_MINOR_VERSION = 1 OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html" OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token" - -SUPPORTED_FEATURES: Final = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index cf31b06cbcd..fa5d5c87a2f 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -5,7 +5,11 @@ from typing import Any from genie_partner_sdk.client import AladdinConnectClient -from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -14,7 +18,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import api -from .const import DOMAIN, SUPPORTED_FEATURES +from .const import DOMAIN from .model import GarageDoor SCAN_INTERVAL = timedelta(seconds=15) @@ -75,7 +79,7 @@ class AladdinDevice(CoverEntity): """Representation of Aladdin Connect cover.""" _attr_device_class = CoverDeviceClass.GARAGE - _attr_supported_features = SUPPORTED_FEATURES + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE _attr_has_entity_name = True _attr_name = None From 5d6fe7387e5a485920f838f841896dbdf8936e22 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 10:29:51 +0200 Subject: [PATCH 110/234] Use model from Aladdin Connect lib (#118778) * Use model from Aladdin Connect lib * Fix --- .coveragerc | 1 - .../components/aladdin_connect/cover.py | 2 +- .../components/aladdin_connect/model.py | 30 ------------------- .../components/aladdin_connect/sensor.py | 2 +- 4 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 homeassistant/components/aladdin_connect/model.py diff --git a/.coveragerc b/.coveragerc index a4215bc0991..1fe4d24e3a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -62,7 +62,6 @@ omit = homeassistant/components/aladdin_connect/api.py homeassistant/components/aladdin_connect/application_credentials.py homeassistant/components/aladdin_connect/cover.py - homeassistant/components/aladdin_connect/model.py homeassistant/components/aladdin_connect/sensor.py homeassistant/components/alarmdecoder/__init__.py homeassistant/components/alarmdecoder/alarm_control_panel.py diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index fa5d5c87a2f..54f0ab32db9 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -4,6 +4,7 @@ from datetime import timedelta from typing import Any from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor from homeassistant.components.cover import ( CoverDeviceClass, @@ -19,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import api from .const import DOMAIN -from .model import GarageDoor SCAN_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/aladdin_connect/model.py b/homeassistant/components/aladdin_connect/model.py deleted file mode 100644 index db08cb7b8b8..00000000000 --- a/homeassistant/components/aladdin_connect/model.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Models for Aladdin connect cover platform.""" - -from __future__ import annotations - -from typing import TypedDict - - -class GarageDoorData(TypedDict): - """Aladdin door data.""" - - device_id: str - door_number: int - name: str - status: str - link_status: str - battery_level: int - - -class GarageDoor: - """Aladdin Garage Door Entity.""" - - def __init__(self, data: GarageDoorData) -> None: - """Create `GarageDoor` from dictionary of data.""" - self.device_id = data["device_id"] - self.door_number = data["door_number"] - self.unique_id = f"{self.device_id}-{self.door_number}" - self.name = data["name"] - self.status = data["status"] - self.link_status = data["link_status"] - self.battery_level = data["battery_level"] diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 231928656a8..f9ed2a6aeeb 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import cast from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,7 +23,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import api from .const import DOMAIN -from .model import GarageDoor @dataclass(frozen=True, kw_only=True) From c702174fa0e8ed9929f9e1c27e639c65a6305951 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 10:51:28 +0200 Subject: [PATCH 111/234] Add coordinator to Aladdin Connect (#118781) --- .../components/aladdin_connect/__init__.py | 12 ++- .../components/aladdin_connect/coordinator.py | 38 ++++++++++ .../components/aladdin_connect/cover.py | 73 +++++++------------ .../components/aladdin_connect/entity.py | 27 +++++++ .../components/aladdin_connect/sensor.py | 50 +++++-------- 5 files changed, 118 insertions(+), 82 deletions(-) create mode 100644 homeassistant/components/aladdin_connect/coordinator.py create mode 100644 homeassistant/components/aladdin_connect/entity.py diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index dcd26c6cd04..6317cf8358e 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +from genie_partner_sdk.client import AladdinConnectClient + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -12,10 +14,11 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .api import AsyncConfigEntryAuth +from .coordinator import AladdinConnectCoordinator PLATFORMS: list[Platform] = [Platform.COVER] -type AladdinConnectConfigEntry = ConfigEntry[AsyncConfigEntryAuth] +type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator] async def async_setup_entry( @@ -25,8 +28,13 @@ async def async_setup_entry( implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + coordinator = AladdinConnectCoordinator(hass, AladdinConnectClient(auth)) - entry.runtime_data = AsyncConfigEntryAuth(async_get_clientsession(hass), session) + await coordinator.async_setup() + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py new file mode 100644 index 00000000000..d9af0da9450 --- /dev/null +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -0,0 +1,38 @@ +"""Define an object to coordinate fetching Aladdin Connect data.""" + +from datetime import timedelta +import logging + +from genie_partner_sdk.client import AladdinConnectClient +from genie_partner_sdk.model import GarageDoor + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AladdinConnectCoordinator(DataUpdateCoordinator[None]): + """Aladdin Connect Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, acc: AladdinConnectClient) -> None: + """Initialize.""" + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=15), + ) + self.acc = acc + self.doors: list[GarageDoor] = [] + + async def async_setup(self) -> None: + """Fetch initial data.""" + self.doors = await self.acc.get_doors() + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + for door in self.doors: + await self.acc.update_door(door.device_id, door.door_number) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 54f0ab32db9..29629593c75 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,9 +1,7 @@ """Cover Entity for Genie Garage Door.""" -from datetime import timedelta from typing import Any -from genie_partner_sdk.client import AladdinConnectClient from genie_partner_sdk.model import GarageDoor from homeassistant.components.cover import ( @@ -11,52 +9,36 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import api +from . import AladdinConnectConfigEntry, AladdinConnectCoordinator from .const import DOMAIN - -SCAN_INTERVAL = timedelta(seconds=15) +from .entity import AladdinConnectEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AladdinConnectConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aladdin Connect platform.""" - session: api.AsyncConfigEntryAuth = config_entry.runtime_data - acc = AladdinConnectClient(session) - doors = await acc.get_doors() - if doors is None: - raise PlatformNotReady("Error from Aladdin Connect getting doors") - device_registry = dr.async_get(hass) - doors_to_add = [] - for door in doors: - existing = device_registry.async_get(door.unique_id) - if existing is None: - doors_to_add.append(door) + coordinator = config_entry.runtime_data - async_add_entities( - (AladdinDevice(acc, door, config_entry) for door in doors_to_add), - ) - remove_stale_devices(hass, config_entry, doors) + async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors) + remove_stale_devices(hass, config_entry) def remove_stale_devices( - hass: HomeAssistant, config_entry: ConfigEntry, devices: list[GarageDoor] + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry ) -> None: """Remove stale devices from device registry.""" device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids = {door.unique_id for door in devices} + all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors} for device_entry in device_entries: device_id: str | None = None @@ -75,45 +57,38 @@ def remove_stale_devices( ) -class AladdinDevice(CoverEntity): +class AladdinDevice(AladdinConnectEntity, CoverEntity): """Representation of Aladdin Connect cover.""" _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - _attr_has_entity_name = True _attr_name = None def __init__( - self, acc: AladdinConnectClient, device: GarageDoor, entry: ConfigEntry + self, coordinator: AladdinConnectCoordinator, device: GarageDoor ) -> None: """Initialize the Aladdin Connect cover.""" - self._acc = acc - self._device_id = device.device_id - self._number = device.door_number - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.unique_id)}, - name=device.name, - manufacturer="Overhead Door", - ) + super().__init__(coordinator, device) self._attr_unique_id = device.unique_id async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - await self._acc.open_door(self._device_id, self._number) + await self.coordinator.acc.open_door( + self._device.device_id, self._device.door_number + ) async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - await self._acc.close_door(self._device_id, self._number) - - async def async_update(self) -> None: - """Update status of cover.""" - await self._acc.update_door(self._device_id, self._number) + await self.coordinator.acc.close_door( + self._device.device_id, self._device.door_number + ) @property def is_closed(self) -> bool | None: """Update is closed attribute.""" - value = self._acc.get_door_status(self._device_id, self._number) + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number + ) if value is None: return None return bool(value == "closed") @@ -121,7 +96,9 @@ class AladdinDevice(CoverEntity): @property def is_closing(self) -> bool | None: """Update is closing attribute.""" - value = self._acc.get_door_status(self._device_id, self._number) + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number + ) if value is None: return None return bool(value == "closing") @@ -129,7 +106,9 @@ class AladdinDevice(CoverEntity): @property def is_opening(self) -> bool | None: """Update is opening attribute.""" - value = self._acc.get_door_status(self._device_id, self._number) + value = self.coordinator.acc.get_door_status( + self._device.device_id, self._device.door_number + ) if value is None: return None return bool(value == "opening") diff --git a/homeassistant/components/aladdin_connect/entity.py b/homeassistant/components/aladdin_connect/entity.py new file mode 100644 index 00000000000..8d9eeefcdfb --- /dev/null +++ b/homeassistant/components/aladdin_connect/entity.py @@ -0,0 +1,27 @@ +"""Defines a base Aladdin Connect entity.""" + +from genie_partner_sdk.model import GarageDoor + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AladdinConnectCoordinator + + +class AladdinConnectEntity(CoordinatorEntity[AladdinConnectCoordinator]): + """Defines a base Aladdin Connect entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: AladdinConnectCoordinator, device: GarageDoor + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._device = device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.unique_id)}, + name=device.name, + manufacturer="Overhead Door", + ) diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index f9ed2a6aeeb..2bd0168a500 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import cast from genie_partner_sdk.client import AladdinConnectClient from genie_partner_sdk.model import GarageDoor @@ -15,21 +14,19 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import api -from .const import DOMAIN +from . import AladdinConnectConfigEntry, AladdinConnectCoordinator +from .entity import AladdinConnectEntity @dataclass(frozen=True, kw_only=True) class AccSensorEntityDescription(SensorEntityDescription): """Describes AladdinConnect sensor entity.""" - value_fn: Callable + value_fn: Callable[[AladdinConnectClient, str, int], float | None] SENSORS: tuple[AccSensorEntityDescription, ...] = ( @@ -45,52 +42,39 @@ SENSORS: tuple[AccSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AladdinConnectConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Aladdin Connect sensor devices.""" + coordinator = entry.runtime_data - session: api.AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] - acc = AladdinConnectClient(session) - - entities = [] - doors = await acc.get_doors() - - for door in doors: - entities.extend( - [AladdinConnectSensor(acc, door, description) for description in SENSORS] - ) - - async_add_entities(entities) + async_add_entities( + AladdinConnectSensor(coordinator, door, description) + for description in SENSORS + for door in coordinator.doors + ) -class AladdinConnectSensor(SensorEntity): +class AladdinConnectSensor(AladdinConnectEntity, SensorEntity): """A sensor implementation for Aladdin Connect devices.""" entity_description: AccSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - acc: AladdinConnectClient, + coordinator: AladdinConnectCoordinator, device: GarageDoor, description: AccSensorEntityDescription, ) -> None: """Initialize a sensor for an Aladdin Connect device.""" - self._device_id = device.device_id - self._number = device.door_number - self._acc = acc + super().__init__(coordinator, device) self.entity_description = description self._attr_unique_id = f"{device.unique_id}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.unique_id)}, - name=device.name, - manufacturer="Overhead Door", - ) @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return cast( - float, - self.entity_description.value_fn(self._acc, self._device_id, self._number), + return self.entity_description.value_fn( + self.coordinator.acc, self._device.device_id, self._device.door_number ) From ba96fc272b9d97a019ba038013d98ef7871afd85 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 11:04:10 +0200 Subject: [PATCH 112/234] Re-enable sensor platform for Aladdin Connect (#118782) --- homeassistant/components/aladdin_connect/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 6317cf8358e..504e53764f0 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( from .api import AsyncConfigEntryAuth from .coordinator import AladdinConnectCoordinator -PLATFORMS: list[Platform] = [Platform.COVER] +PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR] type AladdinConnectConfigEntry = ConfigEntry[AladdinConnectCoordinator] From b3b8ae31fd9d6f59a6f023ca6fcd9a09fe8e8c06 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 11:34:21 +0200 Subject: [PATCH 113/234] Move Aladdin stale device removal to init module (#118784) --- .../components/aladdin_connect/__init__.py | 31 +++++++++++++++++++ .../components/aladdin_connect/cover.py | 30 ------------------ 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 504e53764f0..436e797271f 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -7,6 +7,7 @@ from genie_partner_sdk.client import AladdinConnectClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -14,6 +15,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from .api import AsyncConfigEntryAuth +from .const import DOMAIN from .coordinator import AladdinConnectCoordinator PLATFORMS: list[Platform] = [Platform.COVER, Platform.SENSOR] @@ -38,6 +40,8 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + async_remove_stale_devices(hass, entry) + return True @@ -61,3 +65,30 @@ async def async_migrate_entry( ) return True + + +def async_remove_stale_devices( + hass: HomeAssistant, config_entry: AladdinConnectConfigEntry +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors} + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 29629593c75..b8c48048192 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -10,11 +10,9 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.core import HomeAssistant -import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AladdinConnectConfigEntry, AladdinConnectCoordinator -from .const import DOMAIN from .entity import AladdinConnectEntity @@ -27,34 +25,6 @@ async def async_setup_entry( coordinator = config_entry.runtime_data async_add_entities(AladdinDevice(coordinator, door) for door in coordinator.doors) - remove_stale_devices(hass, config_entry) - - -def remove_stale_devices( - hass: HomeAssistant, config_entry: AladdinConnectConfigEntry -) -> None: - """Remove stale devices from device registry.""" - device_registry = dr.async_get(hass) - device_entries = dr.async_entries_for_config_entry( - device_registry, config_entry.entry_id - ) - all_device_ids = {door.unique_id for door in config_entry.runtime_data.doors} - - for device_entry in device_entries: - device_id: str | None = None - - for identifier in device_entry.identifiers: - if identifier[0] == DOMAIN: - device_id = identifier[1] - break - - if device_id is None or device_id not in all_device_ids: - # If device_id is None an invalid device entry was found for this config entry. - # If the device_id is not in existing device ids it's a stale device entry. - # Remove config entry from this device entry in either case. - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=config_entry.entry_id - ) class AladdinDevice(AladdinConnectEntity, CoverEntity): From f2b1635969d30feec2da9fe4cf483369786fffa6 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 3 Jun 2024 13:16:12 +0200 Subject: [PATCH 114/234] Refactor fixture calling for BMW tests (#118708) * Refactor BMW tests to use pytest.mark.usefixtures * Fix freeze_time --------- Co-authored-by: Richard --- .../bmw_connected_drive/test_button.py | 6 +++--- .../bmw_connected_drive/test_coordinator.py | 16 ++++++++++------ .../bmw_connected_drive/test_diagnostics.py | 12 ++++++------ .../components/bmw_connected_drive/test_init.py | 3 +-- .../bmw_connected_drive/test_number.py | 8 ++++---- .../bmw_connected_drive/test_select.py | 8 ++++---- .../bmw_connected_drive/test_sensor.py | 10 ++++------ .../bmw_connected_drive/test_switch.py | 5 ++--- 8 files changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index 25d01fa74c9..3c7db219d54 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -14,10 +14,10 @@ from homeassistant.exceptions import HomeAssistantError from . import check_remote_service_call, setup_mocked_integration +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test button options and values.""" @@ -57,9 +57,9 @@ async def test_service_call_success( check_remote_service_call(bmw_fixture, remote_service) +@pytest.mark.usefixtures("bmw_fixture") async def test_service_call_fail( hass: HomeAssistant, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test failed button press.""" diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index 812d309a257..5b3f99a9414 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -5,7 +5,7 @@ from unittest.mock import patch from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError from freezegun.api import FrozenDateTimeFactory -import respx +import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant @@ -18,7 +18,8 @@ from . import FIXTURE_CONFIG_ENTRY from tests.common import MockConfigEntry, async_fire_time_changed -async def test_update_success(hass: HomeAssistant, bmw_fixture: respx.Router) -> None: +@pytest.mark.usefixtures("bmw_fixture") +async def test_update_success(hass: HomeAssistant) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) config_entry.add_to_hass(hass) @@ -32,8 +33,10 @@ async def test_update_success(hass: HomeAssistant, bmw_fixture: respx.Router) -> ) +@pytest.mark.usefixtures("bmw_fixture") async def test_update_failed( - hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) @@ -59,8 +62,10 @@ async def test_update_failed( assert isinstance(coordinator.last_exception, UpdateFailed) is True +@pytest.mark.usefixtures("bmw_fixture") async def test_update_reauth( - hass: HomeAssistant, bmw_fixture: respx.Router, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, ) -> None: """Test the reauth form.""" config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) @@ -96,10 +101,9 @@ async def test_update_reauth( assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True +@pytest.mark.usefixtures("bmw_fixture") async def test_init_reauth( hass: HomeAssistant, - bmw_fixture: respx.Router, - freezer: FrozenDateTimeFactory, issue_registry: ir.IssueRegistry, ) -> None: """Test the reauth form.""" diff --git a/tests/components/bmw_connected_drive/test_diagnostics.py b/tests/components/bmw_connected_drive/test_diagnostics.py index fedfb1c2351..984275eab6a 100644 --- a/tests/components/bmw_connected_drive/test_diagnostics.py +++ b/tests/components/bmw_connected_drive/test_diagnostics.py @@ -19,11 +19,11 @@ from tests.typing import ClientSessionGenerator @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_config_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - bmw_fixture, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" @@ -38,12 +38,12 @@ async def test_config_entry_diagnostics( @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - bmw_fixture, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics.""" @@ -63,12 +63,12 @@ async def test_device_diagnostics( @pytest.mark.freeze_time(datetime.datetime(2022, 7, 10, 11, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_device_diagnostics_vehicle_not_found( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - bmw_fixture, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test device diagnostics when the vehicle cannot be found.""" diff --git a/tests/components/bmw_connected_drive/test_init.py b/tests/components/bmw_connected_drive/test_init.py index b8081d8d119..d648ad65f5d 100644 --- a/tests/components/bmw_connected_drive/test_init.py +++ b/tests/components/bmw_connected_drive/test_init.py @@ -3,7 +3,6 @@ from unittest.mock import patch import pytest -import respx from homeassistant.components.bmw_connected_drive.const import DOMAIN as BMW_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -137,10 +136,10 @@ async def test_dont_migrate_unique_ids( assert entity_migrated != entity_not_changed +@pytest.mark.usefixtures("bmw_fixture") async def test_remove_stale_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry, - bmw_fixture: respx.Router, ) -> None: """Test remove stale device registry entries.""" mock_config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY) diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index 1047e595c95..53e61439003 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -14,10 +14,10 @@ from homeassistant.exceptions import HomeAssistantError from . import check_remote_service_call, setup_mocked_integration +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test number options and values..""" @@ -62,6 +62,7 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "value"), [ @@ -72,7 +73,6 @@ async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, - bmw_fixture: respx.Router, ) -> None: """Test not allowed values for number inputs.""" @@ -92,6 +92,7 @@ async def test_service_call_invalid_input( assert hass.states.get(entity_id).state == old_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -104,7 +105,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 0c78d89cd8a..f3877119e3e 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -14,10 +14,10 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import check_remote_service_call, setup_mocked_integration +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test select options and values..""" @@ -74,6 +74,7 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "value"), [ @@ -85,7 +86,6 @@ async def test_service_call_invalid_input( hass: HomeAssistant, entity_id: str, value: str, - bmw_fixture: respx.Router, ) -> None: """Test not allowed values for select inputs.""" @@ -105,6 +105,7 @@ async def test_service_call_invalid_input( assert hass.states.get(entity_id).state == old_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -117,7 +118,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 18c589bb72a..2e48189e4a1 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,8 +1,6 @@ """Test BMW sensors.""" -from freezegun import freeze_time import pytest -import respx from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -15,11 +13,11 @@ from homeassistant.util.unit_system import ( from . import setup_mocked_integration -@freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.freeze_time("2023-06-22 10:30:00+00:00") +@pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test sensor options and values..""" @@ -31,6 +29,7 @@ async def test_entity_state_attrs( assert hass.states.async_all("sensor") == snapshot +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ @@ -56,7 +55,6 @@ async def test_unit_conversion( unit_system: UnitSystem, value: str, unit_of_measurement: str, - bmw_fixture, ) -> None: """Test conversion between metric and imperial units for sensors.""" diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index a667966d099..6cf20d8077e 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -14,10 +14,9 @@ from homeassistant.exceptions import HomeAssistantError from . import check_remote_service_call, setup_mocked_integration +@pytest.mark.usefixtures("bmw_fixture") async def test_entity_state_attrs( hass: HomeAssistant, - bmw_fixture: respx.Router, - entity_registry_enabled_by_default: None, snapshot: SnapshotAssertion, ) -> None: """Test switch options and values..""" @@ -65,6 +64,7 @@ async def test_service_call_success( assert hass.states.get(entity_id).state == new_value +@pytest.mark.usefixtures("bmw_fixture") @pytest.mark.parametrize( ("raised", "expected"), [ @@ -77,7 +77,6 @@ async def test_service_call_fail( hass: HomeAssistant, raised: Exception, expected: Exception, - bmw_fixture: respx.Router, monkeypatch: pytest.MonkeyPatch, ) -> None: """Test exception handling.""" From 4bfff1257056cae7862c1aecc8cfdf3d911c7c6a Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 3 Jun 2024 07:48:48 +0200 Subject: [PATCH 115/234] Set lock state to unkown on BMW API error (#118559) * Revert to previous lock state on BMW API error * Set lock state to unkown on error and force refresh from API --------- Co-authored-by: Richard --- .../components/bmw_connected_drive/lock.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index bbfadcef9db..e138f31ba24 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -65,11 +65,13 @@ class BMWLock(BMWBaseEntity, LockEntity): try: await self.vehicle.remote_services.trigger_remote_door_lock() except MyBMWAPIError as ex: - self._attr_is_locked = False + # Set the state to unknown if the command fails + self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError(ex) from ex - - self.coordinator.async_update_listeners() + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" @@ -83,11 +85,13 @@ class BMWLock(BMWBaseEntity, LockEntity): try: await self.vehicle.remote_services.trigger_remote_door_unlock() except MyBMWAPIError as ex: - self._attr_is_locked = True + # Set the state to unknown if the command fails + self._attr_is_locked = None self.async_write_ha_state() raise HomeAssistantError(ex) from ex - - self.coordinator.async_update_listeners() + finally: + # Always update the listeners to get the latest state + self.coordinator.async_update_listeners() @callback def _handle_coordinator_update(self) -> None: From c8538f3c0819aaf98ea4f78e18bf78c2381d29b1 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 3 Jun 2024 19:46:04 +0200 Subject: [PATCH 116/234] Use snapshot_platform helper for BMW tests (#118735) * Use snapshot_platform helper * Remove comments --------- Co-authored-by: Richard --- .../snapshots/test_button.ambr | 1117 ++++++-- .../snapshots/test_number.ambr | 146 +- .../snapshots/test_select.ambr | 424 ++- .../snapshots/test_sensor.ambr | 2451 +++++++++++++---- .../snapshots/test_switch.ambr | 232 +- .../bmw_connected_drive/test_button.py | 16 +- .../bmw_connected_drive/test_number.py | 18 +- .../bmw_connected_drive/test_select.py | 16 +- .../bmw_connected_drive/test_sensor.py | 15 +- .../bmw_connected_drive/test_switch.py | 17 +- 10 files changed, 3486 insertions(+), 966 deletions(-) diff --git a/tests/components/bmw_connected_drive/snapshots/test_button.ambr b/tests/components/bmw_connected_drive/snapshots/test_button.ambr index 17866878ba3..cd3f94c7e5e 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_button.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_button.ambr @@ -1,233 +1,894 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Flash lights', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', +# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Sound horn', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Find vehicle', - }), - 'context': , - 'entity_id': 'button.ix_xdrive50_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Flash lights', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Sound horn', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Find vehicle', - }), - 'context': , - 'entity_id': 'button.i4_edrive40_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Flash lights', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Sound horn', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Deactivate air conditioning', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Find vehicle', - }), - 'context': , - 'entity_id': 'button.m340i_xdrive_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Flash lights', - }), - 'context': , - 'entity_id': 'button.i3_rex_flash_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Sound horn', - }), - 'context': , - 'entity_id': 'button.i3_rex_sound_horn', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Activate air conditioning', - }), - 'context': , - 'entity_id': 'button.i3_rex_activate_air_conditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Find vehicle', - }), - 'context': , - 'entity_id': 'button.i3_rex_find_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBY00000000REXI01-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.i3_rex_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBY00000000REXI01-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Find vehicle', + }), + 'context': , + 'entity_id': 'button.i3_rex_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBY00000000REXI01-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Flash lights', + }), + 'context': , + 'entity_id': 'button.i3_rex_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i3_rex_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBY00000000REXI01-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i3_rex_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Sound horn', + }), + 'context': , + 'entity_id': 'button.i3_rex_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO02-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO02-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO02-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Find vehicle', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO02-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Flash lights', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.i4_edrive40_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO02-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.i4_edrive40_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Sound horn', + }), + 'context': , + 'entity_id': 'button.i4_edrive40_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO01-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO01-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO01-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Find vehicle', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO01-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Flash lights', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ix_xdrive50_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO01-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.ix_xdrive50_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Sound horn', + }), + 'context': , + 'entity_id': 'button.ix_xdrive50_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activate_air_conditioning', + 'unique_id': 'WBA00000000DEMO03-activate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_activate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Activate air conditioning', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_activate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Deactivate air conditioning', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'deactivate_air_conditioning', + 'unique_id': 'WBA00000000DEMO03-deactivate_air_conditioning', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_deactivate_air_conditioning-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Deactivate air conditioning', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_deactivate_air_conditioning', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_find_vehicle', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Find vehicle', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'find_vehicle', + 'unique_id': 'WBA00000000DEMO03-find_vehicle', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_find_vehicle-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Find vehicle', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_find_vehicle', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_flash', + 'unique_id': 'WBA00000000DEMO03-light_flash', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Flash lights', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.m340i_xdrive_sound_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sound horn', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sound_horn', + 'unique_id': 'WBA00000000DEMO03-sound_horn', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[button.m340i_xdrive_sound_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Sound horn', + }), + 'context': , + 'entity_id': 'button.m340i_xdrive_sound_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_number.ambr b/tests/components/bmw_connected_drive/snapshots/test_number.ambr index 93580ddc7b7..f24ea43d8e8 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_number.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_number.ambr @@ -1,39 +1,115 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'iX xDrive50 Target SoC', - 'max': 100.0, - 'min': 20.0, - 'mode': , - 'step': 5.0, - }), - 'context': , - 'entity_id': 'number.ix_xdrive50_target_soc', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', +# name: test_entity_state_attrs[number.i4_edrive40_target_soc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i4 eDrive40 Target SoC', - 'max': 100.0, - 'min': 20.0, - 'mode': , - 'step': 5.0, - }), - 'context': , - 'entity_id': 'number.i4_edrive40_target_soc', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, }), - ]) + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.i4_edrive40_target_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target SoC', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_soc', + 'unique_id': 'WBA00000000DEMO02-target_soc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[number.i4_edrive40_target_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Target SoC', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.i4_edrive40_target_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ix_xdrive50_target_soc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target SoC', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_soc', + 'unique_id': 'WBA00000000DEMO01-target_soc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[number.ix_xdrive50_target_soc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Target SoC', + 'max': 100.0, + 'min': 20.0, + 'mode': , + 'step': 5.0, + }), + 'context': , + 'entity_id': 'number.ix_xdrive50_target_soc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_select.ambr b/tests/components/bmw_connected_drive/snapshots/test_select.ambr index e72708345b1..94155598ef7 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_select.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_select.ambr @@ -1,109 +1,327 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 AC Charging Limit', - 'options': list([ - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '20', - '32', - ]), - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'select.ix_xdrive50_ac_charging_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16', +# name: test_entity_state_attrs[select.i3_rex_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.ix_xdrive50_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'IMMEDIATE_CHARGING', + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 AC Charging Limit', - '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_reported': , - 'last_updated': , - 'state': '16', + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i3_rex_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.i4_edrive40_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'IMMEDIATE_CHARGING', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging Mode', - 'options': list([ - 'IMMEDIATE_CHARGING', - 'DELAYED_CHARGING', - ]), - }), - 'context': , - 'entity_id': 'select.i3_rex_charging_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'DELAYED_CHARGING', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBY00000000REXI01-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.i3_rex_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging Mode', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i3_rex_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'DELAYED_CHARGING', + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i4_edrive40_ac_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Charging Limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_limit', + 'unique_id': 'WBA00000000DEMO02-ac_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_ac_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC Charging Limit', + '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_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.i4_edrive40_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBA00000000DEMO02-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.i4_edrive40_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging Mode', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.i4_edrive40_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ix_xdrive50_ac_charging_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC Charging Limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_limit', + 'unique_id': 'WBA00000000DEMO01-ac_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_ac_charging_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 AC Charging Limit', + 'options': list([ + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '13', + '14', + '15', + '16', + '20', + '32', + ]), + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_ac_charging_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.ix_xdrive50_charging_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging Mode', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_mode', + 'unique_id': 'WBA00000000DEMO01-charging_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[select.ix_xdrive50_charging_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging Mode', + 'options': list([ + 'IMMEDIATE_CHARGING', + 'DELAYED_CHARGING', + ]), + }), + 'context': , + 'entity_id': 'select.ix_xdrive50_charging_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'IMMEDIATE_CHARGING', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index bf35398cd90..e3833add777 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -1,537 +1,1924 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 AC current limit', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_ac_current_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16', +# name: test_entity_state_attrs[sensor.i3_rex_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'iX xDrive50 Charging start time', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_start_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'iX xDrive50 Charging end time', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-06-22T10:40:00+00:00', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging status', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'CHARGING', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging target', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'iX xDrive50 Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '70', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '340', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '340', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'iX xDrive50 Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.ix_xdrive50_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'inactive', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 AC current limit', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_ac_current_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '16', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i4 eDrive40 Charging start time', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_start_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i4 eDrive40 Charging end time', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-06-22T10:40:00+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging status', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'NOT_CHARGING', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Charging target', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i4 eDrive40 Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '472', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '472', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'i4 eDrive40 Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.i4_edrive40_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'heating', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1121', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '629', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining range fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '629', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '40', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Remaining fuel percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'enum', - 'friendly_name': 'M340i xDrive Climate status', - 'options': list([ - 'cooling', - 'heating', - 'inactive', - 'standby', - ]), - }), - 'context': , - 'entity_id': 'sensor.m340i_xdrive_climate_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'inactive', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) AC current limit', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_ac_current_limit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i3 (+ REX) Charging start time', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_start_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-06-23T01:01:00+00:00', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'timestamp', - 'friendly_name': 'i3 (+ REX) Charging end time', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_end_time', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging status', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_status', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'WAITING_FOR_CHARGING', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Charging target', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_charging_target', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'device_class': 'battery', - 'friendly_name': 'i3 (+ REX) Remaining battery percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_battery_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '82', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Mileage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_mileage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '137009', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range total', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_total', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '279', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range electric', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_electric', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '174', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining range fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_range_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '105', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining fuel', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_fuel', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '6', - }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i3 (+ REX) Remaining fuel percent', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBY00000000REXI01-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBY00000000REXI01-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBY00000000REXI01-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging start time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-23T01:01:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBY00000000REXI01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging status', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'WAITING_FOR_CHARGING', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBY00000000REXI01-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBY00000000REXI01-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '137009', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBY00000000REXI01-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '82', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel', + 'unique_id': 'WBY00000000REXI01-remaining_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel_percent', + 'unique_id': 'WBY00000000REXI01-remaining_fuel_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_fuel_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBY00000000REXI01-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '174', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_fuel', + 'unique_id': 'WBY00000000REXI01-remaining_range_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '105', + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBY00000000REXI01-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i3_rex_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '279', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBA00000000DEMO02-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBA00000000DEMO02-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBA00000000DEMO02-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging start time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO02-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging status', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'NOT_CHARGING', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBA00000000DEMO02-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO02-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'i4 eDrive40 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO02-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBA00000000DEMO02-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBA00000000DEMO02-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '472', + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO02-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.i4_edrive40_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '472', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_ac_current_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_ac_current_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'AC current limit', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current_limit', + 'unique_id': 'WBA00000000DEMO01-ac_current_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_ac_current_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 AC current limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_ac_current_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_end_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging end time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_end_time', + 'unique_id': 'WBA00000000DEMO01-charging_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging start time', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_start_time', + 'unique_id': 'WBA00000000DEMO01-charging_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging start time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_status', + 'unique_id': 'WBA00000000DEMO01-charging_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging status', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'CHARGING', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging target', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_target', + 'unique_id': 'WBA00000000DEMO01-charging_target', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_charging_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging target', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO01-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'iX xDrive50 Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO01-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_battery_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Remaining battery percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_battery_percent', + 'unique_id': 'WBA00000000DEMO01-remaining_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_battery_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_electric-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range electric', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_electric', + 'unique_id': 'WBA00000000DEMO01-remaining_range_electric', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_electric-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range electric', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '340', + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO01-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.ix_xdrive50_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '340', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_climate_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Climate status', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_status', + 'unique_id': 'WBA00000000DEMO03-activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_climate_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'enum', + 'friendly_name': 'M340i xDrive Climate status', + 'options': list([ + 'cooling', + 'heating', + 'inactive', + 'standby', + ]), + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_climate_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'inactive', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_mileage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mileage', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'WBA00000000DEMO03-mileage', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_mileage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Mileage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1121', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel', + 'unique_id': 'WBA00000000DEMO03-remaining_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining fuel percent', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_fuel_percent', + 'unique_id': 'WBA00000000DEMO03-remaining_fuel_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_fuel_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_fuel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range fuel', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_fuel', + 'unique_id': 'WBA00000000DEMO03-remaining_range_fuel', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_fuel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '629', + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remaining range total', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_range_total', + 'unique_id': 'WBA00000000DEMO03-remaining_range_total', + 'unit_of_measurement': , + }) +# --- +# name: test_entity_state_attrs[sensor.m340i_xdrive_remaining_range_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '629', + }) # --- diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr index a3c8ffb6d3b..5a87a6ddd84 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -1,53 +1,189 @@ # serializer version: 1 -# name: test_entity_state_attrs - list([ - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Climate', - }), - 'context': , - 'entity_id': 'switch.ix_xdrive50_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', +# name: test_entity_state_attrs[switch.i4_edrive40_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'iX xDrive50 Charging', - }), - 'context': , - 'entity_id': 'switch.ix_xdrive50_charging', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.i4_edrive40_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'i4 eDrive40 Climate', - }), - 'context': , - 'entity_id': 'switch.i4_edrive40_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', + 'name': None, + 'options': dict({ }), - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'Data provided by MyBMW', - 'friendly_name': 'M340i xDrive Climate', - }), - 'context': , - 'entity_id': 'switch.m340i_xdrive_climate', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }), - ]) + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO02-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.i4_edrive40_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ix_xdrive50_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'WBA00000000DEMO01-charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.ix_xdrive50_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO01-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.ix_xdrive50_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Climate', + }), + 'context': , + 'entity_id': 'switch.ix_xdrive50_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_entity_state_attrs[switch.m340i_xdrive_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.m340i_xdrive_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'bmw_connected_drive', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate', + 'unique_id': 'WBA00000000DEMO03-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_state_attrs[switch.m340i_xdrive_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Climate', + }), + 'context': , + 'entity_id': 'switch.m340i_xdrive_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) # --- diff --git a/tests/components/bmw_connected_drive/test_button.py b/tests/components/bmw_connected_drive/test_button.py index 3c7db219d54..99cabc900fa 100644 --- a/tests/components/bmw_connected_drive/test_button.py +++ b/tests/components/bmw_connected_drive/test_button.py @@ -1,6 +1,6 @@ """Test BMW buttons.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,25 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test button options and values.""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.BUTTON], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all button entities - assert hass.states.async_all("button") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index 53e61439003..f2a50ce4df6 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -1,6 +1,6 @@ """Test BMW numbers.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,25 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: - """Test number options and values..""" + """Test number options and values.""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.NUMBER], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all number entities - assert hass.states.async_all("number") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index f3877119e3e..37aea4e0839 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -1,6 +1,6 @@ """Test BMW selects.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,25 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + @pytest.mark.usefixtures("bmw_fixture") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test select options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.SELECT], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all select entities - assert hass.states.async_all("select") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 2e48189e4a1..b4cdc23ad68 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,9 +1,13 @@ """Test BMW sensors.""" +from unittest.mock import patch + import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.unit_system import ( METRIC_SYSTEM as METRIC, US_CUSTOMARY_SYSTEM as IMPERIAL, @@ -12,6 +16,8 @@ from homeassistant.util.unit_system import ( from . import setup_mocked_integration +from tests.common import snapshot_platform + @pytest.mark.freeze_time("2023-06-22 10:30:00+00:00") @pytest.mark.usefixtures("bmw_fixture") @@ -19,14 +25,17 @@ from . import setup_mocked_integration async def test_entity_state_attrs( hass: HomeAssistant, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test sensor options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", [Platform.SENSOR] + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all select entities - assert hass.states.async_all("sensor") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.usefixtures("bmw_fixture") diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py index 6cf20d8077e..58bddbfc937 100644 --- a/tests/components/bmw_connected_drive/test_switch.py +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -1,6 +1,6 @@ """Test BMW switches.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError from bimmer_connected.vehicle.remote_services import RemoteServices @@ -8,24 +8,33 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import check_remote_service_call, setup_mocked_integration +from tests.common import snapshot_platform + @pytest.mark.usefixtures("bmw_fixture") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_entity_state_attrs( hass: HomeAssistant, snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, ) -> None: """Test switch options and values..""" # Setup component - assert await setup_mocked_integration(hass) + with patch( + "homeassistant.components.bmw_connected_drive.PLATFORMS", + [Platform.SWITCH], + ): + mock_config_entry = await setup_mocked_integration(hass) - # Get all switch entities - assert hass.states.async_all("switch") == snapshot + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( From 50efce4e53c42f43a1cc5869072780def261f74f Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 4 Jun 2024 09:06:23 +0200 Subject: [PATCH 117/234] Allow per-sensor unit conversion on BMW sensors (#110272) * Update BMW sensors to use device_class * Test adjustments * Trigger CI * Remove unneeded cast * Set suggested_display_precision to 0 * Rebase for climate_status * Change charging_status to ENUM device class * Add test for Enum translations * Pin Enum sensor values * Use snapshot_platform helper * Remove translation tests * Formatting * Remove comment * Use const.STATE_UNKOWN * Fix typo * Update strings * Loop through Enum sensors * Revert enum sensor changes --------- Co-authored-by: Richard --- .../components/bmw_connected_drive/sensor.py | 97 ++++++------ .../snapshots/test_sensor.ambr | 141 +++++++++++++++--- .../bmw_connected_drive/test_sensor.py | 10 +- 3 files changed, 172 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 0e8ad9726f1..e7f56075e63 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -6,9 +6,8 @@ from collections.abc import Callable from dataclasses import dataclass import datetime import logging -from typing import cast -from bimmer_connected.models import ValueWithUnit +from bimmer_connected.models import StrEnum, ValueWithUnit from bimmer_connected.vehicle import MyBMWVehicle from homeassistant.components.sensor import ( @@ -18,14 +17,19 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent +from homeassistant.const import ( + PERCENTAGE, + STATE_UNKNOWN, + UnitOfElectricCurrent, + UnitOfLength, + UnitOfVolume, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from . import BMWBaseEntity -from .const import CLIMATE_ACTIVITY_STATE, DOMAIN, UNIT_MAP +from .const import CLIMATE_ACTIVITY_STATE, DOMAIN from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -36,34 +40,18 @@ class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" key_class: str | None = None - unit_type: str | None = None - value: Callable = lambda x, y: x is_available: Callable[[MyBMWVehicle], bool] = lambda v: v.is_lsc_enabled -def convert_and_round( - state: ValueWithUnit, - converter: Callable[[float | None, str], float], - precision: int, -) -> float | None: - """Safely convert and round a value from ValueWithUnit.""" - if state.value and state.unit: - return round( - converter(state.value, UNIT_MAP.get(state.unit, state.unit)), precision - ) - if state.value: - return state.value - return None - - SENSOR_TYPES: list[BMWSensorEntityDescription] = [ - # --- Generic --- BMWSensorEntityDescription( key="ac_current_limit", translation_key="ac_current_limit", key_class="charging_profile", - unit_type=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( @@ -85,74 +73,81 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ key="charging_status", translation_key="charging_status", key_class="fuel_and_battery", - value=lambda x, y: x.value, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="charging_target", translation_key="charging_target", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_battery_percent", translation_key="remaining_battery_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), - # --- Specific --- BMWSensorEntityDescription( key="mileage", translation_key="mileage", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_total", translation_key="remaining_range_total", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), BMWSensorEntityDescription( key="remaining_range_electric", translation_key="remaining_range_electric", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_electric_drivetrain, ), BMWSensorEntityDescription( key="remaining_range_fuel", translation_key="remaining_range_fuel", key_class="fuel_and_battery", - unit_type=LENGTH, - value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel", translation_key="remaining_fuel", key_class="fuel_and_battery", - unit_type=VOLUME, - value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( key="remaining_fuel_percent", translation_key="remaining_fuel_percent", key_class="fuel_and_battery", - unit_type=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, is_available=lambda v: v.is_lsc_enabled and v.has_combustion_drivetrain, ), BMWSensorEntityDescription( @@ -161,7 +156,6 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [ key_class="climate", device_class=SensorDeviceClass.ENUM, options=CLIMATE_ACTIVITY_STATE, - value=lambda x, _: x.lower() if x != "UNKNOWN" else None, is_available=lambda v: v.is_remote_climate_stop_enabled, ), ] @@ -201,13 +195,6 @@ class BMWSensor(BMWBaseEntity, SensorEntity): self.entity_description = description self._attr_unique_id = f"{vehicle.vin}-{description.key}" - # Set the correct unit of measurement based on the unit_type - if description.unit_type: - self._attr_native_unit_of_measurement = ( - coordinator.hass.config.units.as_dict().get(description.unit_type) - or description.unit_type - ) - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" @@ -225,8 +212,18 @@ class BMWSensor(BMWBaseEntity, SensorEntity): # For datetime without tzinfo, we assume it to be the same timezone as the HA instance if isinstance(state, datetime.datetime) and state.tzinfo is None: state = state.replace(tzinfo=dt_util.get_default_time_zone()) + # For enum types, we only want the value + elif isinstance(state, ValueWithUnit): + state = state.value + # Get lowercase values from StrEnum + elif isinstance(state, StrEnum): + state = state.value.lower() + if state == STATE_UNKNOWN: + state = None - self._attr_native_value = cast( - StateType, self.entity_description.value(state, self.hass) - ) + # special handling for charging_status to avoid a breaking change + if self.entity_description.key == "charging_status" and state: + state = state.upper() + + self._attr_native_value = state super()._handle_coordinator_update() diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr index e3833add777..3455a4599b5 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -20,8 +20,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -36,6 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'i3 (+ REX) AC current limit', 'unit_of_measurement': , }), @@ -211,8 +215,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -227,6 +234,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'i3 (+ REX) Charging target', 'unit_of_measurement': '%', }), @@ -261,8 +269,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -277,6 +288,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Mileage', 'state_class': , 'unit_of_measurement': , @@ -312,6 +324,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -364,8 +379,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', @@ -380,6 +398,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', 'friendly_name': 'i3 (+ REX) Remaining fuel', 'state_class': , 'unit_of_measurement': , @@ -415,6 +434,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -466,8 +488,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -482,6 +507,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -517,8 +543,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', @@ -533,6 +562,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range fuel', 'state_class': , 'unit_of_measurement': , @@ -568,8 +598,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -584,6 +617,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i3 (+ REX) Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -617,8 +651,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -633,6 +670,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'i4 eDrive40 AC current limit', 'unit_of_measurement': , }), @@ -808,8 +846,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -824,6 +865,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'i4 eDrive40 Charging target', 'unit_of_measurement': '%', }), @@ -919,8 +961,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -935,6 +980,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Mileage', 'state_class': , 'unit_of_measurement': , @@ -970,6 +1016,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1022,8 +1071,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -1038,6 +1090,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -1073,8 +1126,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1089,6 +1145,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'i4 eDrive40 Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -1122,8 +1179,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'AC current limit', 'platform': 'bmw_connected_drive', @@ -1138,6 +1198,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'current', 'friendly_name': 'iX xDrive50 AC current limit', 'unit_of_measurement': , }), @@ -1313,8 +1374,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Charging target', 'platform': 'bmw_connected_drive', @@ -1329,6 +1393,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', 'friendly_name': 'iX xDrive50 Charging target', 'unit_of_measurement': '%', }), @@ -1424,8 +1489,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -1440,6 +1508,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Mileage', 'state_class': , 'unit_of_measurement': , @@ -1475,6 +1544,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, @@ -1527,8 +1599,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range electric', 'platform': 'bmw_connected_drive', @@ -1543,6 +1618,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Remaining range electric', 'state_class': , 'unit_of_measurement': , @@ -1578,8 +1654,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1594,6 +1673,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'iX xDrive50 Remaining range total', 'state_class': , 'unit_of_measurement': , @@ -1690,8 +1770,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Mileage', 'platform': 'bmw_connected_drive', @@ -1706,6 +1789,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Mileage', 'state_class': , 'unit_of_measurement': , @@ -1741,8 +1825,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining fuel', 'platform': 'bmw_connected_drive', @@ -1757,6 +1844,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'volume', 'friendly_name': 'M340i xDrive Remaining fuel', 'state_class': , 'unit_of_measurement': , @@ -1792,6 +1880,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': None, 'original_icon': None, @@ -1843,8 +1934,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range fuel', 'platform': 'bmw_connected_drive', @@ -1859,6 +1953,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Remaining range fuel', 'state_class': , 'unit_of_measurement': , @@ -1894,8 +1989,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Remaining range total', 'platform': 'bmw_connected_drive', @@ -1910,6 +2008,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by MyBMW', + 'device_class': 'distance', 'friendly_name': 'M340i xDrive Remaining range total', 'state_class': , 'unit_of_measurement': , diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index b4cdc23ad68..2f83fa108e5 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -43,17 +43,17 @@ async def test_entity_state_attrs( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ ("sensor.i3_rex_remaining_range_total", METRIC, "279", "km"), - ("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.36", "mi"), + ("sensor.i3_rex_remaining_range_total", IMPERIAL, "173.362562634216", "mi"), ("sensor.i3_rex_mileage", METRIC, "137009", "km"), - ("sensor.i3_rex_mileage", IMPERIAL, "85133.45", "mi"), + ("sensor.i3_rex_mileage", IMPERIAL, "85133.4456772449", "mi"), ("sensor.i3_rex_remaining_battery_percent", METRIC, "82", "%"), ("sensor.i3_rex_remaining_battery_percent", IMPERIAL, "82", "%"), ("sensor.i3_rex_remaining_range_electric", METRIC, "174", "km"), - ("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.12", "mi"), + ("sensor.i3_rex_remaining_range_electric", IMPERIAL, "108.118587449296", "mi"), ("sensor.i3_rex_remaining_fuel", METRIC, "6", "L"), - ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.59", "gal"), + ("sensor.i3_rex_remaining_fuel", IMPERIAL, "1.58503231414889", "gal"), ("sensor.i3_rex_remaining_range_fuel", METRIC, "105", "km"), - ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.24", "mi"), + ("sensor.i3_rex_remaining_range_fuel", IMPERIAL, "65.2439751849201", "mi"), ("sensor.m340i_xdrive_remaining_fuel_percent", METRIC, "80", "%"), ("sensor.m340i_xdrive_remaining_fuel_percent", IMPERIAL, "80", "%"), ], From 2151f7ebf31500e526bf10fb0da232de4fd4168d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Jun 2024 12:20:22 +0200 Subject: [PATCH 118/234] Bump version to 2024.6.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 bc19054193f..11e79f23fb4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index a045c2969fa..be8ef8b3c46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b6" +version = "2024.6.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ff8752ea4fe00de6591d959d7f51afca78df5104 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Wed, 5 Jun 2024 07:19:09 +1200 Subject: [PATCH 119/234] Fix calculation of Starlink sleep end setting (#115507) Co-authored-by: J. Nick Koston --- homeassistant/components/starlink/coordinator.py | 6 +++++- homeassistant/components/starlink/time.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 7a09b2f2dee..a891941fb8e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -119,12 +119,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): async def async_set_sleep_duration(self, end: int) -> None: """Set Starlink system sleep schedule end time.""" + duration = end - self.data.sleep[0] + if duration < 0: + # If the duration pushed us into the next day, add one days worth to correct that. + duration += 1440 async with asyncio.timeout(4): try: await self.hass.async_add_executor_job( set_sleep_config, self.data.sleep[0], - end, + duration, self.data.sleep[2], self.channel_context, ) diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 6475610564d..7395ec101ba 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -62,6 +62,8 @@ class StarlinkTimeEntity(StarlinkEntity, TimeEntity): def _utc_minutes_to_time(utc_minutes: int, timezone: tzinfo) -> time: hour = math.floor(utc_minutes / 60) + if hour > 23: + hour -= 24 minute = utc_minutes % 60 try: utc = datetime.now(UTC).replace( From 111d11aacae0c0bd8fc138df9bb1eb074f584b89 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Tue, 4 Jun 2024 21:55:38 +0300 Subject: [PATCH 120/234] Fix updating options in Jewish Calendar (#118643) --- .../components/jewish_calendar/__init__.py | 10 ++++++++-- .../components/jewish_calendar/config_flow.py | 15 ++++++++++++++- .../jewish_calendar/test_config_flow.py | 19 ++++++++++--------- tests/components/jewish_calendar/test_init.py | 5 ++++- .../components/jewish_calendar/test_sensor.py | 2 ++ 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index d4edcadf6f7..8383f9181fc 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -119,10 +119,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up a configuration entry for Jewish calendar.""" language = config_entry.data.get(CONF_LANGUAGE, DEFAULT_LANGUAGE) diaspora = config_entry.data.get(CONF_DIASPORA, DEFAULT_DIASPORA) - candle_lighting_offset = config_entry.data.get( + candle_lighting_offset = config_entry.options.get( CONF_CANDLE_LIGHT_MINUTES, DEFAULT_CANDLE_LIGHT ) - havdalah_offset = config_entry.data.get( + havdalah_offset = config_entry.options.get( CONF_HAVDALAH_OFFSET_MINUTES, DEFAULT_HAVDALAH_OFFSET_MINUTES ) @@ -154,6 +154,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async_update_unique_ids(ent_reg, config_entry.entry_id, old_prefix) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + # Trigger update of states for all platforms + await hass.config_entries.async_reload(config_entry.entry_id) + + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 626dc168db8..8f04d73915f 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -100,10 +100,23 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: + _options = {} + if CONF_CANDLE_LIGHT_MINUTES in user_input: + _options[CONF_CANDLE_LIGHT_MINUTES] = user_input[ + CONF_CANDLE_LIGHT_MINUTES + ] + del user_input[CONF_CANDLE_LIGHT_MINUTES] + if CONF_HAVDALAH_OFFSET_MINUTES in user_input: + _options[CONF_HAVDALAH_OFFSET_MINUTES] = user_input[ + CONF_HAVDALAH_OFFSET_MINUTES + ] + del user_input[CONF_HAVDALAH_OFFSET_MINUTES] if CONF_LOCATION in user_input: user_input[CONF_LATITUDE] = user_input[CONF_LOCATION][CONF_LATITUDE] user_input[CONF_LONGITUDE] = user_input[CONF_LOCATION][CONF_LONGITUDE] - return self.async_create_entry(title=DEFAULT_NAME, data=user_input) + return self.async_create_entry( + title=DEFAULT_NAME, data=user_input, options=_options + ) return self.async_show_form( step_id="user", diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 55c2f39b7eb..3189571a5a7 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -9,9 +9,7 @@ from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, CONF_HAVDALAH_OFFSET_MINUTES, - DEFAULT_CANDLE_LIGHT, DEFAULT_DIASPORA, - DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, DOMAIN, ) @@ -73,10 +71,8 @@ async def test_import_no_options(hass: HomeAssistant, language, diaspora) -> Non entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert entries[0].data == conf[DOMAIN] | { - CONF_CANDLE_LIGHT_MINUTES: DEFAULT_CANDLE_LIGHT, - CONF_HAVDALAH_OFFSET_MINUTES: DEFAULT_HAVDALAH_OFFSET_MINUTES, - } + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == conf[DOMAIN][entry_key] async def test_import_with_options(hass: HomeAssistant) -> None: @@ -99,7 +95,10 @@ async def test_import_with_options(hass: HomeAssistant) -> None: entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert entries[0].data == conf[DOMAIN] + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == conf[DOMAIN][entry_key] + for entry_key, entry_val in entries[0].options.items(): + assert entry_val == conf[DOMAIN][entry_key] async def test_single_instance_allowed( @@ -135,5 +134,7 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_CANDLE_LIGHT_MINUTES] == 25 - assert result["data"][CONF_HAVDALAH_OFFSET_MINUTES] == 34 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].options[CONF_CANDLE_LIGHT_MINUTES] == 25 + assert entries[0].options[CONF_HAVDALAH_OFFSET_MINUTES] == 34 diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index 49dad98fa89..f052d4e7f46 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -58,7 +58,10 @@ async def test_import_unique_id_migration(hass: HomeAssistant) -> None: entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert entries[0].data == yaml_conf[DOMAIN] + for entry_key, entry_val in entries[0].data.items(): + assert entry_val == yaml_conf[DOMAIN][entry_key] + for entry_key, entry_val in entries[0].options.items(): + assert entry_val == yaml_conf[DOMAIN][entry_key] # Assert that the unique_id was updated new_unique_id = ent_reg.async_get(sample_entity.entity_id).unique_id diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 729eca78467..965e461083b 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -519,6 +519,8 @@ async def test_shabbat_times_sensor( data={ CONF_LANGUAGE: language, CONF_DIASPORA: diaspora, + }, + options={ CONF_CANDLE_LIGHT_MINUTES: candle_lighting, CONF_HAVDALAH_OFFSET_MINUTES: havdalah, }, From 38ee32fed2d0f5f42c6aca07caa14749f0a3d88d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2024 11:18:07 -0400 Subject: [PATCH 121/234] Include script description in LLM exposed entities (#118749) * Include script description in LLM exposed entities * Fix race in test * Fix type * Expose script * Remove fields --- homeassistant/helpers/llm.py | 16 ++++++++++++++++ homeassistant/helpers/service.py | 8 ++++++++ tests/helpers/test_llm.py | 26 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 31e3c791630..3c240692d52 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -29,6 +29,7 @@ from . import ( entity_registry as er, floor_registry as fr, intent, + service, ) from .singleton import singleton @@ -407,6 +408,7 @@ def _get_exposed_entities( entity_entry = entity_registry.async_get(state.entity_id) names = [state.name] area_names = [] + description: str | None = None if entity_entry is not None: names.extend(entity_entry.aliases) @@ -426,11 +428,25 @@ def _get_exposed_entities( area_names.append(area.name) area_names.extend(area.aliases) + if ( + state.domain == "script" + and entity_entry.unique_id + and ( + service_desc := service.async_get_cached_service_description( + hass, "script", entity_entry.unique_id + ) + ) + ): + description = service_desc.get("description") + info: dict[str, Any] = { "names": ", ".join(names), "state": state.state, } + if description: + info["description"] = description + if area_names: info["areas"] = ", ".join(area_names) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index d20cba8909f..3a828ada9c2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -655,6 +655,14 @@ def _load_services_files( return [_load_services_file(hass, integration) for integration in integrations] +@callback +def async_get_cached_service_description( + hass: HomeAssistant, domain: str, service: str +) -> dict[str, Any] | None: + """Return the cached description for a service.""" + return hass.data.get(SERVICE_DESCRIPTION_CACHE, {}).get((domain, service)) + + @bind_hass async def async_get_all_descriptions( hass: HomeAssistant, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 6c9451bc843..3f61ed8a0ed 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest import voluptuous as vol +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.intent import async_register_timer_handler from homeassistant.core import Context, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError @@ -293,6 +294,26 @@ async def test_assist_api_prompt( ) # Expose entities + + # Create a script with a unique ID + assert await async_setup_component( + hass, + "script", + { + "script": { + "test_script": { + "description": "This is a test script", + "sequence": [], + "fields": { + "beer": {"description": "Number of beers"}, + "wine": {}, + }, + } + } + }, + ) + async_expose_entity(hass, "conversation", "script.test_script", True) + entry = MockConfigEntry(title=None) entry.add_to_hass(hass) device = device_registry.async_get_or_create( @@ -471,6 +492,11 @@ async def test_assist_api_prompt( "names": "Unnamed Device", "state": "unavailable", }, + "script.test_script": { + "description": "This is a test script", + "names": "test_script", + "state": "off", + }, } exposed_entities_prompt = ( "An overview of the areas and the devices in this smart home:\n" From 776675404a673bf6765d61617c655927771d8fb1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 4 Jun 2024 16:00:53 +0200 Subject: [PATCH 122/234] Set unique id in aladdin connect config flow (#118798) --- .../components/aladdin_connect/config_flow.py | 28 ++- tests/components/aladdin_connect/conftest.py | 31 +++ .../aladdin_connect/test_config_flow.py | 179 ++++++++++++++++-- 3 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 tests/components/aladdin_connect/conftest.py diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index e1a7b44830d..507085fa27f 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -4,9 +4,10 @@ from collections.abc import Mapping import logging from typing import Any -import voluptuous as vol +import jwt from homeassistant.config_entries import ConfigEntry, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import DOMAIN @@ -35,20 +36,33 @@ class AladdinConnectOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({}), - ) + return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an oauth config entry or update existing entry for reauth.""" - if self.reauth_entry: + token_payload = jwt.decode( + data[CONF_TOKEN][CONF_ACCESS_TOKEN], options={"verify_signature": False} + ) + if not self.reauth_entry: + await self.async_set_unique_id(token_payload["sub"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=token_payload["username"], + data=data, + ) + + if self.reauth_entry.unique_id == token_payload["username"]: return self.async_update_reload_and_abort( self.reauth_entry, data=data, + unique_id=token_payload["sub"], ) - return await super().async_oauth_create_entry(data) + if self.reauth_entry.unique_id == token_payload["sub"]: + return self.async_update_reload_and_abort(self.reauth_entry, data=data) + + return self.async_abort(reason="wrong_account") @property def logger(self) -> logging.Logger: diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py new file mode 100644 index 00000000000..a3f8ae417e1 --- /dev/null +++ b/tests/components/aladdin_connect/conftest.py @@ -0,0 +1,31 @@ +"""Test fixtures for the Aladdin Connect Garage Door integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.aladdin_connect import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return an Aladdin Connect config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={}, + title="test@test.com", + unique_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + version=2, + ) diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index d460d62625b..02244420925 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,10 +1,9 @@ """Test the Aladdin Connect Garage Door config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries from homeassistant.components.aladdin_connect.const import ( DOMAIN, OAUTH2_AUTHORIZE, @@ -14,13 +13,25 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + CLIENT_ID = "1234" CLIENT_SECRET = "5678" +EXAMPLE_TOKEN = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhYWFhYWFhYS1iYmJiLWNjY2MtZGRk" + "ZC1lZWVlZWVlZWVlZWUiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW" + "1lIjoidGVzdEB0ZXN0LmNvbSJ9.CTU1YItIrUl8nSM3koJxlFJr5CjLghgc9gS6h45D8dE" +) + @pytest.fixture async def setup_credentials(hass: HomeAssistant) -> None: @@ -33,18 +44,13 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) -async def test_full_flow( +async def _oauth_actions( hass: HomeAssistant, - hass_client_no_auth, - aioclient_mock, - current_request_with_host, - setup_credentials, + result: ConfigFlowResult, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Check full flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + state = config_entry_oauth2_flow._encode_jwt( hass, { "flow_id": result["flow_id"], @@ -67,16 +73,153 @@ async def test_full_flow( OAUTH2_TOKEN, json={ "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", + "access_token": EXAMPLE_TOKEN, "type": "Bearer", "expires_in": 60, }, ) - with patch( - "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True - ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@test.com" + assert result["data"]["token"]["access_token"] == EXAMPLE_TOKEN + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].unique_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort with duplicate entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauthentication.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_wrong_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauthentication with wrong account.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + title="test@test.com", + unique_id="aaaaaaaa-bbbb-ffff-dddd-eeeeeeeeeeee", + version=2, + ) + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" + + +async def test_reauth_old_account( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauthentication with old account.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + title="test@test.com", + unique_id="test@test.com", + version=2, + ) + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await _oauth_actions(hass, result, hass_client_no_auth, aioclient_mock) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.unique_id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" From b107ffd30d2ff798b196250cd35c4f3688c1a5cd Mon Sep 17 00:00:00 2001 From: arturyak <109509698+arturyak@users.noreply.github.com> Date: Tue, 4 Jun 2024 21:58:58 +0300 Subject: [PATCH 123/234] Add missing FAN_ONLY mode to ccm15 (#118804) --- homeassistant/components/ccm15/climate.py | 1 + tests/components/ccm15/snapshots/test_climate.ambr | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py index b4038fbbf43..a6e5d2cab61 100644 --- a/homeassistant/components/ccm15/climate.py +++ b/homeassistant/components/ccm15/climate.py @@ -57,6 +57,7 @@ class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): HVACMode.HEAT, HVACMode.COOL, HVACMode.DRY, + HVACMode.FAN_ONLY, HVACMode.AUTO, ] _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr index 10423919187..27dcbcb3405 100644 --- a/tests/components/ccm15/snapshots/test_climate.ambr +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -16,6 +16,7 @@ , , , + , , ]), 'max_temp': 35, @@ -70,6 +71,7 @@ , , , + , , ]), 'max_temp': 35, @@ -125,6 +127,7 @@ , , , + , , ]), 'max_temp': 35, @@ -164,6 +167,7 @@ , , , + , , ]), 'max_temp': 35, @@ -202,6 +206,7 @@ , , , + , , ]), 'max_temp': 35, @@ -256,6 +261,7 @@ , , , + , , ]), 'max_temp': 35, @@ -308,6 +314,7 @@ , , , + , , ]), 'max_temp': 35, @@ -342,6 +349,7 @@ , , , + , , ]), 'max_temp': 35, From b1b26af92b41d63761ae9358d55eece156070c09 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 4 Jun 2024 18:40:18 +0200 Subject: [PATCH 124/234] Check if Shelly `entry.runtime_data` is available (#118805) * Check if runtime_data is available * Add tests * Use `is` operator --------- Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- .../components/shelly/coordinator.py | 6 +- .../components/shelly/test_device_trigger.py | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 9d8416d64d9..cf6e9cc897f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -737,7 +737,8 @@ def get_block_coordinator_by_device_id( entry = hass.config_entries.async_get_entry(config_entry) if ( entry - and entry.state == ConfigEntryState.LOADED + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") and isinstance(entry.runtime_data, ShellyEntryData) and (coordinator := entry.runtime_data.block) ): @@ -756,7 +757,8 @@ def get_rpc_coordinator_by_device_id( entry = hass.config_entries.async_get_entry(config_entry) if ( entry - and entry.state == ConfigEntryState.LOADED + and entry.state is ConfigEntryState.LOADED + and hasattr(entry, "runtime_data") and isinstance(entry.runtime_data, ShellyEntryData) and (coordinator := entry.runtime_data.rpc) ): diff --git a/tests/components/shelly/test_device_trigger.py b/tests/components/shelly/test_device_trigger.py index 39238f1674a..42ea13aec24 100644 --- a/tests/components/shelly/test_device_trigger.py +++ b/tests/components/shelly/test_device_trigger.py @@ -385,3 +385,93 @@ async def test_validate_trigger_invalid_triggers( ) assert "Invalid (type,subtype): ('single', 'button3')" in caplog.text + + +async def test_rpc_no_runtime_data( + hass: HomeAssistant, + calls: list[ServiceCall], + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the device trigger for the RPC device when there is no runtime_data in the entry.""" + entry = await init_integration(hass, 2) + monkeypatch.delattr(entry, "runtime_data") + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "single_push", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single_push"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: device.id, + ATTR_CLICK_TYPE: "single_push", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single_push" + + +async def test_block_no_runtime_data( + hass: HomeAssistant, + calls: list[ServiceCall], + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test the device trigger for the block device when there is no runtime_data in the entry.""" + entry = await init_integration(hass, 1) + monkeypatch.delattr(entry, "runtime_data") + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, entry.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "single", + CONF_SUBTYPE: "button1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_single"}, + }, + }, + ] + }, + ) + message = { + CONF_DEVICE_ID: device.id, + ATTR_CLICK_TYPE: "single", + ATTR_CHANNEL: 1, + } + hass.bus.async_fire(EVENT_SHELLY_CLICK, message) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data["some"] == "test_trigger_single" From 74b29c2e549318f73bd08d690af93ef0ed8c9f44 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 4 Jun 2024 18:23:22 +0200 Subject: [PATCH 125/234] Bump Python Matter Server library to 6.1.0 (#118806) --- 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 d3ad4348950..369657df90c 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==6.1.0b1"], + "requirements": ["python-matter-server==6.1.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e473e33634..5d58ff7a2a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2272,7 +2272,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==6.1.0b1 +python-matter-server==6.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24021b642ff..e2ae607e5c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1766,7 +1766,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==6.1.0b1 +python-matter-server==6.1.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 6e30fd7633407f6bd7253b632c28d55026d19073 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 4 Jun 2024 18:26:47 +0200 Subject: [PATCH 126/234] Update frontend to 20240604.0 (#118811) --- 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 dd112f5094a..d474e9d2f14 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==20240603.0"] + "requirements": ["home-assistant-frontend==20240604.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 379adb18cc0..f3e8820ad0f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240603.0 +home-assistant-frontend==20240604.0 home-assistant-intents==2024.6.3 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5d58ff7a2a0..5708cab8e78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240603.0 +home-assistant-frontend==20240604.0 # homeassistant.components.conversation home-assistant-intents==2024.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2ae607e5c4..d6c84e45d5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240603.0 +home-assistant-frontend==20240604.0 # homeassistant.components.conversation home-assistant-intents==2024.6.3 From b02c9aa2ef5c3b0dc7412a56d583047a37429264 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Jun 2024 11:48:29 -0500 Subject: [PATCH 127/234] Ensure name of task is logged for unhandled loop exceptions (#118822) --- homeassistant/runner.py | 6 ++++-- tests/test_runner.py | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 523dafdecf3..a1510336302 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -137,16 +137,18 @@ def _async_loop_exception_handler(_: Any, context: dict[str, Any]) -> None: if source_traceback := context.get("source_traceback"): stack_summary = "".join(traceback.format_list(source_traceback)) logger.error( - "Error doing job: %s: %s", + "Error doing job: %s (%s): %s", context["message"], + context.get("task"), stack_summary, **kwargs, # type: ignore[arg-type] ) return logger.error( - "Error doing job: %s", + "Error doing job: %s (%s)", context["message"], + context.get("task"), **kwargs, # type: ignore[arg-type] ) diff --git a/tests/test_runner.py b/tests/test_runner.py index 79768aaf7cf..a4bec12bc0d 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -115,11 +115,11 @@ def test_run_does_not_block_forever_with_shielded_task( 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) + await asyncio.sleep(0) return 0 with ( - patch.object(runner, "TASK_CANCELATION_TIMEOUT", 1), + patch.object(runner, "TASK_CANCELATION_TIMEOUT", 0.1), patch("homeassistant.bootstrap.async_setup_hass", return_value=hass), patch("threading._shutdown"), patch("homeassistant.core.HomeAssistant.async_run", _async_create_tasks), @@ -145,7 +145,7 @@ async def test_unhandled_exception_traceback( try: hass.loop.set_debug(True) - task = asyncio.create_task(_unhandled_exception()) + task = asyncio.create_task(_unhandled_exception(), name="name_of_task") await raised.wait() # Delete it without checking result to trigger unhandled exception del task @@ -155,6 +155,7 @@ async def test_unhandled_exception_traceback( assert "Task exception was never retrieved" in caplog.text assert "This is unhandled" in caplog.text assert "_unhandled_exception" in caplog.text + assert "name_of_task" in caplog.text def test_enable_posix_spawn() -> None: From 9157905f80ff0688ba7d3f9ee008b1712c7b88aa Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 4 Jun 2024 20:47:06 +0200 Subject: [PATCH 128/234] Initialize the Sentry SDK within an import executor job to not block event loop (#118830) --- homeassistant/components/sentry/__init__.py | 46 +++++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index dcbcc59a749..8c042621db6 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform, inst from homeassistant.helpers.event import async_call_later from homeassistant.helpers.system_info import async_get_system_info from homeassistant.loader import Integration, async_get_custom_components +from homeassistant.setup import SetupPhases, async_pause_setup from .const import ( CONF_DSN, @@ -41,7 +42,6 @@ from .const import ( CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$") @@ -81,23 +81,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } - sentry_sdk.init( - dsn=entry.data[CONF_DSN], - environment=entry.options.get(CONF_ENVIRONMENT), - integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()], - release=current_version, - before_send=lambda event, hint: process_before_send( - hass, - entry.options, - channel, - huuid, - system_info, - custom_components, - event, - hint, - ), - **tracing, - ) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # sentry_sdk.init imports modules based on the selected integrations + def _init_sdk(): + """Initialize the Sentry SDK.""" + sentry_sdk.init( + dsn=entry.data[CONF_DSN], + environment=entry.options.get(CONF_ENVIRONMENT), + integrations=[ + sentry_logging, + AioHttpIntegration(), + SqlalchemyIntegration(), + ], + release=current_version, + before_send=lambda event, hint: process_before_send( + hass, + entry.options, + channel, + huuid, + system_info, + custom_components, + event, + hint, + ), + **tracing, + ) + + await hass.async_add_import_executor_job(_init_sdk) async def update_system_info(now): nonlocal system_info From f1e6375406b17f605d93cb5b7a9810fd26b1ae7e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 4 Jun 2024 21:32:36 +0200 Subject: [PATCH 129/234] Bump version to 2024.6.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 11e79f23fb4..65e5dbe0bfc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index be8ef8b3c46..e0dedee2f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b7" +version = "2024.6.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 0084d6c5bd89f88ef40ac1d7d09a5ae553314a4b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 5 Jun 2024 08:18:41 -0400 Subject: [PATCH 130/234] Fix Hydrawise sensor availability (#118669) Co-authored-by: Robert Resch --- .../components/hydrawise/binary_sensor.py | 13 +++- homeassistant/components/hydrawise/entity.py | 5 ++ .../hydrawise/test_binary_sensor.py | 24 ++++++- .../hydrawise/test_entity_availability.py | 65 +++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/components/hydrawise/test_entity_availability.py diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index d3382dbce39..e8426e5423a 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -24,13 +24,17 @@ class HydrawiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes Hydrawise binary sensor.""" value_fn: Callable[[HydrawiseBinarySensor], bool | None] + always_available: bool = False CONTROLLER_BINARY_SENSORS: tuple[HydrawiseBinarySensorEntityDescription, ...] = ( HydrawiseBinarySensorEntityDescription( key="status", device_class=BinarySensorDeviceClass.CONNECTIVITY, - value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success, + value_fn=lambda status_sensor: status_sensor.coordinator.last_update_success + and status_sensor.controller.online, + # Connectivtiy sensor is always available + always_available=True, ), ) @@ -98,3 +102,10 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): def _update_attrs(self) -> None: """Update state attributes.""" self._attr_is_on = self.entity_description.value_fn(self) + + @property + def available(self) -> bool: + """Set the entity availability.""" + if self.entity_description.always_available: + return True + return super().available diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 7b3ce6551a5..67dd6375b0e 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -70,3 +70,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): self.controller = self.coordinator.data.controllers[self.controller.id] self._update_attrs() super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Set the entity availability.""" + return super().available and self.controller.online diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index 6343b345d99..a42f9b1c044 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock, patch from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller from syrupy.assertion import SnapshotAssertion from homeassistant.components.hydrawise.const import SCAN_INTERVAL -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -47,4 +48,23 @@ async def test_update_data_fails( connectivity = hass.states.get("binary_sensor.home_controller_connectivity") assert connectivity is not None - assert connectivity.state == "unavailable" + assert connectivity.state == STATE_OFF + + +async def test_controller_offline( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, + controller: Controller, +) -> None: + """Test the binary_sensor for the controller being online.""" + # Make the coordinator refresh data. + controller.online = False + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity + assert connectivity.state == STATE_OFF diff --git a/tests/components/hydrawise/test_entity_availability.py b/tests/components/hydrawise/test_entity_availability.py new file mode 100644 index 00000000000..58ded5fe6c3 --- /dev/null +++ b/tests/components/hydrawise/test_entity_availability.py @@ -0,0 +1,65 @@ +"""Test entity availability.""" + +from collections.abc import Awaitable, Callable +from datetime import timedelta +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +from pydrawise.schema import Controller + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + +_SPECIAL_ENTITIES = {"binary_sensor.home_controller_connectivity": STATE_OFF} + + +async def test_controller_offline( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + controller: Controller, +) -> None: + """Test availability for sensors when controller is offline.""" + controller.online = False + config_entry = await mock_add_config_entry() + _test_availability(hass, config_entry, entity_registry) + + +async def test_api_offline( + hass: HomeAssistant, + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], + entity_registry: er.EntityRegistry, + mock_pydrawise: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test availability of sensors when API call fails.""" + config_entry = await mock_add_config_entry() + mock_pydrawise.get_user.reset_mock(return_value=True) + mock_pydrawise.get_user.side_effect = ClientError + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + _test_availability(hass, config_entry, entity_registry) + + +def _test_availability( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries + for entity_entry in entity_entries: + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state.state == _SPECIAL_ENTITIES.get( + entity_entry.entity_id, STATE_UNAVAILABLE + ) From 3784c993056225abb76937de8e684328d0a772c7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 5 Jun 2024 03:54:31 +0200 Subject: [PATCH 131/234] Conserve Reolink battery by not waking the camera on each update (#118773) * update to new cmd_list type * Wake battery cams each 1 hour * fix styling * fix epoch * fix timezone * force full update when using generic update service * improve comment * Use time.time() instead of datetime * fix import order --- homeassistant/components/reolink/entity.py | 5 +++++ homeassistant/components/reolink/host.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 53a81f2b162..f0ff25abf5e 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -101,6 +101,11 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): await super().async_will_remove_from_hass() + async def async_update(self) -> None: + """Force full update from the generic entity update service.""" + self._host.last_wake = 0 + await super().async_update() + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index b1a1a9adf0f..e557eb1d60e 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -6,6 +6,7 @@ import asyncio from collections import defaultdict from collections.abc import Mapping import logging +from time import time from typing import Any, Literal import aiohttp @@ -40,6 +41,10 @@ POLL_INTERVAL_NO_PUSH = 5 LONG_POLL_COOLDOWN = 0.75 LONG_POLL_ERROR_COOLDOWN = 30 +# Conserve battery by not waking the battery cameras each minute during normal update +# Most props are cached in the Home Hub and updated, but some are skipped +BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds + _LOGGER = logging.getLogger(__name__) @@ -68,6 +73,7 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.last_wake: float = 0 self._update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( lambda: defaultdict(int) ) @@ -337,7 +343,13 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states(cmd_list=self._update_cmd) + wake = False + if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL: + # wake the battery cameras for a complete update + wake = True + self.last_wake = time() + + await self._api.get_states(cmd_list=self._update_cmd, wake=wake) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" From f1445bc8f59e9479fd4ae15c56470e5f0a7b20cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Jun 2024 03:35:54 +0200 Subject: [PATCH 132/234] Fix capitalization of protocols in Reolink option flow (#118839) --- .../components/reolink/config_flow.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 773c4f3bc30..29da4a55ea1 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from .const import CONF_USE_HTTPS, DOMAIN @@ -60,7 +60,24 @@ class ReolinkOptionsFlowHandler(OptionsFlow): vol.Required( CONF_PROTOCOL, default=self.config_entry.options[CONF_PROTOCOL], - ): vol.In(["rtsp", "rtmp", "flv"]), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value="rtsp", + label="RTSP", + ), + selector.SelectOptionDict( + value="rtmp", + label="RTMP", + ), + selector.SelectOptionDict( + value="flv", + label="FLV", + ), + ], + ), + ), } ), ) From 18af423a78d9230335e749bfdd41515bf0c8a0d9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 4 Jun 2024 23:59:25 -0400 Subject: [PATCH 133/234] Fix the radio browser doing I/O in the event loop (#118842) --- homeassistant/components/radio_browser/media_source.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index d23d09cce3a..2f95acf407d 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -5,6 +5,7 @@ from __future__ import annotations import mimetypes from radios import FilterBy, Order, RadioBrowser, Station +from radios.radio_browser import pycountry from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source.error import Unresolvable @@ -145,6 +146,8 @@ class RadioMediaSource(MediaSource): # We show country in the root additionally, when there is no item if not item.identifier or category == "country": + # Trigger the lazy loading of the country database to happen inside the executor + await self.hass.async_add_executor_job(lambda: len(pycountry.countries)) countries = await radios.countries(order=Order.NAME) return [ BrowseMediaSource( From ac6a377478481a38d7023258b8cbc40a358c6521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ethem=20Cem=20=C3=96zkan?= Date: Wed, 5 Jun 2024 10:22:05 +0200 Subject: [PATCH 134/234] Bump python-roborock to 2.2.3 (#118853) Co-authored-by: G Johansson --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 69dea8d0c25..3fd6dd7d782 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.2.2", + "python-roborock==2.2.3", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 5708cab8e78..fc8a7b09052 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.2.2 +python-roborock==2.2.3 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6c84e45d5d..309b2750678 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1794,7 +1794,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.2.2 +python-roborock==2.2.3 # homeassistant.components.smarttub python-smarttub==0.0.36 From 63947e4980a626c1566666d4fd900094e492d782 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 5 Jun 2024 15:41:22 +0200 Subject: [PATCH 135/234] Improve repair issue when notify service is still being used (#118855) Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- homeassistant/components/ecobee/notify.py | 4 +++- homeassistant/components/file/notify.py | 4 +++- homeassistant/components/knx/notify.py | 4 +++- homeassistant/components/notify/repairs.py | 24 +++++++++++++++++++- homeassistant/components/notify/strings.json | 11 +++++++++ homeassistant/components/tibber/notify.py | 8 ++++++- tests/components/ecobee/test_repairs.py | 6 ++--- tests/components/knx/test_repairs.py | 6 ++--- tests/components/notify/test_repairs.py | 18 +++++++++++---- tests/components/tibber/test_repairs.py | 6 ++--- 10 files changed, 72 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py index b9dafae0f4e..167233e4071 100644 --- a/homeassistant/components/ecobee/notify.py +++ b/homeassistant/components/ecobee/notify.py @@ -43,7 +43,9 @@ class EcobeeNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message and raise issue.""" - migrate_notify_issue(self.hass, DOMAIN, "Ecobee", "2024.11.0") + migrate_notify_issue( + self.hass, DOMAIN, "Ecobee", "2024.11.0", service_name=self._service_name + ) await self.hass.async_add_executor_job( partial(self.send_message, message, **kwargs) ) diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index b51be280e75..244bd69aa32 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -69,7 +69,9 @@ class FileNotificationService(BaseNotificationService): """Send a message to a file.""" # The use of the legacy notify service was deprecated with HA Core 2024.6.0 # and will be removed with HA Core 2024.12 - migrate_notify_issue(self.hass, DOMAIN, "File", "2024.12.0") + migrate_notify_issue( + self.hass, DOMAIN, "File", "2024.12.0", service_name=self._service_name + ) await self.hass.async_add_executor_job( partial(self.send_message, message, **kwargs) ) diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 1b6cd325f21..997bdb81057 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -60,7 +60,9 @@ class KNXNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a notification to knx bus.""" - migrate_notify_issue(self.hass, DOMAIN, "KNX", "2024.11.0") + migrate_notify_issue( + self.hass, DOMAIN, "KNX", "2024.11.0", service_name=self._service_name + ) if "target" in kwargs: await self._async_send_to_device(message, kwargs["target"]) else: diff --git a/homeassistant/components/notify/repairs.py b/homeassistant/components/notify/repairs.py index 5c91a9a4731..d188f07c2ed 100644 --- a/homeassistant/components/notify/repairs.py +++ b/homeassistant/components/notify/repairs.py @@ -12,9 +12,31 @@ from .const import DOMAIN @callback def migrate_notify_issue( - hass: HomeAssistant, domain: str, integration_title: str, breaks_in_ha_version: str + hass: HomeAssistant, + domain: str, + integration_title: str, + breaks_in_ha_version: str, + service_name: str | None = None, ) -> None: """Ensure an issue is registered.""" + if service_name is not None: + ir.async_create_issue( + hass, + DOMAIN, + f"migrate_notify_{domain}_{service_name}", + breaks_in_ha_version=breaks_in_ha_version, + issue_domain=domain, + is_fixable=True, + is_persistent=True, + translation_key="migrate_notify_service", + translation_placeholders={ + "domain": domain, + "integration_title": integration_title, + "service_name": service_name, + }, + severity=ir.IssueSeverity.WARNING, + ) + return ir.async_create_issue( hass, DOMAIN, diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index 96482f5a7d5..947b192c4cd 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -72,6 +72,17 @@ } } } + }, + "migrate_notify_service": { + "title": "Legacy service `notify.{service_name}` stll being used", + "fix_flow": { + "step": { + "confirm": { + "description": "The {integration_title} `notify.{service_name}` service is migrated, but it seems the old `notify` service is still being used.\n\nA new `notify` entity is available now to replace each legacy `notify` service.\n\nUpdate any automations or scripts to use the new `notify.send_message` service exposed with this new entity. When this is done, select Submit and restart Home Assistant.", + "title": "Migrate legacy {integration_title} notify service for domain `{domain}`" + } + } + } } } } diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 24ae86c9e7f..1c9f86ed502 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -50,7 +50,13 @@ class TibberNotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to Tibber devices.""" - migrate_notify_issue(self.hass, TIBBER_DOMAIN, "Tibber", "2024.12.0") + migrate_notify_issue( + self.hass, + TIBBER_DOMAIN, + "Tibber", + "2024.12.0", + service_name=self._service_name, + ) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) try: await self._notify(title=title, message=message) diff --git a/tests/components/ecobee/test_repairs.py b/tests/components/ecobee/test_repairs.py index 897594c582f..9821d31ac64 100644 --- a/tests/components/ecobee/test_repairs.py +++ b/tests/components/ecobee/test_repairs.py @@ -49,13 +49,13 @@ async def test_ecobee_repair_flow( # Assert the issue is present assert issue_registry.async_get_issue( domain="notify", - issue_id=f"migrate_notify_{DOMAIN}", + issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}", ) assert len(issue_registry.issues) == 1 url = RepairsFlowIndexView.url resp = await http_client.post( - url, json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}"} + url, json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_{DOMAIN}"} ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -74,6 +74,6 @@ async def test_ecobee_repair_flow( # Assert the issue is no longer present assert not issue_registry.async_get_issue( domain="notify", - issue_id="migrate_notify", + issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}", ) assert len(issue_registry.issues) == 0 diff --git a/tests/components/knx/test_repairs.py b/tests/components/knx/test_repairs.py index 025f298e123..690d6e450cb 100644 --- a/tests/components/knx/test_repairs.py +++ b/tests/components/knx/test_repairs.py @@ -55,13 +55,13 @@ async def test_knx_notify_service_issue( assert len(issue_registry.issues) == 1 assert issue_registry.async_get_issue( domain="notify", - issue_id=f"migrate_notify_{DOMAIN}", + issue_id=f"migrate_notify_{DOMAIN}_notify", ) # Test confirm step in repair flow resp = await http_client.post( RepairsFlowIndexView.url, - json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}"}, + json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_notify"}, ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -79,6 +79,6 @@ async def test_knx_notify_service_issue( # Assert the issue is no longer present assert not issue_registry.async_get_issue( domain="notify", - issue_id=f"migrate_notify_{DOMAIN}", + issue_id=f"migrate_notify_{DOMAIN}_notify", ) assert len(issue_registry.issues) == 0 diff --git a/tests/components/notify/test_repairs.py b/tests/components/notify/test_repairs.py index f4e016418fe..fef5818e1e6 100644 --- a/tests/components/notify/test_repairs.py +++ b/tests/components/notify/test_repairs.py @@ -3,6 +3,8 @@ from http import HTTPStatus from unittest.mock import AsyncMock +import pytest + from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, migrate_notify_issue, @@ -24,11 +26,17 @@ from tests.typing import ClientSessionGenerator THERMOSTAT_ID = 0 +@pytest.mark.usefixtures("config_flow_fixture") +@pytest.mark.parametrize( + ("service_name", "translation_key"), + [(None, "migrate_notify_test"), ("bla", "migrate_notify_test_bla")], +) async def test_notify_migration_repair_flow( hass: HomeAssistant, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, - config_flow_fixture: None, + service_name: str | None, + translation_key: str, ) -> None: """Test the notify service repair flow is triggered.""" await async_setup_component(hass, NOTIFY_DOMAIN, {}) @@ -49,18 +57,18 @@ async def test_notify_migration_repair_flow( assert await hass.config_entries.async_setup(config_entry.entry_id) # Simulate legacy service being used and issue being registered - migrate_notify_issue(hass, "test", "Test", "2024.12.0") + migrate_notify_issue(hass, "test", "Test", "2024.12.0", service_name=service_name) await hass.async_block_till_done() # Assert the issue is present assert issue_registry.async_get_issue( domain=NOTIFY_DOMAIN, - issue_id="migrate_notify_test", + issue_id=translation_key, ) assert len(issue_registry.issues) == 1 url = RepairsFlowIndexView.url resp = await http_client.post( - url, json={"handler": NOTIFY_DOMAIN, "issue_id": "migrate_notify_test"} + url, json={"handler": NOTIFY_DOMAIN, "issue_id": translation_key} ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -79,6 +87,6 @@ async def test_notify_migration_repair_flow( # Assert the issue is no longer present assert not issue_registry.async_get_issue( domain=NOTIFY_DOMAIN, - issue_id="migrate_notify_test", + issue_id=translation_key, ) assert len(issue_registry.issues) == 0 diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py index 9aaec81618d..89e85e5f8e1 100644 --- a/tests/components/tibber/test_repairs.py +++ b/tests/components/tibber/test_repairs.py @@ -36,13 +36,13 @@ async def test_repair_flow( # Assert the issue is present assert issue_registry.async_get_issue( domain="notify", - issue_id="migrate_notify_tibber", + issue_id=f"migrate_notify_tibber_{service}", ) assert len(issue_registry.issues) == 1 url = RepairsFlowIndexView.url resp = await http_client.post( - url, json={"handler": "notify", "issue_id": "migrate_notify_tibber"} + url, json={"handler": "notify", "issue_id": f"migrate_notify_tibber_{service}"} ) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -61,6 +61,6 @@ async def test_repair_flow( # Assert the issue is no longer present assert not issue_registry.async_get_issue( domain="notify", - issue_id="migrate_notify_tibber", + issue_id=f"migrate_notify_tibber_{service}", ) assert len(issue_registry.issues) == 0 From 06df32d9d4be1cc392d1466e7d913cd85469a9da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 5 Jun 2024 12:47:52 +0200 Subject: [PATCH 136/234] Fix TypeAliasType not callable in senz (#118872) --- homeassistant/components/senz/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/senz/__init__.py b/homeassistant/components/senz/__init__.py index 288bf005a5c..bd4dfae4571 100644 --- a/homeassistant/components/senz/__init__.py +++ b/homeassistant/components/senz/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except RequestError as err: raise ConfigEntryNotReady from err - coordinator = SENZDataUpdateCoordinator( + coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator( hass, _LOGGER, name=account.username, From 3b74b63b235d88590998d0e95a76d36fa7ce078a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 5 Jun 2024 13:32:50 +0200 Subject: [PATCH 137/234] Update frontend to 20240605.0 (#118875) --- 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 d474e9d2f14..27322b423d0 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==20240604.0"] + "requirements": ["home-assistant-frontend==20240605.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f3e8820ad0f..dd7627482ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240604.0 +home-assistant-frontend==20240605.0 home-assistant-intents==2024.6.3 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fc8a7b09052..7426e86aa33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240604.0 +home-assistant-frontend==20240605.0 # homeassistant.components.conversation home-assistant-intents==2024.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 309b2750678..1be1a31f723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.49 # homeassistant.components.frontend -home-assistant-frontend==20240604.0 +home-assistant-frontend==20240605.0 # homeassistant.components.conversation home-assistant-intents==2024.6.3 From e5804307e7ae3af1975ab17123ea31a2a2147bdf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Jun 2024 15:51:19 +0200 Subject: [PATCH 138/234] Bump version to 2024.6.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 65e5dbe0bfc..9a8e16e02b8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index e0dedee2f82..ed5d8c9b8ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b8" +version = "2024.6.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5e35ce2996920bd47c2c5d413aa211391ef88466 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 5 Jun 2024 18:53:44 +0200 Subject: [PATCH 139/234] Improve WS command validate_config (#118864) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Robert Resch --- .../components/websocket_api/commands.py | 5 ++- .../components/websocket_api/test_commands.py | 32 +++++++++++++++---- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e159880c8bc..f66930c8d00 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -862,7 +862,10 @@ async def handle_validate_config( try: await validator(hass, schema(msg[key])) - except vol.Invalid as err: + except ( + vol.Invalid, + HomeAssistantError, + ) as err: result[key] = {"valid": False, "error": str(err)} else: result[key] = {"valid": True, "error": None} diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 655d8adf1ea..a51e51b81b0 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -3,6 +3,7 @@ import asyncio from copy import deepcopy import logging +from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -2529,13 +2530,14 @@ async def test_integration_setup_info( ], ) async def test_validate_config_works( - websocket_client: MockHAClientWebSocket, key, config + websocket_client: MockHAClientWebSocket, + key: str, + config: dict[str, Any] | list[dict[str, Any]], ) -> None: """Test config validation.""" - await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) + await websocket_client.send_json_auto_id({"type": "validate_config", key: config}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == {key: {"valid": True, "error": None}} @@ -2544,11 +2546,13 @@ async def test_validate_config_works( @pytest.mark.parametrize( ("key", "config", "error"), [ + # Raises vol.Invalid ( "trigger", {"platform": "non_existing", "event_type": "hello"}, "Invalid platform 'non_existing' specified", ), + # Raises vol.Invalid ( "condition", { @@ -2562,6 +2566,20 @@ async def test_validate_config_works( "@ data[0]" ), ), + # Raises HomeAssistantError + ( + "condition", + { + "above": 50, + "condition": "device", + "device_id": "a51a57e5af051eb403d56eb9e6fd691c", + "domain": "sensor", + "entity_id": "7d18a157b7c00adbf2982ea7de0d0362", + "type": "is_carbon_dioxide", + }, + "Unknown device 'a51a57e5af051eb403d56eb9e6fd691c'", + ), + # Raises vol.Invalid ( "action", {"non_existing": "domain_test.test_service"}, @@ -2570,13 +2588,15 @@ async def test_validate_config_works( ], ) async def test_validate_config_invalid( - websocket_client: MockHAClientWebSocket, key, config, error + websocket_client: MockHAClientWebSocket, + key: str, + config: dict[str, Any], + error: str, ) -> None: """Test config validation.""" - await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) + await websocket_client.send_json_auto_id({"type": "validate_config", key: config}) msg = await websocket_client.receive_json() - assert msg["id"] == 7 assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == {key: {"valid": False, "error": error}} From 0f4a1b421e9cd360c48c4d0d764ee59a24e5f8f7 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 5 Jun 2024 11:43:28 -0500 Subject: [PATCH 140/234] Bump intents to 2024.6.5 (#118890) --- 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 6873e47e647..a3af6607aba 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.3"] + "requirements": ["hassil==1.7.1", "home-assistant-intents==2024.6.5"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dd7627482ba..690b0f2615d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 home-assistant-frontend==20240605.0 -home-assistant-intents==2024.6.3 +home-assistant-intents==2024.6.5 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 7426e86aa33..286e447a0da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1090,7 +1090,7 @@ holidays==0.49 home-assistant-frontend==20240605.0 # homeassistant.components.conversation -home-assistant-intents==2024.6.3 +home-assistant-intents==2024.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1be1a31f723..8888e9f632d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -892,7 +892,7 @@ holidays==0.49 home-assistant-frontend==20240605.0 # homeassistant.components.conversation -home-assistant-intents==2024.6.3 +home-assistant-intents==2024.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 From c27f0c560edea121ed78b9f96a0597a2680921a3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 5 Jun 2024 18:21:03 +0200 Subject: [PATCH 141/234] Replace slave by meter in v2c (#118893) --- homeassistant/components/v2c/icons.json | 2 +- homeassistant/components/v2c/sensor.py | 9 +++++++-- homeassistant/components/v2c/strings.json | 6 +++--- tests/components/v2c/snapshots/test_sensor.ambr | 12 ++++++------ tests/components/v2c/test_sensor.py | 6 +++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/v2c/icons.json b/homeassistant/components/v2c/icons.json index fa8449135bb..1b76b669956 100644 --- a/homeassistant/components/v2c/icons.json +++ b/homeassistant/components/v2c/icons.json @@ -16,7 +16,7 @@ "fv_power": { "default": "mdi:solar-power-variant" }, - "slave_error": { + "meter_error": { "default": "mdi:alert" }, "battery_power": { diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 799d6c3d03c..0c59993ac0e 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -35,7 +35,12 @@ class V2CSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[TrydanData], StateType] -_METER_ERROR_OPTIONS = [error.name.lower() for error in SlaveCommunicationState] +def get_meter_value(value: SlaveCommunicationState) -> str: + """Return the value of the enum and replace slave by meter.""" + return value.name.lower().replace("slave", "meter") + + +_METER_ERROR_OPTIONS = [get_meter_value(error) for error in SlaveCommunicationState] TRYDAN_SENSORS = ( V2CSensorEntityDescription( @@ -82,7 +87,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="meter_error", translation_key="meter_error", - value_fn=lambda evse_data: evse_data.slave_error.name.lower(), + value_fn=lambda evse_data: get_meter_value(evse_data.slave_error), entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, options=_METER_ERROR_OPTIONS, diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index bc0d870b635..3342652cfb4 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -60,12 +60,12 @@ "no_error": "No error", "communication": "Communication", "reading": "Reading", - "slave": "Meter", + "meter": "Meter", "waiting_wifi": "Waiting for Wi-Fi", "waiting_communication": "Waiting communication", "wrong_ip": "Wrong IP", - "slave_not_found": "Meter not found", - "wrong_slave": "Wrong Meter", + "meter_not_found": "Meter not found", + "wrong_meter": "Wrong meter", "no_response": "No response", "clamp_not_connected": "Clamp not connected", "illegal_function": "Illegal function", diff --git a/tests/components/v2c/snapshots/test_sensor.ambr b/tests/components/v2c/snapshots/test_sensor.ambr index 859e5f83e15..cc8077333cb 100644 --- a/tests/components/v2c/snapshots/test_sensor.ambr +++ b/tests/components/v2c/snapshots/test_sensor.ambr @@ -265,12 +265,12 @@ 'no_error', 'communication', 'reading', - 'slave', + 'meter', 'waiting_wifi', 'waiting_communication', 'wrong_ip', - 'slave_not_found', - 'wrong_slave', + 'meter_not_found', + 'wrong_meter', 'no_response', 'clamp_not_connected', 'illegal_function', @@ -335,12 +335,12 @@ 'no_error', 'communication', 'reading', - 'slave', + 'meter', 'waiting_wifi', 'waiting_communication', 'wrong_ip', - 'slave_not_found', - 'wrong_slave', + 'meter_not_found', + 'wrong_meter', 'no_response', 'clamp_not_connected', 'illegal_function', diff --git a/tests/components/v2c/test_sensor.py b/tests/components/v2c/test_sensor.py index 93f7e36327c..c7ce41c1017 100644 --- a/tests/components/v2c/test_sensor.py +++ b/tests/components/v2c/test_sensor.py @@ -32,12 +32,12 @@ async def test_sensor( "no_error", "communication", "reading", - "slave", + "meter", "waiting_wifi", "waiting_communication", "wrong_ip", - "slave_not_found", - "wrong_slave", + "meter_not_found", + "wrong_meter", "no_response", "clamp_not_connected", "illegal_function", From 21fd01244724117ef18ed110ca2aa13b3332e4b4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 5 Jun 2024 19:00:08 +0200 Subject: [PATCH 142/234] Bump version to 2024.6.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 9a8e16e02b8..e4ece15cd57 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index ed5d8c9b8ce..516a2e5bf72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0b9" +version = "2024.6.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 00dd86fb4b933cc8bbefd520dee7a5d412d507e6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 6 Jun 2024 17:11:49 +0200 Subject: [PATCH 143/234] Update requests to 2.32.3 (#118868) Co-authored-by: Robert Resch --- 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 690b0f2615d..e24ccc4fac9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ python-slugify==8.0.4 PyTurboJPEG==1.7.1 pyudev==0.24.1 PyYAML==6.0.1 -requests==2.31.0 +requests==2.32.3 SQLAlchemy==2.0.30 typing-extensions>=4.12.0,<5.0 ulid-transform==0.9.0 diff --git a/pyproject.toml b/pyproject.toml index 516a2e5bf72..bfae6c15cd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ dependencies = [ "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.1", - "requests==2.31.0", + "requests==2.32.3", "SQLAlchemy==2.0.30", "typing-extensions>=4.12.0,<5.0", "ulid-transform==0.9.0", diff --git a/requirements.txt b/requirements.txt index 7e2107a4490..2701c7b6099 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ pip>=21.3.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.1 -requests==2.31.0 +requests==2.32.3 SQLAlchemy==2.0.30 typing-extensions>=4.12.0,<5.0 ulid-transform==0.9.0 From 0f9a91d36980263d9347b25f3239e04bdd90e324 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Jun 2024 13:20:34 -0500 Subject: [PATCH 144/234] Prioritize literal text with name slots in sentence matching (#118900) Prioritize literal text with name slots --- .../components/conversation/default_agent.py | 11 ++++- .../test_default_agent_intents.py | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index d5454883292..7bb2c2182b3 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -429,8 +429,15 @@ class DefaultAgent(ConversationEntity): intent_context=intent_context, language=language, ): - if ("name" in result.entities) and ( - not result.entities["name"].is_wildcard + # Prioritize results with a "name" slot, but still prefer ones with + # more literal text matched. + if ( + ("name" in result.entities) + and (not result.entities["name"].is_wildcard) + and ( + (name_result is None) + or (result.text_chunks_matched > name_result.text_chunks_matched) + ) ): name_result = result diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index f5050f4483e..b1c4a6d51af 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -1,5 +1,7 @@ """Test intents for the default agent.""" +from unittest.mock import patch + import pytest from homeassistant.components import ( @@ -7,6 +9,7 @@ from homeassistant.components import ( cover, light, media_player, + todo, vacuum, valve, ) @@ -35,6 +38,27 @@ from homeassistant.setup import async_setup_component from tests.common import async_mock_service +class MockTodoListEntity(todo.TodoListEntity): + """Test todo list entity.""" + + def __init__(self, items: list[todo.TodoItem] | None = None) -> None: + """Initialize entity.""" + self._attr_todo_items = items or [] + + @property + def items(self) -> list[todo.TodoItem]: + """Return the items in the To-do list.""" + return self._attr_todo_items + + async def async_create_todo_item(self, item: todo.TodoItem) -> None: + """Add an item to the To-do list.""" + self._attr_todo_items.append(item) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + self._attr_todo_items = [item for item in self.items if item.uid not in uids] + + @pytest.fixture async def init_components(hass: HomeAssistant): """Initialize relevant components with empty configs.""" @@ -365,3 +389,27 @@ async def test_turn_floor_lights_on_off( assert {s.entity_id for s in result.response.matched_states} == { bedroom_light.entity_id } + + +async def test_todo_add_item_fr( + hass: HomeAssistant, + init_components, +) -> None: + """Test that wildcard matches prioritize results with more literal text matched.""" + assert await async_setup_component(hass, todo.DOMAIN, {}) + hass.states.async_set("todo.liste_des_courses", 0, {}) + + with ( + patch.object(hass.config, "language", "fr"), + patch( + "homeassistant.components.todo.intent.ListAddItemIntent.async_handle", + return_value=intent.IntentResponse(hass.config.language), + ) as mock_handle, + ): + await conversation.async_converse( + hass, "Ajoute de la farine a la liste des courses", None, Context(), None + ) + mock_handle.assert_called_once() + assert mock_handle.call_args.args + intent_obj = mock_handle.call_args.args[0] + assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine" From 5a7332a13507820300f4992c752ea278f666f971 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 6 Jun 2024 16:10:03 +0400 Subject: [PATCH 145/234] Check if imap message text has a value instead of checking if its not None (#118901) * Check if message_text has a value instead of checking if its not None * Strip message_text to ensure that its actually empty or not * Add test with multipart payload having empty plain text --- homeassistant/components/imap/coordinator.py | 6 +-- tests/components/imap/const.py | 39 ++++++++++++++++++++ tests/components/imap/test_init.py | 3 ++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index c0123b89ee4..a9d0fdfbd48 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -195,13 +195,13 @@ class ImapMessage: ): message_untyped_text = str(part.get_payload()) - if message_text is not None: + if message_text is not None and message_text.strip(): return message_text - if message_html is not None: + if message_html: return message_html - if message_untyped_text is not None: + if message_untyped_text: return message_untyped_text return str(self.email_message.get_payload()) diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 677eea7a473..037960c9e5d 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -59,6 +59,11 @@ TEST_CONTENT_TEXT_PLAIN = ( b"Content-Transfer-Encoding: 7bit\r\n\r\nTest body\r\n" ) +TEST_CONTENT_TEXT_PLAIN_EMPTY = ( + b'Content-Type: text/plain; charset="utf-8"\r\n' + b"Content-Transfer-Encoding: 7bit\r\n\r\n \r\n" +) + TEST_CONTENT_TEXT_BASE64 = ( b'Content-Type: text/plain; charset="utf-8"\r\n' b"Content-Transfer-Encoding: base64\r\n\r\nVGVzdCBib2R5\r\n" @@ -108,6 +113,15 @@ TEST_CONTENT_MULTIPART = ( + b"\r\n--Mark=_100584970350292485166--\r\n" ) +TEST_CONTENT_MULTIPART_EMPTY_PLAIN = ( + b"\r\nThis is a multi-part message in MIME format.\r\n" + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_TEXT_PLAIN_EMPTY + + b"\r\n--Mark=_100584970350292485166\r\n" + + TEST_CONTENT_HTML + + b"\r\n--Mark=_100584970350292485166--\r\n" +) + TEST_CONTENT_MULTIPART_BASE64 = ( b"\r\nThis is a multi-part message in MIME format.\r\n" b"\r\n--Mark=_100584970350292485166\r\n" @@ -155,6 +169,18 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN = ( ], ) +TEST_FETCH_RESPONSE_TEXT_PLAIN_EMPTY = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN_EMPTY)).encode("utf-8") + + b"}", + bytearray(TEST_MESSAGE + TEST_CONTENT_TEXT_PLAIN_EMPTY), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) + TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( "OK", [ @@ -249,6 +275,19 @@ TEST_FETCH_RESPONSE_MULTIPART = ( b"Fetch completed (0.0001 + 0.000 secs).", ], ) +TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_EMPTY_PLAIN)).encode( + "utf-8" + ) + + b"}", + bytearray(TEST_MESSAGE_MULTIPART + TEST_CONTENT_MULTIPART_EMPTY_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) TEST_FETCH_RESPONSE_MULTIPART_BASE64 = ( "OK", [ diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index e6e6ffe7114..fe10770fc64 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -29,6 +29,7 @@ from .const import ( TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_MULTIPART_BASE64, TEST_FETCH_RESPONSE_MULTIPART_BASE64_INVALID, + TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, TEST_FETCH_RESPONSE_NO_SUBJECT_TO_FROM, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -116,6 +117,7 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), + (TEST_FETCH_RESPONSE_MULTIPART_EMPTY_PLAIN, True), (TEST_FETCH_RESPONSE_MULTIPART_BASE64, True), (TEST_FETCH_RESPONSE_BINARY, True), ], @@ -129,6 +131,7 @@ async def test_entry_startup_fails( "other", "html", "multipart", + "multipart_empty_plain", "multipart_base64", "binary", ], From 86b13e8ae35816793e0f4102600c3095d6a6d044 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 5 Jun 2024 23:37:14 +0200 Subject: [PATCH 146/234] Fix flaky Google Assistant test (#118914) * Fix flaky Google Assistant test * Trigger full ci --- tests/components/google_assistant/test_http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index 1dac75875a6..416d569b286 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -577,6 +577,8 @@ async def test_async_get_users_from_store(tmpdir: py.path.local) -> None: assert await async_get_users(hass) == ["agent_1"] + await hass.async_stop() + VALID_STORE_DATA = json.dumps( { From 394c13af1dd2e1093681830736d6a54cd42fd79c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 5 Jun 2024 22:37:35 -0500 Subject: [PATCH 147/234] Revert "Bump orjson to 3.10.3 (#116945)" (#118920) This reverts commit dc50095d0618f545a7ee80d2f10b9997c1bc40da. --- 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 e24ccc4fac9..b1d82e3c58b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.3 +orjson==3.9.15 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.3.0 diff --git a/pyproject.toml b/pyproject.toml index bfae6c15cd6..c3e03374b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "cryptography==42.0.5", "Pillow==10.3.0", "pyOpenSSL==24.1.0", - "orjson==3.10.3", + "orjson==3.9.15", "packaging>=23.1", "pip>=21.3.1", "psutil-home-assistant==0.0.1", diff --git a/requirements.txt b/requirements.txt index 2701c7b6099..05b0eb35c1e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ PyJWT==2.8.0 cryptography==42.0.5 Pillow==10.3.0 pyOpenSSL==24.1.0 -orjson==3.10.3 +orjson==3.9.15 packaging>=23.1 pip>=21.3.1 psutil-home-assistant==0.0.1 From 6e9a53d02e434b769e3c646c1be37fc7db6eb363 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 6 Jun 2024 12:26:07 +0200 Subject: [PATCH 148/234] Bump `imgw-pib` backend library to version `1.0.2` (#118953) Bump imgw-pib to version 1.0.2 Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index c6a230244ec..9a9994a73e5 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==1.0.1"] + "requirements": ["imgw_pib==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 286e447a0da..6ecb9660fc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ iglo==1.2.7 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.1 +imgw_pib==1.0.2 # homeassistant.components.incomfort incomfort-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8888e9f632d..af1126c3298 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -933,7 +933,7 @@ idasen-ha==2.5.3 ifaddr==0.2.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.1 +imgw_pib==1.0.2 # homeassistant.components.influxdb influxdb-client==1.24.0 From 62f73cfccac0995f44d89de01a7efc47192b4a7d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 6 Jun 2024 14:21:30 +0300 Subject: [PATCH 149/234] Fix Alarm control panel not require code in several integrations (#118961) --- homeassistant/components/agent_dvr/alarm_control_panel.py | 1 + homeassistant/components/blink/alarm_control_panel.py | 1 + homeassistant/components/egardia/alarm_control_panel.py | 1 + homeassistant/components/hive/alarm_control_panel.py | 1 + homeassistant/components/ialarm/alarm_control_panel.py | 1 + homeassistant/components/lupusec/alarm_control_panel.py | 1 + homeassistant/components/nx584/alarm_control_panel.py | 1 + homeassistant/components/overkiz/alarm_control_panel.py | 1 + homeassistant/components/point/alarm_control_panel.py | 1 + homeassistant/components/spc/alarm_control_panel.py | 1 + homeassistant/components/tuya/alarm_control_panel.py | 1 + homeassistant/components/xiaomi_miio/alarm_control_panel.py | 1 + 12 files changed, 12 insertions(+) diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index e703bcad6ae..f098184321f 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -43,6 +43,7 @@ class AgentBaseStation(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index b7dc50a5c51..0ad15cf0d31 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -46,6 +46,7 @@ class BlinkSyncModuleHA( """Representation of a Blink Alarm Control Panel.""" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index ad08b8cbc4d..706ba0db719 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -67,6 +67,7 @@ class EgardiaAlarm(AlarmControlPanelEntity): """Representation of a Egardia alarm.""" _attr_state: str | None + _attr_code_arm_required = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index 78e8606a43c..06383784a3f 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -51,6 +51,7 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER ) + _attr_code_arm_required = False async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index a7118fb03cc..912f04a1d1e 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -37,6 +37,7 @@ class IAlarmPanel( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None: """Create the entity with a DataUpdateCoordinator.""" diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 090d9ab3ced..73aba775a2a 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -48,6 +48,7 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__( self, data: lupupy.Lupusec, device: lupupy.devices.LupusecAlarm, entry_id: str diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index a86cda83dd7..2e306de5908 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -100,6 +100,7 @@ class NX584Alarm(AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) + _attr_code_arm_required = False def __init__(self, name: str, alarm_client: client.Client, url: str) -> None: """Init the nx584 alarm panel.""" diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 72c99982a1b..151f91790cf 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -240,6 +240,7 @@ class OverkizAlarmControlPanel(OverkizDescriptiveEntity, AlarmControlPanelEntity """Representation of an Overkiz Alarm Control Panel.""" entity_description: OverkizAlarmDescription + _attr_code_arm_required = False def __init__( self, diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index b04742af06a..844d1eba553 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -55,6 +55,7 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): """The platform class required by Home Assistant.""" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False def __init__(self, point_client: MinutPointClient, home_id: str) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index ae349d2497e..7e584ff5e63 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -62,6 +62,7 @@ class SpcAlarm(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_code_arm_required = False def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 868f6634bc9..29da625a990 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -88,6 +88,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): """Tuya Alarm Entity.""" _attr_name = None + _attr_code_arm_required = False def __init__( self, diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 72530227e88..58d5ed247ad 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -54,6 +54,7 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): _attr_icon = "mdi:shield-home" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY + _attr_code_arm_required = False def __init__( self, gateway_device, gateway_name, model, mac_address, gateway_device_id From d6e1d05e87d62c35c92b58152805fb2ef7466b8f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 6 Jun 2024 14:34:03 +0300 Subject: [PATCH 150/234] Bump python-holidays to 0.50 (#118965) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 5ac6611592d..bc7ce0e8dd1 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.49", "babel==2.13.1"] + "requirements": ["holidays==0.50", "babel==2.13.1"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 7faf82ad71a..71c26a30e94 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.49"] + "requirements": ["holidays==0.50"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ecb9660fc9..c88a0a67238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.49 +holidays==0.50 # homeassistant.components.frontend home-assistant-frontend==20240605.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af1126c3298..06a48f20f86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,7 +886,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.49 +holidays==0.50 # homeassistant.components.frontend home-assistant-frontend==20240605.0 From 14da1e9b23bee2278281f8036837ac5a6fb6cac0 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Thu, 6 Jun 2024 11:28:13 -0400 Subject: [PATCH 151/234] Bump pydrawise to 2024.6.3 (#118977) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 0426b8bf2cc..dc6408407e7 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.6.2"] + "requirements": ["pydrawise==2024.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index c88a0a67238..3e39ff0ba86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1794,7 +1794,7 @@ pydiscovergy==3.0.1 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.6.2 +pydrawise==2024.6.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06a48f20f86..2be2dfb7022 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1405,7 +1405,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.1 # homeassistant.components.hydrawise -pydrawise==2024.6.2 +pydrawise==2024.6.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 52d1432d8182d82311aaa72c098a3284211b0f96 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 6 Jun 2024 17:14:02 +0200 Subject: [PATCH 152/234] Bump `imgw-pib` library to version `1.0.4` (#118978) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 9a9994a73e5..fe714691f13 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==1.0.2"] + "requirements": ["imgw_pib==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3e39ff0ba86..5d0a195b8e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ iglo==1.2.7 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.2 +imgw_pib==1.0.4 # homeassistant.components.incomfort incomfort-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2be2dfb7022..ae1a1f3fd72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -933,7 +933,7 @@ idasen-ha==2.5.3 ifaddr==0.2.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.2 +imgw_pib==1.0.4 # homeassistant.components.influxdb influxdb-client==1.24.0 From 1f6be7b4d1a1e3cc312e9d6d8da16709121595ba Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Jun 2024 09:17:36 +0200 Subject: [PATCH 153/234] Fix unit of measurement for airgradient sensor (#118981) --- homeassistant/components/airgradient/sensor.py | 1 + homeassistant/components/airgradient/strings.json | 2 +- .../airgradient/snapshots/test_sensor.ambr | 15 ++++++++------- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index e2fc580fce5..f21f13b80ab 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -103,6 +103,7 @@ SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = ( AirGradientSensorEntityDescription( key="pm003", translation_key="pm003_count", + native_unit_of_measurement="particles/dL", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda status: status.pm003_count, ), diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json index 3b1e9f9ee41..20322eed33c 100644 --- a/homeassistant/components/airgradient/strings.json +++ b/homeassistant/components/airgradient/strings.json @@ -48,7 +48,7 @@ "name": "Nitrogen index" }, "pm003_count": { - "name": "PM0.3 count" + "name": "PM0.3" }, "raw_total_volatile_organic_component": { "name": "Raw total VOC" diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index 27d8043a395..b9b6be41ff4 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -150,7 +150,7 @@ 'state': '1', }) # --- -# name: test_all_entities[sensor.airgradient_pm0_3_count-entry] +# name: test_all_entities[sensor.airgradient_pm0_3-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -164,7 +164,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.airgradient_pm0_3_count', + 'entity_id': 'sensor.airgradient_pm0_3', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -176,23 +176,24 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'PM0.3 count', + 'original_name': 'PM0.3', 'platform': 'airgradient', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pm003_count', 'unique_id': '84fce612f5b8-pm003', - 'unit_of_measurement': None, + 'unit_of_measurement': 'particles/dL', }) # --- -# name: test_all_entities[sensor.airgradient_pm0_3_count-state] +# name: test_all_entities[sensor.airgradient_pm0_3-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Airgradient PM0.3 count', + 'friendly_name': 'Airgradient PM0.3', 'state_class': , + 'unit_of_measurement': 'particles/dL', }), 'context': , - 'entity_id': 'sensor.airgradient_pm0_3_count', + 'entity_id': 'sensor.airgradient_pm0_3', 'last_changed': , 'last_reported': , 'last_updated': , From 56db7fc7dce30ebbdb9ff13e0cef1441a711360d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 6 Jun 2024 20:41:25 -0500 Subject: [PATCH 154/234] Fix exposure checks on some intents (#118988) * Check exposure in climate intent * Check exposure in todo list * Check exposure for weather * Check exposure in humidity intents * Add extra checks to weather tests * Add more checks to todo intent test * Move climate intents to async_match_targets * Update test_intent.py * Update test_intent.py * Remove patch --- homeassistant/components/climate/intent.py | 90 ++------ homeassistant/components/humidifier/intent.py | 45 ++-- homeassistant/components/todo/intent.py | 20 +- homeassistant/components/weather/intent.py | 52 ++--- homeassistant/helpers/intent.py | 2 + tests/components/climate/test_intent.py | 202 +++++++++++++++--- tests/components/humidifier/test_intent.py | 128 ++++++++++- tests/components/todo/test_init.py | 42 +++- tests/components/weather/test_intent.py | 76 ++++--- 9 files changed, 453 insertions(+), 204 deletions(-) diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 48b5c134bbd..53d0891fcda 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -4,11 +4,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, ClimateEntity +from . import DOMAIN INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" @@ -23,7 +22,10 @@ class GetTemperatureIntent(intent.IntentHandler): intent_type = INTENT_GET_TEMPERATURE description = "Gets the current temperature of a climate device or entity" - slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str} + slot_schema = { + vol.Optional("area"): intent.non_empty_string, + vol.Optional("name"): intent.non_empty_string, + } platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -31,74 +33,24 @@ class GetTemperatureIntent(intent.IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] - entities: list[ClimateEntity] = list(component.entities) - climate_entity: ClimateEntity | None = None - climate_state: State | None = None + name: str | None = None + if "name" in slots: + name = slots["name"]["value"] - if not entities: - raise intent.IntentHandleError("No climate entities") + area: str | None = None + if "area" in slots: + area = slots["area"]["value"] - name_slot = slots.get("name", {}) - entity_name: str | None = name_slot.get("value") - entity_text: str | None = name_slot.get("text") - - area_slot = slots.get("area", {}) - area_id = area_slot.get("value") - - if area_id: - # Filter by area and optionally name - area_name = area_slot.get("text") - - for maybe_climate in intent.async_match_states( - hass, name=entity_name, area_name=area_id, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - reason=intent.MatchFailedReason.AREA, - name=entity_text or entity_name, - area=area_name or area_id, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - elif entity_name: - # Filter by name - for maybe_climate in intent.async_match_states( - hass, name=entity_name, domains=[DOMAIN] - ): - climate_state = maybe_climate - break - - if climate_state is None: - raise intent.NoStatesMatchedError( - reason=intent.MatchFailedReason.NAME, - name=entity_name, - area=None, - floor=None, - domains={DOMAIN}, - device_classes=None, - ) - - climate_entity = component.get_entity(climate_state.entity_id) - else: - # First entity - climate_entity = entities[0] - climate_state = hass.states.get(climate_entity.entity_id) - - assert climate_entity is not None - - if climate_state is None: - raise intent.IntentHandleError(f"No state for {climate_entity.name}") - - assert climate_state is not None + match_constraints = intent.MatchTargetsConstraints( + name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) response = intent_obj.create_response() response.response_type = intent.IntentResponseType.QUERY_ANSWER - response.async_set_states(matched_states=[climate_state]) + response.async_set_states(matched_states=match_result.states) return response diff --git a/homeassistant/components/humidifier/intent.py b/homeassistant/components/humidifier/intent.py index c713f08b857..425fdbcc679 100644 --- a/homeassistant/components/humidifier/intent.py +++ b/homeassistant/components/humidifier/intent.py @@ -35,7 +35,7 @@ class HumidityHandler(intent.IntentHandler): intent_type = INTENT_HUMIDITY description = "Set desired humidity level" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("humidity"): vol.All(vol.Coerce(int), vol.Range(0, 100)), } platforms = {DOMAIN} @@ -44,18 +44,19 @@ class HumidityHandler(intent.IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} humidity = slots["humidity"]["value"] @@ -89,7 +90,7 @@ class SetModeHandler(intent.IntentHandler): intent_type = INTENT_MODE description = "Set humidifier mode" slot_schema = { - vol.Required("name"): cv.string, + vol.Required("name"): intent.non_empty_string, vol.Required("mode"): cv.string, } platforms = {DOMAIN} @@ -98,18 +99,18 @@ class SetModeHandler(intent.IntentHandler): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - states = list( - intent.async_match_states( - hass, - name=slots["name"]["value"], - states=hass.states.async_all(DOMAIN), - ) + match_constraints = intent.MatchTargetsConstraints( + name=slots["name"]["value"], + domains=[DOMAIN], + assistant=intent_obj.assistant, ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) - if not states: - raise intent.IntentHandleError("No entities matched") - - state = states[0] + state = match_result.states[0] service_data = {ATTR_ENTITY_ID: state.entity_id} intent.async_test_feature(state, HumidifierEntityFeature.MODES, "modes") diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index c3c18ea304f..50afe916b27 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -4,7 +4,6 @@ from __future__ import annotations from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity @@ -22,7 +21,7 @@ class ListAddItemIntent(intent.IntentHandler): intent_type = INTENT_LIST_ADD_ITEM description = "Add item to a todo list" - slot_schema = {"item": cv.string, "name": cv.string} + slot_schema = {"item": intent.non_empty_string, "name": intent.non_empty_string} platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -37,18 +36,19 @@ class ListAddItemIntent(intent.IntentHandler): target_list: TodoListEntity | None = None # Find matching list - for list_state in intent.async_match_states( - hass, name=list_name, domains=[DOMAIN] - ): - target_list = component.get_entity(list_state.entity_id) - if target_list is not None: - break + match_constraints = intent.MatchTargetsConstraints( + name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints + ) + target_list = component.get_entity(match_result.states[0].entity_id) if target_list is None: raise intent.IntentHandleError(f"No to-do list: {list_name}") - assert target_list is not None - # Add to list await target_list.async_create_todo_item( TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py index cbb46b943e8..e00a386b619 100644 --- a/homeassistant/components/weather/intent.py +++ b/homeassistant/components/weather/intent.py @@ -6,10 +6,8 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, State from homeassistant.helpers import intent -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, WeatherEntity +from . import DOMAIN INTENT_GET_WEATHER = "HassGetWeather" @@ -24,7 +22,7 @@ class GetWeatherIntent(intent.IntentHandler): intent_type = INTENT_GET_WEATHER description = "Gets the current weather" - slot_schema = {vol.Optional("name"): cv.string} + slot_schema = {vol.Optional("name"): intent.non_empty_string} platforms = {DOMAIN} async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: @@ -32,43 +30,21 @@ class GetWeatherIntent(intent.IntentHandler): hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - weather: WeatherEntity | None = None weather_state: State | None = None - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] - entities = list(component.entities) - + name: str | None = None if "name" in slots: - # Named weather entity - weather_name = slots["name"]["value"] + name = slots["name"]["value"] - # Find matching weather entity - matching_states = intent.async_match_states( - hass, name=weather_name, domains=[DOMAIN] + match_constraints = intent.MatchTargetsConstraints( + name=name, domains=[DOMAIN], assistant=intent_obj.assistant + ) + match_result = intent.async_match_targets(hass, match_constraints) + if not match_result.is_match: + raise intent.MatchFailedError( + result=match_result, constraints=match_constraints ) - for maybe_weather_state in matching_states: - weather = component.get_entity(maybe_weather_state.entity_id) - if weather is not None: - weather_state = maybe_weather_state - break - if weather is None: - raise intent.IntentHandleError( - f"No weather entity named {weather_name}" - ) - elif entities: - # First weather entity - weather = entities[0] - weather_name = weather.name - weather_state = hass.states.get(weather.entity_id) - - if weather is None: - raise intent.IntentHandleError("No weather entity") - - if weather_state is None: - raise intent.IntentHandleError(f"No state for weather: {weather.name}") - - assert weather is not None - assert weather_state is not None + weather_state = match_result.states[0] # Create response response = intent_obj.create_response() @@ -77,8 +53,8 @@ class GetWeatherIntent(intent.IntentHandler): success_results=[ intent.IntentResponseTarget( type=intent.IntentResponseTargetType.ENTITY, - name=weather_name, - id=weather.entity_id, + name=weather_state.name, + id=weather_state.entity_id, ) ] ) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index ccef934d6ad..d7c0f90e2f9 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -712,6 +712,7 @@ def async_match_states( domains: Collection[str] | None = None, device_classes: Collection[str] | None = None, states: list[State] | None = None, + assistant: str | None = None, ) -> Iterable[State]: """Simplified interface to async_match_targets that returns states matching the constraints.""" result = async_match_targets( @@ -722,6 +723,7 @@ def async_match_states( floor_name=floor_name, domains=domains, device_classes=device_classes, + assistant=assistant, ), states=states, ) diff --git a/tests/components/climate/test_intent.py b/tests/components/climate/test_intent.py index 1aaea386320..c9bc27fce53 100644 --- a/tests/components/climate/test_intent.py +++ b/tests/components/climate/test_intent.py @@ -1,21 +1,23 @@ """Test climate intents.""" from collections.abc import Generator -from unittest.mock import patch import pytest +from homeassistant.components import conversation from homeassistant.components.climate import ( DOMAIN, ClimateEntity, HVACMode, intent as climate_intent, ) +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import area_registry as ar, entity_registry as er, intent from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -113,6 +115,7 @@ async def test_get_temperature( entity_registry: er.EntityRegistry, ) -> None: """Test HassClimateGetTemperature intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) climate_1 = MockClimateEntity() @@ -148,10 +151,14 @@ async def test_get_temperature( # First climate entity will be selected (no area) response = await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - assert len(response.matched_states) == 1 + assert response.matched_states assert response.matched_states[0].entity_id == climate_1.entity_id state = response.matched_states[0] assert state.attributes["current_temperature"] == 10.0 @@ -162,6 +169,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -175,6 +183,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"name": {"value": "Climate 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 @@ -189,6 +198,7 @@ async def test_get_temperature( "test", climate_intent.INTENT_GET_TEMPERATURE, {"area": {"value": office_area.name}}, + assistant=conversation.DOMAIN, ) # Exception should contain details of what we tried to match @@ -197,7 +207,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name is None assert constraints.area_name == office_area.name - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None # Check wrong name @@ -214,7 +224,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name == "Does not exist" assert constraints.area_name is None - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None # Check wrong name with area @@ -231,7 +241,7 @@ async def test_get_temperature( constraints = error.value.constraints assert constraints.name == "Climate 1" assert constraints.area_name == bedroom_area.name - assert constraints.domains == {DOMAIN} + assert constraints.domains and (set(constraints.domains) == {DOMAIN}) assert constraints.device_classes is None @@ -239,62 +249,190 @@ async def test_get_temperature_no_entities( hass: HomeAssistant, ) -> None: """Test HassClimateGetTemperature intent with no climate entities.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) await create_mock_platform(hass, []) - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN -async def test_get_temperature_no_state( +async def test_not_exposed( hass: HomeAssistant, area_registry: ar.AreaRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test HassClimateGetTemperature intent when states are missing.""" + """Test HassClimateGetTemperature intent when entities aren't exposed.""" + assert await async_setup_component(hass, "homeassistant", {}) await climate_intent.async_setup_intents(hass) climate_1 = MockClimateEntity() climate_1._attr_name = "Climate 1" climate_1._attr_unique_id = "1234" + climate_1._attr_current_temperature = 10.0 entity_registry.async_get_or_create( DOMAIN, "test", "1234", suggested_object_id="climate_1" ) - await create_mock_platform(hass, [climate_1]) + climate_2 = MockClimateEntity() + climate_2._attr_name = "Climate 2" + climate_2._attr_unique_id = "5678" + climate_2._attr_current_temperature = 22.0 + entity_registry.async_get_or_create( + DOMAIN, "test", "5678", suggested_object_id="climate_2" + ) + await create_mock_platform(hass, [climate_1, climate_2]) + + # Add climate entities to same area living_room_area = area_registry.async_create(name="Living Room") + bedroom_area = area_registry.async_create(name="Bedroom") entity_registry.async_update_entity( climate_1.entity_id, area_id=living_room_area.id ) + entity_registry.async_update_entity( + climate_2.entity_id, area_id=living_room_area.id + ) - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle( - hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} - ) - - with ( - patch("homeassistant.core.StateMachine.async_all", return_value=[]), - pytest.raises(intent.MatchFailedError) as error, - ): + # Should fail with empty name + with pytest.raises(intent.InvalidSlotInfo): await intent.async_handle( hass, "test", climate_intent.INTENT_GET_TEMPERATURE, - {"area": {"value": "Living Room"}}, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, ) - # Exception should contain details of what we tried to match - assert isinstance(error.value, intent.MatchFailedError) - assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA - constraints = error.value.constraints - assert constraints.name is None - assert constraints.area_name == "Living Room" - assert constraints.domains == {DOMAIN} - assert constraints.device_classes is None + # Should fail with empty area + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + # Expose second, hide first + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the area should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the exposed entity should work + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_2.name}}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_2.entity_id + + # Using the name of the *unexposed* entity should fail + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": climate_1.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Expose first, hide second + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + # Second climate entity is exposed + response = await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert response.response_type == intent.IntentResponseType.QUERY_ANSWER + assert len(response.matched_states) == 1 + assert response.matched_states[0].entity_id == climate_1.entity_id + + # Wrong area name + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": bedroom_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA + + # Neither are exposed + async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False) + + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with area + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"area": {"value": living_room_area.name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + + # Should fail with both names + for name in (climate_1.name, climate_2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + climate_intent.INTENT_GET_TEMPERATURE, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT diff --git a/tests/components/humidifier/test_intent.py b/tests/components/humidifier/test_intent.py index 936369f8aa7..6318c5f136d 100644 --- a/tests/components/humidifier/test_intent.py +++ b/tests/components/humidifier/test_intent.py @@ -2,6 +2,8 @@ import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.humidifier import ( ATTR_AVAILABLE_MODES, ATTR_HUMIDITY, @@ -19,13 +21,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.intent import IntentHandleError, async_handle +from homeassistant.helpers.intent import ( + IntentHandleError, + IntentResponseType, + InvalidSlotInfo, + MatchFailedError, + MatchFailedReason, + async_handle, +) +from homeassistant.setup import async_setup_component from tests.common import async_mock_service async def test_intent_set_humidity(hass: HomeAssistant) -> None: """Test the set humidity intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -38,6 +49,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -54,6 +66,7 @@ async def test_intent_set_humidity(hass: HomeAssistant) -> None: async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: """Test the set humidity intent for turned off humidifier.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, {ATTR_HUMIDITY: 40} ) @@ -66,6 +79,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_HUMIDITY, {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -89,6 +103,7 @@ async def test_intent_set_humidity_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -108,6 +123,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -127,6 +143,7 @@ async def test_intent_set_mode(hass: HomeAssistant) -> None: async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: """Test the set mode intent.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_OFF, @@ -146,6 +163,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) await hass.async_block_till_done() @@ -169,6 +187,7 @@ async def test_intent_set_mode_and_turn_on(hass: HomeAssistant) -> None: async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: """Test the set mode intent where modes are not supported.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, {ATTR_HUMIDITY: 40} ) @@ -181,6 +200,7 @@ async def test_intent_set_mode_tests_feature(hass: HomeAssistant) -> None: "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support modes" assert len(mode_calls) == 0 @@ -191,6 +211,7 @@ async def test_intent_set_unknown_mode( hass: HomeAssistant, available_modes: list[str] | None ) -> None: """Test the set mode intent for unsupported mode.""" + assert await async_setup_component(hass, "homeassistant", {}) hass.states.async_set( "humidifier.bedroom_humidifier", STATE_ON, @@ -210,6 +231,111 @@ async def test_intent_set_unknown_mode( "test", intent.INTENT_MODE, {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "eco"}}, + assistant=conversation.DOMAIN, ) assert str(excinfo.value) == "Entity bedroom humidifier does not support eco mode" assert len(mode_calls) == 0 + + +async def test_intent_errors(hass: HomeAssistant) -> None: + """Test the error conditions for set humidity and set mode intents.""" + assert await async_setup_component(hass, "homeassistant", {}) + entity_id = "humidifier.bedroom_humidifier" + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_HUMIDITY: 40, + ATTR_SUPPORTED_FEATURES: 1, + ATTR_AVAILABLE_MODES: ["home", "away"], + ATTR_MODE: None, + }, + ) + async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + async_mock_service(hass, DOMAIN, SERVICE_SET_MODE) + await intent.async_setup_intents(hass) + + # Humidifiers are exposed by default + result = await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + result = await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert result.response_type == IntentResponseType.ACTION_DONE + + # Unexposing it should fail + async_expose_entity(hass, conversation.DOMAIN, entity_id, False) + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "Bedroom humidifier"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "Bedroom humidifier"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.ASSISTANT + + # Expose again to test other errors + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # Empty name should fail + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": ""}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(InvalidSlotInfo): + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": ""}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + + # Wrong name should fail + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_HUMIDITY, + {"name": {"value": "does not exist"}, "humidity": {"value": "50"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME + + with pytest.raises(MatchFailedError) as err: + await async_handle( + hass, + "test", + intent.INTENT_MODE, + {"name": {"value": "does not exist"}, "mode": {"value": "away"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == MatchFailedReason.NAME diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 4b8e35c9061..72cfaf7e544 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -9,6 +9,8 @@ import zoneinfo import pytest import voluptuous as vol +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.todo import ( DOMAIN, TodoItem, @@ -23,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, @@ -1110,6 +1113,7 @@ async def test_add_item_intent( hass_ws_client: WebSocketGenerator, ) -> None: """Test adding items to lists using an intent.""" + assert await async_setup_component(hass, "homeassistant", {}) await todo_intent.async_setup_intents(hass) entity1 = MockTodoListEntity() @@ -1128,6 +1132,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "beer"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1143,6 +1148,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1157,6 +1163,7 @@ async def test_add_item_intent( "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE @@ -1165,13 +1172,46 @@ async def test_add_item_intent( assert entity2.items[1].summary == "wine" assert entity2.items[1].status == TodoItemStatus.NEEDS_ACTION + # Should fail if lists are not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "cookies"}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + # Missing list - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError): await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, + assistant=conversation.DOMAIN, + ) + + # Fail with empty name/item + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": "wine"}, "name": {"value": ""}}, + assistant=conversation.DOMAIN, + ) + + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + todo_intent.INTENT_LIST_ADD_ITEM, + {"item": {"value": ""}, "name": {"value": "list 1"}}, + assistant=conversation.DOMAIN, ) diff --git a/tests/components/weather/test_intent.py b/tests/components/weather/test_intent.py index 1fde5882d6e..0f9884791a5 100644 --- a/tests/components/weather/test_intent.py +++ b/tests/components/weather/test_intent.py @@ -1,9 +1,9 @@ """Test weather intents.""" -from unittest.mock import patch - import pytest +from homeassistant.components import conversation +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.weather import ( DOMAIN, WeatherEntity, @@ -16,15 +16,18 @@ from homeassistant.setup import async_setup_component async def test_get_weather(hass: HomeAssistant) -> None: """Test get weather for first entity and by name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() entity1._attr_name = "Weather 1" entity1.entity_id = "weather.test_1" + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) entity2 = WeatherEntity() entity2._attr_name = "Weather 2" entity2.entity_id = "weather.test_2" + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, True) await hass.data[DOMAIN].async_add_entities([entity1, entity2]) @@ -45,15 +48,31 @@ async def test_get_weather(hass: HomeAssistant) -> None: "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "Weather 2"}}, + assistant=conversation.DOMAIN, ) assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert len(response.matched_states) == 1 state = response.matched_states[0] assert state.entity_id == entity2.entity_id + # Should fail if not exposed + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, False) + async_expose_entity(hass, conversation.DOMAIN, entity2.entity_id, False) + for name in (entity1.name, entity2.name): + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": name}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT + async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: """Test get weather with the wrong name.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) entity1 = WeatherEntity() @@ -63,48 +82,43 @@ async def test_get_weather_wrong_name(hass: HomeAssistant) -> None: await hass.data[DOMAIN].async_add_entities([entity1]) await weather_intent.async_setup_intents(hass) + async_expose_entity(hass, conversation.DOMAIN, entity1.entity_id, True) # Incorrect name - with pytest.raises(intent.IntentHandleError): + with pytest.raises(intent.MatchFailedError) as err: await intent.async_handle( hass, "test", weather_intent.INTENT_GET_WEATHER, {"name": {"value": "not the right name"}}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.NAME + + # Empty name + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {"name": {"value": ""}}, + assistant=conversation.DOMAIN, ) async def test_get_weather_no_entities(hass: HomeAssistant) -> None: """Test get weather with no weather entities.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "weather", {"weather": {}}) await weather_intent.async_setup_intents(hass) # No weather entities - with pytest.raises(intent.IntentHandleError): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) - - -async def test_get_weather_no_state(hass: HomeAssistant) -> None: - """Test get weather when state is not returned.""" - assert await async_setup_component(hass, "weather", {"weather": {}}) - - entity1 = WeatherEntity() - entity1._attr_name = "Weather 1" - entity1.entity_id = "weather.test_1" - - await hass.data[DOMAIN].async_add_entities([entity1]) - - await weather_intent.async_setup_intents(hass) - - # Success with state - response = await intent.async_handle( - hass, "test", weather_intent.INTENT_GET_WEATHER, {} - ) - assert response.response_type == intent.IntentResponseType.QUERY_ANSWER - - # Failure without state - with ( - patch("homeassistant.core.StateMachine.get", return_value=None), - pytest.raises(intent.IntentHandleError), - ): - await intent.async_handle(hass, "test", weather_intent.INTENT_GET_WEATHER, {}) + with pytest.raises(intent.MatchFailedError) as err: + await intent.async_handle( + hass, + "test", + weather_intent.INTENT_GET_WEATHER, + {}, + assistant=conversation.DOMAIN, + ) + assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN From cfa619b67e5be90d91e14a8abbed4685054d245c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Jun 2024 02:21:53 -0500 Subject: [PATCH 155/234] Remove isal from after_dependencies in http (#119000) --- homeassistant/bootstrap.py | 13 ++++++++++--- homeassistant/components/http/manifest.json | 1 - tests/test_circular_imports.py | 4 ++-- tests/test_requirements.py | 9 ++++----- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 391c6ebfa45..74196cdc625 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -134,8 +134,15 @@ COOLDOWN_TIME = 60 DEBUGGER_INTEGRATIONS = {"debugpy"} + +# Core integrations are unconditionally loaded CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} -LOGGING_INTEGRATIONS = { + +# Integrations that are loaded right after the core is set up +LOGGING_AND_HTTP_DEPS_INTEGRATIONS = { + # isal is loaded right away before `http` to ensure if its + # enabled, that `isal` is up to date. + "isal", # Set log levels "logger", # Error logging @@ -214,8 +221,8 @@ CRITICAL_INTEGRATIONS = { } SETUP_ORDER = ( - # Load logging as soon as possible - ("logging", LOGGING_INTEGRATIONS), + # Load logging and http deps as soon as possible + ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), # Setup frontend and recorder ("frontend, recorder", {*FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS}), # Start up debuggers. Start these first in case they want to wait. diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index b48a188cf47..fb804251edc 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -1,7 +1,6 @@ { "domain": "http", "name": "HTTP", - "after_dependencies": ["isal"], "codeowners": ["@home-assistant/core"], "documentation": "https://www.home-assistant.io/integrations/http", "integration_type": "system", diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index 79f0fd9caf7..dfdee65b2b0 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -10,7 +10,7 @@ from homeassistant.bootstrap import ( DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, FRONTEND_INTEGRATIONS, - LOGGING_INTEGRATIONS, + LOGGING_AND_HTTP_DEPS_INTEGRATIONS, RECORDER_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -23,7 +23,7 @@ from homeassistant.bootstrap import ( { *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_INTEGRATIONS, + *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, *FRONTEND_INTEGRATIONS, *RECORDER_INTEGRATIONS, *STAGE_1_INTEGRATIONS, diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 2b2415e22a8..73f3f54c3c4 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -591,7 +591,7 @@ async def test_discovery_requirements_mqtt(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "mqtt_comp") - assert len(mock_process.mock_calls) == 2 + assert len(mock_process.mock_calls) == 1 assert mock_process.mock_calls[0][1][1] == mqtt.requirements @@ -608,13 +608,12 @@ async def test_discovery_requirements_ssdp(hass: HomeAssistant) -> None: ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") - assert len(mock_process.mock_calls) == 4 + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == ssdp.requirements assert { mock_process.mock_calls[1][1][0], mock_process.mock_calls[2][1][0], - mock_process.mock_calls[3][1][0], - } == {"network", "recorder", "isal"} + } == {"network", "recorder"} @pytest.mark.parametrize( @@ -638,7 +637,7 @@ async def test_discovery_requirements_zeroconf( ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 4 + assert len(mock_process.mock_calls) == 3 assert mock_process.mock_calls[0][1][1] == zeroconf.requirements From 5bb4e4f5d9d31301863749d2b5dd4724a0b61886 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 7 Jun 2024 10:50:05 +0300 Subject: [PATCH 156/234] Hold connection lock in Shelly RPC reconnect (#119009) --- homeassistant/components/shelly/coordinator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index cf6e9cc897f..c12e1aea289 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -584,11 +584,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): raise UpdateFailed( f"Sleeping device did not update within {self.sleep_period} seconds interval" ) - if self.device.connected: - return - if not await self._async_device_connect_task(): - raise UpdateFailed("Device reconnect error") + async with self._connection_lock: + if self.device.connected: # Already connected + return + + if not await self._async_device_connect_task(): + raise UpdateFailed("Device reconnect error") async def _async_disconnected(self, reconnect: bool) -> None: """Handle device disconnected.""" From 581fb2f9f41e48339b5b067404853259e13a86d1 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Fri, 7 Jun 2024 04:52:15 -0400 Subject: [PATCH 157/234] Always have addon url in detached_addon_missing (#119011) --- homeassistant/components/hassio/issues.py | 7 +++---- tests/components/hassio/test_issues.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 2de6f71d838..9c2152489d6 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -267,15 +267,14 @@ class SupervisorIssues: placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING: + placeholders[PLACEHOLDER_KEY_ADDON_URL] = ( + f"/hassio/addon/{issue.reference}" + ) addons = get_addons_info(self._hass) if addons and issue.reference in addons: placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][ "name" ] - if "url" in addons[issue.reference]: - placeholders[PLACEHOLDER_KEY_ADDON_URL] = addons[ - issue.reference - ]["url"] else: placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index c6db7d56261..ff0e4a8dd92 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -878,6 +878,6 @@ async def test_supervisor_issues_detached_addon_missing( placeholders={ "reference": "test", "addon": "test", - "addon_url": "https://github.com/home-assistant/addons/test", + "addon_url": "/hassio/addon/test", }, ) From de3a0841d8cd8262f9c74d82320553a58f952243 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 30 May 2024 21:42:11 +0200 Subject: [PATCH 158/234] Increase test coverage for KNX Climate (#117903) * Increase test coverage fro KNX Climate * fix test type annotation --- tests/components/knx/test_climate.py | 80 ++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index c81a6fccf15..3b286a0cdb9 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -54,11 +54,12 @@ async def test_climate_basic_temperature_set( assert len(events) == 1 -@pytest.mark.parametrize("heat_cool", [False, True]) +@pytest.mark.parametrize("heat_cool_ga", [None, "4/4/4"]) async def test_climate_on_off( - hass: HomeAssistant, knx: KNXTestKit, heat_cool: bool + hass: HomeAssistant, knx: KNXTestKit, heat_cool_ga: str | None ) -> None: """Test KNX climate on/off.""" + on_off_ga = "3/3/3" await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -66,15 +67,15 @@ async def test_climate_on_off( ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", - ClimateSchema.CONF_ON_OFF_ADDRESS: "1/2/8", + ClimateSchema.CONF_ON_OFF_ADDRESS: on_off_ga, ClimateSchema.CONF_ON_OFF_STATE_ADDRESS: "1/2/9", } | ( { - ClimateSchema.CONF_HEAT_COOL_ADDRESS: "1/2/10", + ClimateSchema.CONF_HEAT_COOL_ADDRESS: heat_cool_ga, ClimateSchema.CONF_HEAT_COOL_STATE_ADDRESS: "1/2/11", } - if heat_cool + if heat_cool_ga else {} ) } @@ -82,7 +83,7 @@ async def test_climate_on_off( await hass.async_block_till_done() # read heat/cool state - if heat_cool: + if heat_cool_ga: await knx.assert_read("1/2/11") await knx.receive_response("1/2/11", 0) # cool # read temperature state @@ -102,7 +103,7 @@ async def test_climate_on_off( {"entity_id": "climate.test"}, blocking=True, ) - await knx.assert_write("1/2/8", 0) + await knx.assert_write(on_off_ga, 0) assert hass.states.get("climate.test").state == "off" # turn on @@ -112,8 +113,8 @@ async def test_climate_on_off( {"entity_id": "climate.test"}, blocking=True, ) - await knx.assert_write("1/2/8", 1) - if heat_cool: + await knx.assert_write(on_off_ga, 1) + if heat_cool_ga: # does not fall back to default hvac mode after turn_on assert hass.states.get("climate.test").state == "cool" else: @@ -126,7 +127,7 @@ async def test_climate_on_off( {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, blocking=True, ) - await knx.assert_write("1/2/8", 0) + await knx.assert_write(on_off_ga, 0) # set hvac mode to heat await hass.services.async_call( @@ -135,15 +136,19 @@ async def test_climate_on_off( {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, blocking=True, ) - if heat_cool: + if heat_cool_ga: # only set new hvac_mode without changing on/off - actuator shall handle that - await knx.assert_write("1/2/10", 1) + await knx.assert_write(heat_cool_ga, 1) else: - await knx.assert_write("1/2/8", 1) + await knx.assert_write(on_off_ga, 1) -async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: +@pytest.mark.parametrize("on_off_ga", [None, "4/4/4"]) +async def test_climate_hvac_mode( + hass: HomeAssistant, knx: KNXTestKit, on_off_ga: str | None +) -> None: """Test KNX climate hvac mode.""" + controller_mode_ga = "3/3/3" await knx.setup_integration( { ClimateSchema.PLATFORM: { @@ -151,11 +156,17 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", - ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: "1/2/6", + ClimateSchema.CONF_CONTROLLER_MODE_ADDRESS: controller_mode_ga, ClimateSchema.CONF_CONTROLLER_MODE_STATE_ADDRESS: "1/2/7", - ClimateSchema.CONF_ON_OFF_ADDRESS: "1/2/8", ClimateSchema.CONF_OPERATION_MODES: ["Auto"], } + | ( + { + ClimateSchema.CONF_ON_OFF_ADDRESS: on_off_ga, + } + if on_off_ga + else {} + ) } ) @@ -171,23 +182,50 @@ async def test_climate_hvac_mode(hass: HomeAssistant, knx: KNXTestKit) -> None: await knx.assert_read("1/2/5") await knx.receive_response("1/2/5", RAW_FLOAT_22_0) - # turn hvac mode to off + # turn hvac mode to off - set_hvac_mode() doesn't send to on_off if dedicated hvac mode is available await hass.services.async_call( "climate", "set_hvac_mode", {"entity_id": "climate.test", "hvac_mode": HVACMode.OFF}, blocking=True, ) - await knx.assert_write("1/2/6", (0x06,)) + await knx.assert_write(controller_mode_ga, (0x06,)) - # turn hvac on + # set hvac to non default mode await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.test", "hvac_mode": HVACMode.HEAT}, + {"entity_id": "climate.test", "hvac_mode": HVACMode.COOL}, blocking=True, ) - await knx.assert_write("1/2/6", (0x01,)) + await knx.assert_write(controller_mode_ga, (0x03,)) + + # turn off + await hass.services.async_call( + "climate", + "turn_off", + {"entity_id": "climate.test"}, + blocking=True, + ) + if on_off_ga: + await knx.assert_write(on_off_ga, 0) + else: + await knx.assert_write(controller_mode_ga, (0x06,)) + assert hass.states.get("climate.test").state == "off" + + # turn on + await hass.services.async_call( + "climate", + "turn_on", + {"entity_id": "climate.test"}, + blocking=True, + ) + if on_off_ga: + await knx.assert_write(on_off_ga, 1) + else: + # restore last hvac mode + await knx.assert_write(controller_mode_ga, (0x03,)) + assert hass.states.get("climate.test").state == "cool" async def test_climate_preset_mode( From 31b44b7846ffcd330317980d73a218a1162606c2 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 6 Jun 2024 22:40:04 +0200 Subject: [PATCH 159/234] Fix KNX `climate.set_hvac_mode` not turning `on` (#119012) --- homeassistant/components/knx/climate.py | 5 +---- tests/components/knx/test_climate.py | 10 +++++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 674e76d66e3..e1179641cdc 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -283,16 +283,13 @@ class KNXClimate(KnxEntity, ClimateEntity): ) if knx_controller_mode in self._device.mode.controller_modes: await self._device.mode.set_controller_mode(knx_controller_mode) - self.async_write_ha_state() - return if self._device.supports_on_off: if hvac_mode == HVACMode.OFF: await self._device.turn_off() elif not self._device.is_on: - # for default hvac mode, otherwise above would have triggered await self._device.turn_on() - self.async_write_ha_state() + self.async_write_ha_state() @property def preset_mode(self) -> str | None: diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index 3b286a0cdb9..9c431386b43 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -128,6 +128,7 @@ async def test_climate_on_off( blocking=True, ) await knx.assert_write(on_off_ga, 0) + assert hass.states.get("climate.test").state == "off" # set hvac mode to heat await hass.services.async_call( @@ -137,10 +138,11 @@ async def test_climate_on_off( blocking=True, ) if heat_cool_ga: - # only set new hvac_mode without changing on/off - actuator shall handle that await knx.assert_write(heat_cool_ga, 1) + await knx.assert_write(on_off_ga, 1) else: await knx.assert_write(on_off_ga, 1) + assert hass.states.get("climate.test").state == "heat" @pytest.mark.parametrize("on_off_ga", [None, "4/4/4"]) @@ -190,6 +192,9 @@ async def test_climate_hvac_mode( blocking=True, ) await knx.assert_write(controller_mode_ga, (0x06,)) + if on_off_ga: + await knx.assert_write(on_off_ga, 0) + assert hass.states.get("climate.test").state == "off" # set hvac to non default mode await hass.services.async_call( @@ -199,6 +204,9 @@ async def test_climate_hvac_mode( blocking=True, ) await knx.assert_write(controller_mode_ga, (0x03,)) + if on_off_ga: + await knx.assert_write(on_off_ga, 1) + assert hass.states.get("climate.test").state == "cool" # turn off await hass.services.async_call( From 1cbd3ab9307fed9e75f898bbe2c4f66a8c8990f5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Jun 2024 13:09:48 -0500 Subject: [PATCH 160/234] Fix refactoring error in snmp switch (#119028) --- homeassistant/components/snmp/switch.py | 76 ++++++++++++++----------- homeassistant/components/snmp/util.py | 36 +++++++++--- 2 files changed, 72 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 40083ed4213..02a94aeb8c1 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -8,6 +8,8 @@ from typing import Any import pysnmp.hlapi.asyncio as hlapi from pysnmp.hlapi.asyncio import ( CommunityData, + ObjectIdentity, + ObjectType, UdpTransportTarget, UsmUserData, getCmd, @@ -63,7 +65,12 @@ from .const import ( MAP_PRIV_PROTOCOLS, SNMP_VERSIONS, ) -from .util import RequestArgsType, async_create_request_cmd_args +from .util import ( + CommandArgsType, + RequestArgsType, + async_create_command_cmd_args, + async_create_request_cmd_args, +) _LOGGER = logging.getLogger(__name__) @@ -125,23 +132,23 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name: str = config[CONF_NAME] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] community = config.get(CONF_COMMUNITY) baseoid: str = config[CONF_BASEOID] - command_oid = config.get(CONF_COMMAND_OID) - command_payload_on = config.get(CONF_COMMAND_PAYLOAD_ON) - command_payload_off = config.get(CONF_COMMAND_PAYLOAD_OFF) + command_oid: str | None = config.get(CONF_COMMAND_OID) + command_payload_on: str | None = config.get(CONF_COMMAND_PAYLOAD_ON) + command_payload_off: str | None = config.get(CONF_COMMAND_PAYLOAD_OFF) version: str = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) authproto: str = config[CONF_AUTH_PROTOCOL] privkey = config.get(CONF_PRIV_KEY) privproto: str = config[CONF_PRIV_PROTOCOL] - payload_on = config.get(CONF_PAYLOAD_ON) - payload_off = config.get(CONF_PAYLOAD_OFF) - vartype = config.get(CONF_VARTYPE) + payload_on: str = config[CONF_PAYLOAD_ON] + payload_off: str = config[CONF_PAYLOAD_OFF] + vartype: str = config[CONF_VARTYPE] if version == "3": if not authkey: @@ -159,9 +166,11 @@ async def async_setup_platform( else: auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) + transport = UdpTransportTarget((host, port)) request_args = await async_create_request_cmd_args( - hass, auth_data, UdpTransportTarget((host, port)), baseoid + hass, auth_data, transport, baseoid ) + command_args = await async_create_command_cmd_args(hass, auth_data, transport) async_add_entities( [ @@ -177,6 +186,7 @@ async def async_setup_platform( command_payload_off, vartype, request_args, + command_args, ) ], True, @@ -188,21 +198,22 @@ class SnmpSwitch(SwitchEntity): def __init__( self, - name, - host, - port, - baseoid, - commandoid, - payload_on, - payload_off, - command_payload_on, - command_payload_off, - vartype, - request_args, + name: str, + host: str, + port: int, + baseoid: str, + commandoid: str | None, + payload_on: str, + payload_off: str, + command_payload_on: str | None, + command_payload_off: str | None, + vartype: str, + request_args: RequestArgsType, + command_args: CommandArgsType, ) -> None: """Initialize the switch.""" - self._name = name + self._attr_name = name self._baseoid = baseoid self._vartype = vartype @@ -215,7 +226,8 @@ class SnmpSwitch(SwitchEntity): self._payload_on = payload_on self._payload_off = payload_off self._target = UdpTransportTarget((host, port)) - self._request_args: RequestArgsType = request_args + self._request_args = request_args + self._command_args = command_args async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" @@ -226,7 +238,7 @@ class SnmpSwitch(SwitchEntity): """Turn off the switch.""" await self._execute_command(self._command_payload_off) - async def _execute_command(self, command): + async def _execute_command(self, command: str) -> None: # User did not set vartype and command is not a digit if self._vartype == "none" and not self._command_payload_on.isdigit(): await self._set(command) @@ -265,14 +277,12 @@ class SnmpSwitch(SwitchEntity): self._state = None @property - def name(self): - """Return the switch's name.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if switch is on; False if off. None if unknown.""" return self._state - async def _set(self, value): - await setCmd(*self._request_args, value) + async def _set(self, value: Any) -> None: + """Set the state of the switch.""" + await setCmd( + *self._command_args, ObjectType(ObjectIdentity(self._commandoid), value) + ) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py index 23adbdf0b90..dd3e9a6b6d2 100644 --- a/homeassistant/components/snmp/util.py +++ b/homeassistant/components/snmp/util.py @@ -25,6 +25,14 @@ DATA_SNMP_ENGINE = "snmp_engine" _LOGGER = logging.getLogger(__name__) +type CommandArgsType = tuple[ + SnmpEngine, + UsmUserData | CommunityData, + UdpTransportTarget | Udp6TransportTarget, + ContextData, +] + + type RequestArgsType = tuple[ SnmpEngine, UsmUserData | CommunityData, @@ -34,20 +42,34 @@ type RequestArgsType = tuple[ ] +async def async_create_command_cmd_args( + hass: HomeAssistant, + auth_data: UsmUserData | CommunityData, + target: UdpTransportTarget | Udp6TransportTarget, +) -> CommandArgsType: + """Create command arguments. + + The ObjectType needs to be created dynamically by the caller. + """ + engine = await async_get_snmp_engine(hass) + return (engine, auth_data, target, ContextData()) + + async def async_create_request_cmd_args( hass: HomeAssistant, auth_data: UsmUserData | CommunityData, target: UdpTransportTarget | Udp6TransportTarget, object_id: str, ) -> RequestArgsType: - """Create request arguments.""" - return ( - await async_get_snmp_engine(hass), - auth_data, - target, - ContextData(), - ObjectType(ObjectIdentity(object_id)), + """Create request arguments. + + The same ObjectType is used for all requests. + """ + engine, auth_data, target, context_data = await async_create_command_cmd_args( + hass, auth_data, target ) + object_type = ObjectType(ObjectIdentity(object_id)) + return (engine, auth_data, target, context_data, object_type) @singleton(DATA_SNMP_ENGINE) From 20b77aa15f37f1fed2f5e8d89181030b421a6421 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 7 Jun 2024 02:07:47 -0500 Subject: [PATCH 161/234] Fix remember_the_milk calling configurator async api from the wrong thread (#119029) --- homeassistant/components/remember_the_milk/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 3d1654960a7..425a12d5c4d 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -137,7 +137,7 @@ def _register_new_account( configurator.request_done(hass, request_id) - request_id = configurator.async_request_config( + request_id = configurator.request_config( hass, f"{DOMAIN} - {account_name}", callback=register_account_callback, From b5693ca6047a14e0f703d044833bafd9ff525f1c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Jun 2024 09:18:16 +0200 Subject: [PATCH 162/234] Fix AirGradient name (#119046) --- homeassistant/components/airgradient/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index c30d7a4c42f..b9a1e2da54f 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -1,6 +1,6 @@ { "domain": "airgradient", - "name": "Airgradient", + "name": "AirGradient", "codeowners": ["@airgradienthq", "@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airgradient", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 70995bb3d63..27b7e0466e1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -94,7 +94,7 @@ "iot_class": "local_polling" }, "airgradient": { - "name": "Airgradient", + "name": "AirGradient", "integration_type": "device", "config_flow": true, "iot_class": "local_polling" From 093f07c04e88e546ef24a055430d4e2772ada71b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:13:33 +0200 Subject: [PATCH 163/234] Add type ignore comments (#119052) --- homeassistant/components/google_assistant_sdk/__init__.py | 2 +- homeassistant/components/google_assistant_sdk/helpers.py | 2 +- homeassistant/components/google_sheets/__init__.py | 4 +++- homeassistant/components/google_sheets/config_flow.py | 4 +++- homeassistant/components/nest/api.py | 4 ++-- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index 52950a82b93..b92b3c54579 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -165,7 +165,7 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): await session.async_ensure_token_valid() self.assistant = None if not self.assistant or user_input.language != self.language: - credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) + credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] self.language = user_input.language self.assistant = TextAssistant(credentials, self.language) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index b6b13f92fcf..24da381e8e0 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -72,7 +72,7 @@ async def async_send_text_commands( entry.async_start_reauth(hass) raise - credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) + credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass)) with TextAssistant( credentials, language_code, audio_out=bool(media_players) diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index f346f913e0c..713a801257d 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -93,7 +93,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None: """Run append in the executor.""" - service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + service = Client( + Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] + ) try: sheet = service.open_by_key(entry.unique_id) except RefreshError: diff --git a/homeassistant/components/google_sheets/config_flow.py b/homeassistant/components/google_sheets/config_flow.py index a0a99742249..ab0c084c317 100644 --- a/homeassistant/components/google_sheets/config_flow.py +++ b/homeassistant/components/google_sheets/config_flow.py @@ -61,7 +61,9 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow, or update existing entry.""" - service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN])) + service = Client( + Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] + ) if self.reauth_entry: _LOGGER.debug("service.open_by_key") diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 8c9ca4bec96..3ef26747115 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -57,7 +57,7 @@ class AsyncConfigEntryAuth(AbstractAuth): # even when it is expired to fully hand off this responsibility and # know it is working at startup (then if not, fail loudly). token = self._oauth_session.token - creds = Credentials( + creds = Credentials( # type: ignore[no-untyped-call] token=token["access_token"], refresh_token=token["refresh_token"], token_uri=OAUTH2_TOKEN, @@ -92,7 +92,7 @@ class AccessTokenAuthImpl(AbstractAuth): async def async_get_creds(self) -> Credentials: """Return an OAuth credential for Pub/Sub Subscriber.""" - return Credentials( + return Credentials( # type: ignore[no-untyped-call] token=self._access_token, token_uri=OAUTH2_TOKEN, scopes=SDM_SCOPES, From ed22e98861a5c98a66f1cffab7a44fd56951941c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 7 Jun 2024 16:31:50 +0200 Subject: [PATCH 164/234] Fix Azure Data Explorer strings (#119067) --- homeassistant/components/azure_data_explorer/strings.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json index a3a82a6eb3c..64005872579 100644 --- a/homeassistant/components/azure_data_explorer/strings.json +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -5,12 +5,13 @@ "title": "Setup your Azure Data Explorer integration", "description": "Enter connection details.", "data": { - "clusteringesturi": "Cluster Ingest URI", + "cluster_ingest_uri": "Cluster ingest URI", "database": "Database name", "table": "Table name", "client_id": "Client ID", "client_secret": "Client secret", - "authority_id": "Authority ID" + "authority_id": "Authority ID", + "use_queued_ingestion": "Use queued ingestion" } } }, From 3f70e2b6f043562fb0a5610d88550c1f99fa1bd4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 7 Jun 2024 20:26:53 +0200 Subject: [PATCH 165/234] Bump version to 2024.6.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 e4ece15cd57..86be19b95d8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index c3e03374b55..867bc1d1513 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.0" +version = "2024.6.1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From fc83bb17375f2d090f6bde061c064a70c0eb1037 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 10 Jun 2024 11:23:21 -0700 Subject: [PATCH 166/234] Fix statistic_during_period wrongly prioritizing ST statistics over LT (#115291) * Fix statistic_during_period wrongly prioritizing ST statistics over LT * comment * start of a test * more testcases * fix sts insertion range * update from review * remove unneeded comments * update logic * min/mean/max testing --- .../components/recorder/statistics.py | 20 +- .../components/recorder/test_websocket_api.py | 328 ++++++++++++++++++ 2 files changed, 343 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7b5c6811e29..4fe40e6bac8 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1263,6 +1263,7 @@ def _get_oldest_sum_statistic( main_start_time: datetime | None, tail_start_time: datetime | None, oldest_stat: datetime | None, + oldest_5_min_stat: datetime | None, tail_only: bool, metadata_id: int, ) -> float | None: @@ -1307,6 +1308,15 @@ def _get_oldest_sum_statistic( if ( head_start_time is not None + and oldest_5_min_stat is not None + and ( + # If we want stats older than the short term purge window, don't lookup + # the oldest sum in the short term table, as it would be prioritized + # over older LongTermStats. + (oldest_stat is None) + or (oldest_5_min_stat < oldest_stat) + or (oldest_5_min_stat <= head_start_time) + ) and ( oldest_sum := _get_oldest_sum_statistic_in_sub_period( session, head_start_time, StatisticsShortTerm, metadata_id @@ -1478,12 +1488,11 @@ def statistic_during_period( tail_end_time: datetime | None = None if end_time is None: tail_start_time = now.replace(minute=0, second=0, microsecond=0) + elif tail_only: + tail_start_time = start_time + tail_end_time = end_time elif end_time.minute: - tail_start_time = ( - start_time - if tail_only - else end_time.replace(minute=0, second=0, microsecond=0) - ) + tail_start_time = end_time.replace(minute=0, second=0, microsecond=0) tail_end_time = end_time # Calculate the main period @@ -1518,6 +1527,7 @@ def statistic_during_period( main_start_time, tail_start_time, oldest_stat, + oldest_5_min_stat, tail_only, metadata_id, ) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 9c8e0a9203a..639e0abeefe 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -794,6 +794,334 @@ async def test_statistic_during_period_hole( } +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 6, 31, tzinfo=datetime.UTC)) +async def test_statistic_during_period_partial_overlap( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test statistic_during_period.""" + now = dt_util.utcnow() + + await async_recorder_block_till_done(hass) + client = await hass_ws_client() + + zero = now + start = zero.replace(hour=0, minute=0, second=0, microsecond=0) + + # Sum shall be tracking a hypothetical sensor that is 0 at midnight, and grows by 1 per minute. + # The test will have 4 hours of LTS-only data (0:00-3:59:59), followed by 2 hours of overlapping STS/LTS (4:00-5:59:59), followed by 30 minutes of STS only (6:00-6:29:59) + # similar to how a real recorder might look after purging STS. + + # The datapoint at i=0 (start = 0:00) will be 60 as that is the growth during the hour starting at the start period + imported_stats_hours = [ + { + "start": (start + timedelta(hours=i)), + "min": i * 60, + "max": i * 60 + 60, + "mean": i * 60 + 30, + "sum": (i + 1) * 60, + } + for i in range(6) + ] + + # The datapoint at i=0 (start = 4:00) would be the sensor's value at t=4:05, or 245 + imported_stats_5min = [ + { + "start": (start + timedelta(hours=4, minutes=5 * i)), + "min": 4 * 60 + i * 5, + "max": 4 * 60 + i * 5 + 5, + "mean": 4 * 60 + i * 5 + 2.5, + "sum": 4 * 60 + (i + 1) * 5, + } + for i in range(30) + ] + + assert imported_stats_hours[-1]["sum"] == 360 + assert imported_stats_hours[-1]["start"] == start.replace( + hour=5, minute=0, second=0, microsecond=0 + ) + assert imported_stats_5min[-1]["sum"] == 390 + assert imported_stats_5min[-1]["start"] == start.replace( + hour=6, minute=25, second=0, microsecond=0 + ) + + statId = "sensor.test_overlapping" + imported_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy overlapping", + "source": "recorder", + "statistic_id": statId, + "unit_of_measurement": "kWh", + } + + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_hours, + Statistics, + ) + recorder.get_instance(hass).async_import_statistics( + imported_metadata, + imported_stats_5min, + StatisticsShortTerm, + ) + await async_wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={statId}) + metadata_id = metadata[statId][0] + run_cache = get_short_term_statistics_run_cache(hass) + # Verify the import of the short term statistics + # also updates the run cache + assert run_cache.get_latest_ids({metadata_id}) is not None + + # Get all the stats, should consider all hours and 5mins + await client.send_json_auto_id( + { + "type": "recorder/statistic_during_period", + "statistic_id": statId, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "change": 390, + "max": 390, + "min": 0, + "mean": 195, + } + + async def assert_stat_during_fixed(client, start_time, end_time, expect): + json = { + "type": "recorder/statistic_during_period", + "types": list(expect.keys()), + "statistic_id": statId, + "fixed_period": {}, + } + if start_time: + json["fixed_period"]["start_time"] = start_time.isoformat() + if end_time: + json["fixed_period"]["end_time"] = end_time.isoformat() + + await client.send_json_auto_id(json) + response = await client.receive_json() + assert response["success"] + assert response["result"] == expect + + # One hours worth of growth in LTS-only + start_time = start.replace(hour=1) + end_time = start.replace(hour=2) + await assert_stat_during_fixed( + client, start_time, end_time, {"change": 60, "min": 60, "max": 120, "mean": 90} + ) + + # Five minutes of growth in STS-only + start_time = start.replace(hour=6, minute=15) + end_time = start.replace(hour=6, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 15, + "max": 6 * 60 + 20, + "mean": 6 * 60 + (15 + 20) / 2, + }, + ) + + # Six minutes of growth in STS-only + start_time = start.replace(hour=6, minute=14) + end_time = start.replace(hour=6, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 15, + "max": 6 * 60 + 20, + "mean": 6 * 60 + (15 + 20) / 2, + }, + ) + + # Six minutes of growth in STS-only + # 5-minute Change includes start times exactly on or before a statistics start, but end times are not counted unless they are greater than start. + start_time = start.replace(hour=6, minute=15) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 10, + "min": 6 * 60 + 15, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (15 + 25) / 2, + }, + ) + + # Five minutes of growth in overlapping LTS+STS + start_time = start.replace(hour=5, minute=15) + end_time = start.replace(hour=5, minute=20) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 5 * 60 + 15, + "max": 5 * 60 + 20, + "mean": 5 * 60 + (15 + 20) / 2, + }, + ) + + # Five minutes of growth in overlapping LTS+STS (start of hour) + start_time = start.replace(hour=5, minute=0) + end_time = start.replace(hour=5, minute=5) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5, "min": 5 * 60, "max": 5 * 60 + 5, "mean": 5 * 60 + (5) / 2}, + ) + + # Five minutes of growth in overlapping LTS+STS (end of hour) + start_time = start.replace(hour=4, minute=55) + end_time = start.replace(hour=5, minute=0) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 4 * 60 + 55, + "max": 5 * 60, + "mean": 4 * 60 + (55 + 60) / 2, + }, + ) + + # Five minutes of growth in STS-only, with a minute offset. Despite that this does not cover the full period, result is still 5 + start_time = start.replace(hour=6, minute=16) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 5, + "min": 6 * 60 + 20, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (20 + 25) / 2, + }, + ) + + # 7 minutes of growth in STS-only, spanning two intervals + start_time = start.replace(hour=6, minute=14) + end_time = start.replace(hour=6, minute=21) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 10, + "min": 6 * 60 + 15, + "max": 6 * 60 + 25, + "mean": 6 * 60 + (15 + 25) / 2, + }, + ) + + # One hours worth of growth in LTS-only, with arbitrary minute offsets + # Since this does not fully cover the hour, result is None? + start_time = start.replace(hour=1, minute=40) + end_time = start.replace(hour=2, minute=12) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": None, "min": None, "max": None, "mean": None}, + ) + + # One hours worth of growth in LTS-only, with arbitrary minute offsets, covering a whole 1-hour period + start_time = start.replace(hour=1, minute=40) + end_time = start.replace(hour=3, minute=12) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 60, "min": 120, "max": 180, "mean": 150}, + ) + + # 90 minutes of growth in window overlapping LTS+STS/STS-only (4:41 - 6:11) + start_time = start.replace(hour=4, minute=41) + end_time = start_time + timedelta(minutes=90) + await assert_stat_during_fixed( + client, + start_time, + end_time, + { + "change": 90, + "min": 4 * 60 + 45, + "max": 4 * 60 + 45 + 90, + "mean": 4 * 60 + 45 + 45, + }, + ) + + # 4 hours of growth in overlapping LTS-only/LTS+STS (2:01-6:01) + start_time = start.replace(hour=2, minute=1) + end_time = start_time + timedelta(minutes=240) + # 60 from LTS (3:00-3:59), 125 from STS (25 intervals) (4:00-6:01) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 185, "min": 3 * 60, "max": 3 * 60 + 185, "mean": 3 * 60 + 185 / 2}, + ) + + # 4 hours of growth in overlapping LTS-only/LTS+STS (1:31-5:31) + start_time = start.replace(hour=1, minute=31) + end_time = start_time + timedelta(minutes=240) + # 120 from LTS (2:00-3:59), 95 from STS (19 intervals) 4:00-5:31 + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 215, "min": 2 * 60, "max": 2 * 60 + 215, "mean": 2 * 60 + 215 / 2}, + ) + + # 5 hours of growth, start time only (1:31-end) + start_time = start.replace(hour=1, minute=31) + end_time = None + # will be actually 2:00 - end + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 4 * 60 + 30, "min": 120, "max": 390, "mean": (390 + 120) / 2}, + ) + + # 5 hours of growth, end_time_only (0:00-5:00) + start_time = None + end_time = start.replace(hour=5) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5 * 60, "min": 0, "max": 5 * 60, "mean": (5 * 60) / 2}, + ) + + # 5 hours 1 minute of growth, end_time_only (0:00-5:01) + start_time = None + end_time = start.replace(hour=5, minute=1) + # 4 hours LTS, 1 hour and 5 minutes STS (4:00-5:01) + await assert_stat_during_fixed( + client, + start_time, + end_time, + {"change": 5 * 60 + 5, "min": 0, "max": 5 * 60 + 5, "mean": (5 * 60 + 5) / 2}, + ) + + @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), From 461f0865af2521cdc2883adea4b8fc4d25ee47d5 Mon Sep 17 00:00:00 2001 From: Ruben Bokobza Date: Tue, 11 Jun 2024 08:04:25 +0300 Subject: [PATCH 167/234] Bump pyElectra to 1.2.1 (#118958) --- .strict-typing | 1 - homeassistant/components/electrasmart/manifest.json | 2 +- mypy.ini | 10 ---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 1 - 6 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.strict-typing b/.strict-typing index 313dda48649..86fbf3c3563 100644 --- a/.strict-typing +++ b/.strict-typing @@ -163,7 +163,6 @@ homeassistant.components.easyenergy.* homeassistant.components.ecovacs.* homeassistant.components.ecowitt.* homeassistant.components.efergy.* -homeassistant.components.electrasmart.* homeassistant.components.electric_kiwi.* homeassistant.components.elgato.* homeassistant.components.elkm1.* diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json index 405d9ee688a..e00b818e2a6 100644 --- a/homeassistant/components/electrasmart/manifest.json +++ b/homeassistant/components/electrasmart/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/electrasmart", "iot_class": "cloud_polling", - "requirements": ["pyElectra==1.2.0"] + "requirements": ["pyElectra==1.2.1"] } diff --git a/mypy.ini b/mypy.ini index 4e4d9cc624b..ac3945872a1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1393,16 +1393,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.electrasmart.*] -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.electric_kiwi.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5d0a195b8e8..abb6563c7c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1670,7 +1670,7 @@ pyControl4==1.1.0 pyDuotecno==2024.5.1 # homeassistant.components.electrasmart -pyElectra==1.2.0 +pyElectra==1.2.1 # homeassistant.components.emby pyEmby==1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae1a1f3fd72..ec8893e3db9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ pyControl4==1.1.0 pyDuotecno==2024.5.1 # homeassistant.components.electrasmart -pyElectra==1.2.0 +pyElectra==1.2.1 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index f9a8ec2db92..d35d96121c5 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -30,7 +30,6 @@ PIP_VERSION_RANGE_SEPARATOR = re.compile(r"^(==|>=|<=|~=|!=|<|>|===)?(.*)$") IGNORE_STANDARD_LIBRARY_VIOLATIONS = { # Integrations which have standard library requirements. - "electrasmart", "slide", "suez_water", } From 7da10794a88ea860038c6e5eeeb2d6b327e1644a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 7 Jun 2024 00:48:23 +0200 Subject: [PATCH 168/234] Update gardena library to 1.4.2 (#119010) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 6598aeaafd8..1e3ef156d72 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena-bluetooth==1.4.1"] + "requirements": ["gardena-bluetooth==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index abb6563c7c1..71f96c11bfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -912,7 +912,7 @@ fyta_cli==0.4.1 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.1 +gardena-bluetooth==1.4.2 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec8893e3db9..167790fc162 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ fyta_cli==0.4.1 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.1 +gardena-bluetooth==1.4.2 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 From ebb0a453f41ab21b154487d1d209d5cb2d557af2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 11 Jun 2024 09:26:44 +0200 Subject: [PATCH 169/234] Calculate attributes when entity information available in Group sensor (#119021) --- homeassistant/components/group/sensor.py | 32 +++++++++++++++- tests/components/group/test_sensor.py | 49 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 203b1b3fc8e..2e6c321be1e 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -36,7 +36,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity import ( @@ -45,6 +52,7 @@ from homeassistant.helpers.entity import ( get_unit_of_measurement, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, @@ -329,6 +337,7 @@ class SensorGroup(GroupEntity, SensorEntity): self._native_unit_of_measurement = unit_of_measurement self._valid_units: set[str | None] = set() self._can_convert: bool = False + self.calculate_attributes_later: CALLBACK_TYPE | None = None self._attr_name = name if name == DEFAULT_NAME: self._attr_name = f"{DEFAULT_NAME} {sensor_type}".capitalize() @@ -345,13 +354,32 @@ class SensorGroup(GroupEntity, SensorEntity): async def async_added_to_hass(self) -> None: """When added to hass.""" + for entity_id in self._entity_ids: + if self.hass.states.get(entity_id) is None: + self.calculate_attributes_later = async_track_state_change_event( + self.hass, self._entity_ids, self.calculate_state_attributes + ) + break + if not self.calculate_attributes_later: + await self.calculate_state_attributes() + await super().async_added_to_hass() + + async def calculate_state_attributes( + self, event: Event[EventStateChangedData] | None = None + ) -> None: + """Calculate state attributes.""" + for entity_id in self._entity_ids: + if self.hass.states.get(entity_id) is None: + return + if self.calculate_attributes_later: + self.calculate_attributes_later() + self.calculate_attributes_later = None self._attr_state_class = self._calculate_state_class(self._state_class) self._attr_device_class = self._calculate_device_class(self._device_class) self._attr_native_unit_of_measurement = self._calculate_unit_of_measurement( self._native_unit_of_measurement ) self._valid_units = self._get_valid_units() - await super().async_added_to_hass() @callback def async_update_group_state(self) -> None: diff --git a/tests/components/group/test_sensor.py b/tests/components/group/test_sensor.py index c5331aa2f60..db642506361 100644 --- a/tests/components/group/test_sensor.py +++ b/tests/components/group/test_sensor.py @@ -763,3 +763,52 @@ async def test_last_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.test_last") assert str(float(value)) == state.state assert entity_id == state.attributes.get("last_entity_id") + + +async def test_sensors_attributes_added_when_entity_info_available( + hass: HomeAssistant, +) -> None: + """Test the sensor calculate attributes once all entities attributes are available.""" + config = { + SENSOR_DOMAIN: { + "platform": GROUP_DOMAIN, + "name": DEFAULT_NAME, + "type": "sum", + "entities": ["sensor.test_1", "sensor.test_2", "sensor.test_3"], + "unique_id": "very_unique_id", + } + } + + entity_ids = config["sensor"]["entities"] + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.sensor_group_sum") + + assert state.state == STATE_UNAVAILABLE + assert state.attributes.get(ATTR_ENTITY_ID) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + for entity_id, value in dict(zip(entity_ids, VALUES, strict=False)).items(): + hass.states.async_set( + entity_id, + value, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.VOLUME, + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + ATTR_UNIT_OF_MEASUREMENT: "L", + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.sensor_group_sum") + + assert float(state.state) == pytest.approx(float(SUM_VALUE)) + assert state.attributes.get(ATTR_ENTITY_ID) == entity_ids + 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.TOTAL + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "L" From db7a9321be43a689e4903d92e751454e9e45b955 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Jun 2024 11:50:55 -0700 Subject: [PATCH 170/234] Bump google-generativeai to 0.6.0 (#119062) --- .../google_generative_ai_conversation/conversation.py | 10 +++++----- .../google_generative_ai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 6b2f3c11dcc..6c2bd64a7b5 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import Any, Literal -import google.ai.generativelanguage as glm from google.api_core.exceptions import GoogleAPICallError import google.generativeai as genai +from google.generativeai import protos import google.generativeai.types as genai_types from google.protobuf.json_format import MessageToDict import voluptuous as vol @@ -93,7 +93,7 @@ def _format_tool(tool: llm.Tool) -> dict[str, Any]: parameters = _format_schema(convert(tool.parameters)) - return glm.Tool( + return protos.Tool( { "function_declarations": [ { @@ -349,13 +349,13 @@ class GoogleGenerativeAIConversationEntity( LOGGER.debug("Tool response: %s", function_response) tool_responses.append( - glm.Part( - function_response=glm.FunctionResponse( + protos.Part( + function_response=protos.FunctionResponse( name=tool_name, response=function_response ) ) ) - chat_request = glm.Content(parts=tool_responses) + chat_request = protos.Content(parts=tool_responses) intent_response.async_set_speech( " ".join([part.text.strip() for part in chat_response.parts if part.text]) diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 1886b16985f..168fee105a0 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["google-generativeai==0.5.4", "voluptuous-openapi==0.0.4"] + "requirements": ["google-generativeai==0.6.0", "voluptuous-openapi==0.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71f96c11bfe..d8267c8d0e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -974,7 +974,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.5.4 +google-generativeai==0.6.0 # homeassistant.components.nest google-nest-sdm==4.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 167790fc162..12e4bad7fa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -800,7 +800,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.5.4 +google-generativeai==0.6.0 # homeassistant.components.nest google-nest-sdm==4.0.4 From a1f2140ed79b8a5d14043e2a1fcd43907a6c36f2 Mon Sep 17 00:00:00 2001 From: kaareseras Date: Tue, 11 Jun 2024 09:18:06 +0200 Subject: [PATCH 171/234] Fix Azure data explorer (#119089) Co-authored-by: Robert Resch --- .../azure_data_explorer/__init__.py | 9 ++-- .../components/azure_data_explorer/client.py | 41 ++++++++++++------- .../azure_data_explorer/config_flow.py | 5 ++- .../components/azure_data_explorer/const.py | 2 +- .../azure_data_explorer/strings.json | 14 ++++--- tests/components/azure_data_explorer/const.py | 8 ++-- 6 files changed, 47 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/azure_data_explorer/__init__.py b/homeassistant/components/azure_data_explorer/__init__.py index 62718d6938e..319f7e4389b 100644 --- a/homeassistant/components/azure_data_explorer/__init__.py +++ b/homeassistant/components/azure_data_explorer/__init__.py @@ -62,13 +62,12 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: Adds an empty filter to hass data. Tries to get a filter from yaml, if present set to hass data. - If config is empty after getting the filter, return, otherwise emit - deprecated warning and pass the rest to the config flow. """ - hass.data.setdefault(DOMAIN, {DATA_FILTER: {}}) + hass.data.setdefault(DOMAIN, {DATA_FILTER: FILTER_SCHEMA({})}) if DOMAIN in yaml_config: - hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN][CONF_FILTER] + hass.data[DOMAIN][DATA_FILTER] = yaml_config[DOMAIN].pop(CONF_FILTER) + return True @@ -207,6 +206,6 @@ class AzureDataExplorer: if "\n" in state.state: return None, dropped + 1 - json_event = str(json.dumps(obj=state, cls=JSONEncoder).encode("utf-8")) + json_event = json.dumps(obj=state, cls=JSONEncoder) return (json_event, dropped) diff --git a/homeassistant/components/azure_data_explorer/client.py b/homeassistant/components/azure_data_explorer/client.py index 40528bc6a6f..88609ff8e10 100644 --- a/homeassistant/components/azure_data_explorer/client.py +++ b/homeassistant/components/azure_data_explorer/client.py @@ -23,7 +23,7 @@ from .const import ( CONF_APP_REG_ID, CONF_APP_REG_SECRET, CONF_AUTHORITY_ID, - CONF_USE_FREE, + CONF_USE_QUEUED_CLIENT, ) _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,6 @@ class AzureDataExplorerClient: def __init__(self, data: Mapping[str, Any]) -> None: """Create the right class.""" - self._cluster_ingest_uri = data[CONF_ADX_CLUSTER_INGEST_URI] self._database = data[CONF_ADX_DATABASE_NAME] self._table = data[CONF_ADX_TABLE_NAME] self._ingestion_properties = IngestionProperties( @@ -45,24 +44,36 @@ class AzureDataExplorerClient: ingestion_mapping_reference="ha_json_mapping", ) - # Create cLient for ingesting and querying data - kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication( - self._cluster_ingest_uri, - data[CONF_APP_REG_ID], - data[CONF_APP_REG_SECRET], - data[CONF_AUTHORITY_ID], + # Create client for ingesting data + kcsb_ingest = ( + KustoConnectionStringBuilder.with_aad_application_key_authentication( + data[CONF_ADX_CLUSTER_INGEST_URI], + data[CONF_APP_REG_ID], + data[CONF_APP_REG_SECRET], + data[CONF_AUTHORITY_ID], + ) ) - if data[CONF_USE_FREE] is True: - # Queded is the only option supported on free tear of ADX - self.write_client = QueuedIngestClient(kcsb) - else: - self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb) + # Create client for querying data + kcsb_query = ( + KustoConnectionStringBuilder.with_aad_application_key_authentication( + data[CONF_ADX_CLUSTER_INGEST_URI].replace("ingest-", ""), + data[CONF_APP_REG_ID], + data[CONF_APP_REG_SECRET], + data[CONF_AUTHORITY_ID], + ) + ) - self.query_client = KustoClient(kcsb) + if data[CONF_USE_QUEUED_CLIENT] is True: + # Queded is the only option supported on free tear of ADX + self.write_client = QueuedIngestClient(kcsb_ingest) + else: + self.write_client = ManagedStreamingIngestClient.from_dm_kcsb(kcsb_ingest) + + self.query_client = KustoClient(kcsb_query) def test_connection(self) -> None: - """Test connection, will throw Exception when it cannot connect.""" + """Test connection, will throw Exception if it cannot connect.""" query = f"{self._table} | take 1" diff --git a/homeassistant/components/azure_data_explorer/config_flow.py b/homeassistant/components/azure_data_explorer/config_flow.py index d8390246b41..4ffb5ea7cf7 100644 --- a/homeassistant/components/azure_data_explorer/config_flow.py +++ b/homeassistant/components/azure_data_explorer/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.selector import BooleanSelector from . import AzureDataExplorerClient from .const import ( @@ -19,7 +20,7 @@ from .const import ( CONF_APP_REG_ID, CONF_APP_REG_SECRET, CONF_AUTHORITY_ID, - CONF_USE_FREE, + CONF_USE_QUEUED_CLIENT, DEFAULT_OPTIONS, DOMAIN, ) @@ -34,7 +35,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_APP_REG_ID): str, vol.Required(CONF_APP_REG_SECRET): str, vol.Required(CONF_AUTHORITY_ID): str, - vol.Optional(CONF_USE_FREE, default=False): bool, + vol.Required(CONF_USE_QUEUED_CLIENT, default=False): BooleanSelector(), } ) diff --git a/homeassistant/components/azure_data_explorer/const.py b/homeassistant/components/azure_data_explorer/const.py index ca98110597a..a88a6b8b94f 100644 --- a/homeassistant/components/azure_data_explorer/const.py +++ b/homeassistant/components/azure_data_explorer/const.py @@ -17,7 +17,7 @@ CONF_AUTHORITY_ID = "authority_id" CONF_SEND_INTERVAL = "send_interval" CONF_MAX_DELAY = "max_delay" CONF_FILTER = DATA_FILTER = "filter" -CONF_USE_FREE = "use_queued_ingestion" +CONF_USE_QUEUED_CLIENT = "use_queued_ingestion" DATA_HUB = "hub" STEP_USER = "user" diff --git a/homeassistant/components/azure_data_explorer/strings.json b/homeassistant/components/azure_data_explorer/strings.json index 64005872579..c8ec158a844 100644 --- a/homeassistant/components/azure_data_explorer/strings.json +++ b/homeassistant/components/azure_data_explorer/strings.json @@ -3,15 +3,19 @@ "step": { "user": { "title": "Setup your Azure Data Explorer integration", - "description": "Enter connection details.", + "description": "Enter connection details", "data": { - "cluster_ingest_uri": "Cluster ingest URI", - "database": "Database name", - "table": "Table name", + "cluster_ingest_uri": "Cluster Ingest URI", + "authority_id": "Authority ID", "client_id": "Client ID", "client_secret": "Client secret", - "authority_id": "Authority ID", + "database": "Database name", + "table": "Table name", "use_queued_ingestion": "Use queued ingestion" + }, + "data_description": { + "cluster_ingest_uri": "Ingest-URI of the cluster", + "use_queued_ingestion": "Must be enabled when using ADX free cluster" } } }, diff --git a/tests/components/azure_data_explorer/const.py b/tests/components/azure_data_explorer/const.py index d29f4d5ba93..d20be1584a1 100644 --- a/tests/components/azure_data_explorer/const.py +++ b/tests/components/azure_data_explorer/const.py @@ -8,7 +8,7 @@ from homeassistant.components.azure_data_explorer.const import ( CONF_APP_REG_SECRET, CONF_AUTHORITY_ID, CONF_SEND_INTERVAL, - CONF_USE_FREE, + CONF_USE_QUEUED_CLIENT, ) AZURE_DATA_EXPLORER_PATH = "homeassistant.components.azure_data_explorer" @@ -29,7 +29,7 @@ BASE_CONFIG_URI = { } BASIC_OPTIONS = { - CONF_USE_FREE: False, + CONF_USE_QUEUED_CLIENT: False, CONF_SEND_INTERVAL: 5, } @@ -39,10 +39,10 @@ BASE_CONFIG_FULL = BASE_CONFIG | BASIC_OPTIONS | BASE_CONFIG_URI BASE_CONFIG_IMPORT = { CONF_ADX_CLUSTER_INGEST_URI: "https://cluster.region.kusto.windows.net", - CONF_USE_FREE: False, + CONF_USE_QUEUED_CLIENT: False, CONF_SEND_INTERVAL: 5, } -FREE_OPTIONS = {CONF_USE_FREE: True, CONF_SEND_INTERVAL: 5} +FREE_OPTIONS = {CONF_USE_QUEUED_CLIENT: True, CONF_SEND_INTERVAL: 5} BASE_CONFIG_FREE = BASE_CONFIG | FREE_OPTIONS From 87f48b15d150dd0f335263e8b8e306520a902fd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 8 Jun 2024 16:07:39 -0500 Subject: [PATCH 172/234] Ensure multiple executions of a restart automation in the same event loop iteration are allowed (#119100) * Add test for restarting automation related issue #119097 * fix * add a delay since restart is an infinite loop * tests --- homeassistant/helpers/script.py | 4 - tests/components/automation/test_init.py | 137 ++++++++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 4d315f428c3..1a4d57e6929 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1758,10 +1758,6 @@ class Script: # runs before sleeping as otherwise if two runs are started at the exact # same time they will cancel each other out. self._log("Restarting") - # Important: yield to the event loop to allow the script to start in case - # the script is restarting itself so it ends up in the script stack and - # the recursion check above will prevent the script from running. - await asyncio.sleep(0) await self.async_stop(update_state=False, spare=run) if started_action: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 7b3d4c4010e..bd5957326ec 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2771,6 +2771,7 @@ async def test_recursive_automation_starting_script( ], "action": [ {"service": "test.automation_started"}, + {"delay": 0.001}, {"service": "script.script1"}, ], } @@ -2817,7 +2818,10 @@ async def test_recursive_automation_starting_script( assert script_warning_msg in caplog.text -@pytest.mark.parametrize("automation_mode", SCRIPT_MODE_CHOICES) +@pytest.mark.parametrize( + "automation_mode", + [mode for mode in SCRIPT_MODE_CHOICES if mode != SCRIPT_MODE_RESTART], +) @pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) async def test_recursive_automation( hass: HomeAssistant, automation_mode, caplog: pytest.LogCaptureFixture @@ -2878,6 +2882,68 @@ async def test_recursive_automation( assert "Disallowed recursion detected" not in caplog.text +@pytest.mark.parametrize("wait_for_stop_scripts_after_shutdown", [True]) +async def test_recursive_automation_restart_mode( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test automation restarting itself. + + The automation is an infinite loop since it keeps restarting itself + + - Illegal recursion detection should not be triggered + - Home Assistant should not hang on shut down + """ + stop_scripts_at_shutdown_called = asyncio.Event() + real_stop_scripts_at_shutdown = _async_stop_scripts_at_shutdown + + async def stop_scripts_at_shutdown(*args): + await real_stop_scripts_at_shutdown(*args) + stop_scripts_at_shutdown_called.set() + + with patch( + "homeassistant.helpers.script._async_stop_scripts_at_shutdown", + wraps=stop_scripts_at_shutdown, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "mode": SCRIPT_MODE_RESTART, + "trigger": [ + {"platform": "event", "event_type": "trigger_automation"}, + ], + "action": [ + {"event": "trigger_automation"}, + {"service": "test.automation_done"}, + ], + } + }, + ) + + service_called = asyncio.Event() + + async def async_service_handler(service): + if service.service == "automation_done": + service_called.set() + + hass.services.async_register("test", "automation_done", async_service_handler) + + hass.bus.async_fire("trigger_automation") + await asyncio.sleep(0) + + # Trigger 1st stage script shutdown + hass.set_state(CoreState.stopping) + hass.bus.async_fire("homeassistant_stop") + await asyncio.wait_for(stop_scripts_at_shutdown_called.wait(), 1) + + # Trigger 2nd stage script shutdown + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) + await hass.async_block_till_done() + + assert "Disallowed recursion detected" not in caplog.text + + async def test_websocket_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -3097,3 +3163,72 @@ async def test_two_automations_call_restart_script_same_time( await hass.async_block_till_done() assert len(events) == 2 cancel() + + +async def test_two_automation_call_restart_script_right_after_each_other( + hass: HomeAssistant, +) -> None: + """Test two automations call a restart script right after each other.""" + + events = async_capture_events(hass, "repeat_test_script_finished") + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + { + input_boolean.DOMAIN: { + "test_1": None, + "test_2": None, + } + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "state", + "entity_id": ["input_boolean.test_1", "input_boolean.test_1"], + "from": "off", + "to": "on", + }, + "action": [ + { + "repeat": { + "count": 2, + "sequence": [ + { + "delay": { + "hours": 0, + "minutes": 0, + "seconds": 0, + "milliseconds": 100, + } + } + ], + } + }, + {"event": "repeat_test_script_finished", "event_data": {}}, + ], + "id": "automation_0", + "mode": "restart", + }, + ] + }, + ) + hass.states.async_set("input_boolean.test_1", "off") + hass.states.async_set("input_boolean.test_2", "off") + await hass.async_block_till_done() + hass.states.async_set("input_boolean.test_1", "on") + hass.states.async_set("input_boolean.test_2", "on") + await asyncio.sleep(0) + hass.states.async_set("input_boolean.test_1", "off") + hass.states.async_set("input_boolean.test_2", "off") + await asyncio.sleep(0) + hass.states.async_set("input_boolean.test_1", "on") + hass.states.async_set("input_boolean.test_2", "on") + await hass.async_block_till_done() + assert len(events) == 1 From 96ac566032f10adf73a960bdb58db1a9a269eea0 Mon Sep 17 00:00:00 2001 From: Austin Drummond Date: Mon, 10 Jun 2024 02:48:11 -0400 Subject: [PATCH 173/234] Fix control 4 on os 2 (#119104) --- homeassistant/components/control4/__init__.py | 7 ++++++- homeassistant/components/control4/media_player.py | 11 ++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 86a13de1ac8..c9a6eab5c62 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -120,7 +120,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: director_all_items = json.loads(director_all_items) entry_data[CONF_DIRECTOR_ALL_ITEMS] = director_all_items - entry_data[CONF_UI_CONFIGURATION] = json.loads(await director.getUiConfiguration()) + # Check if OS version is 3 or higher to get UI configuration + entry_data[CONF_UI_CONFIGURATION] = None + if int(entry_data[CONF_DIRECTOR_SW_VERSION].split(".")[0]) >= 3: + entry_data[CONF_UI_CONFIGURATION] = json.loads( + await director.getUiConfiguration() + ) # Load options from config entry entry_data[CONF_SCAN_INTERVAL] = entry.options.get( diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 99d8c27face..72aa44faaed 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -81,11 +81,18 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Control4 rooms from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + ui_config = entry_data[CONF_UI_CONFIGURATION] + + # OS 2 will not have a ui_configuration + if not ui_config: + _LOGGER.debug("No UI Configuration found for Control4") + return + all_rooms = await get_rooms(hass, entry) if not all_rooms: return - entry_data = hass.data[DOMAIN][entry.entry_id] scan_interval = entry_data[CONF_SCAN_INTERVAL] _LOGGER.debug("Scan interval = %s", scan_interval) @@ -119,8 +126,6 @@ async def async_setup_entry( if "parentId" in item and k > 1 } - ui_config = entry_data[CONF_UI_CONFIGURATION] - entity_list = [] for room in all_rooms: room_id = room["id"] From 34477d35595f57a04c9581045c7e1ae2c223629e Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 8 Jun 2024 00:02:00 -0700 Subject: [PATCH 174/234] Properly handle escaped unicode characters passed to tools in Google Generative AI (#119117) --- .../conversation.py | 16 +++++++--------- .../test_conversation.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 6c2bd64a7b5..65c0dc7fd93 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -2,6 +2,7 @@ from __future__ import annotations +import codecs from typing import Any, Literal from google.api_core.exceptions import GoogleAPICallError @@ -106,14 +107,14 @@ def _format_tool(tool: llm.Tool) -> dict[str, Any]: ) -def _adjust_value(value: Any) -> Any: - """Reverse unnecessary single quotes escaping.""" +def _escape_decode(value: Any) -> Any: + """Recursively call codecs.escape_decode on all values.""" if isinstance(value, str): - return value.replace("\\'", "'") + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] if isinstance(value, list): - return [_adjust_value(item) for item in value] + return [_escape_decode(item) for item in value] if isinstance(value, dict): - return {k: _adjust_value(v) for k, v in value.items()} + return {k: _escape_decode(v) for k, v in value.items()} return value @@ -334,10 +335,7 @@ class GoogleGenerativeAIConversationEntity( for function_call in function_calls: tool_call = MessageToDict(function_call._pb) # noqa: SLF001 tool_name = tool_call["name"] - tool_args = { - key: _adjust_value(value) - for key, value in tool_call["args"].items() - } + tool_args = _escape_decode(tool_call["args"]) LOGGER.debug("Tool call: %s(%s)", tool_name, tool_args) tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) try: diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 901216d262f..e84efffe7df 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -12,6 +12,9 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import trace +from homeassistant.components.google_generative_ai_conversation.conversation import ( + _escape_decode, +) from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -504,3 +507,18 @@ async def test_conversation_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" + + +async def test_escape_decode() -> None: + """Test _escape_decode.""" + assert _escape_decode( + { + "param1": ["test_value", "param1\\'s value"], + "param2": "param2\\'s value", + "param3": {"param31": "Cheminée", "param32": "Chemin\\303\\251e"}, + } + ) == { + "param1": ["test_value", "param1's value"], + "param2": "param2's value", + "param3": {"param31": "Cheminée", "param32": "Cheminée"}, + } From 0f8ed4e73d0f1c2049ca56732cdedddce4ef2465 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 7 Jun 2024 23:51:42 -0700 Subject: [PATCH 175/234] Catch GoogleAPICallError in Google Generative AI (#119118) --- .../components/google_generative_ai_conversation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 523198355d1..f115f3923b6 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -71,7 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: response = await model.generate_content_async(prompt_parts) except ( - ClientError, + GoogleAPICallError, ValueError, genai_types.BlockedPromptException, genai_types.StopCandidateException, From df96b949858fcb5e5fc324d7eeae8eb92270de04 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 8 Jun 2024 11:44:37 +0300 Subject: [PATCH 176/234] Bump aioshelly to 10.0.1 (#119123) --- 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 2e8c2d59c1e..b1b00e40c66 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==10.0.0"], + "requirements": ["aioshelly==10.0.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index d8267c8d0e7..5fe0d275939 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==10.0.0 +aioshelly==10.0.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12e4bad7fa7..974d4bd2427 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -326,7 +326,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==10.0.0 +aioshelly==10.0.1 # homeassistant.components.skybell aioskybell==22.7.0 From a696ea18d361ba6b58172c36643cd9aad85db59f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 8 Jun 2024 11:28:45 +0200 Subject: [PATCH 177/234] Bump aiowaqi to 3.1.0 (#119124) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d742fd72858..cb04bd7d6ac 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==3.0.1"] + "requirements": ["aiowaqi==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5fe0d275939..cc10ead5c5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -389,7 +389,7 @@ aiovlc==0.3.2 aiovodafone==0.6.0 # homeassistant.components.waqi -aiowaqi==3.0.1 +aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 974d4bd2427..612a9192855 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiovlc==0.3.2 aiovodafone==0.6.0 # homeassistant.components.waqi -aiowaqi==3.0.1 +aiowaqi==3.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 From 4bb1ea1da187e74a7b3e8102b6060cb75e1f0599 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 8 Jun 2024 11:53:47 -0400 Subject: [PATCH 178/234] Ensure intent tools have safe names (#119144) --- homeassistant/helpers/llm.py | 13 +++++++++++-- tests/helpers/test_llm.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 3c240692d52..903e52af1a2 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -5,8 +5,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum +from functools import cache, partial from typing import Any +import slugify as unicode_slug import voluptuous as vol from homeassistant.components.climate.intent import INTENT_GET_TEMPERATURE @@ -175,10 +177,11 @@ class IntentTool(Tool): def __init__( self, + name: str, intent_handler: intent.IntentHandler, ) -> None: """Init the class.""" - self.name = intent_handler.intent_type + self.name = name self.description = ( intent_handler.description or f"Execute Home Assistant {self.name} intent" ) @@ -261,6 +264,9 @@ class AssistAPI(API): id=LLM_API_ASSIST, name="Assist", ) + self.cached_slugify = cache( + partial(unicode_slug.slugify, separator="_", lowercase=False) + ) async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance: """Return the instance of the API.""" @@ -373,7 +379,10 @@ class AssistAPI(API): or intent_handler.platforms & exposed_domains ] - return [IntentTool(intent_handler) for intent_handler in intent_handlers] + return [ + IntentTool(self.cached_slugify(intent_handler.intent_type), intent_handler) + for intent_handler in intent_handlers + ] def _get_exposed_entities( diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 3f61ed8a0ed..6ac17a2fe0e 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -249,6 +249,39 @@ async def test_assist_api_get_timer_tools( assert "HassStartTimer" in [tool.name for tool in api.tools] +async def test_assist_api_tools( + hass: HomeAssistant, llm_context: llm.LLMContext +) -> None: + """Test getting timer tools with Assist API.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + llm_context.device_id = "test_device" + + async_register_timer_handler(hass, "test_device", lambda *args: None) + + class MyIntentHandler(intent.IntentHandler): + intent_type = "Super crazy intent with unique nåme" + description = "my intent handler" + + intent.async_register(hass, MyIntentHandler()) + + api = await llm.async_get_api(hass, "assist", llm_context) + assert [tool.name for tool in api.tools] == [ + "HassTurnOn", + "HassTurnOff", + "HassSetPosition", + "HassStartTimer", + "HassCancelTimer", + "HassIncreaseTimer", + "HassDecreaseTimer", + "HassPauseTimer", + "HassUnpauseTimer", + "HassTimerStatus", + "Super_crazy_intent_with_unique_name", + ] + + async def test_assist_api_description( hass: HomeAssistant, llm_context: llm.LLMContext ) -> None: From 7912c9e95cc69f4dd4238f9b9586e98da9140571 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Sat, 8 Jun 2024 16:53:20 +0100 Subject: [PATCH 179/234] Fix workday timezone (#119148) --- homeassistant/components/workday/binary_sensor.py | 2 +- tests/components/workday/test_binary_sensor.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 205f500746e..5df8e6c3d75 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -269,7 +269,7 @@ class IsWorkdaySensor(BinarySensorEntity): def _update_state_and_setup_listener(self) -> None: """Update state and setup listener for next interval.""" - now = dt_util.utcnow() + now = dt_util.now() self.update_data(now) self.unsub = async_track_point_in_utc_time( self.hass, self.point_in_time_listener, self.get_next_interval(now) diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index e9f0e8023bc..9aa4dd6b5b4 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -68,7 +68,9 @@ async def test_setup( freezer: FrozenDateTimeFactory, ) -> None: """Test setup from various configs.""" - freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Friday + # Start on a Friday + await hass.config.async_set_time_zone("Europe/Paris") + freezer.move_to(datetime(2022, 4, 15, 0, tzinfo=timezone(timedelta(hours=1)))) await init_integration(hass, config) state = hass.states.get("binary_sensor.workday_sensor") From 40ebf3b2a956b5bf44c5695b1ebfbf230c8cf5cc Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Jun 2024 21:24:59 +0200 Subject: [PATCH 180/234] Bump py-synologydsm-api to 2.4.4 (#119156) bump py-synologydsm-api to 2.4.4 --- 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 caecfcbd0c9..b1133fd61ad 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.4.2"], + "requirements": ["py-synologydsm-api==2.4.4"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index cc10ead5c5e..4d2a02078a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1649,7 +1649,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.4.2 +py-synologydsm-api==2.4.4 # homeassistant.components.zabbix py-zabbix==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 612a9192855..8a4460b1cc9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1311,7 +1311,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.4.2 +py-synologydsm-api==2.4.4 # homeassistant.components.seventeentrack py17track==2021.12.2 From 019d33c06c0db0045d710aa92a0b5e0df735dcf1 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 8 Jun 2024 23:52:14 +0200 Subject: [PATCH 181/234] Use more conservative timeout values in Synology DSM (#119169) use ClientTimeout object --- homeassistant/components/synology_dsm/const.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 35d3008b416..11839caf8be 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from aiohttp import ClientTimeout from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, @@ -40,7 +41,7 @@ DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options DEFAULT_SCAN_INTERVAL = 15 # min -DEFAULT_TIMEOUT = 30 # sec +DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED ENTITY_UNIT_LOAD = "load" From 9a8e3ad5cc98acd43b6c5e3a7f87145674fb87ee Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 9 Jun 2024 12:59:40 +0300 Subject: [PATCH 182/234] Handle Shelly BLE errors during connect and disconnect (#119174) --- homeassistant/components/shelly/__init__.py | 9 +--- .../components/shelly/coordinator.py | 18 ++++++- tests/components/shelly/test_coordinator.py | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 1bcd9c7c1e4..cc1ea5e81a6 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib from typing import Final from aioshelly.block_device import BlockDevice @@ -301,13 +300,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> b entry, platforms ): if shelly_entry_data.rpc: - with contextlib.suppress(DeviceConnectionError): - # If the device is restarting or has gone offline before - # the ping/pong timeout happens, the shutdown command - # will fail, but we don't care since we are unloading - # and if we setup again, we will fix anything that is - # in an inconsistent state at that time. - await shelly_entry_data.rpc.shutdown() + await shelly_entry_data.rpc.shutdown() return unload_ok diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index c12e1aea289..5bb05d48d62 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -625,7 +625,13 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.connected: # Already connected return self.connected = True - await self._async_run_connected_events() + try: + await self._async_run_connected_events() + except DeviceConnectionError as err: + LOGGER.error( + "Error running connected events for device %s: %s", self.name, err + ) + self.last_update_success = False async def _async_run_connected_events(self) -> None: """Run connected events. @@ -699,10 +705,18 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.device.connected: try: await async_stop_scanner(self.device) + await super().shutdown() except InvalidAuthError: self.entry.async_start_reauth(self.hass) return - await super().shutdown() + except DeviceConnectionError as err: + # If the device is restarting or has gone offline before + # the ping/pong timeout happens, the shutdown command + # will fail, but we don't care since we are unloading + # and if we setup again, we will fix anything that is + # in an inconsistent state at that time. + LOGGER.debug("Error during shutdown for device %s: %s", self.name, err) + return await self._async_disconnected(False) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 1dc45a98c44..cd750e53f0b 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -15,12 +15,14 @@ from homeassistant.components.shelly.const import ( ATTR_CLICK_TYPE, ATTR_DEVICE, ATTR_GENERATION, + CONF_BLE_SCANNER_MODE, DOMAIN, ENTRY_RELOAD_COOLDOWN, MAX_PUSH_UPDATE_FAILURES, RPC_RECONNECT_INTERVAL, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, + BLEScannerMode, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE @@ -485,6 +487,25 @@ async def test_rpc_reload_with_invalid_auth( assert flow["context"].get("entry_id") == entry.entry_id +async def test_rpc_connection_error_during_unload( + hass: HomeAssistant, mock_rpc_device: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test RPC DeviceConnectionError suppressed during config entry unload.""" + entry = await init_integration(hass, 2) + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "homeassistant.components.shelly.coordinator.async_stop_scanner", + side_effect=DeviceConnectionError, + ): + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert "Error during shutdown for device" in caplog.text + assert entry.state is ConfigEntryState.NOT_LOADED + + async def test_rpc_click_event( hass: HomeAssistant, mock_rpc_device: Mock, @@ -713,6 +734,32 @@ async def test_rpc_reconnect_error( assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE +async def test_rpc_error_running_connected_events( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test RPC error while running connected events.""" + with patch( + "homeassistant.components.shelly.coordinator.async_ensure_ble_enabled", + side_effect=DeviceConnectionError, + ): + await init_integration( + hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} + ) + + assert "Error running connected events for device" in caplog.text + assert get_entity_state(hass, "switch.test_switch_0") == STATE_UNAVAILABLE + + # Move time to generate reconnect without error + freezer.tick(timedelta(seconds=RPC_RECONNECT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert get_entity_state(hass, "switch.test_switch_0") == STATE_ON + + async def test_rpc_polling_connection_error( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From d8f3778d77b776e274420cf593ec43a982239099 Mon Sep 17 00:00:00 2001 From: Quentin <39061148+LapsTimeOFF@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:58:15 +0200 Subject: [PATCH 183/234] Fix elgato light color detection (#119177) --- homeassistant/components/elgato/light.py | 10 +++++++++- tests/components/elgato/fixtures/light-strip/info.json | 2 +- tests/components/elgato/snapshots/test_light.ambr | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 2cd3d611bf5..339bed97f6f 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -59,7 +59,15 @@ class ElgatoLight(ElgatoEntity, LightEntity): self._attr_unique_id = coordinator.data.info.serial_number # Elgato Light supporting color, have a different temperature range - if self.coordinator.data.settings.power_on_hue is not None: + if ( + self.coordinator.data.info.product_name + in ( + "Elgato Light Strip", + "Elgato Light Strip Pro", + ) + or self.coordinator.data.settings.power_on_hue + or self.coordinator.data.state.hue is not None + ): self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} self._attr_min_mireds = 153 self._attr_max_mireds = 285 diff --git a/tests/components/elgato/fixtures/light-strip/info.json b/tests/components/elgato/fixtures/light-strip/info.json index e2a816df26e..a8c3200e4b9 100644 --- a/tests/components/elgato/fixtures/light-strip/info.json +++ b/tests/components/elgato/fixtures/light-strip/info.json @@ -1,5 +1,5 @@ { - "productName": "Elgato Key Light", + "productName": "Elgato Light Strip", "hardwareBoardType": 53, "firmwareBuildNumber": 192, "firmwareVersion": "1.0.3", diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 6ef773a7304..e2f663d294b 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -218,7 +218,7 @@ 'labels': set({ }), 'manufacturer': 'Elgato', - 'model': 'Elgato Key Light', + 'model': 'Elgato Light Strip', 'name': 'Frenck', 'name_by_user': None, 'serial_number': 'CN11A1A00001', @@ -333,7 +333,7 @@ 'labels': set({ }), 'manufacturer': 'Elgato', - 'model': 'Elgato Key Light', + 'model': 'Elgato Light Strip', 'name': 'Frenck', 'name_by_user': None, 'serial_number': 'CN11A1A00001', From 57cc1f841bc2168bccc60fec520cd3a9313c4adb Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 9 Jun 2024 00:45:59 -0700 Subject: [PATCH 184/234] Bump opower to 0.4.7 (#119183) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7e16bacdfda..d419fdcb043 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.4.6"] + "requirements": ["opower==0.4.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d2a02078a1..bf9e2351951 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1501,7 +1501,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.6 +opower==0.4.7 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a4460b1cc9..bc5a1524ee7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1201,7 +1201,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.4 # homeassistant.components.opower -opower==0.4.6 +opower==0.4.7 # homeassistant.components.oralb oralb-ble==0.17.6 From c71b6bdac91a9d38532ace9b0014e52968f04b50 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 9 Jun 2024 11:59:14 +0200 Subject: [PATCH 185/234] Add fallback to entry_id when no mac address is retrieved in enigma2 (#119185) --- homeassistant/components/enigma2/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 037d82cd6c0..81f4f830833 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -141,10 +141,10 @@ class Enigma2Device(MediaPlayerEntity): self._device: OpenWebIfDevice = device self._entry = entry - self._attr_unique_id = device.mac_address + self._attr_unique_id = device.mac_address or entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.mac_address)}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=about["info"]["brand"], model=about["info"]["model"], configuration_url=device.base, From 8d094bf12ea1e83b2a2aedb92bb977215d57a8ae Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 9 Jun 2024 18:14:46 +0200 Subject: [PATCH 186/234] Fix envisalink alarm (#119212) --- .../envisalink/alarm_control_panel.py | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 119608bbb2a..b962621edea 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -116,8 +116,9 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): ): """Initialize the alarm panel.""" self._partition_number = partition_number - self._code = code self._panic_type = panic_type + self._alarm_control_panel_option_default_code = code + self._attr_code_format = CodeFormat.NUMBER _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) @@ -141,13 +142,6 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): if partition is None or int(partition) == self._partition_number: self.async_write_ha_state() - @property - def code_format(self) -> CodeFormat | None: - """Regex for code format or None if no code is required.""" - if self._code: - return None - return CodeFormat.NUMBER - @property def state(self) -> str: """Return the state of the device.""" @@ -169,34 +163,15 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" - if code: - self.hass.data[DATA_EVL].disarm_partition(str(code), self._partition_number) - else: - self.hass.data[DATA_EVL].disarm_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].disarm_partition(code, self._partition_number) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if code: - self.hass.data[DATA_EVL].arm_stay_partition( - str(code), self._partition_number - ) - else: - self.hass.data[DATA_EVL].arm_stay_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_stay_partition(code, self._partition_number) async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if code: - self.hass.data[DATA_EVL].arm_away_partition( - str(code), self._partition_number - ) - else: - self.hass.data[DATA_EVL].arm_away_partition( - str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_away_partition(code, self._partition_number) async def async_alarm_trigger(self, code: str | None = None) -> None: """Alarm trigger command. Will be used to trigger a panic alarm.""" @@ -204,9 +179,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - self.hass.data[DATA_EVL].arm_night_partition( - str(code) if code else str(self._code), self._partition_number - ) + self.hass.data[DATA_EVL].arm_night_partition(code, self._partition_number) @callback def async_alarm_keypress(self, keypress=None): From 6a656c5d49fea6c52ed1b08718a86d3628d61294 Mon Sep 17 00:00:00 2001 From: Angel Nunez Mencias Date: Mon, 10 Jun 2024 08:41:22 +0200 Subject: [PATCH 187/234] Fixes crashes when receiving malformed decoded payloads (#119216) Co-authored-by: Jan Bouwhuis --- homeassistant/components/thethingsnetwork/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index b8b1dbd7e1d..bc132d171f2 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["ttn_client==0.0.4"] + "requirements": ["ttn_client==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf9e2351951..859563e3a3b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2764,7 +2764,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.thethingsnetwork -ttn_client==0.0.4 +ttn_client==1.0.0 # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc5a1524ee7..ab14dd61525 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2138,7 +2138,7 @@ transmission-rpc==7.0.3 ttls==1.5.1 # homeassistant.components.thethingsnetwork -ttn_client==0.0.4 +ttn_client==1.0.0 # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 From 8b415a0376b28ad54ad22b36fc51490c4de34676 Mon Sep 17 00:00:00 2001 From: wittypluck Date: Mon, 10 Jun 2024 08:25:39 +0200 Subject: [PATCH 188/234] Fix Glances v4 network and container issues (glances-api 0.8.0) (#119226) --- homeassistant/components/glances/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index 68101583b48..e129a375df2 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances-api==0.7.0"] + "requirements": ["glances-api==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 859563e3a3b..cd8e859ec1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -955,7 +955,7 @@ gios==4.0.0 gitterpy==0.1.7 # homeassistant.components.glances -glances-api==0.7.0 +glances-api==0.8.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab14dd61525..8ae089f97e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -784,7 +784,7 @@ getmac==0.9.4 gios==4.0.0 # homeassistant.components.glances -glances-api==0.7.0 +glances-api==0.8.0 # homeassistant.components.goalzero goalzero==0.2.2 From 7896e7675c8653214fc357ae3e1d19a8be95c2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ethem=20Cem=20=C3=96zkan?= Date: Sun, 9 Jun 2024 22:58:49 +0200 Subject: [PATCH 189/234] Bump python-roborock to 2.3.0 (#119228) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 3fd6dd7d782..42c0f9ba347 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==2.2.3", + "python-roborock==2.3.0", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/requirements_all.txt b/requirements_all.txt index cd8e859ec1e..3eb5891d470 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2306,7 +2306,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.2.3 +python-roborock==2.3.0 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ae089f97e0..9aad243bb7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1794,7 +1794,7 @@ python-qbittorrent==0.4.3 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.2.3 +python-roborock==2.3.0 # homeassistant.components.smarttub python-smarttub==0.0.36 From 1e7ab07d9edfbef07ac3b02441432ae7181a6ede Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:20:25 +0200 Subject: [PATCH 190/234] Revert SamsungTV migration (#119234) --- homeassistant/components/samsungtv/__init__.py | 11 +---------- tests/components/samsungtv/test_init.py | 6 +++++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index f49ae276665..992c86d5d7e 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -297,16 +297,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if version == 2: if minor_version < 2: # Cleanup invalid MAC addresses - see #103512 - dev_reg = dr.async_get(hass) - for device in dr.async_entries_for_config_entry( - dev_reg, config_entry.entry_id - ): - new_connections = device.connections.copy() - new_connections.discard((dr.CONNECTION_NETWORK_MAC, "none")) - if new_connections != device.connections: - dev_reg.async_update_device( - device.id, new_connections=new_connections - ) + # Reverted due to device registry collisions - see #119082 / #119249 minor_version = 2 hass.config_entries.async_update_entry(config_entry, minor_version=2) diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 4efcf62c1dd..479664d4ec0 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -220,10 +220,14 @@ async def test_incorrectly_formatted_mac_fixed(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remotews", "rest_api") +@pytest.mark.xfail async def test_cleanup_mac( hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion ) -> None: - """Test for `none` mac cleanup #103512.""" + """Test for `none` mac cleanup #103512. + + Reverted due to device registry collisions in #119249 / #119082 + """ entry = MockConfigEntry( domain=SAMSUNGTV_DOMAIN, data=MOCK_ENTRY_WS_WITH_MAC, From 119d4c2316c1ff5dd811dcf6a6589af3e3f86c0a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 10 Jun 2024 07:47:16 +0200 Subject: [PATCH 191/234] Always provide a currentArmLevel in Google assistant (#119238) --- .../components/google_assistant/trait.py | 32 +++++++++++-------- .../components/google_assistant/test_trait.py | 5 ++- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index e39634a5dd6..3d1daea9810 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1586,6 +1586,17 @@ class ArmDisArmTrait(_Trait): if features & required_feature != 0 ] + def _default_arm_state(self): + states = self._supported_states() + + if STATE_ALARM_TRIGGERED in states: + states.remove(STATE_ALARM_TRIGGERED) + + if len(states) != 1: + raise SmartHomeError(ERR_NOT_SUPPORTED, "ArmLevel missing") + + return states[0] + def sync_attributes(self): """Return ArmDisarm attributes for a sync request.""" response = {} @@ -1609,10 +1620,13 @@ class ArmDisArmTrait(_Trait): def query_attributes(self): """Return ArmDisarm query attributes.""" armed_state = self.state.attributes.get("next_state", self.state.state) - response = {"isArmed": armed_state in self.state_to_service} - if response["isArmed"]: - response.update({"currentArmLevel": armed_state}) - return response + + if armed_state in self.state_to_service: + return {"isArmed": True, "currentArmLevel": armed_state} + return { + "isArmed": False, + "currentArmLevel": self._default_arm_state(), + } async def execute(self, command, data, params, challenge): """Execute an ArmDisarm command.""" @@ -1620,15 +1634,7 @@ class ArmDisArmTrait(_Trait): # If no arm level given, we can only arm it if there is # only one supported arm type. We never default to triggered. if not (arm_level := params.get("armLevel")): - states = self._supported_states() - - if STATE_ALARM_TRIGGERED in states: - states.remove(STATE_ALARM_TRIGGERED) - - if len(states) != 1: - raise SmartHomeError(ERR_NOT_SUPPORTED, "ArmLevel missing") - - arm_level = states[0] + arm_level = self._default_arm_state() if self.state.state == arm_level: raise SmartHomeError(ERR_ALREADY_ARMED, "System is already armed") diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 0ed4d960edc..c72ab3cb85e 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1931,7 +1931,10 @@ async def test_arm_disarm_disarm(hass: HomeAssistant) -> None: } } - assert trt.query_attributes() == {"isArmed": False} + assert trt.query_attributes() == { + "currentArmLevel": "armed_custom_bypass", + "isArmed": False, + } assert trt.can_execute(trait.COMMAND_ARMDISARM, {"arm": False}) From 5beff34069c5dea54cdbafa440a11208ec5e5835 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Jun 2024 13:21:56 -0500 Subject: [PATCH 192/234] Remove myself as codeowner for unifiprotect (#118824) --- CODEOWNERS | 2 -- homeassistant/components/unifiprotect/manifest.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 32f885f6015..97765fd5553 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1486,8 +1486,6 @@ build.json @home-assistant/supervisor /tests/components/unifi/ @Kane610 /homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk -/homeassistant/components/unifiprotect/ @bdraco -/tests/components/unifiprotect/ @bdraco /homeassistant/components/upb/ @gwww /tests/components/upb/ @gwww /homeassistant/components/upc_connect/ @pvizeli @fabaff diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5570d088a7d..a09db1cf01a 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -1,7 +1,7 @@ { "domain": "unifiprotect", "name": "UniFi Protect", - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["http", "repairs"], "dhcp": [ @@ -40,7 +40,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], - "quality_scale": "platinum", "requirements": ["pyunifiprotect==5.1.2", "unifi-discovery==1.1.8"], "ssdp": [ { From f9352dfe8f1a94c8b4b589ed88d4fd24e3529c35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 9 Jun 2024 18:25:39 -0500 Subject: [PATCH 193/234] Switch unifiprotect lib to use uiprotect (#119243) --- homeassistant/components/unifiprotect/__init__.py | 10 +++++----- .../components/unifiprotect/binary_sensor.py | 4 ++-- homeassistant/components/unifiprotect/button.py | 2 +- homeassistant/components/unifiprotect/camera.py | 2 +- homeassistant/components/unifiprotect/config_flow.py | 6 +++--- homeassistant/components/unifiprotect/const.py | 2 +- homeassistant/components/unifiprotect/data.py | 8 ++++---- homeassistant/components/unifiprotect/diagnostics.py | 2 +- homeassistant/components/unifiprotect/entity.py | 2 +- homeassistant/components/unifiprotect/light.py | 2 +- homeassistant/components/unifiprotect/lock.py | 2 +- homeassistant/components/unifiprotect/manifest.json | 4 ++-- .../components/unifiprotect/media_player.py | 4 ++-- .../components/unifiprotect/media_source.py | 12 +++--------- homeassistant/components/unifiprotect/migrate.py | 4 ++-- homeassistant/components/unifiprotect/models.py | 2 +- homeassistant/components/unifiprotect/number.py | 2 +- homeassistant/components/unifiprotect/repairs.py | 6 +++--- homeassistant/components/unifiprotect/select.py | 4 ++-- homeassistant/components/unifiprotect/sensor.py | 2 +- homeassistant/components/unifiprotect/services.py | 6 +++--- homeassistant/components/unifiprotect/switch.py | 2 +- homeassistant/components/unifiprotect/text.py | 2 +- homeassistant/components/unifiprotect/utils.py | 4 ++-- homeassistant/components/unifiprotect/views.py | 4 ++-- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- tests/components/unifiprotect/conftest.py | 4 ++-- tests/components/unifiprotect/test_binary_sensor.py | 4 ++-- tests/components/unifiprotect/test_button.py | 2 +- tests/components/unifiprotect/test_camera.py | 4 ++-- tests/components/unifiprotect/test_config_flow.py | 4 ++-- tests/components/unifiprotect/test_diagnostics.py | 2 +- tests/components/unifiprotect/test_init.py | 4 ++-- tests/components/unifiprotect/test_light.py | 4 ++-- tests/components/unifiprotect/test_lock.py | 2 +- tests/components/unifiprotect/test_media_player.py | 4 ++-- tests/components/unifiprotect/test_media_source.py | 4 ++-- tests/components/unifiprotect/test_migrate.py | 2 +- tests/components/unifiprotect/test_number.py | 2 +- tests/components/unifiprotect/test_recorder.py | 2 +- tests/components/unifiprotect/test_repairs.py | 2 +- tests/components/unifiprotect/test_select.py | 4 ++-- tests/components/unifiprotect/test_sensor.py | 11 ++--------- tests/components/unifiprotect/test_services.py | 6 +++--- tests/components/unifiprotect/test_switch.py | 2 +- tests/components/unifiprotect/test_text.py | 2 +- tests/components/unifiprotect/test_views.py | 4 ++-- tests/components/unifiprotect/utils.py | 8 ++++---- 49 files changed, 91 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d85f91be860..0f41011361d 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -6,14 +6,14 @@ from datetime import timedelta import logging from aiohttp.client_exceptions import ServerDisconnectedError -from pyunifiprotect.data import Bootstrap -from pyunifiprotect.data.types import FirmwareReleaseChannel -from pyunifiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.data import Bootstrap +from uiprotect.data.types import FirmwareReleaseChannel +from uiprotect.exceptions import ClientError, NotAuthorized -# Import the test_util.anonymize module from the pyunifiprotect package +# Import the test_util.anonymize module from the uiprotect package # in __init__ to ensure it gets imported in the executor since the # diagnostics module will not be imported in the executor. -from pyunifiprotect.test_util.anonymize import anonymize_data # noqa: F401 +from uiprotect.test_util.anonymize import anonymize_data # noqa: F401 from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index f779fc7a1ad..b6aaed8f975 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -6,7 +6,7 @@ import dataclasses import logging from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, Light, @@ -16,7 +16,7 @@ from pyunifiprotect.data import ( ProtectModelWithId, Sensor, ) -from pyunifiprotect.data.nvr import UOSDisk +from uiprotect.data.nvr import UOSDisk from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index db27306aedf..0db05a6cdc9 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import logging from typing import Final -from pyunifiprotect.data import ProtectAdoptableDeviceModel, ProtectModelWithId +from uiprotect.data import ProtectAdoptableDeviceModel, ProtectModelWithId from homeassistant.components.button import ( ButtonDeviceClass, diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 8e10c09872b..6b667c1f57e 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -6,7 +6,7 @@ from collections.abc import Generator import logging from typing import Any, cast -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera as UFPCamera, CameraChannel, ModelType, diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 19561a6003d..284b7003485 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Any from aiohttp import CookieJar -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import NVR -from pyunifiprotect.exceptions import ClientError, NotAuthorized +from uiprotect import ProtectApiClient +from uiprotect.data import NVR +from uiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 39be5f0e7cb..f51a58aadc7 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -1,6 +1,6 @@ """Constant definitions for UniFi Protect Integration.""" -from pyunifiprotect.data import ModelType, Version +from uiprotect.data import ModelType, Version from homeassistant.const import Platform diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index b64a08749d5..7b1c73d6dcc 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -8,8 +8,8 @@ from functools import partial import logging from typing import Any, cast -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( NVR, Bootstrap, Camera, @@ -20,8 +20,8 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) -from pyunifiprotect.exceptions import ClientError, NotAuthorized -from pyunifiprotect.utils import log_event +from uiprotect.exceptions import ClientError, NotAuthorized +from uiprotect.utils import log_event from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback diff --git a/homeassistant/components/unifiprotect/diagnostics.py b/homeassistant/components/unifiprotect/diagnostics.py index b85870a08c5..ac651f6138d 100644 --- a/homeassistant/components/unifiprotect/diagnostics.py +++ b/homeassistant/components/unifiprotect/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, cast -from pyunifiprotect.test_util.anonymize import anonymize_data +from uiprotect.test_util.anonymize import anonymize_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 49478ce0582..766c93949bd 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence import logging from typing import TYPE_CHECKING, Any -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, Chime, diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 3ce236b3e23..18e611f2307 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Light, ModelType, ProtectAdoptableDeviceModel, diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index c54f9b316ff..6bb1dd7b4ee 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any, cast -from pyunifiprotect.data import ( +from uiprotect.data import ( Doorlock, LockStatusType, ModelType, diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a09db1cf01a..9cb6ceb7cb9 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -39,8 +39,8 @@ "documentation": "https://www.home-assistant.io/integrations/unifiprotect", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["pyunifiprotect", "unifi_discovery"], - "requirements": ["pyunifiprotect==5.1.2", "unifi-discovery==1.1.8"], + "loggers": ["uiprotect", "unifi_discovery"], + "requirements": ["uiprotect==0.3.9", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 50fec39e9cb..eb17137842b 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -5,14 +5,14 @@ from __future__ import annotations import logging from typing import Any, cast -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, ModelType, ProtectAdoptableDeviceModel, ProtectModelWithId, StateType, ) -from pyunifiprotect.exceptions import StreamError +from uiprotect.exceptions import StreamError from homeassistant.components import media_source from homeassistant.components.media_player import ( diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index 0ff27f562ea..1a67efcfd03 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -7,15 +7,9 @@ from datetime import date, datetime, timedelta from enum import Enum from typing import Any, NoReturn, cast -from pyunifiprotect.data import ( - Camera, - Event, - EventType, - ModelType, - SmartDetectObjectType, -) -from pyunifiprotect.exceptions import NvrError -from pyunifiprotect.utils import from_js_time +from uiprotect.data import Camera, Event, EventType, ModelType, SmartDetectObjectType +from uiprotect.exceptions import NvrError +from uiprotect.utils import from_js_time from yarl import URL from homeassistant.components.camera import CameraImageView diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index cfc8cff7618..a95341f497a 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -6,8 +6,8 @@ from itertools import chain import logging from typing import TypedDict -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import Bootstrap +from uiprotect import ProtectApiClient +from uiprotect.data import Bootstrap from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index a9c79556135..d2ab31d672d 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -8,7 +8,7 @@ from enum import Enum import logging from typing import TYPE_CHECKING, Any, Generic, TypeVar -from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel +from uiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 49c629ac42f..ceb8614e77e 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -7,7 +7,7 @@ from datetime import timedelta import logging from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, Doorlock, Light, diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index baf08c9b5cf..3cc8967ea0d 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -4,9 +4,9 @@ from __future__ import annotations from typing import cast -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import Bootstrap, Camera, ModelType -from pyunifiprotect.data.types import FirmwareReleaseChannel +from uiprotect import ProtectApiClient +from uiprotect.data import Bootstrap, Camera, ModelType +from uiprotect.data.types import FirmwareReleaseChannel import voluptuous as vol from homeassistant import data_entry_flow diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6ba90948fca..f4a9d58e346 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -8,8 +8,8 @@ from enum import Enum import logging from typing import Any, Final -from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect.api import ProtectApiClient +from uiprotect.data import ( Camera, ChimeType, DoorbellMessageType, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 63c9e11c660..00849c095f0 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -7,7 +7,7 @@ from datetime import datetime import logging from typing import Any, cast -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, Light, diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 8c62664f55b..c5c2ffc8bfe 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -7,9 +7,9 @@ import functools from typing import Any, cast from pydantic import ValidationError -from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import Camera, Chime -from pyunifiprotect.exceptions import ClientError +from uiprotect.api import ProtectApiClient +from uiprotect.data import Camera, Chime +from uiprotect.exceptions import ClientError import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index bd7cfa4d2a2..d17b208de12 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import logging from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( NVR, Camera, ProtectAdoptableDeviceModel, diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 584bd511ee5..05e6712fa65 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, DoorbellMessageType, ProtectAdoptableDeviceModel, diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 8199d729943..8a3028bcea7 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -10,8 +10,8 @@ import socket from typing import Any from aiohttp import CookieJar -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( Bootstrap, CameraChannel, Light, diff --git a/homeassistant/components/unifiprotect/views.py b/homeassistant/components/unifiprotect/views.py index 0f9bff63689..b359fd5d948 100644 --- a/homeassistant/components/unifiprotect/views.py +++ b/homeassistant/components/unifiprotect/views.py @@ -9,8 +9,8 @@ from typing import Any from urllib.parse import urlencode from aiohttp import web -from pyunifiprotect.data import Camera, Event -from pyunifiprotect.exceptions import ClientError +from uiprotect.data import Camera, Event +from uiprotect.exceptions import ClientError from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback diff --git a/requirements_all.txt b/requirements_all.txt index 3eb5891d470..93c974c3d37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2357,9 +2357,6 @@ pytrydan==0.6.1 # homeassistant.components.usb pyudev==0.24.1 -# homeassistant.components.unifiprotect -pyunifiprotect==5.1.2 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2781,6 +2778,9 @@ twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifiprotect +uiprotect==0.3.9 + # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9aad243bb7d..b0e043949b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1836,9 +1836,6 @@ pytrydan==0.6.1 # homeassistant.components.usb pyudev==0.24.1 -# homeassistant.components.unifiprotect -pyunifiprotect==5.1.2 - # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2155,6 +2152,9 @@ twitchAPI==4.0.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 +# homeassistant.components.unifiprotect +uiprotect==0.3.9 + # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 5b3f9653d75..9eb1ea312c6 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -13,8 +13,8 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( NVR, Bootstrap, Camera, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 81ed02869b8..dbe8f72b244 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -5,8 +5,8 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor -from pyunifiprotect.data.nvr import EventMetadata +from uiprotect.data import Camera, Event, EventType, Light, MountType, Sensor +from uiprotect.data.nvr import EventMetadata from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.unifiprotect.binary_sensor import ( diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index a38a29b5999..3a283093179 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data.devices import Camera, Chime, Doorlock +from uiprotect.data.devices import Camera, Chime, Doorlock from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index d374f61c2b0..444898fbd85 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera as ProtectCamera, CameraChannel, StateType -from pyunifiprotect.exceptions import NvrError +from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType +from uiprotect.exceptions import NvrError from homeassistant.components.camera import ( CameraEntityFeature, diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 845766809b2..5d02e1cf098 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -7,8 +7,8 @@ import socket from unittest.mock import patch import pytest -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import NVR, Bootstrap, CloudAccount +from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect.data import NVR, Bootstrap, CloudAccount from homeassistant import config_entries from homeassistant.components import dhcp, ssdp diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b13c069b37c..fd882929e96 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -1,6 +1,6 @@ """Test UniFi Protect diagnostics.""" -from pyunifiprotect.data import NVR, Light +from uiprotect.data import NVR, Light from homeassistant.components.unifiprotect.const import CONF_ALLOW_EA from homeassistant.core import HomeAssistant diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 9bb2141631b..3b75afaace8 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient -from pyunifiprotect.data import NVR, Bootstrap, CloudAccount, Light +from uiprotect import NotAuthorized, NvrError, ProtectApiClient +from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 57867a3c7e9..bb0b6992e4e 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -4,8 +4,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Light -from pyunifiprotect.data.types import LEDLevel +from uiprotect.data import Light +from uiprotect.data.types import LEDLevel from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 6785ea2a4f6..62a1cb9ff46 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Doorlock, LockStatusType +from uiprotect.data import Doorlock, LockStatusType from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.const import ( diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 1558d11fbbe..642a3a1e372 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -5,8 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import Camera -from pyunifiprotect.exceptions import StreamError +from uiprotect.data import Camera +from uiprotect.exceptions import StreamError from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_TYPE, diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 7e51031128e..2cdebeafb04 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -5,7 +5,7 @@ from ipaddress import IPv4Address from unittest.mock import AsyncMock, Mock, patch import pytest -from pyunifiprotect.data import ( +from uiprotect.data import ( Bootstrap, Camera, Event, @@ -13,7 +13,7 @@ from pyunifiprotect.data import ( Permission, SmartDetectObjectType, ) -from pyunifiprotect.exceptions import NvrError +from uiprotect.exceptions import NvrError from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.components.media_source import MediaSourceItem diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index a48925d9c67..1fbb650b800 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import patch -from pyunifiprotect.data import Camera +from uiprotect.data import Camera from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.repairs.issue_handler import ( diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 3050992457c..77a409551b1 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -6,7 +6,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Doorlock, IRLEDMode, Light +from uiprotect.data import Camera, Doorlock, IRLEDMode, Light from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.number import ( diff --git a/tests/components/unifiprotect/test_recorder.py b/tests/components/unifiprotect/test_recorder.py index 3e1a8599ea7..94c93413de5 100644 --- a/tests/components/unifiprotect/test_recorder.py +++ b/tests/components/unifiprotect/test_recorder.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import Camera, Event, EventType +from uiprotect.data import Camera, Event, EventType from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index f4be3164fd5..7d76550f7c7 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -6,7 +6,7 @@ from copy import copy, deepcopy from http import HTTPStatus from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera, CloudAccount, ModelType, Version +from uiprotect.data import Camera, CloudAccount, ModelType, Version from homeassistant.components.repairs.issue_handler import ( async_process_repairs_platforms, diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 4ac82f45173..8795af57214 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -5,7 +5,7 @@ from __future__ import annotations from copy import copy from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import ( +from uiprotect.data import ( Camera, DoorbellMessageType, IRLEDMode, @@ -17,7 +17,7 @@ from pyunifiprotect.data import ( RecordingMode, Viewer, ) -from pyunifiprotect.data.nvr import DoorbellMessage +from uiprotect.data.nvr import DoorbellMessage from homeassistant.components.select import ATTR_OPTIONS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index e593f224378..d8014079bf1 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -5,15 +5,8 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import Mock -from pyunifiprotect.data import ( - NVR, - Camera, - Event, - EventType, - Sensor, - SmartDetectObjectType, -) -from pyunifiprotect.data.nvr import EventMetadata, LicensePlateMetadata +from uiprotect.data import NVR, Camera, Event, EventType, Sensor, SmartDetectObjectType +from uiprotect.data.nvr import EventMetadata, LicensePlateMetadata from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.sensor import ( diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 98decab9e4a..919af53ef10 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -5,9 +5,9 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Chime, Color, Light, ModelType -from pyunifiprotect.data.devices import CameraZone -from pyunifiprotect.exceptions import BadRequest +from uiprotect.data import Camera, Chime, Color, Light, ModelType +from uiprotect.data.devices import CameraZone +from uiprotect.exceptions import BadRequest from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN from homeassistant.components.unifiprotect.services import ( diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index e421937632c..16e471c2e7a 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode +from uiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.switch import ( diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index be2ae93203a..3ca11744abb 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import Camera, DoorbellMessageType, LCDMessage +from uiprotect.data import Camera, DoorbellMessageType, LCDMessage from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.text import CAMERA diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index f7930e5ff9a..6d190eb4dd6 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, Mock from aiohttp import ClientResponse import pytest -from pyunifiprotect.data import Camera, Event, EventType -from pyunifiprotect.exceptions import ClientError +from uiprotect.data import Camera, Event, EventType +from uiprotect.exceptions import ClientError from homeassistant.components.unifiprotect.views import ( async_generate_event_video_url, diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 1ade39dafca..ab3aefaa09d 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -8,8 +8,8 @@ from datetime import timedelta from typing import Any from unittest.mock import Mock -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data import ( +from uiprotect import ProtectApiClient +from uiprotect.data import ( Bootstrap, Camera, Event, @@ -18,8 +18,8 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) -from pyunifiprotect.data.bootstrap import ProtectDeviceRef -from pyunifiprotect.test_util.anonymize import random_hex +from uiprotect.data.bootstrap import ProtectDeviceRef +from uiprotect.test_util.anonymize import random_hex from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id From a28f5baeeb6fa03871013bdea17ef501e25d496c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 10 Jun 2024 02:02:38 +0100 Subject: [PATCH 194/234] Fix wrong arg name in Idasen Desk config flow (#119247) --- homeassistant/components/idasen_desk/config_flow.py | 2 +- tests/components/idasen_desk/test_config_flow.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index b7c14089656..782d4988a3c 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -64,7 +64,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN): desk = Desk(None, monitor_height=False) try: - await desk.connect(discovery_info.device, auto_reconnect=False) + await desk.connect(discovery_info.device, retry=False) except AuthFailedError: errors["base"] = "auth_failed" except TimeoutError: diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index a861dc5f5e2..c27cdea58aa 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -305,4 +305,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: } assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 - desk_connect.assert_called_with(ANY, auto_reconnect=False) + desk_connect.assert_called_with(ANY, retry=False) From 38cd84fa5f0c7de9836d21fd5cdfc67b42781c9f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 01:17:29 -0500 Subject: [PATCH 195/234] Fix climate on/off in nexia (#119254) --- homeassistant/components/nexia/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 78c0bc88ef7..7d09f710828 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -388,12 +388,12 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): async def async_turn_off(self) -> None: """Turn off the zone.""" - await self.async_set_hvac_mode(OPERATION_MODE_OFF) + await self.async_set_hvac_mode(HVACMode.OFF) self._signal_zone_update() async def async_turn_on(self) -> None: """Turn on the zone.""" - await self.async_set_hvac_mode(OPERATION_MODE_AUTO) + await self.async_set_hvac_mode(HVACMode.AUTO) self._signal_zone_update() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: From eed126c6d40a3bb791461813384606e494af4334 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 9 Jun 2024 23:35:54 -0700 Subject: [PATCH 196/234] Bump google-nest-sdm to 4.0.5 (#119255) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 5a975bb19ec..d3ba571e65a 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==4.0.4"] + "requirements": ["google-nest-sdm==4.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93c974c3d37..609e64b41c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -977,7 +977,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.6.0 # homeassistant.components.nest -google-nest-sdm==4.0.4 +google-nest-sdm==4.0.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0e043949b2..f1c0293df92 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -803,7 +803,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.6.0 # homeassistant.components.nest -google-nest-sdm==4.0.4 +google-nest-sdm==4.0.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 8d40f4d39fd9ef436927fe2b36c72ae2aac2f9a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 01:36:36 -0500 Subject: [PATCH 197/234] Bump uiprotect to 0.4.0 (#119256) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 9cb6ceb7cb9..ba6319ab0ba 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==0.3.9", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==0.4.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 609e64b41c4..66b40c31df3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.3.9 +uiprotect==0.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1c0293df92..70285bcd234 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.3.9 +uiprotect==0.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 74b49556f9b9f51cb19991c4623d889d6bfbbff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 13:26:52 -0500 Subject: [PATCH 198/234] Improve workday test coverage (#119259) --- .../components/workday/test_binary_sensor.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 9aa4dd6b5b4..e973a9f9c28 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.workday.binary_sensor import SERVICE_CHECK_DATE from homeassistant.components.workday.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import UTC from . import ( @@ -144,18 +145,59 @@ async def test_setup_add_holiday( assert state.state == "off" +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) async def test_setup_no_country_weekend( hass: HomeAssistant, freezer: FrozenDateTimeFactory, + time_zone: str, ) -> None: """Test setup shows weekend as non-workday with no country.""" - freezer.move_to(datetime(2020, 2, 23, 12, tzinfo=UTC)) # Sunday + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2020, 2, 22, 0, 1, 1, tzinfo=zone)) # Saturday await init_integration(hass, TEST_CONFIG_NO_COUNTRY) state = hass.states.get("binary_sensor.workday_sensor") assert state is not None assert state.state == "off" + freezer.move_to(datetime(2020, 2, 24, 23, 59, 59, tzinfo=zone)) # Monday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" + + +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) +async def test_setup_no_country_weekday( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, +) -> None: + """Test setup shows a weekday as a workday with no country.""" + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2020, 2, 21, 23, 59, 59, tzinfo=zone)) # Friday + await init_integration(hass, TEST_CONFIG_NO_COUNTRY) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "on" + + freezer.move_to(datetime(2020, 2, 22, 23, 59, 59, tzinfo=zone)) # Saturday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == "off" + async def test_setup_remove_holiday( hass: HomeAssistant, From 1929e103c0c4d95f4067515c3662141c58c440b6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 10 Jun 2024 14:55:28 +0200 Subject: [PATCH 199/234] Fix persistence on OpenWeatherMap raised repair issue (#119289) --- homeassistant/components/openweathermap/repairs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openweathermap/repairs.py b/homeassistant/components/openweathermap/repairs.py index 0f411a45405..c54484e1e1e 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -73,7 +73,7 @@ def async_create_issue(hass: HomeAssistant, entry_id: str) -> None: domain=DOMAIN, issue_id=_get_issue_id(entry_id), is_fixable=True, - is_persistent=True, + is_persistent=False, severity=ir.IssueSeverity.WARNING, learn_more_url="https://www.home-assistant.io/integrations/openweathermap/", translation_key="deprecated_v25", From 3bc6cf666a1a909a5671935b7756532be7ccca18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 11:58:26 -0500 Subject: [PATCH 200/234] Bump uiprotect to 0.4.1 (#119308) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index ba6319ab0ba..00a96483f70 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==0.4.0", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==0.4.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 66b40c31df3..8e8212560b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.4.0 +uiprotect==0.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70285bcd234..b0a9af3956e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.4.0 +uiprotect==0.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 135735126a1a0e5ba93908864a6f5d375761d040 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 10 Jun 2024 20:09:39 +0200 Subject: [PATCH 201/234] Add more debug logging to Ping integration (#119318) --- homeassistant/components/ping/helpers.py | 31 +++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index f1fd8518d42..7f1696d2ed9 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any from icmplib import NameLookupError, async_ping from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ICMP_TIMEOUT, PING_TIMEOUT @@ -58,9 +59,16 @@ class PingDataICMPLib(PingData): timeout=ICMP_TIMEOUT, privileged=self._privileged, ) - except NameLookupError: + except NameLookupError as err: self.is_alive = False - return + raise UpdateFailed(f"Error resolving host: {self.ip_address}") from err + + _LOGGER.debug( + "async_ping returned: reachable=%s sent=%i received=%s", + data.is_alive, + data.packets_sent, + data.packets_received, + ) self.is_alive = data.is_alive if not self.is_alive: @@ -94,6 +102,10 @@ class PingDataSubProcess(PingData): async def async_ping(self) -> dict[str, Any] | None: """Send ICMP echo request and return details if success.""" + _LOGGER.debug( + "Pinging %s with: `%s`", self.ip_address, " ".join(self._ping_cmd) + ) + pinger = await asyncio.create_subprocess_exec( *self._ping_cmd, stdin=None, @@ -140,20 +152,17 @@ class PingDataSubProcess(PingData): if TYPE_CHECKING: assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - except TimeoutError: - _LOGGER.exception( - "Timed out running command: `%s`, after: %ss", - self._ping_cmd, - self._count + PING_TIMEOUT, - ) + except TimeoutError as err: if pinger: with suppress(TypeError): await pinger.kill() # type: ignore[func-returns-value] del pinger - return None - except AttributeError: - return None + raise UpdateFailed( + f"Timed out running command: `{self._ping_cmd}`, after: {self._count + PING_TIMEOUT}s" + ) from err + except AttributeError as err: + raise UpdateFailed from err return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} async def async_update(self) -> None: From a0ac9fe6c98b6bf5250bdcc4c15de4d5f00c4095 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 10 Jun 2024 20:22:04 +0200 Subject: [PATCH 202/234] Update frontend to 20240610.0 (#119320) --- 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 27322b423d0..d3d19375105 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==20240605.0"] + "requirements": ["home-assistant-frontend==20240610.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b1d82e3c58b..c8c9419339d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240605.0 +home-assistant-frontend==20240610.0 home-assistant-intents==2024.6.5 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8e8212560b2..7f6d59d3a5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.50 # homeassistant.components.frontend -home-assistant-frontend==20240605.0 +home-assistant-frontend==20240610.0 # homeassistant.components.conversation home-assistant-intents==2024.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0a9af3956e..fc4dc71f3aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.50 # homeassistant.components.frontend -home-assistant-frontend==20240605.0 +home-assistant-frontend==20240610.0 # homeassistant.components.conversation home-assistant-intents==2024.6.5 From 6ea18a7b240acb5dd1d86dddb45d0cd1fca2b4e9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 10 Jun 2024 21:44:55 +0200 Subject: [PATCH 203/234] Fix statistic_during_period after core restart (#119323) --- .../components/recorder/statistics.py | 25 +++++++++++++++++-- .../components/recorder/test_websocket_api.py | 18 +++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4fe40e6bac8..0d76cd93724 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1245,7 +1245,7 @@ def _first_statistic( table: type[StatisticsBase], metadata_id: int, ) -> datetime | None: - """Return the data of the oldest statistic row for a given metadata id.""" + """Return the date of the oldest statistic row for a given metadata id.""" stmt = lambda_stmt( lambda: select(table.start_ts) .filter(table.metadata_id == metadata_id) @@ -1257,6 +1257,23 @@ def _first_statistic( return None +def _last_statistic( + session: Session, + table: type[StatisticsBase], + metadata_id: int, +) -> datetime | None: + """Return the date of the newest statistic row for a given metadata id.""" + stmt = lambda_stmt( + lambda: select(table.start_ts) + .filter(table.metadata_id == metadata_id) + .order_by(table.start_ts.desc()) + .limit(1) + ) + if stats := cast(Sequence[Row], execute_stmt_lambda_element(session, stmt)): + return dt_util.utc_from_timestamp(stats[0].start_ts) + return None + + def _get_oldest_sum_statistic( session: Session, head_start_time: datetime | None, @@ -1487,7 +1504,11 @@ def statistic_during_period( tail_start_time: datetime | None = None tail_end_time: datetime | None = None if end_time is None: - tail_start_time = now.replace(minute=0, second=0, microsecond=0) + tail_start_time = _last_statistic(session, Statistics, metadata_id) + if tail_start_time: + tail_start_time += Statistics.duration + else: + tail_start_time = now.replace(minute=0, second=0, microsecond=0) elif tail_only: tail_start_time = start_time tail_end_time = end_time diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 639e0abeefe..b5c0f0bf02b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -7,6 +7,7 @@ import threading from unittest.mock import ANY, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import recorder @@ -794,17 +795,30 @@ async def test_statistic_during_period_hole( } -@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 6, 31, tzinfo=datetime.UTC)) +@pytest.mark.parametrize( + "frozen_time", + [ + # This is the normal case, all statistics runs are available + datetime.datetime(2022, 10, 21, 6, 31, tzinfo=datetime.UTC), + # Statistic only available up until 6:25, this can happen if + # core has been shut down for an hour + datetime.datetime(2022, 10, 21, 7, 31, tzinfo=datetime.UTC), + ], +) async def test_statistic_during_period_partial_overlap( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + frozen_time: datetime, ) -> None: """Test statistic_during_period.""" + client = await hass_ws_client() + + freezer.move_to(frozen_time) now = dt_util.utcnow() await async_recorder_block_till_done(hass) - client = await hass_ws_client() zero = now start = zero.replace(hour=0, minute=0, second=0, microsecond=0) From b656ef4d4f66ffbe25195dcb724a09ea4ca1857d Mon Sep 17 00:00:00 2001 From: swcloudgenie <45437888+swcloudgenie@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:40:24 -0500 Subject: [PATCH 204/234] Fix AladdinConnect OAuth domain (#119336) fix aladdin connect oauth domain --- homeassistant/components/aladdin_connect/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aladdin_connect/const.py b/homeassistant/components/aladdin_connect/const.py index 0fe60724154..a87147c8f09 100644 --- a/homeassistant/components/aladdin_connect/const.py +++ b/homeassistant/components/aladdin_connect/const.py @@ -2,5 +2,5 @@ DOMAIN = "aladdin_connect" -OAUTH2_AUTHORIZE = "https://app.aladdinconnect.com/login.html" +OAUTH2_AUTHORIZE = "https://app.aladdinconnect.net/login.html" OAUTH2_TOKEN = "https://twdvzuefzh.execute-api.us-east-2.amazonaws.com/v1/oauth2/token" From 7ced4e981eb3629caaeffbc59d8122e025b40706 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 11 Jun 2024 09:22:55 +0200 Subject: [PATCH 205/234] Bump `imgw-pib` backend library to version 1.0.5 (#119360) Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index fe714691f13..08946a802f1 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["imgw_pib==1.0.4"] + "requirements": ["imgw_pib==1.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f6d59d3a5e..34c2e2bfa46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ iglo==1.2.7 ihcsdk==2.8.5 # homeassistant.components.imgw_pib -imgw_pib==1.0.4 +imgw_pib==1.0.5 # homeassistant.components.incomfort incomfort-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc4dc71f3aa..5a0dd8f939e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -933,7 +933,7 @@ idasen-ha==2.5.3 ifaddr==0.2.0 # homeassistant.components.imgw_pib -imgw_pib==1.0.4 +imgw_pib==1.0.5 # homeassistant.components.influxdb influxdb-client==1.24.0 From 415bfb40a76ae4aaba2a1f72f19f643e375ab4e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 11 Jun 2024 11:21:51 +0200 Subject: [PATCH 206/234] Bump version to 2024.6.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 86be19b95d8..500a74140f2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index 867bc1d1513..b71f80bbaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.1" +version = "2024.6.2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 74438783335c11f1f4a50681d86758ff7207f08d Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 14 Jun 2024 11:47:41 -0700 Subject: [PATCH 207/234] Make remaining time of timers available to LLMs (#118696) * Include speech_slots in IntentResponse.as_dict * Populate speech_slots only if available * fix typo * Add test * test all fields * Fix another test --------- Co-authored-by: Paulus Schoutsen --- homeassistant/helpers/intent.py | 2 ++ tests/helpers/test_llm.py | 41 +++++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index d7c0f90e2f9..faf16ad572c 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1362,6 +1362,8 @@ class IntentResponse: if self.reprompt: response_dict["reprompt"] = self.reprompt + if self.speech_slots: + response_dict["speech_slots"] = self.speech_slots response_data: dict[str, Any] = {} diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 6ac17a2fe0e..b4a768c4429 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -149,8 +149,13 @@ async def test_assist_api( assert test_context.json_fragment # To reproduce an error case in tracing intent_response = intent.IntentResponse("*") - intent_response.matched_states = [State("light.matched", "on")] - intent_response.unmatched_states = [State("light.unmatched", "on")] + intent_response.async_set_states( + [State("light.matched", "on")], [State("light.unmatched", "on")] + ) + intent_response.async_set_speech("Some speech") + intent_response.async_set_card("Card title", "card content") + intent_response.async_set_speech_slots({"hello": 1}) + intent_response.async_set_reprompt("Do it again") tool_input = llm.ToolInput( tool_name="test_intent", tool_args={"area": "kitchen", "floor": "ground_floor"}, @@ -181,8 +186,22 @@ async def test_assist_api( "success": [], "targets": [], }, + "reprompt": { + "plain": { + "extra_data": None, + "reprompt": "Do it again", + }, + }, "response_type": "action_done", - "speech": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, } # Call with a device/area/floor @@ -227,7 +246,21 @@ async def test_assist_api( "targets": [], }, "response_type": "action_done", - "speech": {}, + "reprompt": { + "plain": { + "extra_data": None, + "reprompt": "Do it again", + }, + }, + "speech": { + "plain": { + "extra_data": None, + "speech": "Some speech", + }, + }, + "speech_slots": { + "hello": 1, + }, } From 5cf0ee936dee4a79b60d085d4b3b344fcf45340a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 20:03:23 -0500 Subject: [PATCH 208/234] Bump uiprotect to 0.10.1 (#119327) Co-authored-by: Jan Bouwhuis --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 00a96483f70..dd04332daa7 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==0.4.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==0.10.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 34c2e2bfa46..46e26dffc6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.4.1 +uiprotect==0.10.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a0dd8f939e..2ce1234545d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.4.1 +uiprotect==0.10.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From fb5de55c3e341885084652326f1dffc148d73856 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 10 Jun 2024 22:50:44 -0500 Subject: [PATCH 209/234] Bump uiprotect to 0.13.0 (#119344) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index dd04332daa7..8bbd3738222 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==0.10.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==0.13.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 46e26dffc6c..727f3561d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.10.1 +uiprotect==0.13.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ce1234545d..5c567a13b6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.10.1 +uiprotect==0.13.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From d602b7d19b9614692a9adccb964943e950464ec0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Jun 2024 13:59:28 -0500 Subject: [PATCH 210/234] Bump uiprotect to 1.0.0 (#119415) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 8bbd3738222..b88eed6f39a 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==0.13.0", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.0.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 727f3561d44..e44b1fb414a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.13.0 +uiprotect==1.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c567a13b6b..7c69d112a46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==0.13.0 +uiprotect==1.0.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 94d79440a02e4635302043172454bdcc420c994b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 11 Jun 2024 13:58:05 -0500 Subject: [PATCH 211/234] Fix incorrect key name in unifiprotect options strings (#119417) --- homeassistant/components/unifiprotect/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index b83d514f836..bac7eaa5bf3 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -55,7 +55,7 @@ "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "override_connection_host": "Override Connection Host", "max_media": "Max number of event to load for Media Browser (increases RAM usage)", - "allow_ea": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" + "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" } } } From 8d547d4599d86ddbca3b9de992ae827990f3c2fc Mon Sep 17 00:00:00 2001 From: MJJ Date: Wed, 12 Jun 2024 00:01:11 +0200 Subject: [PATCH 212/234] Bump buieradar to 1.0.6 (#119433) --- homeassistant/components/buienradar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 4885f45032c..5b08f5c631a 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/buienradar", "iot_class": "cloud_polling", "loggers": ["buienradar", "vincenty"], - "requirements": ["buienradar==1.0.5"] + "requirements": ["buienradar==1.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index e44b1fb414a..25bcda28246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ bthomehub5-devicelist==0.1.1 btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar -buienradar==1.0.5 +buienradar==1.0.6 # homeassistant.components.dhcp cached_ipaddress==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c69d112a46..5b39afa7492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -536,7 +536,7 @@ brunt==1.2.0 bthome-ble==3.8.1 # homeassistant.components.buienradar -buienradar==1.0.5 +buienradar==1.0.6 # homeassistant.components.dhcp cached_ipaddress==0.3.0 From d5e9976b2c5c3a9e9b3b13a91d7e7dd663f72d7f Mon Sep 17 00:00:00 2001 From: Sebastian Goscik Date: Wed, 12 Jun 2024 00:20:00 +0100 Subject: [PATCH 213/234] Bump uiprotect to v1.0.1 (#119436) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b88eed6f39a..5674fcb07a1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.0.0", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.0.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 25bcda28246..90c9d695a4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.0.0 +uiprotect==1.0.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b39afa7492..0c61200950c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.0.0 +uiprotect==1.0.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 4e6e9f35b5e4887be4059d0706cd8487b2e982f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 12 Jun 2024 03:10:40 -0500 Subject: [PATCH 214/234] Bump uiprotect to 1.1.0 (#119449) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5674fcb07a1..5c1d252ce48 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.0.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.1.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 90c9d695a4e..7739abfd9a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.0.1 +uiprotect==1.1.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c61200950c..1d19b501acd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.0.1 +uiprotect==1.1.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From f58882c878c216e2ba109ef5af21ecb9c917893c Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 12 Jun 2024 10:08:41 +0200 Subject: [PATCH 215/234] Add loggers to gardena bluetooth (#119460) --- homeassistant/components/gardena_bluetooth/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 1e3ef156d72..4812def7dde 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,6 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", + "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], "requirements": ["gardena-bluetooth==1.4.2"] } From 4eea448f9df27a89d9afd46310f88b276b0e30dc Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 12 Jun 2024 12:47:47 +0200 Subject: [PATCH 216/234] Revert Use integration fallback configuration for tado water heater fallback (#119466) --- homeassistant/components/tado/climate.py | 26 ++++++--- homeassistant/components/tado/helper.py | 31 ----------- homeassistant/components/tado/water_heater.py | 12 ++--- tests/components/tado/test_helper.py | 54 ------------------- 4 files changed, 25 insertions(+), 98 deletions(-) delete mode 100644 homeassistant/components/tado/helper.py delete mode 100644 tests/components/tado/test_helper.py diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 487bc519a26..6d298a80e79 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -36,6 +36,8 @@ from .const import ( CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_OPTIONS, CONST_OVERLAY_TIMER, DATA, @@ -65,7 +67,6 @@ from .const import ( TYPE_HEATING, ) from .entity import TadoZoneEntity -from .helper import decide_overlay_mode _LOGGER = logging.getLogger(__name__) @@ -597,12 +598,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._tado.reset_zone_overlay(self.zone_id) return - overlay_mode = decide_overlay_mode( - tado=self._tado, - duration=duration, - overlay_mode=overlay_mode, - zone_id=self.zone_id, - ) + # If user gave duration then overlay mode needs to be timer + if duration: + overlay_mode = CONST_OVERLAY_TIMER + # If no duration or timer set to fallback setting + if overlay_mode is None: + overlay_mode = ( + self._tado.fallback + if self._tado.fallback is not None + else CONST_OVERLAY_TADO_MODE + ) + # If default is Tado default then look it up + if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: + overlay_mode = ( + self._tado_zone_data.default_overlay_termination_type + if self._tado_zone_data.default_overlay_termination_type is not None + else CONST_OVERLAY_TADO_MODE + ) # If we ended up with a timer but no duration, set a default duration if overlay_mode == CONST_OVERLAY_TIMER and duration is None: duration = ( diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py deleted file mode 100644 index fee23aef64a..00000000000 --- a/homeassistant/components/tado/helper.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Helper methods for Tado.""" - -from . import TadoConnector -from .const import ( - CONST_OVERLAY_TADO_DEFAULT, - CONST_OVERLAY_TADO_MODE, - CONST_OVERLAY_TIMER, -) - - -def decide_overlay_mode( - tado: TadoConnector, - duration: int | None, - zone_id: int, - overlay_mode: str | None = None, -) -> str: - """Return correct overlay mode based on the action and defaults.""" - # If user gave duration then overlay mode needs to be timer - if duration: - return CONST_OVERLAY_TIMER - # If no duration or timer set to fallback setting - if overlay_mode is None: - overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE - # If default is Tado default then look it up - if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: - overlay_mode = ( - tado.data["zone"][zone_id].default_overlay_termination_type - or CONST_OVERLAY_TADO_MODE - ) - - return overlay_mode diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 9b449dd43cc..f1257f097eb 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -32,7 +32,6 @@ from .const import ( TYPE_HOT_WATER, ) from .entity import TadoZoneEntity -from .helper import decide_overlay_mode _LOGGER = logging.getLogger(__name__) @@ -278,11 +277,12 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) return - overlay_mode = decide_overlay_mode( - tado=self._tado, - duration=duration, - zone_id=self.zone_id, - ) + overlay_mode = CONST_OVERLAY_MANUAL + if duration: + overlay_mode = CONST_OVERLAY_TIMER + elif self._tado.fallback: + # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled + overlay_mode = CONST_OVERLAY_TADO_MODE _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py deleted file mode 100644 index ff85dfce944..00000000000 --- a/tests/components/tado/test_helper.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Helper method tests.""" - -from unittest.mock import patch - -from homeassistant.components.tado import TadoConnector -from homeassistant.components.tado.const import ( - CONST_OVERLAY_MANUAL, - CONST_OVERLAY_TADO_DEFAULT, - CONST_OVERLAY_TADO_MODE, - CONST_OVERLAY_TIMER, -) -from homeassistant.components.tado.helper import decide_overlay_mode -from homeassistant.core import HomeAssistant - - -def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: - """Return dummy tado connector.""" - return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) - - -async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: - """Test overlay method selection when duration is set.""" - tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) - overlay_mode = decide_overlay_mode(tado=tado, duration="01:00:00", zone_id=1) - # Must select TIMER overlay - assert overlay_mode == CONST_OVERLAY_TIMER - - -async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: - """Test overlay method selection when duration is not set.""" - integration_fallback = CONST_OVERLAY_TADO_MODE - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) - # Must fallback to integration wide setting - assert overlay_mode == integration_fallback - - -async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: - """Test overlay method selection when tado default is selected.""" - integration_fallback = CONST_OVERLAY_TADO_DEFAULT - zone_fallback = CONST_OVERLAY_MANUAL - tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) - - class MockZoneData: - def __init__(self) -> None: - self.default_overlay_termination_type = zone_fallback - - zone_id = 1 - - zone_data = {"zone": {zone_id: MockZoneData()}} - with patch.dict(tado.data, zone_data): - overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) - # Must fallback to zone setting - assert overlay_mode == zone_fallback From 7b809a8e55d6eccb9f06e29df8ccf3c2d426211d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 12 Jun 2024 18:08:44 +0200 Subject: [PATCH 217/234] Partially revert "Add more debug logging to Ping integration" (#119487) --- homeassistant/components/ping/helpers.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py index 7f1696d2ed9..82ebf4532da 100644 --- a/homeassistant/components/ping/helpers.py +++ b/homeassistant/components/ping/helpers.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any from icmplib import NameLookupError, async_ping from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ICMP_TIMEOUT, PING_TIMEOUT @@ -59,9 +58,10 @@ class PingDataICMPLib(PingData): timeout=ICMP_TIMEOUT, privileged=self._privileged, ) - except NameLookupError as err: + except NameLookupError: + _LOGGER.debug("Error resolving host: %s", self.ip_address) self.is_alive = False - raise UpdateFailed(f"Error resolving host: {self.ip_address}") from err + return _LOGGER.debug( "async_ping returned: reachable=%s sent=%i received=%s", @@ -152,17 +152,22 @@ class PingDataSubProcess(PingData): if TYPE_CHECKING: assert match is not None rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - except TimeoutError as err: + except TimeoutError: + _LOGGER.debug( + "Timed out running command: `%s`, after: %s", + " ".join(self._ping_cmd), + self._count + PING_TIMEOUT, + ) + if pinger: with suppress(TypeError): await pinger.kill() # type: ignore[func-returns-value] del pinger - raise UpdateFailed( - f"Timed out running command: `{self._ping_cmd}`, after: {self._count + PING_TIMEOUT}s" - ) from err + return None except AttributeError as err: - raise UpdateFailed from err + _LOGGER.debug("Error matching ping output: %s", err) + return None return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} async def async_update(self) -> None: From 4c1d2e7ac86719fe525d3be98054a5b2f161ba08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ethem=20Cem=20=C3=96zkan?= Date: Sat, 15 Jun 2024 18:16:10 +0200 Subject: [PATCH 218/234] Revert "Revert Use integration fallback configuration for tado water fallback" (#119526) * Revert "Revert Use integration fallback configuration for tado water heater fallback (#119466)" This reverts commit ade936e6d5088c4a4d809111417fb3c7080825d5. * add decide method for duration * add repair issue to let users know * test module for repairs * Update strings.json Co-authored-by: Franck Nijhof * repair issue should not be persistent * use issue_registery fixture instead of mocking * fix comment * parameterize repair issue created test case --------- Co-authored-by: Franck Nijhof --- homeassistant/components/tado/climate.py | 41 +++------ homeassistant/components/tado/const.py | 2 + homeassistant/components/tado/helper.py | 51 +++++++++++ homeassistant/components/tado/repairs.py | 34 ++++++++ homeassistant/components/tado/strings.json | 4 + homeassistant/components/tado/water_heater.py | 26 ++++-- tests/components/tado/test_helper.py | 87 +++++++++++++++++++ tests/components/tado/test_repairs.py | 64 ++++++++++++++ 8 files changed, 274 insertions(+), 35 deletions(-) create mode 100644 homeassistant/components/tado/helper.py create mode 100644 homeassistant/components/tado/repairs.py create mode 100644 tests/components/tado/test_helper.py create mode 100644 tests/components/tado/test_repairs.py diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 6d298a80e79..3cb5d7fbce9 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -36,10 +36,7 @@ from .const import ( CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, - CONST_OVERLAY_TADO_DEFAULT, - CONST_OVERLAY_TADO_MODE, CONST_OVERLAY_TADO_OPTIONS, - CONST_OVERLAY_TIMER, DATA, DOMAIN, HA_TERMINATION_DURATION, @@ -67,6 +64,7 @@ from .const import ( TYPE_HEATING, ) from .entity import TadoZoneEntity +from .helper import decide_duration, decide_overlay_mode _LOGGER = logging.getLogger(__name__) @@ -598,31 +596,18 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._tado.reset_zone_overlay(self.zone_id) return - # If user gave duration then overlay mode needs to be timer - if duration: - overlay_mode = CONST_OVERLAY_TIMER - # If no duration or timer set to fallback setting - if overlay_mode is None: - overlay_mode = ( - self._tado.fallback - if self._tado.fallback is not None - else CONST_OVERLAY_TADO_MODE - ) - # If default is Tado default then look it up - if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: - overlay_mode = ( - self._tado_zone_data.default_overlay_termination_type - if self._tado_zone_data.default_overlay_termination_type is not None - else CONST_OVERLAY_TADO_MODE - ) - # If we ended up with a timer but no duration, set a default duration - if overlay_mode == CONST_OVERLAY_TIMER and duration is None: - duration = ( - int(self._tado_zone_data.default_overlay_termination_duration) - if self._tado_zone_data.default_overlay_termination_duration is not None - else 3600 - ) - + overlay_mode = decide_overlay_mode( + tado=self._tado, + duration=duration, + overlay_mode=overlay_mode, + zone_id=self.zone_id, + ) + duration = decide_duration( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + ) _LOGGER.debug( ( "Switching to %s for zone %s (%d) with temperature %s °C and duration" diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index c62352a6d95..be35bbb8e25 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -212,3 +212,5 @@ SERVICE_ADD_METER_READING = "add_meter_reading" CONF_CONFIG_ENTRY = "config_entry" CONF_READING = "reading" ATTR_MESSAGE = "message" + +WATER_HEATER_FALLBACK_REPAIR = "water_heater_fallback" diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py new file mode 100644 index 00000000000..efcd3e7c4ea --- /dev/null +++ b/homeassistant/components/tado/helper.py @@ -0,0 +1,51 @@ +"""Helper methods for Tado.""" + +from . import TadoConnector +from .const import ( + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, +) + + +def decide_overlay_mode( + tado: TadoConnector, + duration: int | None, + zone_id: int, + overlay_mode: str | None = None, +) -> str: + """Return correct overlay mode based on the action and defaults.""" + # If user gave duration then overlay mode needs to be timer + if duration: + return CONST_OVERLAY_TIMER + # If no duration or timer set to fallback setting + if overlay_mode is None: + overlay_mode = tado.fallback or CONST_OVERLAY_TADO_MODE + # If default is Tado default then look it up + if overlay_mode == CONST_OVERLAY_TADO_DEFAULT: + overlay_mode = ( + tado.data["zone"][zone_id].default_overlay_termination_type + or CONST_OVERLAY_TADO_MODE + ) + + return overlay_mode + + +def decide_duration( + tado: TadoConnector, + duration: int | None, + zone_id: int, + overlay_mode: str | None = None, +) -> None | int: + """Return correct duration based on the selected overlay mode/duration and tado config.""" + # If we ended up with a timer but no duration, set a default duration + # If we ended up with a timer but no duration, set a default duration + if overlay_mode == CONST_OVERLAY_TIMER and duration is None: + duration = ( + int(tado.data["zone"][zone_id].default_overlay_termination_duration) + if tado.data["zone"][zone_id].default_overlay_termination_duration + is not None + else 3600 + ) + + return duration diff --git a/homeassistant/components/tado/repairs.py b/homeassistant/components/tado/repairs.py new file mode 100644 index 00000000000..5ffc3c76bf7 --- /dev/null +++ b/homeassistant/components/tado/repairs.py @@ -0,0 +1,34 @@ +"""Repair implementations.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + DOMAIN, + WATER_HEATER_FALLBACK_REPAIR, +) + + +def manage_water_heater_fallback_issue( + hass: HomeAssistant, + water_heater_entities: list, + integration_overlay_fallback: str | None, +) -> None: + """Notify users about water heater respecting fallback setting.""" + if ( + integration_overlay_fallback + in [CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_MANUAL] + and len(water_heater_entities) > 0 + ): + for water_heater_entity in water_heater_entities: + ir.async_create_issue( + hass=hass, + domain=DOMAIN, + issue_id=f"{WATER_HEATER_FALLBACK_REPAIR}_{water_heater_entity.zone_name}", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key=WATER_HEATER_FALLBACK_REPAIR, + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 51e36fe5355..d992befe112 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -165,6 +165,10 @@ "import_failed_invalid_auth": { "title": "Failed to import, invalid credentials", "description": "Failed to import the configuration for the Tado Device Tracker, due to invalid credentials. Please fix the YAML configuration and restart Home Assistant. Alternatively you can use the UI to configure Tado. Don't forget to delete the YAML configuration, once the import is successful." + }, + "water_heater_fallback": { + "title": "Tado Water Heater entities now support fallback options", + "description": "Due to added support for water heaters entities, these entities may use different overlay. Please configure integration entity and tado app water heater zone overlay options." } } } diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index f1257f097eb..a31b70a8f9a 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -32,6 +32,8 @@ from .const import ( TYPE_HOT_WATER, ) from .entity import TadoZoneEntity +from .helper import decide_duration, decide_overlay_mode +from .repairs import manage_water_heater_fallback_issue _LOGGER = logging.getLogger(__name__) @@ -79,6 +81,12 @@ async def async_setup_entry( async_add_entities(entities, True) + manage_water_heater_fallback_issue( + hass=hass, + water_heater_entities=entities, + integration_overlay_fallback=tado.fallback, + ) + def _generate_entities(tado: TadoConnector) -> list[WaterHeaterEntity]: """Create all water heater entities.""" @@ -277,13 +285,17 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) return - overlay_mode = CONST_OVERLAY_MANUAL - if duration: - overlay_mode = CONST_OVERLAY_TIMER - elif self._tado.fallback: - # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled - overlay_mode = CONST_OVERLAY_TADO_MODE - + overlay_mode = decide_overlay_mode( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + ) + duration = decide_duration( + tado=self._tado, + duration=duration, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + ) _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", self._current_tado_hvac_mode, diff --git a/tests/components/tado/test_helper.py b/tests/components/tado/test_helper.py new file mode 100644 index 00000000000..bdd7977f858 --- /dev/null +++ b/tests/components/tado/test_helper.py @@ -0,0 +1,87 @@ +"""Helper method tests.""" + +from unittest.mock import patch + +from homeassistant.components.tado import TadoConnector +from homeassistant.components.tado.const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, +) +from homeassistant.components.tado.helper import decide_duration, decide_overlay_mode +from homeassistant.core import HomeAssistant + + +def dummy_tado_connector(hass: HomeAssistant, fallback) -> TadoConnector: + """Return dummy tado connector.""" + return TadoConnector(hass, username="dummy", password="dummy", fallback=fallback) + + +async def test_overlay_mode_duration_set(hass: HomeAssistant) -> None: + """Test overlay method selection when duration is set.""" + tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_TADO_MODE) + overlay_mode = decide_overlay_mode(tado=tado, duration=3600, zone_id=1) + # Must select TIMER overlay + assert overlay_mode == CONST_OVERLAY_TIMER + + +async def test_overlay_mode_next_time_block_fallback(hass: HomeAssistant) -> None: + """Test overlay method selection when duration is not set.""" + integration_fallback = CONST_OVERLAY_TADO_MODE + tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=1) + # Must fallback to integration wide setting + assert overlay_mode == integration_fallback + + +async def test_overlay_mode_tado_default_fallback(hass: HomeAssistant) -> None: + """Test overlay method selection when tado default is selected.""" + integration_fallback = CONST_OVERLAY_TADO_DEFAULT + zone_fallback = CONST_OVERLAY_MANUAL + tado = dummy_tado_connector(hass=hass, fallback=integration_fallback) + + class MockZoneData: + def __init__(self) -> None: + self.default_overlay_termination_type = zone_fallback + + zone_id = 1 + + zone_data = {"zone": {zone_id: MockZoneData()}} + with patch.dict(tado.data, zone_data): + overlay_mode = decide_overlay_mode(tado=tado, duration=None, zone_id=zone_id) + # Must fallback to zone setting + assert overlay_mode == zone_fallback + + +async def test_duration_enabled_without_tado_default(hass: HomeAssistant) -> None: + """Test duration decide method when overlay is timer and duration is set.""" + overlay = CONST_OVERLAY_TIMER + expected_duration = 600 + tado = dummy_tado_connector(hass=hass, fallback=CONST_OVERLAY_MANUAL) + duration = decide_duration( + tado=tado, duration=expected_duration, overlay_mode=overlay, zone_id=0 + ) + # Should return the same duration value + assert duration == expected_duration + + +async def test_duration_enabled_with_tado_default(hass: HomeAssistant) -> None: + """Test overlay method selection when ended up with timer overlay and None duration.""" + zone_fallback = CONST_OVERLAY_TIMER + expected_duration = 45000 + tado = dummy_tado_connector(hass=hass, fallback=zone_fallback) + + class MockZoneData: + def __init__(self) -> None: + self.default_overlay_termination_duration = expected_duration + + zone_id = 1 + + zone_data = {"zone": {zone_id: MockZoneData()}} + with patch.dict(tado.data, zone_data): + duration = decide_duration( + tado=tado, duration=None, zone_id=zone_id, overlay_mode=zone_fallback + ) + # Must fallback to zone timer setting + assert duration == expected_duration diff --git a/tests/components/tado/test_repairs.py b/tests/components/tado/test_repairs.py new file mode 100644 index 00000000000..2e055884272 --- /dev/null +++ b/tests/components/tado/test_repairs.py @@ -0,0 +1,64 @@ +"""Repair tests.""" + +import pytest + +from homeassistant.components.tado.const import ( + CONST_OVERLAY_MANUAL, + CONST_OVERLAY_TADO_DEFAULT, + CONST_OVERLAY_TADO_MODE, + DOMAIN, + WATER_HEATER_FALLBACK_REPAIR, +) +from homeassistant.components.tado.repairs import manage_water_heater_fallback_issue +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + + +class MockWaterHeater: + """Mock Water heater entity.""" + + def __init__(self, zone_name) -> None: + """Init mock entity class.""" + self.zone_name = zone_name + + +async def test_manage_water_heater_fallback_issue_not_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test water heater fallback issue is not needed.""" + zone_name = "Hot Water" + expected_issue_id = f"{WATER_HEATER_FALLBACK_REPAIR}_{zone_name}" + water_heater_entities = [MockWaterHeater(zone_name)] + manage_water_heater_fallback_issue( + water_heater_entities=water_heater_entities, + integration_overlay_fallback=CONST_OVERLAY_TADO_MODE, + hass=hass, + ) + assert ( + issue_registry.async_get_issue(issue_id=expected_issue_id, domain=DOMAIN) + is None + ) + + +@pytest.mark.parametrize( + "integration_overlay_fallback", [CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_MANUAL] +) +async def test_manage_water_heater_fallback_issue_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + integration_overlay_fallback: str, +) -> None: + """Test water heater fallback issue created cases.""" + zone_name = "Hot Water" + expected_issue_id = f"{WATER_HEATER_FALLBACK_REPAIR}_{zone_name}" + water_heater_entities = [MockWaterHeater(zone_name)] + manage_water_heater_fallback_issue( + water_heater_entities=water_heater_entities, + integration_overlay_fallback=integration_overlay_fallback, + hass=hass, + ) + assert ( + issue_registry.async_get_issue(issue_id=expected_issue_id, domain=DOMAIN) + is not None + ) From 78c2dc708c7104098933a77f3a11ddf6c4522e65 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 13 Jun 2024 09:30:53 +0200 Subject: [PATCH 219/234] Fix error for Reolink snapshot streams (#119572) --- homeassistant/components/reolink/camera.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a2c396e7ef5..4adac1a96d8 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -116,7 +116,6 @@ async def async_setup_entry( class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): """An implementation of a Reolink IP camera.""" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM entity_description: ReolinkCameraEntityDescription def __init__( @@ -130,6 +129,9 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): ReolinkChannelCoordinatorEntity.__init__(self, reolink_data, channel) Camera.__init__(self) + if "snapshots" not in entity_description.stream: + self._attr_supported_features = CameraEntityFeature.STREAM + if self._host.api.model in DUAL_LENS_MODELS: self._attr_translation_key = ( f"{entity_description.translation_key}_lens_{self._channel}" From 4e394597bd254fd77d19823796a8210f383d8795 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jun 2024 12:28:42 -0500 Subject: [PATCH 220/234] Bump uiprotect to 1.2.1 (#119620) * Bump uiprotect to 1.2.0 changelog: https://github.com/uilibs/uiprotect/compare/v1.1.0...v1.2.0 * bump --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5c1d252ce48..f7b3a4bde70 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.1.0", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.2.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 7739abfd9a1..babf4526388 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.1.0 +uiprotect==1.2.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d19b501acd..917b611c4e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.1.0 +uiprotect==1.2.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 78e13d138f84ce6ec69a5f672f0be1a713fe88aa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 14 Jun 2024 07:46:24 +0200 Subject: [PATCH 221/234] Fix group enabled platforms are preloaded if they have alternative states (#119621) --- homeassistant/components/group/manifest.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/group/manifest.json b/homeassistant/components/group/manifest.json index d86fc4ba622..a2045f370b1 100644 --- a/homeassistant/components/group/manifest.json +++ b/homeassistant/components/group/manifest.json @@ -4,7 +4,10 @@ "after_dependencies": [ "alarm_control_panel", "climate", + "cover", "device_tracker", + "lock", + "media_player", "person", "plant", "vacuum", From c77ed921dee13cd1f9ea0b1f2ec142792a258975 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 13 Jun 2024 21:34:58 +0200 Subject: [PATCH 222/234] Update frontend to 20240610.1 (#119634) --- 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 d3d19375105..1b17601a2f6 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==20240610.0"] + "requirements": ["home-assistant-frontend==20240610.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c8c9419339d..94f030c6104 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==3.1.1 hass-nabucasa==0.81.1 hassil==1.7.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240610.0 +home-assistant-frontend==20240610.1 home-assistant-intents==2024.6.5 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index babf4526388..7a7ca7ebffc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1087,7 +1087,7 @@ hole==0.8.0 holidays==0.50 # homeassistant.components.frontend -home-assistant-frontend==20240610.0 +home-assistant-frontend==20240610.1 # homeassistant.components.conversation home-assistant-intents==2024.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 917b611c4e2..a6bffadc391 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -889,7 +889,7 @@ hole==0.8.0 holidays==0.50 # homeassistant.components.frontend -home-assistant-frontend==20240610.0 +home-assistant-frontend==20240610.1 # homeassistant.components.conversation home-assistant-intents==2024.6.5 From 2b44cf898e9af6db1767bf3ce354f063621172cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jun 2024 16:31:39 -0500 Subject: [PATCH 223/234] Soften unifiprotect EA channel message (#119641) --- homeassistant/components/unifiprotect/__init__.py | 6 +++++- homeassistant/components/unifiprotect/strings.json | 2 +- tests/components/unifiprotect/test_repairs.py | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 0f41011361d..00d6adf461c 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -54,6 +54,10 @@ SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +EARLY_ACCESS_URL = ( + "https://www.home-assistant.io/integrations/unifiprotect#software-support" +) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" @@ -123,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "ea_channel_warning", is_fixable=True, is_persistent=True, - learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + learn_more_url=EARLY_ACCESS_URL, severity=IssueSeverity.WARNING, translation_key="ea_channel_warning", translation_placeholders={"version": str(nvr_info.version)}, diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index bac7eaa5bf3..54023a1768f 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -67,7 +67,7 @@ "step": { "start": { "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel. [Home Assistant does not support Early Access versions](https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access), so you should immediately switch to the Official Release Channel. Accidentally upgrading to an Early Access version can break your UniFi Protect integration.\n\nBy submitting this form, you have switched back to the Official Release Channel or agree to run an unsupported version of UniFi Protect, which may break your Home Assistant integration at any time." + "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the Official Release Channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." }, "confirm": { "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 7d76550f7c7..6b54f464b26 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -61,7 +61,7 @@ async def test_ea_warning_ignore( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "start" @@ -73,7 +73,7 @@ async def test_ea_warning_ignore( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "confirm" @@ -123,7 +123,7 @@ async def test_ea_warning_fix( flow_id = data["flow_id"] assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", + "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", "version": str(version), } assert data["step_id"] == "start" From dfe25ff804258b170751d361c95e861e7ceb04d8 Mon Sep 17 00:00:00 2001 From: mletenay Date: Fri, 14 Jun 2024 08:29:32 +0200 Subject: [PATCH 224/234] Bump goodwe to 0.3.6 (#119646) --- 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 8506d1fd6af..41e0ed91f6a 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.3.5"] + "requirements": ["goodwe==0.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a7ca7ebffc..6bac672f752 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -961,7 +961,7 @@ glances-api==0.8.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.5 +goodwe==0.3.6 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6bffadc391..10ab07e87c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -790,7 +790,7 @@ glances-api==0.8.0 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.5 +goodwe==0.3.6 # homeassistant.components.google_mail # homeassistant.components.google_tasks From ace7da2328879d6d3d3114a25b2eeb4214f024ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jun 2024 21:01:25 -0500 Subject: [PATCH 225/234] Bump uiprotect to 1.4.1 (#119653) --- homeassistant/components/unifiprotect/manifest.json | 2 +- homeassistant/components/unifiprotect/utils.py | 6 ++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index f7b3a4bde70..57589c44f85 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.2.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.4.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 8a3028bcea7..cf917d894ac 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -89,10 +89,8 @@ def async_get_devices_by_type( bootstrap: Bootstrap, device_type: ModelType ) -> dict[str, ProtectAdoptableDeviceModel]: """Get devices by type.""" - - devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - bootstrap, f"{device_type.value}s" - ) + devices: dict[str, ProtectAdoptableDeviceModel] + devices = getattr(bootstrap, device_type.devices_key) return devices diff --git a/requirements_all.txt b/requirements_all.txt index 6bac672f752..392517785cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.2.1 +uiprotect==1.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10ab07e87c1..eb294c1d870 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.2.1 +uiprotect==1.4.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 204e9a79c5d875c079ada11baebd969cd3abe69e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Jun 2024 23:34:55 -0500 Subject: [PATCH 226/234] Bump uiprotect to 1.6.0 (#119661) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 57589c44f85..181f87b4469 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.4.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.6.0", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 392517785cc..6ed96bfcd24 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.4.1 +uiprotect==1.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb294c1d870..df58740a646 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.4.1 +uiprotect==1.6.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 2d4176d581c3a60bf62e24a0cd12f1ae9afedec2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 14 Jun 2024 20:45:27 +0200 Subject: [PATCH 227/234] Fix alarm default code in concord232 (#119691) --- homeassistant/components/concord232/alarm_control_panel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 2799481ccaa..0256f5aab37 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -86,6 +86,7 @@ class Concord232Alarm(AlarmControlPanelEntity): self._attr_name = name self._code = code + self._alarm_control_panel_option_default_code = code self._mode = mode self._url = url self._alarm = concord232_client.Client(self._url) From d7d7782a69e2a5b67c327c47929e5d7f62f19009 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Jun 2024 10:56:26 -0500 Subject: [PATCH 228/234] Bump uiprotect to 1.7.1 (#119694) changelog: https://github.com/uilibs/uiprotect/compare/v1.6.0...v1.7.0 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 181f87b4469..4a9822811ef 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.6.0", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.7.1", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 6ed96bfcd24..caa63055553 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.6.0 +uiprotect==1.7.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df58740a646..9e1e64d3393 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.6.0 +uiprotect==1.7.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 5ceb8537ebf0b0b2bdcab1110288ada4e4925dda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Jun 2024 13:43:40 -0500 Subject: [PATCH 229/234] Bump uiprotect to 1.7.2 (#119705) changelog: https://github.com/uilibs/uiprotect/compare/v1.7.1...v1.7.2 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 4a9822811ef..ce512ca3f3c 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==1.7.1", "unifi-discovery==1.1.8"], + "requirements": ["uiprotect==1.7.2", "unifi-discovery==1.1.8"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index caa63055553..648986c6f21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2779,7 +2779,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.7.1 +uiprotect==1.7.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e1e64d3393..a924f5b5d27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2153,7 +2153,7 @@ twitchAPI==4.0.0 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==1.7.1 +uiprotect==1.7.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From dc0fc318b813795900e798053adfaecb0548a735 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 14 Jun 2024 22:33:38 +0200 Subject: [PATCH 230/234] Bump ZHA dependencies (#119713) * Bump bellows to 0.39.1 * Bump zigpy to 0.64.1 --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 8caf296674c..12e427334e2 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,11 +21,11 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.39.0", + "bellows==0.39.1", "pyserial==3.5", "zha-quirks==0.0.116", "zigpy-deconz==0.23.1", - "zigpy==0.64.0", + "zigpy==0.64.1", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/requirements_all.txt b/requirements_all.txt index 648986c6f21..289a4eead5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -547,7 +547,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.39.0 +bellows==0.39.1 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.3 @@ -2981,7 +2981,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.64.0 +zigpy==0.64.1 # homeassistant.components.zoneminder zm-py==0.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a924f5b5d27..6bf487f7ef9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.39.0 +bellows==0.39.1 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.3 @@ -2322,7 +2322,7 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.64.0 +zigpy==0.64.1 # homeassistant.components.zwave_js zwave-js-server-python==0.56.0 From 3a705fd66852b641806fedf92156d6fb71808023 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 15 Jun 2024 14:05:18 +0200 Subject: [PATCH 231/234] Ensure UniFi Protect EA warning is not persistent (#119730) --- homeassistant/components/unifiprotect/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 00d6adf461c..05ae7936fb3 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -126,7 +126,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, "ea_channel_warning", is_fixable=True, - is_persistent=True, + is_persistent=False, learn_more_url=EARLY_ACCESS_URL, severity=IssueSeverity.WARNING, translation_key="ea_channel_warning", From a4a831537666d40423b558c73594aca9ce39f1d2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 15 Jun 2024 14:04:42 +0200 Subject: [PATCH 232/234] Ensure workday issues are not persistent (#119732) --- homeassistant/components/workday/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index f25cf41b992..60a0489ec5c 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -35,7 +35,7 @@ async def _async_validate_country_and_province( DOMAIN, "bad_country", is_fixable=True, - is_persistent=True, + is_persistent=False, severity=IssueSeverity.ERROR, translation_key="bad_country", translation_placeholders={"title": entry.title}, @@ -59,7 +59,7 @@ async def _async_validate_country_and_province( DOMAIN, "bad_province", is_fixable=True, - is_persistent=True, + is_persistent=False, severity=IssueSeverity.ERROR, translation_key="bad_province", translation_placeholders={ From 89ce8478de78315008dbc634edf4004e84f2a1c2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 15 Jun 2024 18:23:39 +0200 Subject: [PATCH 233/234] Bump version to 2024.6.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 500a74140f2..cd340cd5079 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 6 -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, 12, 0) diff --git a/pyproject.toml b/pyproject.toml index b71f80bbaf8..1ca2b5cb40e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.6.2" +version = "2024.6.3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From eba429dc54a76c3712991169394c911192f4f2b6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 10 Jun 2024 08:08:52 +0200 Subject: [PATCH 234/234] Temporary pin CI to Python 3.12.3 (#119261) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 4 ++-- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index b05397280c2..aeb05b1d112 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -10,7 +10,7 @@ on: env: BUILD_TYPE: core - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.12.3" PIP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6cb8f8deec4..5a582586c89 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,8 +37,8 @@ env: UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 8 HA_SHORT_VERSION: "2024.6" - DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12']" + DEFAULT_PYTHON: "3.12.3" + ALL_PYTHON_VERSIONS: "['3.12.3']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f487292e79a..92c4c845e7d 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -10,7 +10,7 @@ on: - "**strings.json" env: - DEFAULT_PYTHON: "3.11" + DEFAULT_PYTHON: "3.12.3" jobs: upload: diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fc169619325..13f5177bd7e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -17,7 +17,7 @@ on: - "script/gen_requirements_all.py" env: - DEFAULT_PYTHON: "3.12" + DEFAULT_PYTHON: "3.12.3" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}}