From c98acd42db3b2f2ebe63f3e735d025de457abb6e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 30 Oct 2024 17:34:45 +0100 Subject: [PATCH 001/511] Bump version to 2024.11.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 76185b829ca..adddbff36d4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 ad0bb5fca49..3d498eabb57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0.dev0" +version = "2024.11.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4dc2433e8b73b765900881111b6b6132b27d6c06 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 31 Oct 2024 12:18:10 +0100 Subject: [PATCH 002/511] Revert "Add musicassistant integration (#128919)" (#129565) This reverts commit 568bdef61fff80ea7115841acf60c019d16e4b92. --- .strict-typing | 1 - CODEOWNERS | 2 - .../components/music_assistant/__init__.py | 164 ------ .../components/music_assistant/config_flow.py | 137 ----- .../components/music_assistant/const.py | 18 - .../components/music_assistant/entity.py | 86 --- .../components/music_assistant/manifest.json | 13 - .../music_assistant/media_player.py | 557 ------------------ .../components/music_assistant/strings.json | 51 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - homeassistant/generated/zeroconf.py | 5 - mypy.ini | 10 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/music_assistant/__init__.py | 1 - tests/components/music_assistant/conftest.py | 35 -- .../fixtures/server_info_message.json | 9 - .../music_assistant/test_config_flow.py | 217 ------- 19 files changed, 1319 deletions(-) delete mode 100644 homeassistant/components/music_assistant/__init__.py delete mode 100644 homeassistant/components/music_assistant/config_flow.py delete mode 100644 homeassistant/components/music_assistant/const.py delete mode 100644 homeassistant/components/music_assistant/entity.py delete mode 100644 homeassistant/components/music_assistant/manifest.json delete mode 100644 homeassistant/components/music_assistant/media_player.py delete mode 100644 homeassistant/components/music_assistant/strings.json delete mode 100644 tests/components/music_assistant/__init__.py delete mode 100644 tests/components/music_assistant/conftest.py delete mode 100644 tests/components/music_assistant/fixtures/server_info_message.json delete mode 100644 tests/components/music_assistant/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 6a6918543ad..4bfacaa64f4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -324,7 +324,6 @@ homeassistant.components.moon.* homeassistant.components.mopeka.* homeassistant.components.motionmount.* homeassistant.components.mqtt.* -homeassistant.components.music_assistant.* homeassistant.components.my.* homeassistant.components.mysensors.* homeassistant.components.myuplink.* diff --git a/CODEOWNERS b/CODEOWNERS index 99cfefa81c6..5cda5610f6c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -954,8 +954,6 @@ build.json @home-assistant/supervisor /homeassistant/components/msteams/ @peroyvind /homeassistant/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys -/homeassistant/components/music_assistant/ @music-assistant -/tests/components/music_assistant/ @music-assistant /homeassistant/components/mutesync/ @currentoor /tests/components/mutesync/ @currentoor /homeassistant/components/my/ @home-assistant/core diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py deleted file mode 100644 index 9f0fc1aad27..00000000000 --- a/homeassistant/components/music_assistant/__init__.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Music Assistant (music-assistant.io) integration.""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from music_assistant_client import MusicAssistantClient -from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion -from music_assistant_models.enums import EventType -from music_assistant_models.errors import MusicAssistantError - -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) - -from .const import DOMAIN, LOGGER - -if TYPE_CHECKING: - from music_assistant_models.event import MassEvent - -type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] - -PLATFORMS = [Platform.MEDIA_PLAYER] - -CONNECT_TIMEOUT = 10 -LISTEN_READY_TIMEOUT = 30 - - -@dataclass -class MusicAssistantEntryData: - """Hold Mass data for the config entry.""" - - mass: MusicAssistantClient - listen_task: asyncio.Task - - -async def async_setup_entry( - hass: HomeAssistant, entry: MusicAssistantConfigEntry -) -> bool: - """Set up from a config entry.""" - http_session = async_get_clientsession(hass, verify_ssl=False) - mass_url = entry.data[CONF_URL] - mass = MusicAssistantClient(mass_url, http_session) - - try: - async with asyncio.timeout(CONNECT_TIMEOUT): - await mass.connect() - except (TimeoutError, CannotConnect) as err: - raise ConfigEntryNotReady( - f"Failed to connect to music assistant server {mass_url}" - ) from err - except InvalidServerVersion as err: - async_create_issue( - hass, - DOMAIN, - "invalid_server_version", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="invalid_server_version", - ) - raise ConfigEntryNotReady(f"Invalid server version: {err}") from err - except MusicAssistantError as err: - LOGGER.exception("Failed to connect to music assistant server", exc_info=err) - raise ConfigEntryNotReady( - f"Unknown error connecting to the Music Assistant server {mass_url}" - ) from err - - async_delete_issue(hass, DOMAIN, "invalid_server_version") - - async def on_hass_stop(event: Event) -> None: - """Handle incoming stop event from Home Assistant.""" - await mass.disconnect() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) - ) - - # launch the music assistant client listen task in the background - # use the init_ready event to wait until initialization is done - init_ready = asyncio.Event() - listen_task = asyncio.create_task(_client_listen(hass, entry, mass, init_ready)) - - try: - async with asyncio.timeout(LISTEN_READY_TIMEOUT): - await init_ready.wait() - except TimeoutError as err: - listen_task.cancel() - raise ConfigEntryNotReady("Music Assistant client not ready") from err - - entry.runtime_data = MusicAssistantEntryData(mass, listen_task) - - # If the listen task is already failed, we need to raise ConfigEntryNotReady - if listen_task.done() and (listen_error := listen_task.exception()) is not None: - await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - try: - await mass.disconnect() - finally: - raise ConfigEntryNotReady(listen_error) from listen_error - - # initialize platforms - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # register listener for removed players - async def handle_player_removed(event: MassEvent) -> None: - """Handle Mass Player Removed event.""" - if event.object_id is None: - return - dev_reg = dr.async_get(hass) - if hass_device := dev_reg.async_get_device({(DOMAIN, event.object_id)}): - dev_reg.async_update_device( - hass_device.id, remove_config_entry_id=entry.entry_id - ) - - entry.async_on_unload( - mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED) - ) - - return True - - -async def _client_listen( - hass: HomeAssistant, - entry: ConfigEntry, - mass: MusicAssistantClient, - init_ready: asyncio.Event, -) -> None: - """Listen with the client.""" - try: - await mass.start_listening(init_ready) - except MusicAssistantError as err: - if entry.state != ConfigEntryState.LOADED: - raise - LOGGER.error("Failed to listen: %s", err) - except Exception as err: # pylint: disable=broad-except - # We need to guard against unknown exceptions to not crash this task. - if entry.state != ConfigEntryState.LOADED: - raise - LOGGER.exception("Unexpected exception: %s", err) - - if not hass.is_stopping: - LOGGER.debug("Disconnected from server. Reloading integration") - hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - mass_entry_data: MusicAssistantEntryData = entry.runtime_data - mass_entry_data.listen_task.cancel() - await mass_entry_data.mass.disconnect() - - return unload_ok diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py deleted file mode 100644 index fc50a2d654b..00000000000 --- a/homeassistant/components/music_assistant/config_flow.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Config flow for MusicAssistant integration.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from music_assistant_client import MusicAssistantClient -from music_assistant_client.exceptions import ( - CannotConnect, - InvalidServerVersion, - MusicAssistantClientException, -) -from music_assistant_models.api import ServerInfoMessage -import voluptuous as vol - -from homeassistant.components import zeroconf -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_URL -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client - -from .const import DOMAIN, LOGGER - -DEFAULT_URL = "http://mass.local:8095" -DEFAULT_TITLE = "Music Assistant" - - -def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: - """Return a schema for the manual step.""" - default_url = user_input.get(CONF_URL, DEFAULT_URL) - return vol.Schema( - { - vol.Required(CONF_URL, default=default_url): str, - } - ) - - -async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage: - """Validate the user input allows us to connect.""" - async with MusicAssistantClient( - url, aiohttp_client.async_get_clientsession(hass) - ) as client: - if TYPE_CHECKING: - assert client.server_info is not None - return client.server_info - - -class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for MusicAssistant.""" - - VERSION = 1 - - def __init__(self) -> None: - """Set up flow instance.""" - self.server_info: ServerInfoMessage | None = None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a manual configuration.""" - errors: dict[str, str] = {} - if user_input is not None: - try: - self.server_info = await get_server_info( - self.hass, user_input[CONF_URL] - ) - await self.async_set_unique_id( - self.server_info.server_id, raise_on_progress=False - ) - self._abort_if_unique_id_configured( - updates={CONF_URL: self.server_info.base_url}, - reload_on_update=True, - ) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidServerVersion: - errors["base"] = "invalid_server_version" - except MusicAssistantClientException: - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry( - title=DEFAULT_TITLE, - data={ - CONF_URL: self.server_info.base_url, - }, - ) - - return self.async_show_form( - step_id="user", data_schema=get_manual_schema(user_input), errors=errors - ) - - return self.async_show_form(step_id="user", data_schema=get_manual_schema({})) - - async def async_step_zeroconf( - self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> ConfigFlowResult: - """Handle a discovered Mass server. - - This flow is triggered by the Zeroconf component. It will check if the - host is already configured and delegate to the import step if not. - """ - # abort if discovery info is not what we expect - if "server_id" not in discovery_info.properties: - return self.async_abort(reason="missing_server_id") - # abort if we already have exactly this server_id - # reload the integration if the host got updated - self.server_info = ServerInfoMessage.from_dict(discovery_info.properties) - await self.async_set_unique_id(self.server_info.server_id) - self._abort_if_unique_id_configured( - updates={CONF_URL: self.server_info.base_url}, - reload_on_update=True, - ) - try: - await get_server_info(self.hass, self.server_info.base_url) - except CannotConnect: - return self.async_abort(reason="cannot_connect") - return await self.async_step_discovery_confirm() - - async def async_step_discovery_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle user-confirmation of discovered server.""" - if TYPE_CHECKING: - assert self.server_info is not None - if user_input is not None: - return self.async_create_entry( - title=DEFAULT_TITLE, - data={ - CONF_URL: self.server_info.base_url, - }, - ) - self._set_confirm_only() - return self.async_show_form( - step_id="discovery_confirm", - description_placeholders={"url": self.server_info.base_url}, - ) diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py deleted file mode 100644 index 6512f58b96c..00000000000 --- a/homeassistant/components/music_assistant/const.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Constants for Music Assistant Component.""" - -import logging - -DOMAIN = "music_assistant" -DOMAIN_EVENT = f"{DOMAIN}_event" - -DEFAULT_NAME = "Music Assistant" - -ATTR_IS_GROUP = "is_group" -ATTR_GROUP_MEMBERS = "group_members" -ATTR_GROUP_PARENTS = "group_parents" - -ATTR_MASS_PLAYER_TYPE = "mass_player_type" -ATTR_ACTIVE_QUEUE = "active_queue" -ATTR_STREAM_TITLE = "stream_title" - -LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py deleted file mode 100644 index f5b6d92b0cf..00000000000 --- a/homeassistant/components/music_assistant/entity.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Base entity model.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.enums import EventType -from music_assistant_models.event import MassEvent -from music_assistant_models.player import Player - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity - -from .const import DOMAIN - -if TYPE_CHECKING: - from music_assistant_client import MusicAssistantClient - - -class MusicAssistantEntity(Entity): - """Base Entity from Music Assistant Player.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: - """Initialize MediaPlayer entity.""" - self.mass = mass - self.player_id = player_id - provider = self.mass.get_provider(self.player.provider) - if TYPE_CHECKING: - assert provider is not None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, player_id)}, - manufacturer=self.player.device_info.manufacturer or provider.name, - model=self.player.device_info.model or self.player.name, - name=self.player.display_name, - configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}", - ) - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await self.async_on_update() - self.async_on_remove( - self.mass.subscribe( - self.__on_mass_update, EventType.PLAYER_UPDATED, self.player_id - ) - ) - self.async_on_remove( - self.mass.subscribe( - self.__on_mass_update, - EventType.QUEUE_UPDATED, - ) - ) - - @property - def player(self) -> Player: - """Return the Mass Player attached to this HA entity.""" - return self.mass.players[self.player_id] - - @property - def unique_id(self) -> str | None: - """Return unique id for entity.""" - _base = self.player_id - if hasattr(self, "entity_description"): - return f"{_base}_{self.entity_description.key}" - return _base - - @property - def available(self) -> bool: - """Return availability of entity.""" - return self.player.available and bool(self.mass.connection.connected) - - async def __on_mass_update(self, event: MassEvent) -> None: - """Call when we receive an event from MusicAssistant.""" - if event.event == EventType.QUEUE_UPDATED and event.object_id not in ( - self.player.active_source, - self.player.active_group, - self.player.player_id, - ): - return - await self.async_on_update() - self.async_write_ha_state() - - async def async_on_update(self) -> None: - """Handle player updates.""" diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json deleted file mode 100644 index c3e05d7a55f..00000000000 --- a/homeassistant/components/music_assistant/manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "domain": "music_assistant", - "name": "Music Assistant", - "after_dependencies": ["media_source", "media_player"], - "codeowners": ["@music-assistant"], - "config_flow": true, - "documentation": "https://music-assistant.io", - "iot_class": "local_push", - "issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues", - "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.0.3"], - "zeroconf": ["_mass._tcp.local."] -} diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py deleted file mode 100644 index f0f3675ee32..00000000000 --- a/homeassistant/components/music_assistant/media_player.py +++ /dev/null @@ -1,557 +0,0 @@ -"""MediaPlayer platform for Music Assistant integration.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Coroutine, Mapping -from contextlib import suppress -import functools -import os -from typing import TYPE_CHECKING, Any - -from music_assistant_models.enums import ( - EventType, - MediaType, - PlayerFeature, - QueueOption, - RepeatMode as MassRepeatMode, -) -from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError -from music_assistant_models.event import MassEvent -from music_assistant_models.media_items import ItemMapping, MediaItemType, Track - -from homeassistant.components import media_source -from homeassistant.components.media_player import ( - ATTR_MEDIA_EXTRA, - BrowseMedia, - MediaPlayerDeviceClass, - MediaPlayerEnqueue, - MediaPlayerEntity, - MediaPlayerEntityFeature, - MediaPlayerState, - MediaType as HAMediaType, - RepeatMode, - async_process_play_media_url, -) -from homeassistant.const import STATE_OFF -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utc_from_timestamp - -from . import MusicAssistantConfigEntry -from .const import ATTR_ACTIVE_QUEUE, ATTR_MASS_PLAYER_TYPE, DOMAIN -from .entity import MusicAssistantEntity - -if TYPE_CHECKING: - from music_assistant_client import MusicAssistantClient - from music_assistant_models.player import Player - from music_assistant_models.player_queue import PlayerQueue - -SUPPORTED_FEATURES = ( - MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.STOP - | MediaPlayerEntityFeature.PREVIOUS_TRACK - | MediaPlayerEntityFeature.NEXT_TRACK - | MediaPlayerEntityFeature.SHUFFLE_SET - | MediaPlayerEntityFeature.REPEAT_SET - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF - | MediaPlayerEntityFeature.PLAY - | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP - | MediaPlayerEntityFeature.CLEAR_PLAYLIST - | MediaPlayerEntityFeature.BROWSE_MEDIA - | MediaPlayerEntityFeature.MEDIA_ENQUEUE - | MediaPlayerEntityFeature.MEDIA_ANNOUNCE - | MediaPlayerEntityFeature.SEEK -) - -QUEUE_OPTION_MAP = { - # map from HA enqueue options to MA enqueue options - # which are the same but just in case - MediaPlayerEnqueue.ADD: QueueOption.ADD, - MediaPlayerEnqueue.NEXT: QueueOption.NEXT, - MediaPlayerEnqueue.PLAY: QueueOption.PLAY, - MediaPlayerEnqueue.REPLACE: QueueOption.REPLACE, -} - -ATTR_RADIO_MODE = "radio_mode" -ATTR_MEDIA_ID = "media_id" -ATTR_MEDIA_TYPE = "media_type" -ATTR_ARTIST = "artist" -ATTR_ALBUM = "album" -ATTR_URL = "url" -ATTR_USE_PRE_ANNOUNCE = "use_pre_announce" -ATTR_ANNOUNCE_VOLUME = "announce_volume" -ATTR_SOURCE_PLAYER = "source_player" -ATTR_AUTO_PLAY = "auto_play" - - -def catch_musicassistant_error[_R, **P]( - func: Callable[..., Awaitable[_R]], -) -> Callable[..., Coroutine[Any, Any, _R | None]]: - """Check and log commands to players.""" - - @functools.wraps(func) - async def wrapper( - self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R | None: - """Catch Music Assistant errors and convert to Home Assistant error.""" - try: - return await func(self, *args, **kwargs) - except MusicAssistantError as err: - error_msg = str(err) or err.__class__.__name__ - raise HomeAssistantError(error_msg) from err - - return wrapper - - -async def async_setup_entry( - hass: HomeAssistant, - entry: MusicAssistantConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up Music Assistant MediaPlayer(s) from Config Entry.""" - mass = entry.runtime_data.mass - added_ids = set() - - async def handle_player_added(event: MassEvent) -> None: - """Handle Mass Player Added event.""" - if TYPE_CHECKING: - assert event.object_id is not None - if event.object_id in added_ids: - return - added_ids.add(event.object_id) - async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) - - # register listener for new players - entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) - mass_players = [] - # add all current players - for player in mass.players: - added_ids.add(player.player_id) - mass_players.append(MusicAssistantPlayer(mass, player.player_id)) - - async_add_entities(mass_players) - - -class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): - """Representation of MediaPlayerEntity from Music Assistant Player.""" - - _attr_name = None - _attr_media_image_remotely_accessible = True - _attr_media_content_type = HAMediaType.MUSIC - - def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: - """Initialize MediaPlayer entity.""" - super().__init__(mass, player_id) - self._attr_icon = self.player.icon.replace("mdi-", "mdi:") - self._attr_supported_features = SUPPORTED_FEATURES - if PlayerFeature.SYNC in self.player.supported_features: - self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING - self._attr_device_class = MediaPlayerDeviceClass.SPEAKER - self._prev_time: float = 0 - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - # we subscribe to player queue time update but we only - # accept a state change on big time jumps (e.g. seeking) - async def queue_time_updated(event: MassEvent) -> None: - if event.object_id != self.player.active_source: - return - if abs((self._prev_time or 0) - event.data) > 5: - await self.async_on_update() - self.async_write_ha_state() - self._prev_time = event.data - - self.async_on_remove( - self.mass.subscribe( - queue_time_updated, - EventType.QUEUE_TIME_UPDATED, - ) - ) - - @property - def active_queue(self) -> PlayerQueue | None: - """Return the active queue for this player (if any).""" - if not self.player.active_source: - return None - return self.mass.player_queues.get(self.player.active_source) - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return additional state attributes.""" - return { - ATTR_MASS_PLAYER_TYPE: self.player.type.value, - ATTR_ACTIVE_QUEUE: ( - self.active_queue.queue_id if self.active_queue else None - ), - } - - async def async_on_update(self) -> None: - """Handle player updates.""" - if not self.available: - return - player = self.player - active_queue = self.active_queue - # update generic attributes - if player.powered and active_queue is not None: - self._attr_state = MediaPlayerState(active_queue.state.value) - if player.powered and player.state is not None: - self._attr_state = MediaPlayerState(player.state.value) - else: - self._attr_state = MediaPlayerState(STATE_OFF) - group_members_entity_ids: list[str] = [] - if player.group_childs: - # translate MA group_childs to HA group_members as entity id's - entity_registry = er.async_get(self.hass) - group_members_entity_ids = [ - entity_id - for child_id in player.group_childs - if ( - entity_id := entity_registry.async_get_entity_id( - self.platform.domain, DOMAIN, child_id - ) - ) - ] - self._attr_group_members = group_members_entity_ids - self._attr_volume_level = ( - player.volume_level / 100 if player.volume_level is not None else None - ) - self._attr_is_volume_muted = player.volume_muted - self._update_media_attributes(player, active_queue) - self._update_media_image_url(player, active_queue) - - @catch_musicassistant_error - async def async_media_play(self) -> None: - """Send play command to device.""" - await self.mass.players.player_command_play(self.player_id) - - @catch_musicassistant_error - async def async_media_pause(self) -> None: - """Send pause command to device.""" - await self.mass.players.player_command_pause(self.player_id) - - @catch_musicassistant_error - async def async_media_stop(self) -> None: - """Send stop command to device.""" - await self.mass.players.player_command_stop(self.player_id) - - @catch_musicassistant_error - async def async_media_next_track(self) -> None: - """Send next track command to device.""" - await self.mass.players.player_command_next_track(self.player_id) - - @catch_musicassistant_error - async def async_media_previous_track(self) -> None: - """Send previous track command to device.""" - await self.mass.players.player_command_previous_track(self.player_id) - - @catch_musicassistant_error - async def async_media_seek(self, position: float) -> None: - """Send seek command.""" - position = int(position) - await self.mass.players.player_command_seek(self.player_id, position) - - @catch_musicassistant_error - async def async_mute_volume(self, mute: bool) -> None: - """Mute the volume.""" - await self.mass.players.player_command_volume_mute(self.player_id, mute) - - @catch_musicassistant_error - async def async_set_volume_level(self, volume: float) -> None: - """Send new volume_level to device.""" - volume = int(volume * 100) - await self.mass.players.player_command_volume_set(self.player_id, volume) - - @catch_musicassistant_error - async def async_volume_up(self) -> None: - """Send new volume_level to device.""" - await self.mass.players.player_command_volume_up(self.player_id) - - @catch_musicassistant_error - async def async_volume_down(self) -> None: - """Send new volume_level to device.""" - await self.mass.players.player_command_volume_down(self.player_id) - - @catch_musicassistant_error - async def async_turn_on(self) -> None: - """Turn on device.""" - await self.mass.players.player_command_power(self.player_id, True) - - @catch_musicassistant_error - async def async_turn_off(self) -> None: - """Turn off device.""" - await self.mass.players.player_command_power(self.player_id, False) - - @catch_musicassistant_error - async def async_set_shuffle(self, shuffle: bool) -> None: - """Set shuffle state.""" - if not self.active_queue: - return - await self.mass.player_queues.queue_command_shuffle( - self.active_queue.queue_id, shuffle - ) - - @catch_musicassistant_error - async def async_set_repeat(self, repeat: RepeatMode) -> None: - """Set repeat state.""" - if not self.active_queue: - return - await self.mass.player_queues.queue_command_repeat( - self.active_queue.queue_id, MassRepeatMode(repeat) - ) - - @catch_musicassistant_error - async def async_clear_playlist(self) -> None: - """Clear players playlist.""" - if TYPE_CHECKING: - assert self.player.active_source is not None - if queue := self.mass.player_queues.get(self.player.active_source): - await self.mass.player_queues.queue_command_clear(queue.queue_id) - - @catch_musicassistant_error - async def async_play_media( - self, - media_type: MediaType | str, - media_id: str, - enqueue: MediaPlayerEnqueue | None = None, - announce: bool | None = None, - **kwargs: Any, - ) -> None: - """Send the play_media command to the media player.""" - if media_source.is_media_source_id(media_id): - # Handle media_source - sourced_media = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - media_id = sourced_media.url - media_id = async_process_play_media_url(self.hass, media_id) - - if announce: - await self._async_handle_play_announcement( - media_id, - use_pre_announce=kwargs[ATTR_MEDIA_EXTRA].get("use_pre_announce"), - announce_volume=kwargs[ATTR_MEDIA_EXTRA].get("announce_volume"), - ) - return - - # forward to our advanced play_media handler - await self._async_handle_play_media( - media_id=[media_id], - enqueue=enqueue, - media_type=media_type, - radio_mode=kwargs[ATTR_MEDIA_EXTRA].get(ATTR_RADIO_MODE), - ) - - @catch_musicassistant_error - async def async_join_players(self, group_members: list[str]) -> None: - """Join `group_members` as a player group with the current player.""" - player_ids: list[str] = [] - for child_entity_id in group_members: - # resolve HA entity_id to MA player_id - if (hass_state := self.hass.states.get(child_entity_id)) is None: - continue - if (mass_player_id := hass_state.attributes.get("mass_player_id")) is None: - continue - player_ids.append(mass_player_id) - await self.mass.players.player_command_sync_many(self.player_id, player_ids) - - @catch_musicassistant_error - async def async_unjoin_player(self) -> None: - """Remove this player from any group.""" - await self.mass.players.player_command_unsync(self.player_id) - - @catch_musicassistant_error - async def _async_handle_play_media( - self, - media_id: list[str], - enqueue: MediaPlayerEnqueue | QueueOption | None = None, - radio_mode: bool | None = None, - media_type: str | None = None, - ) -> None: - """Send the play_media command to the media player.""" - media_uris: list[str] = [] - item: MediaItemType | ItemMapping | None = None - # work out (all) uri(s) to play - for media_id_str in media_id: - # URL or URI string - if "://" in media_id_str: - media_uris.append(media_id_str) - continue - # try content id as library id - if media_type and media_id_str.isnumeric(): - with suppress(MediaNotFoundError): - item = await self.mass.music.get_item( - MediaType(media_type), media_id_str, "library" - ) - if isinstance(item, MediaItemType | ItemMapping) and item.uri: - media_uris.append(item.uri) - continue - # try local accessible filename - elif await asyncio.to_thread(os.path.isfile, media_id_str): - media_uris.append(media_id_str) - continue - - if not media_uris: - raise HomeAssistantError( - f"Could not resolve {media_id} to playable media item" - ) - - # determine active queue to send the play request to - if TYPE_CHECKING: - assert self.player.active_source is not None - if queue := self.mass.player_queues.get(self.player.active_source): - queue_id = queue.queue_id - else: - queue_id = self.player_id - - await self.mass.player_queues.play_media( - queue_id, - media=media_uris, - option=self._convert_queueoption_to_media_player_enqueue(enqueue), - radio_mode=radio_mode if radio_mode else False, - ) - - @catch_musicassistant_error - async def _async_handle_play_announcement( - self, - url: str, - use_pre_announce: bool | None = None, - announce_volume: int | None = None, - ) -> None: - """Send the play_announcement command to the media player.""" - await self.mass.players.play_announcement( - self.player_id, url, use_pre_announce, announce_volume - ) - - async def async_browse_media( - self, - media_content_type: MediaType | str | None = None, - media_content_id: str | None = None, - ) -> BrowseMedia: - """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda item: item.media_content_type.startswith("audio/"), - ) - - def _update_media_image_url( - self, player: Player, queue: PlayerQueue | None - ) -> None: - """Update image URL for the active queue item.""" - if queue is None or queue.current_item is None: - self._attr_media_image_url = None - return - if image_url := self.mass.get_media_item_image_url(queue.current_item): - self._attr_media_image_remotely_accessible = ( - self.mass.server_url not in image_url - ) - self._attr_media_image_url = image_url - return - self._attr_media_image_url = None - - def _update_media_attributes( - self, player: Player, queue: PlayerQueue | None - ) -> None: - """Update media attributes for the active queue item.""" - # pylint: disable=too-many-statements - self._attr_media_artist = None - self._attr_media_album_artist = None - self._attr_media_album_name = None - self._attr_media_title = None - self._attr_media_content_id = None - self._attr_media_duration = None - self._attr_media_position = None - self._attr_media_position_updated_at = None - - if queue is None and player.current_media: - # player has some external source active - self._attr_media_content_id = player.current_media.uri - self._attr_app_id = player.active_source - self._attr_media_title = player.current_media.title - self._attr_media_artist = player.current_media.artist - self._attr_media_album_name = player.current_media.album - self._attr_media_duration = player.current_media.duration - # shuffle and repeat are not (yet) supported for external sources - self._attr_shuffle = None - self._attr_repeat = None - if TYPE_CHECKING: - assert player.elapsed_time is not None - self._attr_media_position = int(player.elapsed_time) - self._attr_media_position_updated_at = ( - utc_from_timestamp(player.elapsed_time_last_updated) - if player.elapsed_time_last_updated - else None - ) - if TYPE_CHECKING: - assert player.elapsed_time is not None - self._prev_time = player.elapsed_time - return - - if queue is None: - # player has no MA queue active - self._attr_source = player.active_source - self._attr_app_id = player.active_source - return - - # player has an MA queue active (either its own queue or some group queue) - self._attr_app_id = DOMAIN - self._attr_shuffle = queue.shuffle_enabled - self._attr_repeat = queue.repeat_mode.value - if not (cur_item := queue.current_item): - # queue is empty - return - - self._attr_media_content_id = queue.current_item.uri - self._attr_media_duration = queue.current_item.duration - self._attr_media_position = int(queue.elapsed_time) - self._attr_media_position_updated_at = utc_from_timestamp( - queue.elapsed_time_last_updated - ) - self._prev_time = queue.elapsed_time - - # handle stream title (radio station icy metadata) - if (stream_details := cur_item.streamdetails) and stream_details.stream_title: - self._attr_media_album_name = cur_item.name - if " - " in stream_details.stream_title: - stream_title_parts = stream_details.stream_title.split(" - ", 1) - self._attr_media_title = stream_title_parts[1] - self._attr_media_artist = stream_title_parts[0] - else: - self._attr_media_title = stream_details.stream_title - return - - if not (media_item := cur_item.media_item): - # queue is not playing a regular media item (edge case?!) - self._attr_media_title = cur_item.name - return - - # queue is playing regular media item - self._attr_media_title = media_item.name - # for tracks we can extract more info - if media_item.media_type == MediaType.TRACK: - if TYPE_CHECKING: - assert isinstance(media_item, Track) - self._attr_media_artist = media_item.artist_str - if media_item.version: - self._attr_media_title += f" ({media_item.version})" - if media_item.album: - self._attr_media_album_name = media_item.album.name - self._attr_media_album_artist = getattr( - media_item.album, "artist_str", None - ) - - def _convert_queueoption_to_media_player_enqueue( - self, queue_option: MediaPlayerEnqueue | QueueOption | None - ) -> QueueOption | None: - """Convert a QueueOption to a MediaPlayerEnqueue.""" - if isinstance(queue_option, MediaPlayerEnqueue): - queue_option = QUEUE_OPTION_MAP.get(queue_option) - return queue_option diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json deleted file mode 100644 index f15b0b1b306..00000000000 --- a/homeassistant/components/music_assistant/strings.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "config": { - "step": { - "init": { - "data": { - "url": "URL of the Music Assistant server" - } - }, - "manual": { - "title": "Manually add Music Assistant Server", - "description": "Enter the URL to your already running Music Assistant Server. If you do not have the Music Assistant Server running, you should install it first.", - "data": { - "url": "URL of the Music Assistant server" - } - }, - "discovery_confirm": { - "description": "Do you want to add the Music Assistant Server `{url}` to Home Assistant?", - "title": "Discovered Music Assistant Server" - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_server_version": "The Music Assistant server is not the correct version", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "Configuration flow is already in progress", - "reconfiguration_successful": "Successfully reconfigured the Music Assistant integration.", - "cannot_connect": "Failed to connect", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" - } - }, - "issues": { - "invalid_server_version": { - "title": "The Music Assistant server is not the correct version", - "description": "Check if there are updates available for the Music Assistant Server and/or integration." - } - }, - "selector": { - "enqueue": { - "options": { - "play": "Play", - "next": "Play next", - "add": "Add to queue", - "replace": "Play now and clear queue", - "replace_next": "Play next and clear queue" - } - } - } -} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 98140955552..e80238c47a4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -383,7 +383,6 @@ FLOWS = { "mpd", "mqtt", "mullvad", - "music_assistant", "mutesync", "mysensors", "mystrom", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7d8383c90cd..6e0ab856b57 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3944,12 +3944,6 @@ "iot_class": "cloud_polling", "single_config_entry": true }, - "music_assistant": { - "name": "Music Assistant", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, "mutesync": { "name": "mutesync", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1fbd6337fdb..eb3c1b3a105 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -639,11 +639,6 @@ ZEROCONF = { }, }, ], - "_mass._tcp.local.": [ - { - "domain": "music_assistant", - }, - ], "_matter._tcp.local.": [ { "domain": "matter", diff --git a/mypy.ini b/mypy.ini index 1b988777594..794579eb48f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2995,16 +2995,6 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.music_assistant.*] -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.my.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4be98eea735..329b227d01a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1405,9 +1405,6 @@ mozart-api==4.1.1.116.0 # homeassistant.components.mullvad mullvad-api==1.0.0 -# homeassistant.components.music_assistant -music-assistant-client==1.0.3 - # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7596dd5e23b..052b5307bcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1174,9 +1174,6 @@ mozart-api==4.1.1.116.0 # homeassistant.components.mullvad mullvad-api==1.0.0 -# homeassistant.components.music_assistant -music-assistant-client==1.0.3 - # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/__init__.py b/tests/components/music_assistant/__init__.py deleted file mode 100644 index 6893b862e2d..00000000000 --- a/tests/components/music_assistant/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The tests for the Music Assistant component.""" diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py deleted file mode 100644 index b03a56ab4a6..00000000000 --- a/tests/components/music_assistant/conftest.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Music Assistant test fixtures.""" - -from collections.abc import Generator -from unittest.mock import patch - -from music_assistant_models.api import ServerInfoMessage -import pytest - -from homeassistant.components.music_assistant.config_flow import CONF_URL -from homeassistant.components.music_assistant.const import DOMAIN - -from tests.common import AsyncMock, MockConfigEntry, load_fixture - - -@pytest.fixture -def mock_get_server_info() -> Generator[AsyncMock]: - """Mock the function to get server info.""" - with patch( - "homeassistant.components.music_assistant.config_flow.get_server_info" - ) as mock_get_server_info: - mock_get_server_info.return_value = ServerInfoMessage.from_json( - load_fixture("server_info_message.json", DOMAIN) - ) - yield mock_get_server_info - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Mock a config entry.""" - return MockConfigEntry( - domain=DOMAIN, - title="Music Assistant", - data={CONF_URL: "http://localhost:8095"}, - unique_id="1234", - ) diff --git a/tests/components/music_assistant/fixtures/server_info_message.json b/tests/components/music_assistant/fixtures/server_info_message.json deleted file mode 100644 index 907ec8af820..00000000000 --- a/tests/components/music_assistant/fixtures/server_info_message.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "server_id": "1234", - "server_version": "0.0.0", - "schema_version": 23, - "min_supported_schema_version": 23, - "base_url": "http://localhost:8095", - "homeassistant_addon": false, - "onboard_done": false -} diff --git a/tests/components/music_assistant/test_config_flow.py b/tests/components/music_assistant/test_config_flow.py deleted file mode 100644 index c700060889c..00000000000 --- a/tests/components/music_assistant/test_config_flow.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Define tests for the Music Assistant Integration config flow.""" - -from copy import deepcopy -from ipaddress import ip_address -from unittest import mock -from unittest.mock import AsyncMock - -from music_assistant_client.exceptions import ( - CannotConnect, - InvalidServerVersion, - MusicAssistantClientException, -) -from music_assistant_models.api import ServerInfoMessage -import pytest - -from homeassistant.components.music_assistant.config_flow import CONF_URL -from homeassistant.components.music_assistant.const import DEFAULT_NAME, DOMAIN -from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from tests.common import MockConfigEntry, load_fixture - -SERVER_INFO = { - "server_id": "1234", - "base_url": "http://localhost:8095", - "server_version": "0.0.0", - "schema_version": 23, - "min_supported_schema_version": 23, - "homeassistant_addon": True, -} - -ZEROCONF_DATA = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - hostname="mock_hostname", - port=None, - type=mock.ANY, - name=mock.ANY, - properties=SERVER_INFO, -) - - -async def test_full_flow( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, -) -> None: - """Test full flow.""" - 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_URL: "http://localhost:8095"}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"] == { - CONF_URL: "http://localhost:8095", - } - assert result["result"].unique_id == "1234" - - -async def test_zero_conf_flow( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, -) -> None: - """Test zeroconf flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=ZEROCONF_DATA, - ) - 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 - assert result["title"] == DEFAULT_NAME - assert result["data"] == { - CONF_URL: "http://localhost:8095", - } - assert result["result"].unique_id == "1234" - - -async def test_zero_conf_missing_server_id( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, -) -> None: - """Test zeroconf flow with missing server id.""" - bad_zero_conf_data = deepcopy(ZEROCONF_DATA) - bad_zero_conf_data.properties.pop("server_id") - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=bad_zero_conf_data, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_server_id" - - -async def test_duplicate_user( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test duplicate user flow.""" - mock_config_entry.add_to_hass(hass) - - 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_URL: "http://localhost:8095"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_duplicate_zeroconf( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test duplicate zeroconf flow.""" - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=ZEROCONF_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize( - ("exception", "error_message"), - [ - (InvalidServerVersion("invalid_server_version"), "invalid_server_version"), - (CannotConnect("cannot_connect"), "cannot_connect"), - (MusicAssistantClientException("unknown"), "unknown"), - ], -) -async def test_flow_user_server_version_invalid( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, - exception: MusicAssistantClientException, - error_message: str, -) -> None: - """Test user flow when server url is invalid.""" - mock_get_server_info.side_effect = exception - - 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_URL: "http://localhost:8095"}, - ) - await hass.async_block_till_done() - assert result["errors"] == {"base": error_message} - - mock_get_server_info.side_effect = None - mock_get_server_info.return_value = ServerInfoMessage.from_json( - load_fixture("server_info_message.json", DOMAIN) - ) - - assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_URL: "http://localhost:8095"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - - -async def test_flow_zeroconf_connect_issue( - hass: HomeAssistant, - mock_get_server_info: AsyncMock, -) -> None: - """Test zeroconf flow when server connect be reached.""" - mock_get_server_info.side_effect = CannotConnect("cannot_connect") - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=ZEROCONF_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" From d10553d6245a782f5fd99ebac257e3b8fc2c22a4 Mon Sep 17 00:00:00 2001 From: Aurore <74768535+AuroreVgn@users.noreply.github.com> Date: Wed, 30 Oct 2024 18:41:10 +0100 Subject: [PATCH 003/511] Fix timeout issue on Roomba integration when adding a new device (#129230) * Update const.py DEFAULT_DELAY = 1 to DEFAULT_DELAY = 100 to fix timeout when adding a new device * Update config_flow.py continuous=False to continuous=True to fix timeout when adding a new device * Update homeassistant/components/roomba/const.py Co-authored-by: Jan Bouwhuis * Update test_config_flow.py Change CONF_DELAY to match DEFAULT_DELAY (30 sec instead of 1) * Update tests/components/roomba/test_config_flow.py Co-authored-by: Jan Bouwhuis * Use constant for DEFAULT_DELAY in tests --------- Co-authored-by: Jan Bouwhuis Co-authored-by: jbouwh --- .../components/roomba/config_flow.py | 2 +- homeassistant/components/roomba/const.py | 2 +- tests/components/roomba/test_config_flow.py | 29 +++++++++++-------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d690bcce978..d0c29faca69 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -57,7 +57,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, address=data[CONF_HOST], blid=data[CONF_BLID], password=data[CONF_PASSWORD], - continuous=False, + continuous=True, delay=data[CONF_DELAY], ) ) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index 331c0900682..7f1e3b8e1ee 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -9,5 +9,5 @@ CONF_CONTINUOUS = "continuous" CONF_BLID = "blid" DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_CONTINUOUS = True -DEFAULT_DELAY = 1 +DEFAULT_DELAY = 30 ROOMBA_SESSION = "roomba_session" diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 8139e42d43d..dedccc14249 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -8,7 +8,12 @@ from roombapy import RoombaConnectionError, RoombaInfo from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow -from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.components.roomba.const import ( + CONF_BLID, + CONF_CONTINUOUS, + DEFAULT_DELAY, + DOMAIN, +) from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_IGNORE, @@ -206,7 +211,7 @@ async def test_form_user_discovery_and_password_fetch(hass: HomeAssistant) -> No assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -331,7 +336,7 @@ async def test_form_user_discovery_manual_and_auto_password_fetch( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -468,7 +473,7 @@ async def test_form_user_discovery_no_devices_found_and_auto_password_fetch( assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -541,7 +546,7 @@ async def test_form_user_discovery_no_devices_found_and_password_fetch_fails( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -677,7 +682,7 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -738,7 +743,7 @@ async def test_dhcp_discovery_and_roomba_discovery_finds( assert result2["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -816,7 +821,7 @@ async def test_dhcp_discovery_falls_back_to_manual( assert result4["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -886,7 +891,7 @@ async def test_dhcp_discovery_no_devices_falls_back_to_manual( assert result3["data"] == { CONF_BLID: "BLID", CONF_CONTINUOUS: True, - CONF_DELAY: 1, + CONF_DELAY: DEFAULT_DELAY, CONF_HOST: MOCK_IP, CONF_PASSWORD: "password", } @@ -1119,10 +1124,10 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_CONTINUOUS: True, CONF_DELAY: 1}, + user_input={CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_CONTINUOUS: True, CONF_DELAY: 1} - assert config_entry.options == {CONF_CONTINUOUS: True, CONF_DELAY: 1} + assert result["data"] == {CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY} + assert config_entry.options == {CONF_CONTINUOUS: True, CONF_DELAY: DEFAULT_DELAY} From 2ac0ff03fcccdef37f73c698e4eab397206ebd31 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 31 Oct 2024 14:57:09 +0100 Subject: [PATCH 004/511] Fix current temperature calculation for incomfort boiler (#129496) --- .../components/incomfort/water_heater.py | 6 ++- .../components/incomfort/test_water_heater.py | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 28424069d1c..e7620ac2a1a 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -54,12 +54,16 @@ class IncomfortWaterHeater(IncomfortBoilerEntity, WaterHeaterEntity): return {k: v for k, v in self._heater.status.items() if k in HEATER_ATTRS} @property - def current_temperature(self) -> float: + def current_temperature(self) -> float | None: """Return the current temperature.""" if self._heater.is_tapping: return self._heater.tap_temp if self._heater.is_pumping: return self._heater.heater_temp + if self._heater.heater_temp is None: + return self._heater.tap_temp + if self._heater.tap_temp is None: + return self._heater.heater_temp return max(self._heater.heater_temp, self._heater.tap_temp) @property diff --git a/tests/components/incomfort/test_water_heater.py b/tests/components/incomfort/test_water_heater.py index 5b7aebc50a8..082aecf6d49 100644 --- a/tests/components/incomfort/test_water_heater.py +++ b/tests/components/incomfort/test_water_heater.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -9,6 +10,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MOCK_HEATER_STATUS + from tests.common import snapshot_platform @@ -23,3 +26,44 @@ async def test_setup_platform( """Test the incomfort entities are set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("mock_heater_status", "current_temperature"), + [ + (MOCK_HEATER_STATUS, 35.3), + (MOCK_HEATER_STATUS | {"is_tapping": True}, 30.2), + (MOCK_HEATER_STATUS | {"is_pumping": True}, 35.3), + (MOCK_HEATER_STATUS | {"heater_temp": None}, 30.2), + (MOCK_HEATER_STATUS | {"tap_temp": None}, 35.3), + (MOCK_HEATER_STATUS | {"heater_temp": None, "tap_temp": None}, None), + ], + ids=[ + "both_temps_available_choose_highest", + "is_tapping_choose_tapping_temp", + "is_pumping_choose_heater_temp", + "heater_temp_not_available_choose_tapping_temp", + "tapping_temp_not_available_choose_heater_temp", + "tapping_and_heater_temp_not_available_unknown", + ], +) +@patch("homeassistant.components.incomfort.PLATFORMS", [Platform.WATER_HEATER]) +async def test_current_temperature_cases( + hass: HomeAssistant, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: ConfigEntry, + current_temperature: float | None, +) -> None: + """Test incomfort entities with alternate current temperature calculation. + + The boilers current temperature is calculated from the testdata: + heater_temp: 35.34 + tap_temp: 30.21 + + It is based on the operating mode as the boiler can heat tap water or + the house. + """ + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert (state := hass.states.get("water_heater.boiler")) is not None + assert state.attributes.get("current_temperature") == current_temperature From bf3f1b4b49703f5a8139ecc5525f5cadf51efdd7 Mon Sep 17 00:00:00 2001 From: cryptk <421501+cryptk@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:43:34 -0500 Subject: [PATCH 005/511] Bump uiprotect to 6.3.2 (#129513) --- 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 ae7b2d94f21..4617a8aae80 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==6.3.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.3.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 329b227d01a..08df367a7b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2885,7 +2885,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.1 +uiprotect==6.3.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 052b5307bcf..7048e45b069 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2298,7 +2298,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.1 +uiprotect==6.3.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From d7e304badfd9bb1aab4273f36251742dd7c89fbc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 30 Oct 2024 21:17:03 +0100 Subject: [PATCH 006/511] Fix async_config_entry_first_refresh used after config entry is loaded in speedtestdotcom (#129527) * Fix async_config_entry_first_refresh used after config entry is loaded in speedtestdotcom * is --- homeassistant/components/speedtestdotnet/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index aed1cce33db..e4c51ab7aa0 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -6,7 +6,7 @@ from functools import partial import speedtest -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -35,7 +35,10 @@ async def async_setup_entry( async def _async_finish_startup(hass: HomeAssistant) -> None: """Run this only when HA has finished its startup.""" - await coordinator.async_config_entry_first_refresh() + if config_entry.state is ConfigEntryState.LOADED: + await coordinator.async_refresh() + else: + await coordinator.async_config_entry_first_refresh() # Don't start a speedtest during startup async_at_started(hass, _async_finish_startup) From 4ef31f93311fb1486264444959e76b153c16088c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 30 Oct 2024 22:24:07 +0100 Subject: [PATCH 007/511] Bump reolink_aio to 0.10.2 (#129528) --- homeassistant/components/reolink/light.py | 1 + homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index d545a878068..0f239a30813 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,6 +57,7 @@ LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", cmd_key="GetWhiteLed", + cmd_id=291, translation_key="floodlight", supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 8262c395d3b..282fe908e4c 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.10.1"] + "requirements": ["reolink-aio==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08df367a7b2..fbd17ddfadd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2547,7 +2547,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.1 +reolink-aio==0.10.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7048e45b069..556a0b6139a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2038,7 +2038,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.1 +reolink-aio==0.10.2 # homeassistant.components.rflink rflink==0.0.66 From 81421992a27c2a29dce2a8ad93af4a0155074686 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 31 Oct 2024 09:50:32 +0100 Subject: [PATCH 008/511] Missing config_flow in manifest for local_file (#129529) --- homeassistant/components/local_file/manifest.json | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/local_file/manifest.json b/homeassistant/components/local_file/manifest.json index 46268ff2a77..0e6e64d17e5 100644 --- a/homeassistant/components/local_file/manifest.json +++ b/homeassistant/components/local_file/manifest.json @@ -2,6 +2,7 @@ "domain": "local_file", "name": "Local File", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_file", "iot_class": "local_polling" } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e80238c47a4..e1694f8bc54 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -336,6 +336,7 @@ FLOWS = { "litterrobot", "livisi", "local_calendar", + "local_file", "local_ip", "local_todo", "locative", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6e0ab856b57..3ed09c6fb9f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3398,7 +3398,7 @@ "local_file": { "name": "Local File", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "local_ip": { From fc602b1888d62c9af8c4df4f53f82a4d4cf132b0 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Wed, 30 Oct 2024 22:22:17 +0100 Subject: [PATCH 009/511] Fix bthome UnitOfConductivity (#129535) Fix unit --- homeassistant/components/bthome/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 64e6d61cefb..417df9f5068 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -364,7 +364,7 @@ SENSOR_DESCRIPTIONS = { ): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}", device_class=SensorDeviceClass.CONDUCTIVITY, - native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, state_class=SensorStateClass.MEASUREMENT, ), } From c49b155c29586173844b986ba07eb246c5c09622 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 30 Oct 2024 22:56:59 +0100 Subject: [PATCH 010/511] Allow importing homeassistant.core.Config until 2025.11 (#129537) --- homeassistant/core.py | 14 ++++++++++++++ tests/test_core.py | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index 6c18da3bcdd..ab852056353 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -83,6 +83,7 @@ from .exceptions import ( Unauthorized, ) from .helpers.deprecation import ( + DeferredDeprecatedAlias, DeprecatedConstantEnum, EnumWithDeprecatedMembers, all_with_deprecated_constants, @@ -184,6 +185,19 @@ _DEPRECATED_SOURCE_STORAGE = DeprecatedConstantEnum(ConfigSource.STORAGE, "2025. _DEPRECATED_SOURCE_YAML = DeprecatedConstantEnum(ConfigSource.YAML, "2025.1") +def _deprecated_core_config() -> Any: + # pylint: disable-next=import-outside-toplevel + from . import core_config + + return core_config.Config + + +# The Config class was moved to core_config in Home Assistant 2024.11 +_DEPRECATED_Config = DeferredDeprecatedAlias( + _deprecated_core_config, "homeassistant.core_config.Config", "2025.11" +) + + # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 diff --git a/tests/test_core.py b/tests/test_core.py index bd5fa62048d..67ed99daa09 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -48,6 +48,7 @@ from homeassistant.core import ( callback, get_release_channel, ) +from homeassistant.core_config import Config from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError, @@ -66,6 +67,7 @@ from .common import ( async_capture_events, async_mock_service, help_test_all, + import_and_test_deprecated_alias, import_and_test_deprecated_constant_enum, ) @@ -2994,6 +2996,11 @@ def test_deprecated_constants( import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") +def test_deprecated_config(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated Config class.""" + import_and_test_deprecated_alias(caplog, ha, "Config", Config, "2025.11") + + def test_one_time_listener_repr(hass: HomeAssistant) -> None: """Test one time listener repr.""" From 4ec5d5ae1e26c3b83085ca2e98f0a9e683a72bbd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Oct 2024 17:56:29 -0500 Subject: [PATCH 011/511] Bump yarl to 1.17.1 (#129539) changelog: https://github.com/aio-libs/yarl/compare/v1.17.0...v1.17.1 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index de10176b5f0..acdae25ccdc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -66,7 +66,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.1.0 -yarl==1.17.0 +yarl==1.17.1 zeroconf==0.136.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 3d498eabb57..c4e90018323 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.17.0", + "yarl==1.17.1", "webrtc-models==0.1.0", ] diff --git a/requirements.txt b/requirements.txt index 281062214ae..ce6fad44332 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,5 +43,5 @@ uv==0.4.28 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.17.0 +yarl==1.17.1 webrtc-models==0.1.0 From 3f6e9a54fe874516f746614d7696682f55a0d5de Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 31 Oct 2024 17:56:03 +1000 Subject: [PATCH 012/511] Fix "home" route in Tesla Fleet & Teslemetry (#129546) * translate Home to home * refactor for mypy * Fix home state * Revert key change * Add testing --- homeassistant/components/tesla_fleet/device_tracker.py | 6 +++++- homeassistant/components/teslemetry/device_tracker.py | 6 +++++- tests/components/tesla_fleet/fixtures/vehicle_data.json | 1 + .../tesla_fleet/snapshots/test_device_tracker.ambr | 2 +- .../components/tesla_fleet/snapshots/test_diagnostics.ambr | 1 + tests/components/teslemetry/fixtures/vehicle_data.json | 1 + .../teslemetry/snapshots/test_device_tracker.ambr | 2 +- tests/components/teslemetry/snapshots/test_diagnostics.ambr | 1 + 8 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 62c084c9fe5..d6dcef895a6 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -4,6 +4,7 @@ from __future__ import annotations from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -84,4 +85,7 @@ class TeslaFleetDeviceTrackerRouteEntity(TeslaFleetDeviceTrackerEntity): @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" - return self.get("drive_state_active_route_destination") + location = self.get("drive_state_active_route_destination") + if location == "Home": + return STATE_HOME + return location diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 6577bcf88d6..2b0ffd88cc6 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.const import STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,4 +81,7 @@ class TeslemetryDeviceTrackerRouteEntity(TeslemetryDeviceTrackerEntity): @property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" - return self.get("drive_state_active_route_destination") + location = self.get("drive_state_active_route_destination") + if location == "Home": + return STATE_HOME + return location diff --git a/tests/components/tesla_fleet/fixtures/vehicle_data.json b/tests/components/tesla_fleet/fixtures/vehicle_data.json index 3845ae48559..d99bc8de5a8 100644 --- a/tests/components/tesla_fleet/fixtures/vehicle_data.json +++ b/tests/components/tesla_fleet/fixtures/vehicle_data.json @@ -112,6 +112,7 @@ "wiper_blade_heater": false }, "drive_state": { + "active_route_destination": "Home", "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_miles_to_arrival": 0.039491, diff --git a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr index 194eda6fcff..02ad4b01002 100644 --- a/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr +++ b/tests/components/tesla_fleet/snapshots/test_device_tracker.ambr @@ -96,6 +96,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'not_home', + 'state': 'home', }) # --- diff --git a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr index 902c7af131e..eb8c57910a4 100644 --- a/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr +++ b/tests/components/tesla_fleet/snapshots/test_diagnostics.ambr @@ -269,6 +269,7 @@ 'climate_state_timestamp': 1705707520649, 'climate_state_wiper_blade_heater': False, 'color': None, + 'drive_state_active_route_destination': 'Home', 'drive_state_active_route_latitude': '**REDACTED**', 'drive_state_active_route_longitude': '**REDACTED**', 'drive_state_active_route_miles_to_arrival': 0.039491, diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 3845ae48559..d99bc8de5a8 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -112,6 +112,7 @@ "wiper_blade_heater": false }, "drive_state": { + "active_route_destination": "Home", "active_route_latitude": 30.2226265, "active_route_longitude": -97.6236871, "active_route_miles_to_arrival": 0.039491, diff --git a/tests/components/teslemetry/snapshots/test_device_tracker.ambr b/tests/components/teslemetry/snapshots/test_device_tracker.ambr index 9859d9db360..6c18cdf75c6 100644 --- a/tests/components/teslemetry/snapshots/test_device_tracker.ambr +++ b/tests/components/teslemetry/snapshots/test_device_tracker.ambr @@ -96,6 +96,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'not_home', + 'state': 'home', }) # --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 11f8a91c1aa..3b96d6f70c0 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -270,6 +270,7 @@ 'climate_state_timestamp': 1705707520649, 'climate_state_wiper_blade_heater': False, 'color': None, + 'drive_state_active_route_destination': 'Home', 'drive_state_active_route_latitude': '**REDACTED**', 'drive_state_active_route_longitude': '**REDACTED**', 'drive_state_active_route_miles_to_arrival': 0.039491, From 964ab5b3515818577962373cfedc4b752de74439 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 31 Oct 2024 09:49:27 +0100 Subject: [PATCH 013/511] Log Reolink select value KeyError only once (#129559) --- homeassistant/components/reolink/select.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index b4175d41069..1306c881059 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -272,7 +272,7 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): try: option = self.entity_description.value(self._host.api, self._channel) - except ValueError: + except (ValueError, KeyError): if self._log_error: _LOGGER.exception("Reolink '%s' has an unknown value", self.name) self._log_error = False @@ -314,7 +314,7 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): """Return the current option.""" try: option = self.entity_description.value(self._chime) - except ValueError: + except (ValueError, KeyError): if self._log_error: _LOGGER.exception("Reolink '%s' has an unknown value", self.name) self._log_error = False From 2df094de2b22a1eda095435a20cab3f989ccaedd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Oct 2024 13:54:27 +0100 Subject: [PATCH 014/511] Stringify discovered hassio uuid (#129572) * Stringify discovered hassio uuid * Correct DiscoveryKey * Adjust tests --- homeassistant/components/hassio/discovery.py | 4 ++-- tests/components/hassio/test_discovery.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 802f2f56b77..8166b0f2c7e 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -130,11 +130,11 @@ class HassIODiscovery(HomeAssistantView): config=data.config, name=addon_info.name, slug=data.addon, - uuid=data.uuid, + uuid=str(data.uuid), ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, - key=data.uuid, + key=str(data.uuid), version=1, ), ) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index df84fbd6ec9..09bcc251e6f 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -91,7 +91,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", - uuid=uuid, + uuid=str(uuid), ) ) @@ -283,7 +283,7 @@ async def test_hassio_rediscover( ) expected_context = { - "discovery_key": DiscoveryKey(domain="hassio", key=uuid, version=1), + "discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1), "source": config_entries.SOURCE_HASSIO, } From 7f287412ba1a2b4cb8c6f1ad6f8e09cc65e5709b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:10:27 +0100 Subject: [PATCH 015/511] Log type as well as value for unique_id checks (#129575) --- homeassistant/config_entries.py | 3 ++- tests/test_config_entries.py | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ebd460d3cdb..e99c730145e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1638,11 +1638,12 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): _LOGGER.error( ( "Config entry '%s' from integration %s has an invalid unique_id" - " '%s', please %s" + " '%s' of type %s when a string is expected, please %s" ), entry.title, entry.domain, entry.unique_id, + type(entry.unique_id).__name__, report_issue, ) else: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index cc762f8c1de..e0135657c2b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5447,16 +5447,17 @@ async def test_string_unique_id_no_warning( @pytest.mark.parametrize( - "unique_id", + ("unique_id", "type_name"), [ - (123), - (2.3), + (123, "int"), + (2.3, "float"), ], ) async def test_hashable_unique_id( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any, + type_name: str, ) -> None: """Test the ConfigEntryItems user dict handles hashable non string unique_id.""" entries = config_entries.ConfigEntryItems(hass) @@ -5477,6 +5478,7 @@ async def test_hashable_unique_id( assert ( "Config entry 'title' from integration test has an invalid unique_id" + f" '{unique_id}' of type {type_name} when a string is expected" ) in caplog.text assert entry.entry_id in entries From e9d1f4f46efc2ccff2b61eb82cf301998b3049b3 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 31 Oct 2024 16:35:36 +0100 Subject: [PATCH 016/511] Update frontend to 20241031.0 (#129583) --- 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 dfe86d74933..52eee7db199 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==20241030.0"] + "requirements": ["home-assistant-frontend==20241031.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index acdae25ccdc..52c1439106a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fbd17ddfadd..a737b6aab73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 556a0b6139a..572b69e5a93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241030.0 +home-assistant-frontend==20241031.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From 41590f91ac816a68090e83874c62095252c02348 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 31 Oct 2024 16:38:09 +0100 Subject: [PATCH 017/511] Bump version to 2024.11.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 adddbff36d4..9077e852365 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 c4e90018323..4c399d43790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b0" +version = "2024.11.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6f094e8a5480c7af89c4517a04b9fd12934be349 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:57:40 +0000 Subject: [PATCH 018/511] Check for async web offer overrides in camera capabilities (#129519) --- homeassistant/components/camera/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index aa6cfc1c891..58826eb07ce 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -867,6 +867,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer + or type(self).async_handle_async_webrtc_offer + != Camera.async_handle_async_webrtc_offer ): # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) From b25ab04d2c0606033b9ce92bd5257a72e5646e2e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 17:33:39 +0100 Subject: [PATCH 019/511] Fix Geniushub setup (#129569) --- homeassistant/components/geniushub/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 18580f331d2..f3081e50289 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -170,7 +170,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> ) session = async_get_clientsession(hass) - unique_id: str if CONF_HOST in entry.data: client = GeniusHub( entry.data[CONF_HOST], @@ -178,10 +177,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: GeniusHubConfigEntry) -> password=entry.data[CONF_PASSWORD], session=session, ) - unique_id = entry.data[CONF_MAC] else: client = GeniusHub(entry.data[CONF_TOKEN], session=session) - unique_id = entry.entry_id + + unique_id = entry.unique_id or entry.entry_id broker = entry.runtime_data = GeniusBroker(hass, client, unique_id) From df2506bfbb997cec1aea042a6ed689a0398c793f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 15:12:15 +0100 Subject: [PATCH 020/511] Bump spotifyaio to 0.8.1 (#129573) --- .../components/spotify/manifest.json | 2 +- homeassistant/components/spotify/sensor.py | 28 ++++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../spotify/snapshots/test_sensor.ambr | 22 +++++++-------- 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index f799f9d8ea5..61d559232d6 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.7.1"], + "requirements": ["spotifyaio==0.8.1"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/homeassistant/components/spotify/sensor.py b/homeassistant/components/spotify/sensor.py index 032799e69d0..3486a911b0d 100644 --- a/homeassistant/components/spotify/sensor.py +++ b/homeassistant/components/spotify/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from spotifyaio.models import AudioFeatures +from spotifyaio.models import AudioFeatures, Key from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,14 +25,28 @@ class SpotifyAudioFeaturesSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[AudioFeatures], float | str | None] +KEYS: dict[Key, str] = { + Key.C: "C", + Key.C_SHARP_D_FLAT: "C♯/D♭", + Key.D: "D", + Key.D_SHARP_E_FLAT: "D♯/E♭", + Key.E: "E", + Key.F: "F", + Key.F_SHARP_G_FLAT: "F♯/G♭", + Key.G: "G", + Key.G_SHARP_A_FLAT: "G♯/A♭", + Key.A: "A", + Key.A_SHARP_B_FLAT: "A♯/B♭", + Key.B: "B", +} + +KEY_OPTIONS = list(KEYS.values()) + + def _get_key(audio_features: AudioFeatures) -> str | None: if audio_features.key is None: return None - key_name = audio_features.key.name - base = key_name[0] - if len(key_name) > 1: - base = f"{base}♯" - return base + return KEYS[audio_features.key] AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = ( @@ -119,7 +133,7 @@ AUDIO_FEATURE_SENSORS: tuple[SpotifyAudioFeaturesSensorEntityDescription, ...] = key="key", translation_key="key", device_class=SensorDeviceClass.ENUM, - options=["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"], + options=KEY_OPTIONS, value_fn=_get_key, entity_registry_enabled_default=False, ), diff --git a/requirements_all.txt b/requirements_all.txt index a737b6aab73..221e16e8092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.7.1 +spotifyaio==0.8.1 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 572b69e5a93..77d1fbbc5cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.7.1 +spotifyaio==0.8.1 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/tests/components/spotify/snapshots/test_sensor.ambr b/tests/components/spotify/snapshots/test_sensor.ambr index 347b12dd1d8..ce77dda479f 100644 --- a/tests/components/spotify/snapshots/test_sensor.ambr +++ b/tests/components/spotify/snapshots/test_sensor.ambr @@ -207,16 +207,16 @@ 'capabilities': dict({ 'options': list([ 'C', - 'C♯', + 'C♯/D♭', 'D', - 'D♯', + 'D♯/E♭', 'E', 'F', - 'F♯', + 'F♯/G♭', 'G', - 'G♯', + 'G♯/A♭', 'A', - 'A♯', + 'A♯/B♭', 'B', ]), }), @@ -254,16 +254,16 @@ 'friendly_name': 'Spotify spotify_1 Song key', 'options': list([ 'C', - 'C♯', + 'C♯/D♭', 'D', - 'D♯', + 'D♯/E♭', 'E', 'F', - 'F♯', + 'F♯/G♭', 'G', - 'G♯', + 'G♯/A♭', 'A', - 'A♯', + 'A♯/B♭', 'B', ]), }), @@ -272,7 +272,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'D♯', + 'state': 'D♯/E♭', }) # --- # name: test_entities[sensor.spotify_spotify_1_song_liveness-entry] From 76f9a93ed7a7fc044bad3dfa8573ebfaac451d23 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 31 Oct 2024 19:37:31 +0100 Subject: [PATCH 021/511] Bump aiohasupervisor to version 0.2.1 (#129574) --- homeassistant/components/hassio/discovery.py | 7 ++++--- homeassistant/components/hassio/handler.py | 2 +- homeassistant/components/hassio/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hassio/test_discovery.py | 13 ++++++++----- 9 files changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 8166b0f2c7e..6181fe4624c 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import logging from typing import Any +from uuid import UUID from aiohasupervisor import SupervisorError from aiohasupervisor.models import Discovery @@ -86,7 +87,7 @@ class HassIODiscovery(HomeAssistantView): """Handle new discovery requests.""" # Fetch discovery data and prevent injections try: - data = await self._supervisor_client.discovery.get(uuid) + data = await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError as err: _LOGGER.error("Can't read discovery data: %s", err) raise HTTPServiceUnavailable from None @@ -104,7 +105,7 @@ class HassIODiscovery(HomeAssistantView): async def async_rediscover(self, uuid: str) -> None: """Rediscover add-on when config entry is removed.""" try: - data = await self._supervisor_client.discovery.get(uuid) + data = await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError as err: _LOGGER.debug("Can't read discovery data: %s", err) else: @@ -146,7 +147,7 @@ class HassIODiscovery(HomeAssistantView): # Check if really deletet / prevent injections try: - data = await self._supervisor_client.discovery.get(uuid) + await self._supervisor_client.discovery.get(UUID(uuid)) except SupervisorError: pass else: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d96c3f49e95..f69ee40293b 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -382,7 +382,7 @@ def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient: """Return supervisor client.""" hassio: HassIO = hass.data[DOMAIN] return SupervisorClient( - hassio.base_url, + str(hassio.base_url), os.environ.get("SUPERVISOR_TOKEN", ""), session=hassio.websession, ) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index fb9ad8fdb31..31fa27a92c4 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["aiohasupervisor==0.2.0"], + "requirements": ["aiohasupervisor==0.2.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52c1439106a..aa9e614acef 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 aiohttp==3.10.10 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 4c399d43790..f1072012d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 - "aiohasupervisor==0.2.0", + "aiohasupervisor==0.2.1", "aiohttp==3.10.10", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", diff --git a/requirements.txt b/requirements.txt index ce6fad44332..ecca136e1a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 aiohttp==3.10.10 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 221e16e8092..d352e388d71 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,7 +259,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller aiohomekit==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77d1fbbc5cb..524984cbda7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -244,7 +244,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.hassio -aiohasupervisor==0.2.0 +aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller aiohomekit==3.2.5 diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 09bcc251e6f..bb3a101d1f9 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -181,8 +181,8 @@ async def test_hassio_discovery_webhook( addon_installed.return_value.name = "Mosquitto Test" resp = await hassio_client.post( - "/api/hassio_push/discovery/testuuid", - json={"addon": "mosquitto", "service": "mqtt", "uuid": "testuuid"}, + f"/api/hassio_push/discovery/{uuid!s}", + json={"addon": "mosquitto", "service": "mqtt", "uuid": str(uuid)}, ) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -208,6 +208,9 @@ async def test_hassio_discovery_webhook( ) +TEST_UUID = str(uuid4()) + + @pytest.mark.parametrize( ( "entry_domain", @@ -217,13 +220,13 @@ async def test_hassio_discovery_webhook( # Matching discovery key ( "mock-domain", - {"hassio": (DiscoveryKey(domain="hassio", key="test", version=1),)}, + {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), # Matching discovery key ( "mock-domain", { - "hassio": (DiscoveryKey(domain="hassio", key="test", version=1),), + "hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),), "other": (DiscoveryKey(domain="other", key="blah", version=1),), }, ), @@ -232,7 +235,7 @@ async def test_hassio_discovery_webhook( # entry. Such a check can be added if needed. ( "comp", - {"hassio": (DiscoveryKey(domain="hassio", key="test", version=1),)}, + {"hassio": (DiscoveryKey(domain="hassio", key=TEST_UUID, version=1),)}, ), ], ) From 5fe827f6c4cc409751cd493634b84aa0c5ed1c5e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 1 Nov 2024 10:15:20 +0100 Subject: [PATCH 022/511] Fix flaky camera test (#129576) --- tests/components/camera/test_init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 58d87a42572..e0d4e38fb57 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -929,7 +929,8 @@ async def _test_capabilities( # Assert WebSocket response assert msg["type"] == TYPE_RESULT assert msg["success"] - assert msg["result"] == {"frontend_stream_types": list(expected_types)} + assert msg["result"] == {"frontend_stream_types": ANY} + assert sorted(msg["result"]["frontend_stream_types"]) == sorted(expected_types) await test(expected_stream_types) From 3c1f6d97cca47954994efb6d6d773fdfab3a9d25 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 31 Oct 2024 18:28:53 +0100 Subject: [PATCH 023/511] Bump aiowithings to 3.1.1 (#129586) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index e0d85f207a3..a0a86be5da3 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.0"] + "requirements": ["aiowithings==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d352e388d71..2033b28d083 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.0 +aiowithings==3.1.1 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 524984cbda7..0b7ae07ac5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -396,7 +396,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.0 +aiowithings==3.1.1 # homeassistant.components.yandex_transport aioymaps==1.2.5 From d05ee9ff60aca88eeed1b29dbdbec61c2d2f1ea2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 31 Oct 2024 20:56:53 +0100 Subject: [PATCH 024/511] Add go2rtc debug_ui yaml key to enable go2rtc ui (#129587) * Add go2rtc debug_ui yaml key to enable go2rtc ui * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Order imports --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/go2rtc/__init__.py | 16 +++++++++--- homeassistant/components/go2rtc/const.py | 3 ++- homeassistant/components/go2rtc/server.py | 28 ++++++++++++-------- tests/components/go2rtc/test_init.py | 29 ++++++++++++++++++--- tests/components/go2rtc/test_server.py | 26 ++++++++++++++---- 5 files changed, 77 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 9501bee776b..0bf01490a47 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -37,7 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN from .server import Server _LOGGER = logging.getLogger(__name__) @@ -72,9 +72,15 @@ _SUPPORTED_STREAMS = frozenset( ) ) - CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Optional(CONF_URL): cv.url})}, + { + DOMAIN: vol.Schema( + { + vol.Exclusive(CONF_URL, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.url, + vol.Exclusive(CONF_DEBUG_UI, DOMAIN, DEBUG_UI_URL_MESSAGE): cv.boolean, + } + ) + }, extra=vol.ALLOW_EXTRA, ) @@ -104,7 +110,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return False # HA will manage the binary - server = Server(hass, binary) + server = Server( + hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) + ) await server.start() async def on_stop(event: Event) -> None: diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index af8266e0d72..b0d52e4fd39 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -2,4 +2,5 @@ DOMAIN = "go2rtc" -CONF_BINARY = "binary" +CONF_DEBUG_UI = "debug_ui" +DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index febb6b2680e..df4b5b7f13e 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -10,15 +10,15 @@ from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 -_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=127.0.0.1:1984" - +_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" +_LOCALHOST_IP = "127.0.0.1" # Default configuration for HA # - Api is listening only on localhost # - Disable rtsp listener # - Clear default ice servers -_GO2RTC_CONFIG = """ +_GO2RTC_CONFIG_FORMAT = r""" api: - listen: "127.0.0.1:1984" + listen: "{api_ip}:1984" rtsp: # ffmpeg needs rtsp for opus audio transcoding @@ -29,29 +29,37 @@ webrtc: """ -def _create_temp_file() -> str: +def _create_temp_file(api_ip: str) -> str: """Create temporary config file.""" # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: - file.write(_GO2RTC_CONFIG.encode()) + file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) return file.name class Server: """Go2rtc server.""" - def __init__(self, hass: HomeAssistant, binary: str) -> None: + def __init__( + self, hass: HomeAssistant, binary: str, *, enable_ui: bool = False + ) -> None: """Initialize the server.""" self._hass = hass self._binary = binary self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() + self._api_ip = _LOCALHOST_IP + if enable_ui: + # Listen on all interfaces for allowing access from all ips + self._api_ip = "" async def start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") - config_file = await self._hass.async_add_executor_job(_create_temp_file) + config_file = await self._hass.async_add_executor_job( + _create_temp_file, self._api_ip + ) self._startup_complete.clear() @@ -84,9 +92,7 @@ class Server: async for line in process.stdout: msg = line[:-1].decode().strip() _LOGGER.debug(msg) - if not self._startup_complete.is_set() and msg.endswith( - _SUCCESSFUL_BOOT_MESSAGE - ): + if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() async def stop(self) -> None: diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index a215b826010..c4a23731a93 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -31,7 +31,11 @@ from homeassistant.components.camera import ( ) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN from homeassistant.components.go2rtc import WebRTCProvider -from homeassistant.components.go2rtc.const import DOMAIN +from homeassistant.components.go2rtc.const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, +) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -265,7 +269,15 @@ async def _test_setup_and_signaling( "mock_is_docker_env", "mock_go2rtc_entry", ) -@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("config", "ui_enabled"), + [ + ({DOMAIN: {}}, False), + ({DOMAIN: {CONF_DEBUG_UI: True}}, True), + ({DEFAULT_CONFIG_DOMAIN: {}}, False), + ({DEFAULT_CONFIG_DOMAIN: {}, DOMAIN: {CONF_DEBUG_UI: True}}, True), + ], +) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) async def test_setup_go_binary( hass: HomeAssistant, @@ -277,12 +289,13 @@ async def test_setup_go_binary( init_test_integration: MockCamera, has_go2rtc_entry: bool, config: ConfigType, + ui_enabled: bool, ) -> None: """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry def after_setup() -> None: - server.assert_called_once_with(hass, "/usr/bin/go2rtc") + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) server_start.assert_called_once() await _test_setup_and_signaling( @@ -468,7 +481,9 @@ ERR_CONNECT = "Could not connect to go2rtc instance" ERR_CONNECT_RETRY = ( "Could not connect to go2rtc instance on http://localhost:1984/; Retrying" ) -ERR_INVALID_URL = "Invalid config for 'go2rtc': invalid url" +_INVALID_CONFIG = "Invalid config for 'go2rtc': " +ERR_INVALID_URL = _INVALID_CONFIG + "invalid url" +ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE ERR_URL_REQUIRED = "Go2rtc URL required in non-docker installs" @@ -501,6 +516,12 @@ async def test_non_user_setup_with_error( ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), + ( + {DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}}, + None, + True, + ERR_EXCLUSIVE, + ), ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 8373b71cee7..42f3f5e098d 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -16,9 +16,15 @@ TEST_BINARY = "/bin/go2rtc" @pytest.fixture -def server(hass: HomeAssistant) -> Server: +def enable_ui() -> bool: + """Fixture to enable the UI.""" + return False + + +@pytest.fixture +def server(hass: HomeAssistant, enable_ui: bool) -> Server: """Fixture to initialize the Server.""" - return Server(hass, binary=TEST_BINARY) + return Server(hass, binary=TEST_BINARY, enable_ui=enable_ui) @pytest.fixture @@ -32,12 +38,20 @@ def mock_tempfile() -> Generator[Mock]: yield file +@pytest.mark.parametrize( + ("enable_ui", "api_ip"), + [ + (True, ""), + (False, "127.0.0.1"), + ], +) async def test_server_run_success( mock_create_subprocess: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, mock_tempfile: Mock, + api_ip: str, ) -> None: """Test that the server runs successfully.""" await server.start() @@ -53,9 +67,10 @@ async def test_server_run_success( ) # Verify that the config file was written - mock_tempfile.write.assert_called_once_with(b""" + mock_tempfile.write.assert_called_once_with( + f""" api: - listen: "127.0.0.1:1984" + listen: "{api_ip}:1984" rtsp: # ffmpeg needs rtsp for opus audio transcoding @@ -63,7 +78,8 @@ rtsp: webrtc: ice_servers: [] -""") +""".encode() + ) # Check that server read the log lines for entry in server_stdout: From 725ab477a8894b88be863c3e31d689ccf3ae8d7a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Nov 2024 20:39:17 +0100 Subject: [PATCH 025/511] Revert "Create a script service schema based on fields" (#129591) --- homeassistant/components/script/__init__.py | 35 +------- tests/components/script/test_init.py | 97 --------------------- 2 files changed, 1 insertion(+), 131 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 1af553165bd..c0d79c446bb 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -18,13 +18,11 @@ from homeassistant.const import ( ATTR_MODE, ATTR_NAME, CONF_ALIAS, - CONF_DEFAULT, CONF_DESCRIPTION, CONF_ICON, CONF_MODE, CONF_NAME, CONF_PATH, - CONF_SELECTOR, CONF_SEQUENCE, CONF_VARIABLES, SERVICE_RELOAD, @@ -60,7 +58,6 @@ from homeassistant.helpers.script import ( ScriptRunResult, script_stack_cv, ) -from homeassistant.helpers.selector import selector from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.trace import trace_get, trace_path from homeassistant.helpers.typing import ConfigType @@ -74,7 +71,6 @@ from .const import ( ATTR_LAST_TRIGGERED, ATTR_VARIABLES, CONF_FIELDS, - CONF_REQUIRED, CONF_TRACE, DOMAIN, ENTITY_ID_FORMAT, @@ -734,40 +730,11 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): unique_id = self.unique_id hass = self.hass - - service_schema = {} - for field_name, field_info in self.fields.items(): - key_cls = vol.Required if field_info[CONF_REQUIRED] else vol.Optional - key_kwargs = {} - if CONF_DEFAULT in field_info: - key_kwargs["default"] = field_info[CONF_DEFAULT] - - if CONF_SELECTOR in field_info: - validator: Any = selector(field_info[CONF_SELECTOR]) - - # Default values need to match the validator. - # When they don't match, we will not enforce validation - if CONF_DEFAULT in field_info: - try: - validator(field_info[CONF_DEFAULT]) - except vol.Invalid: - logging.getLogger(f"{__name__}.{self._attr_unique_id}").warning( - "Field %s has invalid default value %s", - field_name, - field_info[CONF_DEFAULT], - ) - validator = cv.match_all - - else: - validator = cv.match_all - - service_schema[key_cls(field_name, **key_kwargs)] = validator - hass.services.async_register( DOMAIN, unique_id, self._service_handler, - schema=vol.Schema(service_schema, extra=vol.ALLOW_EXTRA), + schema=SCRIPT_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 96ac73438ea..a5eda3757a9 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -6,7 +6,6 @@ from typing import Any from unittest.mock import ANY, Mock, patch import pytest -import voluptuous as vol from homeassistant.components import script from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity @@ -49,7 +48,6 @@ import homeassistant.util.dt as dt_util from tests.common import ( MockConfigEntry, MockUser, - async_capture_events, async_fire_time_changed, async_mock_service, mock_restore_cache, @@ -559,101 +557,6 @@ async def test_reload_unchanged_script( assert len(calls) == 2 -async def test_service_schema( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test that service schema are defined correctly.""" - events = async_capture_events(hass, "test_event") - - assert await async_setup_component( - hass, - "script", - { - "script": { - "test": { - "fields": { - "param_with_default": { - "default": "default_value", - }, - "required_param": { - "required": True, - }, - "selector_param": { - "selector": { - "select": { - "options": [ - "one", - "two", - ] - } - } - }, - "invalid_default": { - "default": "invalid-value", - "selector": {"number": {"min": 0, "max": 2}}, - }, - }, - "sequence": [ - { - "event": "test_event", - "event_data": { - "param_with_default": "{{ param_with_default }}", - "required_param": "{{ required_param }}", - "selector_param": "{{ selector_param | default('not_set') }}", - "invalid_default": "{{ invalid_default }}", - }, - } - ], - } - } - }, - ) - - assert ( - "Field invalid_default has invalid default value invalid-value" in caplog.text - ) - - await hass.services.async_call( - DOMAIN, - "test", - {"required_param": "required_value"}, - blocking=True, - ) - assert len(events) == 1 - assert events[0].data["param_with_default"] == "default_value" - assert events[0].data["required_param"] == "required_value" - assert events[0].data["selector_param"] == "not_set" - assert events[0].data["invalid_default"] == "invalid-value" - - with pytest.raises(vol.Invalid): - await hass.services.async_call( - DOMAIN, - "test", - { - "required_param": "required_value", - "selector_param": "invalid_value", - }, - blocking=True, - ) - - await hass.services.async_call( - DOMAIN, - "test", - { - "param_with_default": "service_set_value", - "required_param": "required_value", - "selector_param": "one", - "invalid_default": "another-value", - }, - blocking=True, - ) - assert len(events) == 2 - assert events[1].data["param_with_default"] == "service_set_value" - assert events[1].data["required_param"] == "required_value" - assert events[1].data["selector_param"] == "one" - assert events[1].data["invalid_default"] == "another-value" - - async def test_service_descriptions(hass: HomeAssistant) -> None: """Test that service descriptions are loaded and reloaded correctly.""" # Test 1: has "description" but no "fields" From d0699079488e686e1fe193bfaa76f90ce24c443c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 31 Oct 2024 16:18:31 -0500 Subject: [PATCH 026/511] Pin async-timeout to 4.0.3 (#129592) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa9e614acef..e1547949588 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -189,3 +189,7 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# 5.0.0 breaks Timeout as a context manager +# TypeError: 'Timeout' object does not support the context manager protocol +async-timeout==4.0.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1ad0d863062..36962ce1fe9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -205,6 +205,10 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# 5.0.0 breaks Timeout as a context manager +# TypeError: 'Timeout' object does not support the context manager protocol +async-timeout==4.0.3 """ GENERATED_MESSAGE = ( From 5c7c2347f7e854295c2426a46aaef2a2ed8db222 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 1 Nov 2024 17:24:44 +0100 Subject: [PATCH 027/511] Bump webrtc-models to 0.2.0 (#129627) --- 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 e1547949588..fbb51b85d88 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,7 +65,7 @@ uv==0.4.28 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -webrtc-models==0.1.0 +webrtc-models==0.2.0 yarl==1.17.1 zeroconf==0.136.0 diff --git a/pyproject.toml b/pyproject.toml index f1072012d9a..6a7e60448e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", "yarl==1.17.1", - "webrtc-models==0.1.0", + "webrtc-models==0.2.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ecca136e1a7..73c674fbc32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,4 +44,4 @@ voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 yarl==1.17.1 -webrtc-models==0.1.0 +webrtc-models==0.2.0 From 0dc8feba055079436b4d5197e993c59c291232ec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 1 Nov 2024 18:25:26 +0100 Subject: [PATCH 028/511] Bump spotifyaio to 0.8.2 (#129639) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 61d559232d6..5885d0103f2 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.1"], + "requirements": ["spotifyaio==0.8.2"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2033b28d083..996da040af0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.1 +spotifyaio==0.8.2 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b7ae07ac5f..0dabca0494f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.1 +spotifyaio==0.8.2 # homeassistant.components.sql sqlparse==0.5.0 From dbae1d2f8b186c86a03923057cab147fee47f7f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Nov 2024 14:01:33 -0500 Subject: [PATCH 029/511] Bump aiohomekit to 3.2.6 (#129640) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 598e8078a2c..cddd61a12c1 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.2.5"], + "requirements": ["aiohomekit==3.2.6"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 996da040af0..f81c0dee32d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -262,7 +262,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.5 +aiohomekit==3.2.6 # homeassistant.components.hue aiohue==4.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0dabca0494f..a9421ca5114 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -247,7 +247,7 @@ aioharmony==0.2.10 aiohasupervisor==0.2.1 # homeassistant.components.homekit_controller -aiohomekit==3.2.5 +aiohomekit==3.2.6 # homeassistant.components.hue aiohue==4.7.3 From e9944b964a203a7f2996b3e71d2293c9461cec27 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Nov 2024 13:16:15 -0500 Subject: [PATCH 030/511] Bump aioesphomeapi to 27.0.1 (#129643) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 410c826c5a0..b9b6a98dcd1 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==27.0.0", + "aioesphomeapi==27.0.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index f81c0dee32d..94231f8c748 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.0 +aioesphomeapi==27.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9421ca5114..c3fa8720f34 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==27.0.0 +aioesphomeapi==27.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 931820a1702c8eeb40dcb200ac7819a08732b659 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Nov 2024 12:26:31 -0500 Subject: [PATCH 031/511] Bump sensorpush-ble to 1.7.1 (#129657) --- homeassistant/components/sensorpush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensorpush/manifest.json b/homeassistant/components/sensorpush/manifest.json index 5e7cf0d0509..7729a67d7a1 100644 --- a/homeassistant/components/sensorpush/manifest.json +++ b/homeassistant/components/sensorpush/manifest.json @@ -17,5 +17,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensorpush", "iot_class": "local_push", - "requirements": ["sensorpush-ble==1.7.0"] + "requirements": ["sensorpush-ble==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 94231f8c748..3d16c5c2b26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2632,7 +2632,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.7.0 +sensorpush-ble==1.7.1 # homeassistant.components.sensoterra sensoterra==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3fa8720f34..c47067e64ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ sensirion-ble==0.1.1 sensorpro-ble==0.5.3 # homeassistant.components.sensorpush -sensorpush-ble==1.7.0 +sensorpush-ble==1.7.1 # homeassistant.components.sensoterra sensoterra==2.0.1 From 8a293a41f565fc8bb11e5922ee3d8667ae0f9aac Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Sat, 2 Nov 2024 18:42:56 +0100 Subject: [PATCH 032/511] Bump autarco lib to v3.1.0 (#129684) Bump autarco to v3.1.0 --- homeassistant/components/autarco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/autarco/manifest.json b/homeassistant/components/autarco/manifest.json index 0058ab9af77..0567aeba722 100644 --- a/homeassistant/components/autarco/manifest.json +++ b/homeassistant/components/autarco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/autarco", "iot_class": "cloud_polling", - "requirements": ["autarco==3.0.0"] + "requirements": ["autarco==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3d16c5c2b26..c8cb043632f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.0.0 +autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c47067e64ff..41f949904e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ auroranoaa==0.0.5 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==3.0.0 +autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 From 5ef45fd12efd58e32a06768ced2307ffdf1b793b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 2 Nov 2024 20:42:48 +0100 Subject: [PATCH 033/511] Bump version to 2024.11.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 9077e852365..c2565fe006f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 6a7e60448e2..f17bc1d5bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b1" +version = "2024.11.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From ab5c65b08c9a439e145b83aa36b1dfbc17b6d451 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Nov 2024 21:04:53 +0100 Subject: [PATCH 034/511] Improve code quality in yale_smart_alarm options flow (#129531) * Improve code quality in yale_smart_alarm options flow * mods * Fix --- .../yale_smart_alarm/config_flow.py | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 7b68a1f5dab..9d653da7a7e 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -23,7 +23,6 @@ from .const import ( CONF_AREA_ID, CONF_LOCK_CODE_DIGITS, DEFAULT_AREA_ID, - DEFAULT_LOCK_CODE_DIGITS, DEFAULT_NAME, DOMAIN, LOGGER, @@ -44,6 +43,14 @@ DATA_SCHEMA_AUTH = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional( + CONF_LOCK_CODE_DIGITS, + ): int, + } +) + class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" @@ -54,7 +61,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> YaleOptionsFlowHandler: """Get the options flow for this handler.""" - return YaleOptionsFlowHandler(config_entry) + return YaleOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -143,32 +150,18 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): class YaleOptionsFlowHandler(OptionsFlow): """Handle Yale options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Yale options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Yale options.""" - errors: dict[str, Any] = {} - if user_input: + if user_input is not None: return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_LOCK_CODE_DIGITS, - description={ - "suggested_value": self.entry.options.get( - CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS - ) - }, - ): int, - } + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, + self.config_entry.options, ), - errors=errors, ) From 144d5ff0cc96b8f6f28a3e4ac601de5b6d35781a Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sun, 3 Nov 2024 21:06:46 +0100 Subject: [PATCH 035/511] Add state class to precipitation_intensity in Aemet (#129670) Update sensor.py --- homeassistant/components/aemet/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 83d490f7fe2..e55344490aa 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -249,6 +249,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( name="Rain", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_RAIN_PROB, @@ -263,6 +264,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( name="Snow", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_SNOW_PROB, From 0cfd8032c0b2cb379b81828e8ebad227039d768f Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sun, 3 Nov 2024 21:07:59 +0100 Subject: [PATCH 036/511] Add Measurement StateClass to HomematicIP Cloud Wind and Rain Sensor (#129724) Add Meassurement StateClass to Wind and Rain Sensor --- homeassistant/components/homematicip_cloud/sensor.py | 2 ++ tests/components/homematicip_cloud/test_sensor.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index eab7ba4f09e..c44d280c190 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -420,6 +420,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity): _attr_device_class = SensorDeviceClass.WIND_SPEED _attr_native_unit_of_measurement = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the windspeed sensor.""" @@ -451,6 +452,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity): _attr_device_class = SensorDeviceClass.PRECIPITATION _attr_native_unit_of_measurement = UnitOfPrecipitationDepth.MILLIMETERS + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the device.""" diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index bdd0b6194ed..2dda3116032 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -23,7 +23,11 @@ from homeassistant.components.homematicip_cloud.sensor import ( ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, LIGHT_LUX, @@ -362,6 +366,7 @@ async def test_hmip_windspeed_sensor( assert ( ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfSpeed.KILOMETERS_PER_HOUR ) + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4) ha_state = hass.states.get(entity_id) assert ha_state.state == "9.4" @@ -411,6 +416,7 @@ async def test_hmip_today_rain_sensor( assert ha_state.state == "3.9" assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfLength.MILLIMETERS + assert ha_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT await async_manipulate_test_data(hass, hmip_device, "todayRainCounter", 14.2) ha_state = hass.states.get(entity_id) assert ha_state.state == "14.2" From 463bffaeb663c5138fbc808eb1b987cde146ef4a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Nov 2024 21:55:12 +0100 Subject: [PATCH 037/511] Bump spotifyaio to 0.8.3 (#129729) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 5885d0103f2..2d86083d49c 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.2"], + "requirements": ["spotifyaio==0.8.3"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6c2d573f03e..02c6853edae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc60a031e03..21040bf22ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 From 8b6c99776eb434cec951d401dc45f07840d2ac94 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 3 Nov 2024 22:57:18 +0100 Subject: [PATCH 038/511] Cleanup unnecessary OptionsFlowWithConfigEntry (part 1) (#129752) * Cleanup unnecessary OptionsFlowWithConfigEntry * Fix emoncms * Fix imap * Fix met * Fix workday --- .../components/analytics_insights/config_flow.py | 9 +++++---- homeassistant/components/axis/config_flow.py | 10 ++++++---- .../components/bmw_connected_drive/config_flow.py | 6 +++--- homeassistant/components/dnsip/config_flow.py | 6 +++--- homeassistant/components/emoncms/config_flow.py | 12 +++++++----- .../components/enphase_envoy/config_flow.py | 10 ++++++---- homeassistant/components/feedreader/config_flow.py | 9 +++++---- homeassistant/components/file/config_flow.py | 11 +++++++---- homeassistant/components/fritz/config_flow.py | 9 +++++---- .../components/google_cloud/config_flow.py | 6 +++--- homeassistant/components/imap/config_flow.py | 14 +++++++------- homeassistant/components/jellyfin/config_flow.py | 12 ++++-------- .../components/jewish_calendar/config_flow.py | 10 ++++++---- .../components/kitchen_sink/config_flow.py | 6 +++--- homeassistant/components/lamarzocco/config_flow.py | 9 +++++---- homeassistant/components/lastfm/config_flow.py | 6 +++--- homeassistant/components/met/config_flow.py | 13 ++++++------- homeassistant/components/onewire/config_flow.py | 10 ++++++---- homeassistant/components/opensky/config_flow.py | 6 +++--- .../components/pvpc_hourly_pricing/config_flow.py | 6 +++--- homeassistant/components/roborock/config_flow.py | 7 +++---- homeassistant/components/roku/config_flow.py | 8 ++++---- homeassistant/components/roomba/config_flow.py | 6 +++--- homeassistant/components/sql/config_flow.py | 6 +++--- .../components/trafikverket_train/config_flow.py | 6 +++--- homeassistant/components/upnp/config_flow.py | 9 +++++---- .../components/vodafone_station/config_flow.py | 9 +++++---- homeassistant/components/wled/config_flow.py | 10 ++++++---- homeassistant/components/workday/config_flow.py | 8 ++++---- homeassistant/components/youtube/config_flow.py | 6 +++--- 30 files changed, 135 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index baf0190967d..0212f208436 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -46,9 +45,11 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeassistantAnalyticsOptionsFlowHandler: """Get the options flow for this handler.""" - return HomeassistantAnalyticsOptionsFlowHandler(config_entry) + return HomeassistantAnalyticsOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -132,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithConfigEntry): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): """Handle Homeassistant Analytics options.""" async def async_step_init( diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 84d9880b7f8..5026f7e7ab6 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_HOST, @@ -59,9 +59,11 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> AxisOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> AxisOptionsFlowHandler: """Get the options flow for this handler.""" - return AxisOptionsFlowHandler(config_entry) + return AxisOptionsFlowHandler() def __init__(self) -> None: """Initialize the Axis config flow.""" @@ -264,7 +266,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN): return await self.async_step_user() -class AxisOptionsFlowHandler(OptionsFlowWithConfigEntry): +class AxisOptionsFlowHandler(OptionsFlow): """Handle Axis device options.""" config_entry: AxisConfigEntry diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 37ff1eb374c..cd43325f129 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -145,10 +145,10 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> BMWOptionsFlow: """Return a MyBMW option flow.""" - return BMWOptionsFlow(config_entry) + return BMWOptionsFlow() -class BMWOptionsFlow(OptionsFlowWithConfigEntry): +class BMWOptionsFlow(OptionsFlow): """Handle a option flow for MyBMW.""" async def async_step_init( diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 6dda0c03910..8c2cfa5e556 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -101,7 +101,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> DnsIPOptionsFlowHandler: """Return Option handler.""" - return DnsIPOptionsFlowHandler(config_entry) + return DnsIPOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ) -class DnsIPOptionsFlowHandler(OptionsFlowWithConfigEntry): +class DnsIPOptionsFlowHandler(OptionsFlow): """Handle a option config flow for dnsip integration.""" async def async_step_init( diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index fdd5d29788e..fa684188713 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -1,5 +1,7 @@ """Configflow for the emoncms integration.""" +from __future__ import annotations + from typing import Any from pyemoncms import EmoncmsClient @@ -9,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant, callback @@ -68,9 +70,9 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> EmoncmsOptionsFlow: """Get the options flow for this handler.""" - return EmoncmsOptionsFlow(config_entry) + return EmoncmsOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -167,7 +169,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): return result -class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry): +class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" async def async_step_init( @@ -175,7 +177,7 @@ class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry): ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} - data = self.options if self.options else self._config_entry.data + data = self.options if self.options else self.config_entry.data url = data[CONF_URL] api_key = data[CONF_API_KEY] include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index d04f77d8e88..23c769293c8 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -66,9 +66,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> EnvoyOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> EnvoyOptionsFlowHandler: """Options flow handler for Enphase_Envoy.""" - return EnvoyOptionsFlowHandler(config_entry) + return EnvoyOptionsFlowHandler() @callback def _async_generate_schema(self) -> vol.Schema: @@ -288,7 +290,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EnvoyOptionsFlowHandler(OptionsFlowWithConfigEntry): +class EnvoyOptionsFlowHandler(OptionsFlow): """Envoy config flow options handler.""" async def async_step_init( diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 2a73e24a3e5..1a19f612e7e 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -46,9 +45,11 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: """Get the options flow for this handler.""" - return FeedReaderOptionsFlowHandler(config_entry) + return FeedReaderOptionsFlowHandler() def show_user_form( self, @@ -147,7 +148,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reconfigure_successful") -class FeedReaderOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FeedReaderOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index d74e36ce935..2b8a9bde749 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -1,5 +1,7 @@ """Config flow for file integration.""" +from __future__ import annotations + from copy import deepcopy import os from typing import Any @@ -11,7 +13,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -74,9 +75,11 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FileOptionsFlowHandler: """Get the options flow for this handler.""" - return FileOptionsFlowHandler(config_entry) + return FileOptionsFlowHandler() async def validate_file_path(self, file_path: str) -> bool: """Ensure the file path is valid.""" @@ -151,7 +154,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=data, options=options) -class FileOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FileOptionsFlowHandler(OptionsFlow): """Handle File options.""" async def async_step_init( diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 0d27894c8ab..38e86519a01 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -23,7 +23,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_HOST, @@ -60,9 +59,11 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FritzBoxToolsOptionsFlowHandler: """Get the options flow for this handler.""" - return FritzBoxToolsOptionsFlowHandler(config_entry) + return FritzBoxToolsOptionsFlowHandler() def __init__(self) -> None: """Initialize FRITZ!Box Tools flow.""" @@ -393,7 +394,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithConfigEntry): +class FritzBoxToolsOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index dec849de4e6..8b8fd751df9 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -135,10 +135,10 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> GoogleCloudOptionsFlowHandler: """Create the options flow.""" - return GoogleCloudOptionsFlowHandler(config_entry) + return GoogleCloudOptionsFlowHandler() -class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry): +class GoogleCloudOptionsFlowHandler(OptionsFlow): """Google Cloud options flow.""" async def async_step_init( diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 5bbb8599cf2..994c53b5b3e 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_NAME, @@ -213,12 +213,12 @@ class IMAPConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> ImapOptionsFlow: """Get the options flow for this handler.""" - return OptionsFlow(config_entry) + return ImapOptionsFlow() -class OptionsFlow(OptionsFlowWithConfigEntry): +class ImapOptionsFlow(OptionsFlow): """Option flow handler.""" async def async_step_init( @@ -226,13 +226,13 @@ class OptionsFlow(OptionsFlowWithConfigEntry): ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] | None = None - entry_data: dict[str, Any] = dict(self._config_entry.data) + entry_data: dict[str, Any] = dict(self.config_entry.data) if user_input is not None: try: self._async_abort_entries_match( { - CONF_SERVER: self._config_entry.data[CONF_SERVER], - CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_SERVER: self.config_entry.data[CONF_SERVER], + CONF_USERNAME: self.config_entry.data[CONF_USERNAME], CONF_FOLDER: user_input[CONF_FOLDER], CONF_SEARCH: user_input[CONF_SEARCH], } diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index f60d96f3efa..0c170d2485f 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -8,11 +8,7 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ( - ConfigFlow, - ConfigFlowResult, - OptionsFlowWithConfigEntry, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback from homeassistant.util.uuid import random_uuid_hex @@ -143,12 +139,12 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: JellyfinConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> OptionsFlowHandler: """Create the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle an option flow for jellyfin.""" async def async_step_init( diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index f96699d01bd..9673fc6cf22 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_ELEVATION, @@ -90,9 +90,11 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithConfigEntry: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> JewishCalendarOptionsFlowHandler: """Get the options flow for this handler.""" - return JewishCalendarOptionsFlowHandler(config_entry) + return JewishCalendarOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -145,7 +147,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlowWithConfigEntry): +class JewishCalendarOptionsFlowHandler(OptionsFlow): """Handle Jewish Calendar options.""" async def async_step_init( diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 986879e3058..74e738a0e04 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.core import callback @@ -33,7 +33,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -54,7 +54,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 43221eed584..bcb55a19275 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -1,5 +1,7 @@ """Config flow for La Marzocco integration.""" +from __future__ import annotations + from collections.abc import Mapping import logging from typing import Any @@ -22,7 +24,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_HOST, @@ -339,12 +340,12 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> LmOptionsFlowHandler: """Create the options flow.""" - return LmOptionsFlowHandler(config_entry) + return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlowWithConfigEntry): +class LmOptionsFlowHandler(OptionsFlow): """Handles options flow for the component.""" async def async_step_init( diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index c6ea120242d..d460792f7c8 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback @@ -80,7 +80,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> LastFmOptionsFlowHandler: """Get the options flow for this handler.""" - return LastFmOptionsFlowHandler(config_entry) + return LastFmOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -155,7 +155,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): +class LastFmOptionsFlowHandler(OptionsFlow): """LastFm Options flow handler.""" async def async_step_init( diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 84a44682413..62964d22bb1 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import ( CONF_ELEVATION, @@ -143,12 +142,12 @@ class MetConfigFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> MetOptionsFlowHandler: """Get the options flow for Met.""" - return MetOptionsFlowHandler(config_entry) + return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlowWithConfigEntry): +class MetOptionsFlowHandler(OptionsFlow): """Options flow for Met component.""" async def async_step_init( @@ -159,13 +158,13 @@ class MetOptionsFlowHandler(OptionsFlowWithConfigEntry): if user_input is not None: # Update config entry with data from user input self.hass.config_entries.async_update_entry( - self._config_entry, data=user_input + self.config_entry, data=user_input ) return self.async_create_entry( - title=self._config_entry.title, data=user_input + title=self.config_entry.title, data=user_input ) return self.async_show_form( step_id="init", - data_schema=_get_data_schema(self.hass, config_entry=self._config_entry), + data_schema=_get_data_schema(self.hass, config_entry=self.config_entry), ) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index a217674e3b4..3ee0563410c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -100,12 +100,14 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OnewireOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" - return OnewireOptionsFlowHandler(config_entry) + return OnewireOptionsFlowHandler() -class OnewireOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OnewireOptionsFlowHandler(OptionsFlow): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 3cfd1ad30a0..f0f599628cb 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_LATITUDE, @@ -45,7 +45,7 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OpenSkyOptionsFlowHandler: """Get the options flow for this handler.""" - return OpenSkyOptionsFlowHandler(config_entry) + return OpenSkyOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,7 +83,7 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OpenSkyOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OpenSkyOptionsFlowHandler(OptionsFlow): """OpenSky Options flow handler.""" async def async_step_init( diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 67f9de458d0..af80c40b75b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_TOKEN, CONF_NAME from homeassistant.core import callback @@ -56,7 +56,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> PVPCOptionsFlowHandler: """Get the options flow for this handler.""" - return PVPCOptionsFlowHandler(config_entry) + return PVPCOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -178,7 +178,7 @@ class TariffSelectorConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm", data_schema=data_schema) -class PVPCOptionsFlowHandler(OptionsFlowWithConfigEntry): +class PVPCOptionsFlowHandler(OptionsFlow): """Handle PVPC options.""" _power: float | None = None diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 06fbf3e717e..e01bb904adf 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -24,7 +24,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -171,12 +170,12 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> RoborockOptionsFlowHandler: """Create the options flow.""" - return RoborockOptionsFlowHandler(config_entry) + return RoborockOptionsFlowHandler() -class RoborockOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" async def async_step_init( diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 3ece9aff3f2..a99c475f515 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -165,12 +165,12 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowWithConfigEntry: + ) -> RokuOptionsFlowHandler: """Create the options flow.""" - return RokuOptionsFlowHandler(config_entry) + return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RokuOptionsFlowHandler(OptionsFlow): """Handle Roku options.""" async def async_step_init( diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index d0c29faca69..a53f0ac857f 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback @@ -92,7 +92,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> RoombaOptionsFlowHandler: """Get the options flow for this handler.""" - return RoombaOptionsFlowHandler(config_entry) + return RoombaOptionsFlowHandler() async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo @@ -300,7 +300,7 @@ class RoombaConfigFlow(ConfigFlow, domain=DOMAIN): ) -class RoombaOptionsFlowHandler(OptionsFlowWithConfigEntry): +class RoombaOptionsFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 5537c7ff3b0..9f0614fae89 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -144,7 +144,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> SQLOptionsFlowHandler: """Get the options flow for this handler.""" - return SQLOptionsFlowHandler(config_entry) + return SQLOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -209,7 +209,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(OptionsFlowWithConfigEntry): +class SQLOptionsFlowHandler(OptionsFlow): """Handle SQL options.""" async def async_step_init( diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index a9eefd09b9b..b3b8180a08d 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -132,7 +132,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> TVTrainOptionsFlowHandler: """Get the options flow for this handler.""" - return TVTrainOptionsFlowHandler(config_entry) + return TVTrainOptionsFlowHandler() async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -229,7 +229,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): ) -class TVTrainOptionsFlowHandler(OptionsFlowWithConfigEntry): +class TVTrainOptionsFlowHandler(OptionsFlow): """Handle Trafikverket Train options.""" async def async_step_init( diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 1a40d4b3442..5f1fdbee88f 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import HomeAssistant, callback @@ -94,9 +93,11 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> UpnpOptionsFlowHandler: """Get the options flow for this handler.""" - return UpnpOptionsFlowHandler(config_entry) + return UpnpOptionsFlowHandler() @property def _discoveries(self) -> dict[str, SsdpServiceInfo]: @@ -299,7 +300,7 @@ class UpnpFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=title, data=data, options=options) -class UpnpOptionsFlowHandler(OptionsFlowWithConfigEntry): +class UpnpOptionsFlowHandler(OptionsFlow): """Handle an options flow.""" async def async_step_init( diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c373520bc58..288ebeb9a07 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -63,9 +62,11 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> VodafoneStationOptionsFlowHandler: """Get the options flow for this handler.""" - return VodafoneStationOptionsFlowHandler(config_entry) + return VodafoneStationOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -143,7 +144,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): ) -class VodafoneStationOptionsFlowHandler(OptionsFlowWithConfigEntry): +class VodafoneStationOptionsFlowHandler(OptionsFlow): """Handle a option flow.""" async def async_step_init( diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2798e0d46d1..67f2f60d13e 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -30,9 +30,11 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> WLEDOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WLEDOptionsFlowHandler: """Get the options flow for this handler.""" - return WLEDOptionsFlowHandler(config_entry) + return WLEDOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -117,7 +119,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlowWithConfigEntry): +class WLEDOptionsFlowHandler(OptionsFlow): """Handle WLED options.""" async def async_step_init( diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 2552fe849e2..759cc13aecf 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -219,7 +219,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> WorkdayOptionsFlowHandler: """Get the options flow for this handler.""" - return WorkdayOptionsFlowHandler(config_entry) + return WorkdayOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -310,7 +310,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): +class WorkdayOptionsFlowHandler(OptionsFlow): """Handle Workday options.""" async def async_step_init( @@ -340,7 +340,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): else: LOGGER.debug("abort_check in options with %s", combined_input) abort_match = { - CONF_COUNTRY: self._config_entry.options.get(CONF_COUNTRY), + CONF_COUNTRY: self.config_entry.options.get(CONF_COUNTRY), CONF_EXCLUDES: combined_input[CONF_EXCLUDES], CONF_OFFSET: combined_input[CONF_OFFSET], CONF_WORKDAYS: combined_input[CONF_WORKDAYS], diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index 8d6c7753282..d03beffdb49 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import callback @@ -54,7 +54,7 @@ class OAuth2FlowHandler( config_entry: ConfigEntry, ) -> YouTubeOptionsFlowHandler: """Get the options flow for this handler.""" - return YouTubeOptionsFlowHandler(config_entry) + return YouTubeOptionsFlowHandler() @property def logger(self) -> logging.Logger: @@ -159,7 +159,7 @@ class OAuth2FlowHandler( ) -class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): +class YouTubeOptionsFlowHandler(OptionsFlow): """YouTube Options flow handler.""" async def async_step_init( From c2ef119e504fe17482811e67d882dd6ffbf08df5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 3 Nov 2024 16:38:52 -0600 Subject: [PATCH 039/511] Add HassRespond intent (#129755) * Add HassHello intent * Rename to HassRespond * LLM's ignore HassRespond intent --- homeassistant/components/intent/__init__.py | 14 +++++++++++++- homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 85fdf5c88c3..1322576f115 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -137,6 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, TimerStatusIntentHandler()) intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) + intent.async_register(hass, HelloIntentHandler()) return True @@ -364,7 +365,7 @@ class NevermindIntentHandler(intent.IntentHandler): description = "Cancels the current request and does nothing" async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Doe not do anything, and produces an empty response.""" + """Do nothing and produces an empty response.""" return intent_obj.create_response() @@ -420,6 +421,17 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler): return response +class HelloIntentHandler(intent.IntentHandler): + """Responds with no action.""" + + intent_type = intent.INTENT_RESPOND + description = "Returns the provided response with no action." + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Return the provided response, but take no action.""" + return intent_obj.create_response() + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 6bd02b8660a..b38f769b302 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -56,6 +56,7 @@ INTENT_UNPAUSE_TIMER = "HassUnpauseTimer" INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" +INTENT_RESPOND = "HassRespond" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 39dff04fb7c..d322810b0ef 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -279,6 +279,7 @@ class AssistAPI(API): intent.INTENT_TOGGLE, intent.INTENT_GET_CURRENT_DATE, intent.INTENT_GET_CURRENT_TIME, + intent.INTENT_RESPOND, } def __init__(self, hass: HomeAssistant) -> None: From f11aba96486743ca4e8ab40c4d430b840d649a05 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 00:25:37 +0100 Subject: [PATCH 040/511] Fix flaky tests in advantage_air (#129758) --- .../advantage_air/test_binary_sensor.py | 44 ++++++------------- tests/components/advantage_air/test_sensor.py | 24 +++------- 2 files changed, 20 insertions(+), 48 deletions(-) diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 7a7b2f8df5b..d0088d96ba5 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,10 +1,8 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch -from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -70,22 +68,14 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) mock_get.reset_mock() - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state @@ -101,22 +91,14 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) mock_get.reset_mock() - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 4389e67228a..3ea368a59fb 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,15 +1,13 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch -from homeassistant.components.advantage_air import ADVANTAGE_AIR_SYNC_INTERVAL from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, ADVANTAGE_AIR_SET_COUNTDOWN_VALUE, ) -from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -124,23 +122,15 @@ async def test_sensor_platform_disabled_entity( assert not hass.states.get(entity_id) - entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) - await hass.async_block_till_done(wait_background_tasks=True) mock_get.reset_mock() - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 1 + with patch("homeassistant.config_entries.RELOAD_AFTER_UPDATE_DELAY", 1): + entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done(wait_background_tasks=True) - async_fire_time_changed( - hass, - dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), - ) - await hass.async_block_till_done(wait_background_tasks=True) - assert len(mock_get.mock_calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) + await hass.async_block_till_done(wait_background_tasks=True) + assert len(mock_get.mock_calls) == 1 state = hass.states.get(entity_id) assert state From a05a34239d3898876afe7c347b15a065a492a77e Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Sun, 3 Nov 2024 15:27:27 -0800 Subject: [PATCH 041/511] Show NUT device serial number if provided in Device Info (#124168) --- homeassistant/components/nut/__init__.py | 5 ++++- homeassistant/components/nut/sensor.py | 2 ++ tests/components/nut/test_init.py | 26 +++++++++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index c9b2bcc13b2..6bbe19e8f3c 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -131,6 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: manufacturer=data.device_info.manufacturer, model=data.device_info.model, sw_version=data.device_info.firmware, + serial_number=data.device_info.serial, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -209,6 +210,7 @@ class NUTDeviceInfo: manufacturer: str | None = None model: str | None = None firmware: str | None = None + serial: str | None = None class PyNUTData: @@ -268,7 +270,8 @@ class PyNUTData: manufacturer = _manufacturer_from_status(self._status) model = _model_from_status(self._status) firmware = _firmware_from_status(self._status) - return NUTDeviceInfo(manufacturer, model, firmware) + serial = _serial_from_status(self._status) + return NUTDeviceInfo(manufacturer, model, firmware, serial) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 7f211d5452b..bb702873052 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_SERIAL_NUMBER, ATTR_SW_VERSION, PERCENTAGE, STATE_UNKNOWN, @@ -42,6 +43,7 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = { "manufacturer": ATTR_MANUFACTURER, "model": ATTR_MODEL, "firmware": ATTR_SW_VERSION, + "serial": ATTR_SERIAL_NUMBER, } _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index 61a5187407b..cd56c209a36 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -8,8 +8,9 @@ from homeassistant.components.nut.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr -from .util import _get_mock_nutclient +from .util import _get_mock_nutclient, async_init_integration from tests.common import MockConfigEntry @@ -96,3 +97,26 @@ async def test_auth_fails(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" + + +async def test_serial_number(hass: HomeAssistant) -> None: + """Test for serial number set on device.""" + mock_serial_number = "A00000000000" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.serial": mock_serial_number}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.serial_number == mock_serial_number From 87ab2beddff0063ad9bce2b3d998cf18df95300f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:16:49 +1300 Subject: [PATCH 042/511] Only set ESPHome configuration url to addon if there is an existing configuration for the device (#129356) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c36a55d1f55..afbe109d5bc 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,7 +570,9 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif dashboard := async_get_dashboard(hass): + elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( + device_info.name + ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" manufacturer = "espressif" From 38afcbb21ff2ce6f134612245ac3c64ac22e9296 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 4 Nov 2024 04:56:45 +0100 Subject: [PATCH 043/511] Bump python-linkplay to 0.0.17 (#129683) --- homeassistant/components/linkplay/manifest.json | 2 +- homeassistant/components/linkplay/media_player.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index dd1e08eda49..f2b2e2da00c 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.15"], + "requirements": ["python-linkplay==0.0.17"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 5e667af37ad..36834610c04 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -48,6 +48,7 @@ STATE_MAP: dict[PlayingStatus, MediaPlayerState] = { } SOURCE_MAP: dict[PlayingMode, str] = { + PlayingMode.NETWORK: "Wifi", PlayingMode.LINE_IN: "Line In", PlayingMode.BLUETOOTH: "Bluetooth", PlayingMode.OPTICAL: "Optical", diff --git a/requirements_all.txt b/requirements_all.txt index 02c6853edae..b200ce519d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2359,7 +2359,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21040bf22ca..9294cc5f32d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1886,7 +1886,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.matter python-matter-server==6.6.0 From 49f0bb6990903ac49b6680ebe568ccef38be832a Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 4 Nov 2024 05:30:21 +0100 Subject: [PATCH 044/511] Bump plugwise to v1.5.0 (#129668) * Bump plugwise to v1.5.0 * And adapt --- homeassistant/components/plugwise/config_flow.py | 1 - homeassistant/components/plugwise/coordinator.py | 1 - homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index b0d68aaa33b..57abb1ccb86 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -71,7 +71,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Smile: password=data[CONF_PASSWORD], port=data[CONF_PORT], username=data[CONF_USERNAME], - timeout=30, websession=websession, ) await api.connect() diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index da2ef810d35..b897a8bf833 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -54,7 +54,6 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): username=self.config_entry.data.get(CONF_USERNAME, DEFAULT_USERNAME), password=self.config_entry.data[CONF_PASSWORD], port=self.config_entry.data.get(CONF_PORT, DEFAULT_PORT), - timeout=30, websession=async_get_clientsession(hass, verify_ssl=False), ) self._current_devices: set[str] = set() diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index a4253a30cb5..dbbad15c0dc 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.4.4"], + "requirements": ["plugwise==1.5.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b200ce519d7..27413878f25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1619,7 +1619,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.4.4 +plugwise==1.5.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9294cc5f32d..ede9e480345 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1326,7 +1326,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.4.4 +plugwise==1.5.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 6718cce203fbfb2566bca1c5ee7c894cf727502b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Nov 2024 20:45:09 -0800 Subject: [PATCH 045/511] Fix nest streams broken due to CameraCapabilities change (#129711) * Fix nest streams broken due to CameraCapabilities change * Fix stream cleanup * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Update homeassistant/components/nest/camera.py --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/camera.py | 230 +++++++++++---------- tests/components/nest/test_camera.py | 79 ++++--- tests/components/nest/test_media_source.py | 7 +- 3 files changed, 181 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 737c0a77bed..30f96f819c1 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,19 +2,17 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import datetime import functools import logging from pathlib import Path -from typing import cast from google_nest_sdm.camera_traits import ( - CameraImageTrait, CameraLiveStreamTrait, RtspStream, - Stream, StreamingProtocol, WebRtcStream, ) @@ -57,19 +55,25 @@ async def async_setup_entry( device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ DATA_DEVICE_MANAGER ] - async_add_entities( - NestCamera(device) - for device in device_manager.devices.values() - if CameraImageTrait.NAME in device.traits - or CameraLiveStreamTrait.NAME in device.traits - ) + entities: list[NestCameraBaseEntity] = [] + for device in device_manager.devices.values(): + if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: + continue + if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: + entities.append(NestWebRTCEntity(device)) + elif StreamingProtocol.RTSP in live_stream.supported_protocols: + entities.append(NestRTSPEntity(device)) + + async_add_entities(entities) -class NestCamera(Camera): +class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" _attr_has_entity_name = True _attr_name = None + _attr_is_streaming = True + _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, device: Device) -> None: """Initialize the camera.""" @@ -79,39 +83,74 @@ class NestCamera(Camera): self._attr_device_info = nest_device_info.device_info self._attr_brand = nest_device_info.device_brand self._attr_model = nest_device_info.device_model - self._rtsp_stream: RtspStream | None = None - self._webrtc_sessions: dict[str, WebRtcStream] = {} - self._create_stream_url_lock = asyncio.Lock() - self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = False - self._attr_supported_features = CameraEntityFeature(0) - self._rtsp_live_stream_trait: CameraLiveStreamTrait | None = None - if CameraLiveStreamTrait.NAME in self._device.traits: - self._attr_is_streaming = True - self._attr_supported_features |= CameraEntityFeature.STREAM - trait = cast( - CameraLiveStreamTrait, self._device.traits[CameraLiveStreamTrait.NAME] - ) - if StreamingProtocol.RTSP in trait.supported_protocols: - self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" + self._stream_refresh_unsub: Callable[[], None] | None = None + + @abstractmethod + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + + @abstractmethod + async def _async_refresh_stream(self) -> None: + """Refresh any stream to extend expiration time.""" + + def _schedule_stream_refresh(self) -> None: + """Schedules an alarm to refresh any streams before expiration.""" + if self._stream_refresh_unsub is not None: + self._stream_refresh_unsub() + + expiration_time = self._stream_expires_at() + if not expiration_time: + return + refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER + _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) + + self._stream_refresh_unsub = async_track_point_in_utc_time( + self.hass, + self._handle_stream_refresh, + refresh_time, + ) + + async def _handle_stream_refresh(self, _: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + _LOGGER.debug("Examining streams to refresh") + self._stream_refresh_unsub = None + try: + await self._async_refresh_stream() + finally: + self._schedule_stream_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._stream_refresh_unsub: + self._stream_refresh_unsub() + + +class NestRTSPEntity(NestCameraBaseEntity): + """Nest cameras that use RTSP.""" + + _rtsp_stream: RtspStream | None = None + _rtsp_live_stream_trait: CameraLiveStreamTrait + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._create_stream_url_lock = asyncio.Lock() + self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] @property def use_stream_for_stills(self) -> bool: - """Whether or not to use stream to generate stills.""" - return self._rtsp_live_stream_trait is not None - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC in trait.supported_protocols: - return StreamType.WEB_RTC - return super().frontend_stream_type + """Always use the RTSP stream to generate snapshots.""" + return True @property def available(self) -> bool: @@ -125,8 +164,6 @@ class NestCamera(Camera): async def stream_source(self) -> str | None: """Return the source of the stream.""" - if not self._rtsp_live_stream_trait: - return None async with self._create_stream_url_lock: if not self._rtsp_stream: _LOGGER.debug("Fetching stream url") @@ -142,50 +179,14 @@ class NestCamera(Camera): _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url - def _all_streams(self) -> list[Stream]: - """Return the current list of active streams.""" - streams: list[Stream] = [] - if self._rtsp_stream: - streams.append(self._rtsp_stream) - streams.extend(list(self._webrtc_sessions.values())) - return streams + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + return self._rtsp_stream.expires_at if self._rtsp_stream else None - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh any streams before expiration.""" - # Schedule an alarm to extend the stream - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - _LOGGER.debug("Scheduling next stream refresh") - expiration_times = [stream.expires_at for stream in self._all_streams()] - if not expiration_times: - _LOGGER.debug("No streams to refresh") - return - - refresh_time = min(expiration_times) - STREAM_EXPIRATION_BUFFER - _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, _: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - _LOGGER.debug("Examining streams to refresh") - await self._handle_rtsp_stream_refresh() - await self._handle_webrtc_stream_refresh() - self._schedule_stream_refresh() - - async def _handle_rtsp_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" if not self._rtsp_stream: return - now = utcnow() - refresh_time = self._rtsp_stream.expires_at - STREAM_EXPIRATION_BUFFER - if now < refresh_time: - return _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() @@ -201,8 +202,38 @@ class NestCamera(Camera): if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) - async def _handle_webrtc_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._rtsp_stream: + try: + await self._rtsp_stream.stop_stream() + except ApiException as err: + _LOGGER.debug("Error stopping stream: %s", err) + self._rtsp_stream = None + + +class NestWebRTCEntity(NestCameraBaseEntity): + """Nest cameras that use WebRTC.""" + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._webrtc_sessions: dict[str, WebRtcStream] = {} + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + if not self._webrtc_sessions: + return None + return min(stream.expires_at for stream in self._webrtc_sessions.values()) + + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" now = utcnow() for webrtc_stream in list(self._webrtc_sessions.values()): if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): @@ -218,32 +249,10 @@ class NestCamera(Camera): else: self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - for stream in self._all_streams(): - _LOGGER.debug("Invalidating stream") - try: - await stream.stop_stream() - except ApiException as err: - _LOGGER.debug("Error stopping stream: %s", err) - self._rtsp_stream = None - self._webrtc_sessions.clear() - - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return bytes of camera image.""" - # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) as a fallback when 'use_stream_for_stills' if False + """Return a placeholder image for WebRTC cameras that don't support snapshots.""" return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod @@ -257,11 +266,6 @@ class NestCamera(Camera): ) -> None: """Return the source of the stream.""" trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC not in trait.supported_protocols: - await super().async_handle_async_webrtc_offer( - offer_sdp, session_id, send_message - ) - return try: stream = await trait.generate_web_rtc_stream(offer_sdp) except ApiException as err: @@ -294,3 +298,9 @@ class NestCamera(Camera): def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration adjustable per integration.""" return WebRTCClientConfiguration(data_channel="dataSendChannel") + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + for session_id in list(self._webrtc_sessions.keys()): + self.close_webrtc_session(session_id) diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 6417fa4ebe9..500dbc0f46f 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -28,7 +28,7 @@ from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup from .conftest import FakeAuth from tests.common import async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator PLATFORM = "camera" CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" @@ -176,6 +176,30 @@ async def async_get_image( return image.content +def get_frontend_stream_type_attribute( + hass: HomeAssistant, entity_id: str +) -> StreamType: + """Get the frontend_stream_type camera attribute.""" + cam = hass.states.get(entity_id) + assert cam is not None + assert cam.state == CameraState.STREAMING + return cam.attributes.get("frontend_stream_type") + + +async def async_frontend_stream_types( + client: MockHAClientWebSocket, entity_id: str +) -> list[str] | None: + """Get the frontend stream types supported.""" + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": entity_id} + ) + msg = await client.receive_json() + assert msg.get("type") == TYPE_RESULT + assert msg.get("success") + assert msg.get("result") + return msg["result"].get("frontend_stream_types") + + async def fire_alarm(hass: HomeAssistant, point_in_time: datetime.datetime) -> None: """Fire an alarm and wait for callbacks to run.""" with freeze_time(point_in_time): @@ -237,16 +261,21 @@ async def test_camera_stream( camera_device: None, auth: FakeAuth, mock_create_stream: Mock, + hass_ws_client: WebSocketGenerator, ) -> None: """Test a basic camera and fetch its live stream.""" auth.responses = [make_stream_url_response()] await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) + client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" @@ -265,12 +294,16 @@ async def test_camera_ws_stream( await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] + await client.send_json( { "id": 2, @@ -322,7 +355,7 @@ async def test_camera_ws_stream_failure( async def test_camera_stream_missing_trait( hass: HomeAssistant, setup_platform, create_device ) -> None: - """Test fetching a video stream when not supported by the API.""" + """Test that cameras missing a live stream are not supported.""" create_device.create( { "sdm.devices.traits.Info": { @@ -338,16 +371,7 @@ async def test_camera_stream_missing_trait( ) await setup_platform() - assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.IDLE - - stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source is None - - # Fallback to placeholder image - await async_get_image(hass) + assert len(hass.states.async_all()) == 0 async def test_refresh_expired_stream_token( @@ -655,6 +679,15 @@ async def test_camera_web_rtc_unsupported( assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": "camera.my_camera"} + ) + msg = await client.receive_json() + + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"frontend_stream_types": ["hls"]} + await client.send_json_auto_id( { "type": "camera/webrtc/offer", @@ -732,8 +765,6 @@ async def test_camera_multiple_streams( """Test a camera supporting multiple stream types.""" expiration = utcnow() + datetime.timedelta(seconds=100) auth.responses = [ - # RTSP response - make_stream_url_response(), # WebRTC response aiohttp.web.json_response( { @@ -770,9 +801,9 @@ async def test_camera_multiple_streams( # Prefer WebRTC over RTSP/HLS assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC - # RTSP stream + # RTSP stream is not supported stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" + assert not stream_source # WebRTC stream client = await hass_ws_client(hass) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 101bfae089d..2526bfdf975 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -48,6 +48,9 @@ CAMERA_TRAITS = { "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraImage": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["RTSP"], + }, "sdm.devices.traits.CameraEventImage": {}, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, @@ -57,7 +60,9 @@ BATTERY_CAMERA_TRAITS = { "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraClipPreview": {}, - "sdm.devices.traits.CameraLiveStream": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["WEB_RTC"], + }, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, } From 04aee812f87c164c5bc4019a56bed81014ebbc10 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 4 Nov 2024 15:17:50 +0900 Subject: [PATCH 046/511] Bump thinqconnect to 1.0.0 (#129769) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 52eb3c31aef..665a5a9e179 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==0.9.9"] + "requirements": ["thinqconnect==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27413878f25..bad52c5b87e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2828,7 +2828,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ede9e480345..3917267e661 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2250,7 +2250,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From eda36512ec909bed9fc2111c4bc04ae70deb9092 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 3 Nov 2024 23:49:48 -0700 Subject: [PATCH 047/511] Change alexa arm handler to allow switching arm states unless in armed_away mode (#129701) * Change alexa arm handler to allow switching arm states unless in armed_away mode * Address PR comments --- homeassistant/components/alexa/handlers.py | 8 +- tests/components/alexa/test_smart_home.py | 102 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index d2f6c292e6f..8ea61ddbceb 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1083,7 +1083,13 @@ async def async_api_arm( arm_state = directive.payload["armState"] data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED: + # Per Alexa Documentation: users are not allowed to switch from armed_away + # directly to another armed state without first disarming the system. + # https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming + if ( + entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY + and arm_state != "ARMED_AWAY" + ): msg = "You must disarm the system before you can set the requested arm state." raise AlexaSecurityPanelAuthorizationRequired(msg) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4ae78421596..68010a6a711 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3999,6 +3999,108 @@ async def test_alarm_control_panel_code_arm_required(hass: HomeAssistant) -> Non await discovery_test(device, hass, expected_endpoints=0) +async def test_alarm_control_panel_disarm_required(hass: HomeAssistant) -> None: + """Test alarm_control_panel disarm required.""" + device = ( + "alarm_control_panel.test_4", + "armed_away", + { + "friendly_name": "Test Alarm Control Panel 4", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_4" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 4" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_4") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + msg = await assert_request_fails( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_home", + hass, + payload={"armState": "ARMED_STAY"}, + ) + assert msg["event"]["payload"]["type"] == "AUTHORIZATION_REQUIRED" + assert ( + msg["event"]["payload"]["message"] + == "You must disarm the system before you can set the requested arm state." + ) + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + +async def test_alarm_control_panel_change_arm_type(hass: HomeAssistant) -> None: + """Test alarm_control_panel change arm type.""" + device = ( + "alarm_control_panel.test_5", + "armed_home", + { + "friendly_name": "Test Alarm Control Panel 5", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_5" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 5" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_5") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_home", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_STAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + async def test_range_unsupported_domain(hass: HomeAssistant) -> None: """Test rangeController with unsupported domain.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) From 7ab8ff56b31e4a6a96fb80cb64e0e9039ffb2e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Mon, 4 Nov 2024 08:11:18 +0100 Subject: [PATCH 048/511] Bump Airthings BLE to 0.9.2 (#129659) Bump airthings ble --- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 6c00fe79e7b..fe2cc0eeb36 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.9.1"] + "requirements": ["airthings-ble==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index bad52c5b87e..8e05edf10dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3917267e661..6479de6cd7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -408,7 +408,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 From d501bb8d52f553ed51f4c91dec524e19dfa24dcb Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:16:49 +1300 Subject: [PATCH 049/511] Only set ESPHome configuration url to addon if there is an existing configuration for the device (#129356) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index c36a55d1f55..afbe109d5bc 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,7 +570,9 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif dashboard := async_get_dashboard(hass): + elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( + device_info.name + ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" manufacturer = "espressif" From a898a5996ef12e8ba8b406a3c21ed0d3232d8351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Mon, 4 Nov 2024 08:11:18 +0100 Subject: [PATCH 050/511] Bump Airthings BLE to 0.9.2 (#129659) Bump airthings ble --- homeassistant/components/airthings_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index 6c00fe79e7b..fe2cc0eeb36 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", "iot_class": "local_polling", - "requirements": ["airthings-ble==0.9.1"] + "requirements": ["airthings-ble==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c8cb043632f..d426eaf626b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -426,7 +426,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41f949904e6..cd20bdfd5c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -408,7 +408,7 @@ airgradient==0.9.1 airly==1.1.0 # homeassistant.components.airthings_ble -airthings-ble==0.9.1 +airthings-ble==0.9.2 # homeassistant.components.airthings airthings-cloud==0.2.0 From e72716222558cbf91f0106682b1a173510e7168b Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 4 Nov 2024 04:56:45 +0100 Subject: [PATCH 051/511] Bump python-linkplay to 0.0.17 (#129683) --- homeassistant/components/linkplay/manifest.json | 2 +- homeassistant/components/linkplay/media_player.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index dd1e08eda49..f2b2e2da00c 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.15"], + "requirements": ["python-linkplay==0.0.17"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 5e667af37ad..36834610c04 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -48,6 +48,7 @@ STATE_MAP: dict[PlayingStatus, MediaPlayerState] = { } SOURCE_MAP: dict[PlayingMode, str] = { + PlayingMode.NETWORK: "Wifi", PlayingMode.LINE_IN: "Line In", PlayingMode.BLUETOOTH: "Bluetooth", PlayingMode.OPTICAL: "Optical", diff --git a/requirements_all.txt b/requirements_all.txt index d426eaf626b..db6d3a35f0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,7 +2356,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd20bdfd5c0..768ecf4191b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.6 # homeassistant.components.linkplay -python-linkplay==0.0.15 +python-linkplay==0.0.17 # homeassistant.components.matter python-matter-server==6.6.0 From 453039e8601dcc3adecc61aca488e43bf0a3d03c Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 3 Nov 2024 23:49:48 -0700 Subject: [PATCH 052/511] Change alexa arm handler to allow switching arm states unless in armed_away mode (#129701) * Change alexa arm handler to allow switching arm states unless in armed_away mode * Address PR comments --- homeassistant/components/alexa/handlers.py | 8 +- tests/components/alexa/test_smart_home.py | 102 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index d2f6c292e6f..8ea61ddbceb 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1083,7 +1083,13 @@ async def async_api_arm( arm_state = directive.payload["armState"] data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - if entity.state != alarm_control_panel.AlarmControlPanelState.DISARMED: + # Per Alexa Documentation: users are not allowed to switch from armed_away + # directly to another armed state without first disarming the system. + # https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-securitypanelcontroller.html#arming + if ( + entity.state == alarm_control_panel.AlarmControlPanelState.ARMED_AWAY + and arm_state != "ARMED_AWAY" + ): msg = "You must disarm the system before you can set the requested arm state." raise AlexaSecurityPanelAuthorizationRequired(msg) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 4ae78421596..68010a6a711 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3999,6 +3999,108 @@ async def test_alarm_control_panel_code_arm_required(hass: HomeAssistant) -> Non await discovery_test(device, hass, expected_endpoints=0) +async def test_alarm_control_panel_disarm_required(hass: HomeAssistant) -> None: + """Test alarm_control_panel disarm required.""" + device = ( + "alarm_control_panel.test_4", + "armed_away", + { + "friendly_name": "Test Alarm Control Panel 4", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_4" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 4" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_4") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + msg = await assert_request_fails( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_home", + hass, + payload={"armState": "ARMED_STAY"}, + ) + assert msg["event"]["payload"]["type"] == "AUTHORIZATION_REQUIRED" + assert ( + msg["event"]["payload"]["message"] + == "You must disarm the system before you can set the requested arm state." + ) + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_4", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + +async def test_alarm_control_panel_change_arm_type(hass: HomeAssistant) -> None: + """Test alarm_control_panel change arm type.""" + device = ( + "alarm_control_panel.test_5", + "armed_home", + { + "friendly_name": "Test Alarm Control Panel 5", + "code_arm_required": False, + "code_format": "FORMAT_NUMBER", + "code": "1234", + "supported_features": 3, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "alarm_control_panel#test_5" + assert appliance["displayCategories"][0] == "SECURITY_PANEL" + assert appliance["friendlyName"] == "Test Alarm Control Panel 5" + assert_endpoint_capabilities( + appliance, "Alexa.SecurityPanelController", "Alexa.EndpointHealth", "Alexa" + ) + + properties = await reported_properties(hass, "alarm_control_panel#test_5") + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_home", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_STAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_STAY") + + _, msg = await assert_request_calls_service( + "Alexa.SecurityPanelController", + "Arm", + "alarm_control_panel#test_5", + "alarm_control_panel.alarm_arm_away", + hass, + response_type="Arm.Response", + payload={"armState": "ARMED_AWAY"}, + ) + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.SecurityPanelController", "armState", "ARMED_AWAY") + + async def test_range_unsupported_domain(hass: HomeAssistant) -> None: """Test rangeController with unsupported domain.""" device = ("switch.test", "on", {"friendly_name": "Test switch"}) From 22d64cb8f489531ea6200e96ebf2f9b71a075f86 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 3 Nov 2024 17:46:16 +0100 Subject: [PATCH 053/511] Bump bring-api to 0.9.1 (#129702) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 79336c086ed..ff24a991350 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.9.0"] + "requirements": ["bring-api==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index db6d3a35f0d..eba5875be05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -632,7 +632,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.0 +bring-api==0.9.1 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 768ecf4191b..9b80b41fbda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.9.0 +bring-api==0.9.1 # homeassistant.components.broadlink broadlink==0.19.0 From 90ed06c3543539db3a0f6cb053d12e00ce8554fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Nov 2024 22:43:21 -0500 Subject: [PATCH 054/511] Bump DoorBirdPy to 3.0.8 (#129709) --- homeassistant/components/doorbird/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json index 85a705d1dab..8480a496762 100644 --- a/homeassistant/components/doorbird/manifest.json +++ b/homeassistant/components/doorbird/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/doorbird", "iot_class": "local_push", "loggers": ["doorbirdpy"], - "requirements": ["DoorBirdPy==3.0.7"], + "requirements": ["DoorBirdPy==3.0.8"], "zeroconf": [ { "type": "_axis-video._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index eba5875be05..bf8b71c7048 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25 Adax-local==0.1.5 # homeassistant.components.doorbird -DoorBirdPy==3.0.7 +DoorBirdPy==3.0.8 # homeassistant.components.homekit HAP-python==4.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b80b41fbda..e062f066698 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,7 +13,7 @@ AIOSomecomfort==0.0.25 Adax-local==0.1.5 # homeassistant.components.doorbird -DoorBirdPy==3.0.7 +DoorBirdPy==3.0.8 # homeassistant.components.homekit HAP-python==4.9.1 From 9cb60c61d1e573f9d0f881abd8f0c89d1b2b2427 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Nov 2024 20:45:09 -0800 Subject: [PATCH 055/511] Fix nest streams broken due to CameraCapabilities change (#129711) * Fix nest streams broken due to CameraCapabilities change * Fix stream cleanup * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Update homeassistant/components/nest/camera.py --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/nest/camera.py | 230 +++++++++++---------- tests/components/nest/test_camera.py | 79 ++++--- tests/components/nest/test_media_source.py | 7 +- 3 files changed, 181 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 737c0a77bed..30f96f819c1 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,19 +2,17 @@ from __future__ import annotations +from abc import ABC, abstractmethod import asyncio from collections.abc import Callable import datetime import functools import logging from pathlib import Path -from typing import cast from google_nest_sdm.camera_traits import ( - CameraImageTrait, CameraLiveStreamTrait, RtspStream, - Stream, StreamingProtocol, WebRtcStream, ) @@ -57,19 +55,25 @@ async def async_setup_entry( device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ DATA_DEVICE_MANAGER ] - async_add_entities( - NestCamera(device) - for device in device_manager.devices.values() - if CameraImageTrait.NAME in device.traits - or CameraLiveStreamTrait.NAME in device.traits - ) + entities: list[NestCameraBaseEntity] = [] + for device in device_manager.devices.values(): + if (live_stream := device.traits.get(CameraLiveStreamTrait.NAME)) is None: + continue + if StreamingProtocol.WEB_RTC in live_stream.supported_protocols: + entities.append(NestWebRTCEntity(device)) + elif StreamingProtocol.RTSP in live_stream.supported_protocols: + entities.append(NestRTSPEntity(device)) + + async_add_entities(entities) -class NestCamera(Camera): +class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" _attr_has_entity_name = True _attr_name = None + _attr_is_streaming = True + _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, device: Device) -> None: """Initialize the camera.""" @@ -79,39 +83,74 @@ class NestCamera(Camera): self._attr_device_info = nest_device_info.device_info self._attr_brand = nest_device_info.device_brand self._attr_model = nest_device_info.device_model - self._rtsp_stream: RtspStream | None = None - self._webrtc_sessions: dict[str, WebRtcStream] = {} - self._create_stream_url_lock = asyncio.Lock() - self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = False - self._attr_supported_features = CameraEntityFeature(0) - self._rtsp_live_stream_trait: CameraLiveStreamTrait | None = None - if CameraLiveStreamTrait.NAME in self._device.traits: - self._attr_is_streaming = True - self._attr_supported_features |= CameraEntityFeature.STREAM - trait = cast( - CameraLiveStreamTrait, self._device.traits[CameraLiveStreamTrait.NAME] - ) - if StreamingProtocol.RTSP in trait.supported_protocols: - self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" + self._stream_refresh_unsub: Callable[[], None] | None = None + + @abstractmethod + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + + @abstractmethod + async def _async_refresh_stream(self) -> None: + """Refresh any stream to extend expiration time.""" + + def _schedule_stream_refresh(self) -> None: + """Schedules an alarm to refresh any streams before expiration.""" + if self._stream_refresh_unsub is not None: + self._stream_refresh_unsub() + + expiration_time = self._stream_expires_at() + if not expiration_time: + return + refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER + _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) + + self._stream_refresh_unsub = async_track_point_in_utc_time( + self.hass, + self._handle_stream_refresh, + refresh_time, + ) + + async def _handle_stream_refresh(self, _: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + _LOGGER.debug("Examining streams to refresh") + self._stream_refresh_unsub = None + try: + await self._async_refresh_stream() + finally: + self._schedule_stream_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._stream_refresh_unsub: + self._stream_refresh_unsub() + + +class NestRTSPEntity(NestCameraBaseEntity): + """Nest cameras that use RTSP.""" + + _rtsp_stream: RtspStream | None = None + _rtsp_live_stream_trait: CameraLiveStreamTrait + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._create_stream_url_lock = asyncio.Lock() + self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] @property def use_stream_for_stills(self) -> bool: - """Whether or not to use stream to generate stills.""" - return self._rtsp_live_stream_trait is not None - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC in trait.supported_protocols: - return StreamType.WEB_RTC - return super().frontend_stream_type + """Always use the RTSP stream to generate snapshots.""" + return True @property def available(self) -> bool: @@ -125,8 +164,6 @@ class NestCamera(Camera): async def stream_source(self) -> str | None: """Return the source of the stream.""" - if not self._rtsp_live_stream_trait: - return None async with self._create_stream_url_lock: if not self._rtsp_stream: _LOGGER.debug("Fetching stream url") @@ -142,50 +179,14 @@ class NestCamera(Camera): _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url - def _all_streams(self) -> list[Stream]: - """Return the current list of active streams.""" - streams: list[Stream] = [] - if self._rtsp_stream: - streams.append(self._rtsp_stream) - streams.extend(list(self._webrtc_sessions.values())) - return streams + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + return self._rtsp_stream.expires_at if self._rtsp_stream else None - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh any streams before expiration.""" - # Schedule an alarm to extend the stream - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - _LOGGER.debug("Scheduling next stream refresh") - expiration_times = [stream.expires_at for stream in self._all_streams()] - if not expiration_times: - _LOGGER.debug("No streams to refresh") - return - - refresh_time = min(expiration_times) - STREAM_EXPIRATION_BUFFER - _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, _: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - _LOGGER.debug("Examining streams to refresh") - await self._handle_rtsp_stream_refresh() - await self._handle_webrtc_stream_refresh() - self._schedule_stream_refresh() - - async def _handle_rtsp_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" if not self._rtsp_stream: return - now = utcnow() - refresh_time = self._rtsp_stream.expires_at - STREAM_EXPIRATION_BUFFER - if now < refresh_time: - return _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() @@ -201,8 +202,38 @@ class NestCamera(Camera): if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) - async def _handle_webrtc_stream_refresh(self) -> None: - """Alarm that fires to check if the stream should be refreshed.""" + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + if self._rtsp_stream: + try: + await self._rtsp_stream.stop_stream() + except ApiException as err: + _LOGGER.debug("Error stopping stream: %s", err) + self._rtsp_stream = None + + +class NestWebRTCEntity(NestCameraBaseEntity): + """Nest cameras that use WebRTC.""" + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__(device) + self._webrtc_sessions: dict[str, WebRtcStream] = {} + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + return StreamType.WEB_RTC + + def _stream_expires_at(self) -> datetime.datetime | None: + """Next time when a stream expires.""" + if not self._webrtc_sessions: + return None + return min(stream.expires_at for stream in self._webrtc_sessions.values()) + + async def _async_refresh_stream(self) -> None: + """Refresh stream to extend expiration time.""" now = utcnow() for webrtc_stream in list(self._webrtc_sessions.values()): if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): @@ -218,32 +249,10 @@ class NestCamera(Camera): else: self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - for stream in self._all_streams(): - _LOGGER.debug("Invalidating stream") - try: - await stream.stop_stream() - except ApiException as err: - _LOGGER.debug("Error stopping stream: %s", err) - self._rtsp_stream = None - self._webrtc_sessions.clear() - - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: - """Return bytes of camera image.""" - # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) as a fallback when 'use_stream_for_stills' if False + """Return a placeholder image for WebRTC cameras that don't support snapshots.""" return await self.hass.async_add_executor_job(self.placeholder_image) @classmethod @@ -257,11 +266,6 @@ class NestCamera(Camera): ) -> None: """Return the source of the stream.""" trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC not in trait.supported_protocols: - await super().async_handle_async_webrtc_offer( - offer_sdp, session_id, send_message - ) - return try: stream = await trait.generate_web_rtc_stream(offer_sdp) except ApiException as err: @@ -294,3 +298,9 @@ class NestCamera(Camera): def _async_get_webrtc_client_configuration(self) -> WebRTCClientConfiguration: """Return the WebRTC client configuration adjustable per integration.""" return WebRTCClientConfiguration(data_channel="dataSendChannel") + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + await super().async_will_remove_from_hass() + for session_id in list(self._webrtc_sessions.keys()): + self.close_webrtc_session(session_id) diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 6417fa4ebe9..500dbc0f46f 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -28,7 +28,7 @@ from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup from .conftest import FakeAuth from tests.common import async_fire_time_changed -from tests.typing import WebSocketGenerator +from tests.typing import MockHAClientWebSocket, WebSocketGenerator PLATFORM = "camera" CAMERA_DEVICE_TYPE = "sdm.devices.types.CAMERA" @@ -176,6 +176,30 @@ async def async_get_image( return image.content +def get_frontend_stream_type_attribute( + hass: HomeAssistant, entity_id: str +) -> StreamType: + """Get the frontend_stream_type camera attribute.""" + cam = hass.states.get(entity_id) + assert cam is not None + assert cam.state == CameraState.STREAMING + return cam.attributes.get("frontend_stream_type") + + +async def async_frontend_stream_types( + client: MockHAClientWebSocket, entity_id: str +) -> list[str] | None: + """Get the frontend stream types supported.""" + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": entity_id} + ) + msg = await client.receive_json() + assert msg.get("type") == TYPE_RESULT + assert msg.get("success") + assert msg.get("result") + return msg["result"].get("frontend_stream_types") + + async def fire_alarm(hass: HomeAssistant, point_in_time: datetime.datetime) -> None: """Fire an alarm and wait for callbacks to run.""" with freeze_time(point_in_time): @@ -237,16 +261,21 @@ async def test_camera_stream( camera_device: None, auth: FakeAuth, mock_create_stream: Mock, + hass_ws_client: WebSocketGenerator, ) -> None: """Test a basic camera and fetch its live stream.""" auth.responses = [make_stream_url_response()] await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) + client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" @@ -265,12 +294,16 @@ async def test_camera_ws_stream( await setup_platform() assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.STREAMING - assert cam.attributes["frontend_stream_type"] == StreamType.HLS + assert ( + get_frontend_stream_type_attribute(hass, "camera.my_camera") == StreamType.HLS + ) client = await hass_ws_client(hass) + frontend_stream_types = await async_frontend_stream_types( + client, "camera.my_camera" + ) + assert frontend_stream_types == [StreamType.HLS] + await client.send_json( { "id": 2, @@ -322,7 +355,7 @@ async def test_camera_ws_stream_failure( async def test_camera_stream_missing_trait( hass: HomeAssistant, setup_platform, create_device ) -> None: - """Test fetching a video stream when not supported by the API.""" + """Test that cameras missing a live stream are not supported.""" create_device.create( { "sdm.devices.traits.Info": { @@ -338,16 +371,7 @@ async def test_camera_stream_missing_trait( ) await setup_platform() - assert len(hass.states.async_all()) == 1 - cam = hass.states.get("camera.my_camera") - assert cam is not None - assert cam.state == CameraState.IDLE - - stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source is None - - # Fallback to placeholder image - await async_get_image(hass) + assert len(hass.states.async_all()) == 0 async def test_refresh_expired_stream_token( @@ -655,6 +679,15 @@ async def test_camera_web_rtc_unsupported( assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/capabilities", "entity_id": "camera.my_camera"} + ) + msg = await client.receive_json() + + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == {"frontend_stream_types": ["hls"]} + await client.send_json_auto_id( { "type": "camera/webrtc/offer", @@ -732,8 +765,6 @@ async def test_camera_multiple_streams( """Test a camera supporting multiple stream types.""" expiration = utcnow() + datetime.timedelta(seconds=100) auth.responses = [ - # RTSP response - make_stream_url_response(), # WebRTC response aiohttp.web.json_response( { @@ -770,9 +801,9 @@ async def test_camera_multiple_streams( # Prefer WebRTC over RTSP/HLS assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC - # RTSP stream + # RTSP stream is not supported stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") - assert stream_source == "rtsp://some/url?auth=g.0.streamingToken" + assert not stream_source # WebRTC stream client = await hass_ws_client(hass) diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 101bfae089d..2526bfdf975 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -48,6 +48,9 @@ CAMERA_TRAITS = { "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraImage": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["RTSP"], + }, "sdm.devices.traits.CameraEventImage": {}, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, @@ -57,7 +60,9 @@ BATTERY_CAMERA_TRAITS = { "customName": DEVICE_NAME, }, "sdm.devices.traits.CameraClipPreview": {}, - "sdm.devices.traits.CameraLiveStream": {}, + "sdm.devices.traits.CameraLiveStream": { + "supportedProtocols": ["WEB_RTC"], + }, "sdm.devices.traits.CameraPerson": {}, "sdm.devices.traits.CameraMotion": {}, } From a592ece9c87a766900399c8b9cad57a513a03bd3 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 3 Nov 2024 20:56:08 +0100 Subject: [PATCH 056/511] Add missing translation string to lamarzocco (#129713) * add missing translation string * Update strings.json * import pytest again --- homeassistant/components/lamarzocco/strings.json | 1 + tests/components/lamarzocco/test_config_flow.py | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index ec3b00a7474..959dda265a9 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -8,6 +8,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "machine_not_found": "Discovered machine not found in given account", "no_machines": "No machines found in account", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 89e5c968724..a2f0b927437 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -366,10 +366,6 @@ async def test_bluetooth_discovery( } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.lamarzocco.config.error.machine_not_found"], -) async def test_bluetooth_discovery_errors( hass: HomeAssistant, mock_lamarzocco: MagicMock, From 5d446f0e14e249f6e4e8a2b958d964af5372b803 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Nov 2024 11:27:57 -0600 Subject: [PATCH 057/511] Bump HAP-python to 4.9.2 (#129715) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index eebdc0026fd..cf74bcc7d67 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.1", + "HAP-python==4.9.2", "fnv-hash-fast==1.0.2", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index bf8b71c7048..aecc5b26f97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -16,7 +16,7 @@ Adax-local==0.1.5 DoorBirdPy==3.0.8 # homeassistant.components.homekit -HAP-python==4.9.1 +HAP-python==4.9.2 # homeassistant.components.tasmota HATasmota==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e062f066698..c91841a10d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -16,7 +16,7 @@ Adax-local==0.1.5 DoorBirdPy==3.0.8 # homeassistant.components.homekit -HAP-python==4.9.1 +HAP-python==4.9.2 # homeassistant.components.tasmota HATasmota==0.9.2 From b38fe0038711f44802a12d2df90234cdcd5110c3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 3 Nov 2024 21:55:12 +0100 Subject: [PATCH 058/511] Bump spotifyaio to 0.8.3 (#129729) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 5885d0103f2..2d86083d49c 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.2"], + "requirements": ["spotifyaio==0.8.3"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index aecc5b26f97..518dd255f97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c91841a10d1..0c05dc0e4a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.2 +spotifyaio==0.8.3 # homeassistant.components.sql sqlparse==0.5.0 From cf8b7607aeb6fef2af7897d5ed30a6ea5824b246 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 4 Nov 2024 15:17:50 +0900 Subject: [PATCH 059/511] Bump thinqconnect to 1.0.0 (#129769) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 52eb3c31aef..665a5a9e179 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==0.9.9"] + "requirements": ["thinqconnect==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 518dd255f97..e12ef685beb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2825,7 +2825,7 @@ thermopro-ble==0.10.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c05dc0e4a9..250d04e35ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2247,7 +2247,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.9 +thinqconnect==1.0.0 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 5141a4d2921151529a5bd4f91a887c991e148090 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Nov 2024 09:32:53 +0100 Subject: [PATCH 060/511] Bump version to 2024.11.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 c2565fe006f..57c31068b2f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 f17bc1d5bc5..32abfd10c78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b2" +version = "2024.11.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 595459bfda1bd8d4b7080050022f888e49e113f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:34:20 +0100 Subject: [PATCH 061/511] Use new helper properties in rfxtrx options flow (#129784) --- .../components/rfxtrx/config_flow.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index ceb9bea4661..866d9ecb1bb 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -87,9 +87,8 @@ class RfxtrxOptionsFlow(OptionsFlow): _device_registry: dr.DeviceRegistry _device_entries: list[dr.DeviceEntry] - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize rfxtrx options flow.""" - self._config_entry = config_entry self._global_options: dict[str, Any] = {} self._selected_device: dict[str, Any] = {} self._selected_device_entry_id: str | None = None @@ -120,9 +119,7 @@ class RfxtrxOptionsFlow(OptionsFlow): event_code = device_data["event_code"] assert event_code self._selected_device_event_code = event_code - self._selected_device = self._config_entry.data[CONF_DEVICES][ - event_code - ] + self._selected_device = self.config_entry.data[CONF_DEVICES][event_code] self._selected_device_object = get_rfx_object(event_code) return await self.async_step_set_device_options() if CONF_EVENT_CODE in user_input: @@ -148,7 +145,7 @@ class RfxtrxOptionsFlow(OptionsFlow): device_registry = dr.async_get(self.hass) device_entries = dr.async_entries_for_config_entry( - device_registry, self._config_entry.entry_id + device_registry, self.config_entry.entry_id ) self._device_registry = device_registry self._device_entries = device_entries @@ -162,11 +159,11 @@ class RfxtrxOptionsFlow(OptionsFlow): options = { vol.Optional( CONF_AUTOMATIC_ADD, - default=self._config_entry.data[CONF_AUTOMATIC_ADD], + default=self.config_entry.data[CONF_AUTOMATIC_ADD], ): bool, vol.Optional( CONF_PROTOCOLS, - default=self._config_entry.data.get(CONF_PROTOCOLS) or [], + default=self.config_entry.data.get(CONF_PROTOCOLS) or [], ): cv.multi_select(RECV_MODES), vol.Optional(CONF_EVENT_CODE): str, vol.Optional(CONF_DEVICE): vol.In(configure_devices), @@ -425,7 +422,7 @@ class RfxtrxOptionsFlow(OptionsFlow): def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool: """Check if device does not already exist.""" new_device_id = get_device_id(new_rfx_obj.device) - for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items(): + for packet_id, entity_info in self.config_entry.data[CONF_DEVICES].items(): rfx_obj = get_rfx_object(packet_id) assert rfx_obj @@ -468,7 +465,7 @@ class RfxtrxOptionsFlow(OptionsFlow): assert entry device_id = get_device_tuple_from_identifiers(entry.identifiers) assert device_id - for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items(): + for packet_id, entity_info in self.config_entry.data[CONF_DEVICES].items(): if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id: event_code = cast(str, packet_id) break @@ -481,8 +478,8 @@ class RfxtrxOptionsFlow(OptionsFlow): devices: dict[str, Any] | None = None, ) -> None: """Update data in ConfigEntry.""" - entry_data = self._config_entry.data.copy() - entry_data[CONF_DEVICES] = copy.deepcopy(self._config_entry.data[CONF_DEVICES]) + entry_data = self.config_entry.data.copy() + entry_data[CONF_DEVICES] = copy.deepcopy(self.config_entry.data[CONF_DEVICES]) if global_options: entry_data.update(global_options) if devices: @@ -494,9 +491,9 @@ class RfxtrxOptionsFlow(OptionsFlow): entry_data[CONF_DEVICES].pop(event_code, None) else: entry_data[CONF_DEVICES][event_code] = options - self.hass.config_entries.async_update_entry(self._config_entry, data=entry_data) + self.hass.config_entries.async_update_entry(self.config_entry, data=entry_data) self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry.entry_id) + self.hass.config_entries.async_reload(self.config_entry.entry_id) ) @@ -637,9 +634,11 @@ class RfxtrxConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> RfxtrxOptionsFlow: """Get the options flow for this handler.""" - return RfxtrxOptionsFlow(config_entry) + return RfxtrxOptionsFlow() def _test_transport(host: str | None, port: int | None, device: str | None) -> bool: From 0883b23d0c223755d4e808613f245749d5ba4a01 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:11 +0100 Subject: [PATCH 062/511] Use new helper properties in yalexs_ble options flow (#129790) --- homeassistant/components/yalexs_ble/config_flow.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index 191ef5a20b2..6de74759686 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -312,16 +312,12 @@ class YalexsConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> YaleXSBLEOptionsFlowHandler: """Get the options flow for this handler.""" - return YaleXSBLEOptionsFlowHandler(config_entry) + return YaleXSBLEOptionsFlowHandler() class YaleXSBLEOptionsFlowHandler(OptionsFlow): """Handle YaleXSBLE options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize YaleXSBLE options flow.""" - self.entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -343,7 +339,9 @@ class YaleXSBLEOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_ALWAYS_CONNECTED, - default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False), + default=self.config_entry.options.get( + CONF_ALWAYS_CONNECTED, False + ), ): bool, } ), From 6a22a2b867d357bf2daab32579c119908530d1a0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:24 +0100 Subject: [PATCH 063/511] Use new helper properties in watttime options flow (#129789) --- homeassistant/components/watttime/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index db68738b302..ad676e166c5 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -126,9 +126,11 @@ class WattTimeConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WattTimeOptionsFlowHandler: """Define the config flow to handle options.""" - return WattTimeOptionsFlowHandler(config_entry) + return WattTimeOptionsFlowHandler() async def async_step_coordinates( self, user_input: dict[str, Any] | None = None @@ -241,10 +243,6 @@ class WattTimeConfigFlow(ConfigFlow, domain=DOMAIN): class WattTimeOptionsFlowHandler(OptionsFlow): """Handle a WattTime options flow.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -258,7 +256,7 @@ class WattTimeOptionsFlowHandler(OptionsFlow): { vol.Required( CONF_SHOW_ON_MAP, - default=self.entry.options.get(CONF_SHOW_ON_MAP, True), + default=self.config_entry.options.get(CONF_SHOW_ON_MAP, True), ): bool } ), From cdc67aa891a8410dc2f5413fcb2cfd124baf8b77 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:38:41 +0100 Subject: [PATCH 064/511] Use new helper properties in verisure options flow (#129788) --- homeassistant/components/verisure/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 42ce7f9e9fe..0f1088ccb80 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -43,9 +43,11 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> VerisureOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> VerisureOptionsFlowHandler: """Get the options flow for this handler.""" - return VerisureOptionsFlowHandler(config_entry) + return VerisureOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -290,10 +292,6 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): class VerisureOptionsFlowHandler(OptionsFlow): """Handle Verisure options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Verisure options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -310,7 +308,7 @@ class VerisureOptionsFlowHandler(OptionsFlow): vol.Optional( CONF_LOCK_CODE_DIGITS, description={ - "suggested_value": self.entry.options.get( + "suggested_value": self.config_entry.options.get( CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS ) }, From cdd5cb28761787131c7b56c401e20394f3d950f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:13 +0100 Subject: [PATCH 065/511] Use new helper properties in tomorrowio options flow (#129787) --- homeassistant/components/tomorrowio/config_flow.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 90bb488a7c2..cce41b17498 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -91,10 +91,6 @@ def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): class TomorrowioOptionsConfigFlow(OptionsFlow): """Handle Tomorrow.io options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize Tomorrow.io options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -105,7 +101,7 @@ class TomorrowioOptionsConfigFlow(OptionsFlow): options_schema = { vol.Required( CONF_TIMESTEP, - default=self._config_entry.options[CONF_TIMESTEP], + default=self.config_entry.options[CONF_TIMESTEP], ): vol.In([1, 5, 15, 30, 60]), } @@ -125,7 +121,7 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> TomorrowioOptionsConfigFlow: """Get the options flow for this handler.""" - return TomorrowioOptionsConfigFlow(config_entry) + return TomorrowioOptionsConfigFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None From 4be2cdf90adbc0276c5f9406f14937a8348f1782 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:27 +0100 Subject: [PATCH 066/511] Use new helper properties in steam_online options flow (#129785) --- .../components/steam_online/config_flow.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 704eef616f6..605f27edb19 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -40,9 +40,9 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: SteamConfigEntry, - ) -> OptionsFlow: + ) -> SteamOptionsFlowHandler: """Get the options flow for this handler.""" - return SteamOptionsFlowHandler(config_entry) + return SteamOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -121,17 +121,12 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]: class SteamOptionsFlowHandler(OptionsFlow): """Handle Steam client options.""" - def __init__(self, entry: SteamConfigEntry) -> None: - """Initialize options flow.""" - self.entry = entry - self.options = dict(entry.options) - async def async_step_init( self, user_input: dict[str, dict[str, str]] | None = None ) -> ConfigFlowResult: """Manage Steam options.""" if user_input is not None: - await self.hass.config_entries.async_unload(self.entry.entry_id) + await self.hass.config_entries.async_unload(self.config_entry.entry_id) for _id in self.options[CONF_ACCOUNTS]: if _id not in user_input[CONF_ACCOUNTS] and ( entity_id := er.async_get(self.hass).async_get_entity_id( @@ -146,7 +141,7 @@ class SteamOptionsFlowHandler(OptionsFlow): if _id in user_input[CONF_ACCOUNTS] } } - await self.hass.config_entries.async_reload(self.entry.entry_id) + await self.hass.config_entries.async_reload(self.config_entry.entry_id) return self.async_create_entry(title="", data=channel_data) error = None try: @@ -176,7 +171,9 @@ class SteamOptionsFlowHandler(OptionsFlow): """Get accounts.""" interface = steam.api.interface("ISteamUser") try: - friends = interface.GetFriendList(steamid=self.entry.data[CONF_ACCOUNT]) + friends = interface.GetFriendList( + steamid=self.config_entry.data[CONF_ACCOUNT] + ) _users_str = [user["steamid"] for user in friends["friendslist"]["friends"]] except steam.api.HTTPError: return [] From 11ab992dbbb2d504eb45691465471d34da0c344b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:41 +0100 Subject: [PATCH 067/511] Use new helper properties in recollect_waste options flow (#129783) --- .../components/recollect_waste/config_flow.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 882eb6a00d2..299af2609e3 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -34,9 +34,9 @@ class RecollectWasteConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> RecollectWasteOptionsFlowHandler: """Define the config flow to handle options.""" - return RecollectWasteOptionsFlowHandler(config_entry) + return RecollectWasteOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -79,10 +79,6 @@ class RecollectWasteConfigFlow(ConfigFlow, domain=DOMAIN): class RecollectWasteOptionsFlowHandler(OptionsFlow): """Handle a Recollect Waste options flow.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize.""" - self._entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -96,7 +92,7 @@ class RecollectWasteOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_FRIENDLY_NAME, - default=self._entry.options.get(CONF_FRIENDLY_NAME), + default=self.config_entry.options.get(CONF_FRIENDLY_NAME), ): bool } ), From b48e2127b8ffa370868adc1988b1bd540cf0c8ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:39:56 +0100 Subject: [PATCH 068/511] Use new helper properties in plaato options flow (#129782) --- homeassistant/components/plaato/config_flow.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 74967c417a4..f398a733cd6 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -176,23 +176,19 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> PlaatoOptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> PlaatoOptionsFlowHandler: """Get the options flow for this handler.""" - return PlaatoOptionsFlowHandler(config_entry) + return PlaatoOptionsFlowHandler() class PlaatoOptionsFlowHandler(OptionsFlow): """Handle Plaato options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize domain options flow.""" - super().__init__() - - self._config_entry = config_entry - async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the options.""" - use_webhook = self._config_entry.data.get(CONF_USE_WEBHOOK, False) + use_webhook = self.config_entry.data.get(CONF_USE_WEBHOOK, False) if use_webhook: return await self.async_step_webhook() @@ -211,7 +207,7 @@ class PlaatoOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_SCAN_INTERVAL, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ), ): cv.positive_int @@ -226,7 +222,7 @@ class PlaatoOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - webhook_id = self._config_entry.data.get(CONF_WEBHOOK_ID, None) + webhook_id = self.config_entry.data.get(CONF_WEBHOOK_ID, None) webhook_url = ( "" if webhook_id is None From 461dc13da9b19e1a6a64674c2c9f50a427745dce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:40:13 +0100 Subject: [PATCH 069/511] Use new helper properties in motioneye options flow (#129780) --- .../components/motioneye/config_flow.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index f6d947dab5f..80a6449a22d 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -179,18 +179,16 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> MotionEyeOptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> MotionEyeOptionsFlow: """Get the Hyperion Options flow.""" - return MotionEyeOptionsFlow(config_entry) + return MotionEyeOptionsFlow() class MotionEyeOptionsFlow(OptionsFlow): """motionEye options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize a motionEye options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -201,14 +199,14 @@ class MotionEyeOptionsFlow(OptionsFlow): schema: dict[vol.Marker, type] = { vol.Required( CONF_WEBHOOK_SET, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET, ), ): bool, vol.Required( CONF_WEBHOOK_SET_OVERWRITE, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_WEBHOOK_SET_OVERWRITE, DEFAULT_WEBHOOK_SET_OVERWRITE, ), @@ -219,9 +217,9 @@ class MotionEyeOptionsFlow(OptionsFlow): # The input URL is not validated as being a URL, to allow for the possibility # the template input won't be a valid URL until after it's rendered description: dict[str, str] | None = None - if CONF_STREAM_URL_TEMPLATE in self._config_entry.options: + if CONF_STREAM_URL_TEMPLATE in self.config_entry.options: description = { - "suggested_value": self._config_entry.options[ + "suggested_value": self.config_entry.options[ CONF_STREAM_URL_TEMPLATE ] } From 9155d561900cbcc8a78cd81df9f8bca4389dddd9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:42:58 +0100 Subject: [PATCH 070/511] Use new helper properties in flux_led options flow (#129776) --- homeassistant/components/flux_led/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index d78fc699579..9a02120f33a 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -71,9 +71,11 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> FluxLedOptionsFlow: """Get the options flow for the Flux LED component.""" - return FluxLedOptionsFlow(config_entry) + return FluxLedOptionsFlow() async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo @@ -320,10 +322,6 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN): class FluxLedOptionsFlow(OptionsFlow): """Handle flux_led options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the flux_led options flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -332,7 +330,7 @@ class FluxLedOptionsFlow(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - options = self._config_entry.options + options = self.config_entry.options options_schema = vol.Schema( { vol.Optional( From 3a293c6bc47f0f571a1656c07966b3dfda752515 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:43:10 +0100 Subject: [PATCH 071/511] Use new helper properties in dsmr options flow (#129775) --- homeassistant/components/dsmr/config_flow.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 49e1818edcc..7d6a641b006 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -171,9 +171,11 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> DSMROptionFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> DSMROptionFlowHandler: """Get the options flow for this handler.""" - return DSMROptionFlowHandler(config_entry) + return DSMROptionFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -311,10 +313,6 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): class DSMROptionFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.entry = entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -328,7 +326,7 @@ class DSMROptionFlowHandler(OptionsFlow): { vol.Optional( CONF_TIME_BETWEEN_UPDATE, - default=self.entry.options.get( + default=self.config_entry.options.get( CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE ), ): vol.All(vol.Coerce(int), vol.Range(min=0)), From 018acc0a3c9e8c4694654524d211c631bdfc03b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:43:25 +0100 Subject: [PATCH 072/511] Use new helper properties in crownstone options flow (#129774) --- .../components/crownstone/config_flow.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 7d86fbbd7fb..4cfbb10a4bd 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -143,7 +143,7 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= config_entry: ConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" - return CrownstoneOptionsFlowHandler(config_entry) + return CrownstoneOptionsFlowHandler() def __init__(self) -> None: """Initialize the flow.""" @@ -210,21 +210,21 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) - self.entry = config_entry - self.updated_options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Crownstone options.""" - self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][self.entry.entry_id].cloud + self.cloud: CrownstoneCloud = self.hass.data[DOMAIN][ + self.config_entry.entry_id + ].cloud spheres = {sphere.name: sphere.cloud_id for sphere in self.cloud.cloud_data} - usb_path = self.entry.options.get(CONF_USB_PATH) - usb_sphere = self.entry.options.get(CONF_USB_SPHERE) + usb_path = self.config_entry.options.get(CONF_USB_PATH) + usb_sphere = self.config_entry.options.get(CONF_USB_SPHERE) options_schema = vol.Schema( {vol.Optional(CONF_USE_USB_OPTION, default=usb_path is not None): bool} @@ -243,14 +243,14 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): if user_input[CONF_USE_USB_OPTION] and usb_path is None: return await self.async_step_usb_config() if not user_input[CONF_USE_USB_OPTION] and usb_path is not None: - self.updated_options[CONF_USB_PATH] = None - self.updated_options[CONF_USB_SPHERE] = None + self.options[CONF_USB_PATH] = None + self.options[CONF_USB_SPHERE] = None elif ( CONF_USB_SPHERE_OPTION in user_input and spheres[user_input[CONF_USB_SPHERE_OPTION]] != usb_sphere ): sphere_id = spheres[user_input[CONF_USB_SPHERE_OPTION]] - self.updated_options[CONF_USB_SPHERE] = sphere_id + self.options[CONF_USB_SPHERE] = sphere_id return self.async_create_new_entry() @@ -260,7 +260,7 @@ class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Create a new entry.""" # these attributes will only change when a usb was configured if self.usb_path is not None and self.usb_sphere_id is not None: - self.updated_options[CONF_USB_PATH] = self.usb_path - self.updated_options[CONF_USB_SPHERE] = self.usb_sphere_id + self.options[CONF_USB_PATH] = self.usb_path + self.options[CONF_USB_SPHERE] = self.usb_sphere_id - return super().async_create_entry(title="", data=self.updated_options) + return super().async_create_entry(title="", data=self.options) From 0a1ba8a4a382416caf9f41094d9c1010dec85b7f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 09:52:35 +0100 Subject: [PATCH 073/511] Small code quality improvement/cleanup in random (#129542) --- homeassistant/components/random/binary_sensor.py | 5 ++--- homeassistant/components/random/config_flow.py | 10 +++++----- homeassistant/components/random/sensor.py | 12 ++++++------ 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 9d33ad52692..ae9a5886d59 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -59,10 +59,9 @@ class RandomBinarySensor(BinarySensorEntity): def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._attr_name = config.get(CONF_NAME) + self._attr_name = config[CONF_NAME] self._attr_device_class = config.get(CONF_DEVICE_CLASS) - if entry_id: - self._attr_unique_id = entry_id + self._attr_unique_id = entry_id async def async_update(self) -> None: """Get new state and update the sensor's state.""" diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py index fcbd77916a9..00314169260 100644 --- a/homeassistant/components/random/config_flow.py +++ b/homeassistant/components/random/config_flow.py @@ -95,7 +95,7 @@ def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: async def choose_options_step(options: dict[str, Any]) -> str: - """Return next step_id for options flow according to template_type.""" + """Return next step_id for options flow according to entity_type.""" return cast(str, options["entity_type"]) @@ -122,7 +122,7 @@ def _validate_unit(options: dict[str, Any]) -> None: def validate_user_input( - template_type: str, + entity_type: str, ) -> Callable[ [SchemaCommonFlowHandler, dict[str, Any]], Coroutine[Any, Any, dict[str, Any]], @@ -136,10 +136,10 @@ def validate_user_input( _: SchemaCommonFlowHandler, user_input: dict[str, Any], ) -> dict[str, Any]: - """Add template type to user input.""" - if template_type == Platform.SENSOR: + """Add entity type to user input.""" + if entity_type == Platform.SENSOR: _validate_unit(user_input) - return {"entity_type": template_type} | user_input + return {"entity_type": entity_type} | user_input return _validate_user_input diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 3c6e67c9918..aad4fcb851c 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -70,22 +70,22 @@ class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_translation_key = "random" + _unrecorded_attributes = frozenset({ATTR_MAXIMUM, ATTR_MINIMUM}) def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._attr_name = config.get(CONF_NAME) - self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) - self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._attr_name = config[CONF_NAME] + self._minimum = config[CONF_MINIMUM] + self._maximum = config[CONF_MAXIMUM] self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_extra_state_attributes = { ATTR_MAXIMUM: self._maximum, ATTR_MINIMUM: self._minimum, } - if entry_id: - self._attr_unique_id = entry_id + self._attr_unique_id = entry_id async def async_update(self) -> None: - """Get a new number and updates the states.""" + """Get a new number and update the state.""" self._attr_native_value = randrange(self._minimum, self._maximum + 1) From 0c40fcdaebc91e5cf885ade5e6fc4249df27e0fb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 10:33:08 +0100 Subject: [PATCH 074/511] Bump yt-dlp to 2024.11.04 (#129794) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 233fef3c7f3..3e4db5d5b04 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.10.22"], + "requirements": ["yt-dlp==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 8e05edf10dc..52cbbe340c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3054,7 +3054,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6479de6cd7d..fa8c40a6bac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2440,7 +2440,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From d75dda0c055b66bde600e9fa428d76c072bdc51f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 10:38:27 +0100 Subject: [PATCH 075/511] Use RTCIceCandidate instead of str for candidate (#129793) --- homeassistant/components/camera/__init__.py | 6 ++++-- homeassistant/components/camera/webrtc.py | 19 +++++++++++++---- homeassistant/components/go2rtc/__init__.py | 9 +++++--- tests/components/camera/test_init.py | 3 ++- tests/components/camera/test_webrtc.py | 23 ++++++++++++++------- tests/components/go2rtc/test_init.py | 7 ++++--- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 58826eb07ce..1feb7dffd3b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ from aiohttp import hdrs, web import attr from propcache import cached_property, under_cached_property import voluptuous as vol -from webrtc_models import RTCIceServer +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -840,7 +840,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return config - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle a WebRTC candidate.""" if self._webrtc_provider: await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index aca2b8291f1..0612c96e40c 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol -from webrtc_models import RTCConfiguration, RTCIceServer +from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback @@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage): class WebRTCCandidate(WebRTCMessage): """WebRTC candidate.""" - candidate: str + candidate: RTCIceCandidate + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the message.""" + return { + "type": self._get_type(), + "candidate": self.candidate.candidate, + } @dataclass(frozen=True) @@ -138,7 +145,9 @@ class CameraWebRTCProvider(ABC): """Handle the WebRTC offer and return the answer via the provided callback.""" @abstractmethod - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -319,7 +328,9 @@ async def ws_candidate( ) return - await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) + await camera.async_on_webrtc_candidate( + msg["session_id"], RTCIceCandidate(msg["candidate"]) + ) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 0bf01490a47..eeaa35fbbb4 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -15,6 +15,7 @@ from go2rtc_client.ws import ( WsError, ) import voluptuous as vol +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( Camera, @@ -219,7 +220,7 @@ class WebRTCProvider(CameraWebRTCProvider): value: WebRTCMessage match message: case WebRTCCandidate(): - value = HAWebRTCCandidate(message.candidate) + value = HAWebRTCCandidate(RTCIceCandidate(message.candidate)) case WebRTCAnswer(): value = HAWebRTCAnswer(message.sdp) case WsError(): @@ -231,11 +232,13 @@ class WebRTCProvider(CameraWebRTCProvider): config = camera.async_get_webrtc_client_configuration() await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" if ws_client := self._sessions.get(session_id): - await ws_client.send(WebRTCCandidate(candidate)) + await ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e0d4e38fb57..e7279f60848 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch import pytest from syrupy.assertion import SnapshotAssertion +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera import ( @@ -960,7 +961,7 @@ async def _test_capabilities( send_message(WebRTCAnswer("answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ec096b5f37a..27c50848ebf 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, @@ -13,7 +14,6 @@ from homeassistant.components.camera import ( Camera, CameraEntityFeature, CameraWebRTCProvider, - RTCIceServer, StreamType, WebRTCAnswer, WebRTCCandidate, @@ -81,7 +81,9 @@ class SomeTestProvider(CameraWebRTCProvider): """ send_message(WebRTCAnswer(answer="answer")) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -503,7 +505,10 @@ async def test_websocket_webrtc_offer( @pytest.mark.parametrize( ("message", "expected_frontend_message"), [ - (WebRTCCandidate("candidate"), {"type": "candidate", "candidate": "candidate"}), + ( + WebRTCCandidate(RTCIceCandidate("candidate")), + {"type": "candidate", "candidate": "candidate"}, + ), ( WebRTCError("webrtc_offer_failed", "error"), {"type": "error", "code": "webrtc_offer_failed", "message": "error"}, @@ -989,7 +994,9 @@ async def test_ws_webrtc_candidate( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1039,7 +1046,9 @@ async def test_ws_webrtc_candidate_webrtc_provider( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1140,7 +1149,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: send_message(WebRTCAnswer(answer="answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" @@ -1150,7 +1159,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: await provider.async_handle_async_webrtc_offer( Mock(), "offer_sdp", "session_id", Mock() ) - await provider.async_on_webrtc_candidate("session_id", "candidate") + await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate")) provider.async_close_session("session_id") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index c4a23731a93..1e73525fbe3 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -17,6 +17,7 @@ from go2rtc_client.ws import ( WsError, ) import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( DOMAIN as CAMERA_DOMAIN, @@ -379,7 +380,7 @@ async def message_callbacks( [ ( WebRTCCandidate("candidate"), - HAWebRTCCandidate("candidate"), + HAWebRTCCandidate(RTCIceCandidate("candidate")), ), ( WebRTCAnswer(ANSWER_SDP), @@ -415,7 +416,7 @@ async def test_on_candidate( session_id = "session_id" # Session doesn't exist - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) assert ( "homeassistant.components.go2rtc", logging.DEBUG, @@ -435,7 +436,7 @@ async def test_on_candidate( ) ws_client.reset_mock() - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) ws_client.send.assert_called_once_with(WebRTCCandidate("candidate")) assert caplog.record_tuples == [] From 274c928ec09f08c331899f140e05752b73619b3a Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:18:12 +0100 Subject: [PATCH 076/511] Add coordinator to suez_water (#129242) Co-authored-by: Joost Lekkerkerker --- .../components/suez_water/__init__.py | 28 +---- homeassistant/components/suez_water/const.py | 4 + .../components/suez_water/coordinator.py | 108 ++++++++++++++++++ homeassistant/components/suez_water/sensor.py | 82 +++++-------- tests/components/suez_water/__init__.py | 14 +++ tests/components/suez_water/conftest.py | 62 +++++++++- .../suez_water/snapshots/test_sensor.ambr | 67 +++++++++++ .../components/suez_water/test_config_flow.py | 8 +- tests/components/suez_water/test_init.py | 35 ++++++ tests/components/suez_water/test_sensor.py | 62 ++++++++++ 10 files changed, 387 insertions(+), 83 deletions(-) create mode 100644 homeassistant/components/suez_water/coordinator.py create mode 100644 tests/components/suez_water/snapshots/test_sensor.ambr create mode 100644 tests/components/suez_water/test_init.py create mode 100644 tests/components/suez_water/test_sensor.py diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index f5b2880e011..06f503b85c2 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -from pysuez import SuezClient -from pysuez.client import PySuezError - 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 ConfigEntryError, ConfigEntryNotReady -from .const import CONF_COUNTER_ID, DOMAIN +from .const import DOMAIN +from .coordinator import SuezWaterCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -18,23 +15,10 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Suez Water from a config entry.""" - def get_client() -> SuezClient: - try: - client = SuezClient( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_COUNTER_ID], - provider=None, - ) - if not client.check_credentials(): - raise ConfigEntryError - except PySuezError as ex: - raise ConfigEntryNotReady from ex - return client + coordinator = SuezWaterCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[ - entry.entry_id - ] = await hass.async_add_executor_job(get_client) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py index 7afc0d3ce3e..cecd779c22c 100644 --- a/homeassistant/components/suez_water/const.py +++ b/homeassistant/components/suez_water/const.py @@ -1,5 +1,9 @@ """Constants for the Suez Water integration.""" +from datetime import timedelta + DOMAIN = "suez_water" CONF_COUNTER_ID = "counter_id" + +DATA_REFRESH_INTERVAL = timedelta(hours=12) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py new file mode 100644 index 00000000000..adcbd39c01b --- /dev/null +++ b/homeassistant/components/suez_water/coordinator.py @@ -0,0 +1,108 @@ +"""Suez water update coordinator.""" + +import asyncio +from dataclasses import dataclass +from datetime import date + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import _LOGGER, HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN + + +@dataclass +class AggregatedSensorData: + """Hold suez water aggregated sensor data.""" + + value: float + current_month: dict[date, float] + previous_month: dict[date, float] + previous_year: dict[str, float] + current_year: dict[str, float] + history: dict[date, float] + highest_monthly_consumption: float + attribution: str + + +class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): + """Suez water coordinator.""" + + _sync_client: SuezClient + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize suez water coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DATA_REFRESH_INTERVAL, + always_update=True, + config_entry=config_entry, + ) + + async def _async_setup(self) -> None: + self._sync_client = await self.hass.async_add_executor_job(self._get_client) + + async def _async_update_data(self) -> AggregatedSensorData: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self._fetch_data) + + def _fetch_data(self) -> AggregatedSensorData: + """Fetch latest data from Suez.""" + try: + self._sync_client.update() + except PySuezError as err: + raise UpdateFailed( + f"Suez coordinator error communicating with API: {err}" + ) from err + current_month = {} + for item in self._sync_client.attributes["thisMonthConsumption"]: + current_month[item] = self._sync_client.attributes["thisMonthConsumption"][ + item + ] + previous_month = {} + for item in self._sync_client.attributes["previousMonthConsumption"]: + previous_month[item] = self._sync_client.attributes[ + "previousMonthConsumption" + ][item] + highest_monthly_consumption = self._sync_client.attributes[ + "highestMonthlyConsumption" + ] + previous_year = self._sync_client.attributes["lastYearOverAll"] + current_year = self._sync_client.attributes["thisYearOverAll"] + history = {} + for item in self._sync_client.attributes["history"]: + history[item] = self._sync_client.attributes["history"][item] + _LOGGER.debug("Retrieved consumption: " + str(self._sync_client.state)) + return AggregatedSensorData( + self._sync_client.state, + current_month, + previous_month, + previous_year, + current_year, + history, + highest_monthly_consumption, + self._sync_client.attributes["attribution"], + ) + + def _get_client(self) -> SuezClient: + try: + client = SuezClient( + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + counter_id=self.config_entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + except PySuezError as ex: + raise ConfigEntryNotReady from ex + return client diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 5b00cbf2dc4..22a61c835e1 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,11 +2,8 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from pysuez import SuezClient -from pysuez.client import PySuezError +from collections.abc import Mapping +from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -14,12 +11,10 @@ from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(hours=12) +from .coordinator import SuezWaterCoordinator async def async_setup_entry( @@ -28,11 +23,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Suez Water sensor from a config entry.""" - client = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezSensor(client, entry.data[CONF_COUNTER_ID])], True) + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) -class SuezSensor(SensorEntity): +class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): """Representation of a Sensor.""" _attr_has_entity_name = True @@ -40,9 +35,9 @@ class SuezSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfVolume.LITERS _attr_device_class = SensorDeviceClass.WATER - def __init__(self, client: SuezClient, counter_id: int) -> None: + def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: """Initialize the data object.""" - self.client = client + super().__init__(coordinator) self._attr_extra_state_attributes = {} self._attr_unique_id = f"{counter_id}_water_usage_yesterday" self._attr_device_info = DeviceInfo( @@ -51,45 +46,24 @@ class SuezSensor(SensorEntity): manufacturer="Suez", ) - def _fetch_data(self) -> None: - """Fetch latest data from Suez.""" - try: - self.client.update() - # _state holds the volume of consumed water during previous day - self._attr_native_value = self.client.state - self._attr_available = True - self._attr_attribution = self.client.attributes["attribution"] + @property + def native_value(self) -> float: + """Return the current daily usage.""" + return self.coordinator.data.value - self._attr_extra_state_attributes["this_month_consumption"] = {} - for item in self.client.attributes["thisMonthConsumption"]: - self._attr_extra_state_attributes["this_month_consumption"][item] = ( - self.client.attributes["thisMonthConsumption"][item] - ) - self._attr_extra_state_attributes["previous_month_consumption"] = {} - for item in self.client.attributes["previousMonthConsumption"]: - self._attr_extra_state_attributes["previous_month_consumption"][ - item - ] = self.client.attributes["previousMonthConsumption"][item] - self._attr_extra_state_attributes["highest_monthly_consumption"] = ( - self.client.attributes["highestMonthlyConsumption"] - ) - self._attr_extra_state_attributes["last_year_overall"] = ( - self.client.attributes["lastYearOverAll"] - ) - self._attr_extra_state_attributes["this_year_overall"] = ( - self.client.attributes["thisYearOverAll"] - ) - self._attr_extra_state_attributes["history"] = {} - for item in self.client.attributes["history"]: - self._attr_extra_state_attributes["history"][item] = ( - self.client.attributes["history"][item] - ) + @property + def attribution(self) -> str: + """Return data attribution message.""" + return self.coordinator.data.attribution - except PySuezError: - self._attr_available = False - _LOGGER.warning("Unable to fetch data") - - def update(self) -> None: - """Return the latest collected data from Suez.""" - self._fetch_data() - _LOGGER.debug("Suez data state is: %s", self.native_value) + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return aggregated data.""" + return { + "this_month_consumption": self.coordinator.data.current_month, + "previous_month_consumption": self.coordinator.data.previous_month, + "highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption, + "last_year_overall": self.coordinator.data.previous_year, + "this_year_overall": self.coordinator.data.current_year, + "history": self.coordinator.data.history, + } diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py index 4605e06344a..a90df738454 100644 --- a/tests/components/suez_water/__init__.py +++ b/tests/components/suez_water/__init__.py @@ -1 +1,15 @@ """Tests for the Suez Water integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Init suez water integration.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index f218fb7d833..bcb817a5025 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,10 +1,31 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from homeassistant.components.suez_water.const import DOMAIN + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create mock config_entry needed by suez_water integration.""" + return MockConfigEntry( + unique_id=MOCK_DATA["username"], + domain=DOMAIN, + title="Suez mock device", + data=MOCK_DATA, + ) + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +34,42 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.suez_water.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="suez_client") +def mock_suez_client() -> Generator[MagicMock]: + """Create mock for suez_water external api.""" + with ( + patch( + "homeassistant.components.suez_water.coordinator.SuezClient", autospec=True + ) as mock_client, + patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.check_credentials.return_value = True + client.update.return_value = None + client.state = 160 + client.attributes = { + "thisMonthConsumption": { + "2024-01-01": 130, + "2024-01-02": 145, + }, + "previousMonthConsumption": { + "2024-12-01": 154, + "2024-12-02": 166, + }, + "highestMonthlyConsumption": 2558, + "lastYearOverAll": 1000, + "thisYearOverAll": 1500, + "history": { + "2024-01-01": 130, + "2024-01-02": 145, + "2024-12-01": 154, + "2024-12-02": 166, + }, + "attribution": "suez water mock test", + } + yield client diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..acc3042f93b --- /dev/null +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-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.suez_mock_device_water_usage_yesterday', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water usage yesterday', + 'platform': 'suez_water', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_usage_yesterday', + 'unique_id': 'test-counter_water_usage_yesterday', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'suez water mock test', + 'device_class': 'water', + 'friendly_name': 'Suez mock device Water usage yesterday', + 'highest_monthly_consumption': 2558, + 'history': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'last_year_overall': 1000, + 'previous_month_consumption': dict({ + '2024-12-01': 154, + '2024-12-02': 166, + }), + 'this_month_consumption': dict({ + '2024-01-01': 130, + '2024-01-02': 145, + }), + 'this_year_overall': 1500, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.suez_mock_device_water_usage_yesterday', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '160', + }) +# --- diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 3170a6779f0..ddf7bcd3d80 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -10,13 +10,9 @@ from homeassistant.components.suez_water.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .conftest import MOCK_DATA -MOCK_DATA = { - "username": "test-username", - "password": "test-password", - "counter_id": "test-counter", -} +from tests.common import MockConfigEntry async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py new file mode 100644 index 00000000000..b9a8875a8a1 --- /dev/null +++ b/tests/components/suez_water/test_init.py @@ -0,0 +1,35 @@ +"""Test Suez_water integration initialization.""" + +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_initialization_invalid_credentials( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water can't be loaded with invalid credentials.""" + + suez_client.check_credentials.return_value = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_initialization_setup_api_error( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that suez_water needs to retry loading if api failed to connect.""" + + suez_client.check_credentials.side_effect = PySuezError("Test failure") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py new file mode 100644 index 00000000000..d3da159ee28 --- /dev/null +++ b/tests/components/suez_water/test_sensor.py @@ -0,0 +1,62 @@ +"""Test Suez_water sensor platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL +from homeassistant.components.suez_water.coordinator import PySuezError +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_sensors_valid_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + suez_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that suez_water sensor is loaded and in a valid state.""" + with patch("homeassistant.components.suez_water.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_failed_update( + hass: HomeAssistant, + suez_client, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that suez_water sensor reflect failure when api fails.""" + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) + assert len(entity_ids) == 1 + + state = hass.states.get(entity_ids[0]) + assert entity_ids[0] + assert state.state != STATE_UNAVAILABLE + + suez_client.update.side_effect = PySuezError("Should fail to update") + + freezer.tick(DATA_REFRESH_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(True) + + state = hass.states.get(entity_ids[0]) + assert state + assert state.state == STATE_UNAVAILABLE From 08a53362a78cb7bb5c8502080afef1ae81598662 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 12:26:34 +0100 Subject: [PATCH 077/511] Fix stringification of discovered hassio uuid (#129797) --- homeassistant/components/hassio/discovery.py | 4 ++-- tests/components/hassio/test_discovery.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6181fe4624c..b51b8e5a8f2 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -131,11 +131,11 @@ class HassIODiscovery(HomeAssistantView): config=data.config, name=addon_info.name, slug=data.addon, - uuid=str(data.uuid), + uuid=data.uuid.hex, ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, - key=str(data.uuid), + key=data.uuid.hex, version=1, ), ) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index bb3a101d1f9..ba6338f84e2 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -91,7 +91,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -286,7 +286,7 @@ async def test_hassio_rediscover( ) expected_context = { - "discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1), + "discovery_key": DiscoveryKey(domain="hassio", key=uuid.hex, version=1), "source": config_entries.SOURCE_HASSIO, } From ae06f734ce7c8e9557afdcaf6b467ab541faad1b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 12:34:00 +0100 Subject: [PATCH 078/511] Improve error handling in Spotify (#129799) --- .../components/spotify/coordinator.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 4a8c6885f9f..9e62d5f137e 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -75,7 +75,10 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: - current = await self.client.get_playback() + try: + current = await self.client.get_playback() + except SpotifyConnectionError as err: + raise UpdateFailed("Error communicating with Spotify API") from err if not current: return SpotifyCoordinatorData( current_playback=None, @@ -90,8 +93,17 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): audio_features: AudioFeatures | None = None if (item := current.item) is not None and item.type == ItemType.TRACK: if item.uri != self._currently_loaded_track: - self._currently_loaded_track = item.uri - audio_features = await self.client.get_audio_features(item.uri) + try: + audio_features = await self.client.get_audio_features(item.uri) + except SpotifyConnectionError: + _LOGGER.debug( + "Unable to load audio features for track '%s'. " + "Continuing without audio features", + item.uri, + ) + audio_features = None + else: + self._currently_loaded_track = item.uri else: audio_features = self.data.audio_features dj_playlist = False From 3cadc1796fc3ed89afbe13d3a077a2e4758bf05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 4 Nov 2024 13:07:11 +0100 Subject: [PATCH 079/511] Use JSON as format for .HA_RESTORE (#129792) * Use JSON as format for .HA_RESTORE * Adjust bakup manager test --- homeassistant/backup_restore.py | 6 +++--- homeassistant/components/backup/manager.py | 2 +- tests/components/backup/test_manager.py | 2 +- tests/test_backup_restore.py | 9 ++------- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 6cf96fdfa91..32991dfb2d3 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -30,11 +30,11 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | """Return the contents of the restore backup file.""" instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) try: - instruction_content = instruction_path.read_text(encoding="utf-8") + instruction_content = json.loads(instruction_path.read_text(encoding="utf-8")) return RestoreBackupFileContent( - backup_file_path=Path(instruction_content.split(";")[0]) + backup_file_path=Path(instruction_content["path"]) ) - except FileNotFoundError: + except (FileNotFoundError, json.JSONDecodeError): return None diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8120e3a6e66..b3cb69861b9 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -308,7 +308,7 @@ class BackupManager(BaseBackupManager): def _write_restore_file() -> None: """Write the restore file.""" Path(self.hass.config.path(RESTORE_BACKUP_FILE)).write_text( - f"{backup.path.as_posix()};", + json.dumps({"path": backup.path.as_posix()}), encoding="utf-8", ) diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a269a3f2f17..a4dba5c6936 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -350,7 +350,7 @@ async def test_async_trigger_restore( patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call, ): await manager.async_restore_backup(TEST_BACKUP.slug) - assert mocked_write_text.call_args[0][0] == "abc123.tar;" + assert mocked_write_text.call_args[0][0] == '{"path": "abc123.tar"}' assert mocked_service_call.called diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index fabb403468d..44a05c0540e 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -15,15 +15,10 @@ from .common import get_test_config_dir ("side_effect", "content", "expected"), [ (FileNotFoundError, "", None), - (None, "", backup_restore.RestoreBackupFileContent(backup_file_path=Path(""))), + (None, "", None), ( None, - "test;", - backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), - ), - ( - None, - "test;;;;", + '{"path": "test"}', backup_restore.RestoreBackupFileContent(backup_file_path=Path("test")), ), ], From 57eeaf1f7526f1493caa21744ac131d0aab83291 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:42:42 +0100 Subject: [PATCH 080/511] Add watchdog to monitor and respawn go2rtc server (#129497) --- homeassistant/components/go2rtc/__init__.py | 4 +- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/server.py | 113 +++++++++++++++++++- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_server.py | 97 +++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index eeaa35fbbb4..013c094dc23 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN from .server import Server _LOGGER = logging.getLogger(__name__) @@ -121,7 +121,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = "http://localhost:1984/" + url = DEFAULT_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index b0d52e4fd39..cb03e224e52 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,3 +4,4 @@ DOMAIN = "go2rtc" CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." +DEFAULT_URL = "http://localhost:1984/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index df4b5b7f13e..b2aa19d5275 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,17 +1,25 @@ """Go2rtc server.""" import asyncio +from contextlib import suppress import logging from tempfile import NamedTemporaryFile +from go2rtc_client import Go2RtcRestClient + from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_RESPAWN_COOLDOWN = 1 + # Default configuration for HA # - Api is listening only on localhost # - Disable rtsp listener @@ -29,6 +37,16 @@ webrtc: """ +class Go2RTCServerStartError(HomeAssistantError): + """Raised when server does not start.""" + + _message = "Go2rtc server didn't start correctly" + + +class Go2RTCWatchdogError(HomeAssistantError): + """Raised on watchdog error.""" + + def _create_temp_file(api_ip: str) -> str: """Create temporary config file.""" # Set delete=False to prevent the file from being deleted when the file is closed @@ -53,8 +71,17 @@ class Server: if enable_ui: # Listen on all interfaces for allowing access from all ips self._api_ip = "" + self._watchdog_task: asyncio.Task | None = None + self._watchdog_tasks: list[asyncio.Task] = [] async def start(self) -> None: + """Start the server.""" + await self._start() + self._watchdog_task = asyncio.create_task( + self._watchdog(), name="Go2rtc respawn" + ) + + async def _start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") config_file = await self._hass.async_add_executor_job( @@ -82,8 +109,8 @@ class Server: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) - await self.stop() - raise HomeAssistantError("Go2rtc server didn't start correctly") from err + await self._stop() + raise Go2RTCServerStartError from err async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" @@ -95,17 +122,95 @@ class Server: if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + async def _watchdog(self) -> None: + """Keep respawning go2rtc servers. + + A new go2rtc server is spawned if the process terminates or the API + stops responding. + """ + while True: + try: + monitor_process_task = asyncio.create_task(self._monitor_process()) + self._watchdog_tasks.append(monitor_process_task) + monitor_process_task.add_done_callback(self._watchdog_tasks.remove) + monitor_api_task = asyncio.create_task(self._monitor_api()) + self._watchdog_tasks.append(monitor_api_task) + monitor_api_task.add_done_callback(self._watchdog_tasks.remove) + try: + await asyncio.gather(monitor_process_task, monitor_api_task) + except Go2RTCWatchdogError: + _LOGGER.debug("Caught Go2RTCWatchdogError") + for task in self._watchdog_tasks: + if task.done(): + if not task.cancelled(): + task.exception() + continue + task.cancel() + await asyncio.sleep(_RESPAWN_COOLDOWN) + try: + await self._stop() + _LOGGER.debug("Spawning new go2rtc server") + with suppress(Go2RTCServerStartError): + await self._start() + except Exception: + _LOGGER.exception( + "Unexpected error when restarting go2rtc server" + ) + except Exception: + _LOGGER.exception("Unexpected error in go2rtc server watchdog") + + async def _monitor_process(self) -> None: + """Raise if the go2rtc process terminates.""" + _LOGGER.debug("Monitoring go2rtc server process") + if self._process: + await self._process.wait() + _LOGGER.debug("go2rtc server terminated") + raise Go2RTCWatchdogError("Process ended") + + async def _monitor_api(self) -> None: + """Raise if the go2rtc process terminates.""" + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + + _LOGGER.debug("Monitoring go2rtc API") + try: + while True: + await client.streams.list() + await asyncio.sleep(10) + except Exception as err: + _LOGGER.debug("go2rtc API did not reply", exc_info=True) + raise Go2RTCWatchdogError("API error") from err + + async def _stop_watchdog(self) -> None: + """Handle watchdog stop request.""" + tasks: list[asyncio.Task] = [] + if watchdog_task := self._watchdog_task: + self._watchdog_task = None + tasks.append(watchdog_task) + watchdog_task.cancel() + for task in self._watchdog_tasks: + tasks.append(task) + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + async def stop(self) -> None: + """Stop the server and abort the watchdog task.""" + _LOGGER.debug("Server stop requested") + await self._stop_watchdog() + await self._stop() + + async def _stop(self) -> None: """Stop the server.""" if self._process: _LOGGER.debug("Stopping go2rtc server") process = self._process self._process = None - process.terminate() + with suppress(ProcessLookupError): + process.terminate() try: await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT) except TimeoutError: _LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it") - process.kill() + with suppress(ProcessLookupError): + process.kill() else: _LOGGER.debug("Go2rtc server has been stopped") diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index b299c28c557..495d42114f1 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -18,6 +18,7 @@ def rest_client() -> Generator[AsyncMock]: patch( "homeassistant.components.go2rtc.Go2RtcRestClient", ) as mock_client, + patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value client.streams = Mock(spec_set=_StreamClient) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 42f3f5e098d..1410fbeb6c3 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -161,3 +161,100 @@ async def test_server_failed_to_start( stderr=subprocess.STDOUT, close_fds=False, ) + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_exit( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted when it exits.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_not_awaited() + + evt.set() + await asyncio.sleep(0.1) + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_api_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + rest_client.streams.list.side_effect = Exception + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling when exception is raised during restart.""" + rest_client.streams.list.side_effect = Exception + mock_create_subprocess.return_value.terminate.side_effect = [Exception, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + assert "Unexpected error when restarting go2rtc server" in caplog.text + + await server.stop() From df35c8e707a6a1d8c31a0cc20604645857e20127 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 13:58:12 +0100 Subject: [PATCH 081/511] Update go2rtc stream if stream_source is not matching (#129804) --- homeassistant/components/go2rtc/__init__.py | 18 ++++++++++-------- tests/components/go2rtc/conftest.py | 3 ++- tests/components/go2rtc/test_init.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 013c094dc23..5be1dbc1a48 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -203,15 +203,17 @@ class WebRTCProvider(CameraWebRTCProvider): self._session, self._url, source=camera.entity_id ) + if not (stream_source := await camera.stream_source()): + send_message( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + ) + return + streams = await self._rest_client.streams.list() - if camera.entity_id not in streams: - if not (stream_source := await camera.stream_source()): - send_message( - WebRTCError( - "go2rtc_webrtc_offer_failed", "Camera has no stream source" - ) - ) - return + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): await self._rest_client.streams.add(camera.entity_id, stream_source) @callback diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 495d42114f1..87c68989fd2 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -21,7 +21,8 @@ def rest_client() -> Generator[AsyncMock]: patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value - client.streams = Mock(spec_set=_StreamClient) + client.streams = streams = Mock(spec_set=_StreamClient) + streams.list.return_value = {} client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 1e73525fbe3..847de248aaf 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -239,6 +239,18 @@ async def _test_setup_and_signaling( rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { From 4784199038e1b8b090770fcaec2d3cb8815b1a88 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:59:10 +0100 Subject: [PATCH 082/511] Fix aborting flows for single config entry integrations (#129805) --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f533a62e753..ec0a559c76f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1457,6 +1457,7 @@ class ConfigEntriesFlowManager( or progress_unique_id == DEFAULT_DISCOVERY_UNIQUE_ID ): self.async_abort(progress_flow_id) + continue # Abort any flows in progress for the same handler # when integration allows only one config entry diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e3f1d110ac0..822dca559a8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5843,8 +5843,20 @@ async def test_avoid_adding_second_config_entry_on_single_config_entry( assert result["translation_domain"] == HOMEASSISTANT_DOMAIN +@pytest.mark.parametrize( + ("flow_1_unique_id", "flow_2_unique_id"), + [ + (None, None), + ("very_unique", "very_unique"), + (None, config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ("very_unique", config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ], +) async def test_in_progress_get_canceled_when_entry_is_created( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + flow_1_unique_id: str | None, + flow_2_unique_id: str | None, ) -> None: """Test that we abort all in progress flows when a new entry is created on a single instance only integration.""" integration = loader.Integration( @@ -5872,6 +5884,15 @@ async def test_in_progress_get_canceled_when_entry_is_created( if user_input is not None: return self.async_create_entry(title="Test Title", data=user_input) + await self.async_set_unique_id(flow_1_unique_id, raise_on_progress=False) + return self.async_show_form(step_id="user") + + async def async_step_zeroconfg(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(title="Test Title", data=user_input) + + await self.async_set_unique_id(flow_2_unique_id, raise_on_progress=False) return self.async_show_form(step_id="user") with ( From 6d561a9796a91d4e28976e6ebd177d61e60bd5c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:21:26 +0100 Subject: [PATCH 083/511] Remove deprecated property setters in option flows (#129773) --- homeassistant/components/anthropic/config_flow.py | 1 - homeassistant/components/cast/config_flow.py | 5 ++--- homeassistant/components/deconz/config_flow.py | 11 ++++------- homeassistant/components/demo/config_flow.py | 7 +------ homeassistant/components/generic/config_flow.py | 5 ++--- .../google_generative_ai_conversation/config_flow.py | 1 - .../components/here_travel_time/config_flow.py | 5 ++--- homeassistant/components/hive/config_flow.py | 1 - homeassistant/components/homekit/config_flow.py | 5 ++--- .../components/hvv_departures/config_flow.py | 6 ++---- homeassistant/components/iss/config_flow.py | 11 ++++------- .../components/keenetic_ndms2/config_flow.py | 5 ++--- homeassistant/components/knx/config_flow.py | 1 - homeassistant/components/nina/config_flow.py | 3 +-- homeassistant/components/nmap_tracker/config_flow.py | 8 ++------ homeassistant/components/ollama/config_flow.py | 5 ++--- .../components/openai_conversation/config_flow.py | 1 - homeassistant/components/plex/config_flow.py | 2 -- homeassistant/components/purpleair/config_flow.py | 5 ++--- homeassistant/components/risco/config_flow.py | 1 - homeassistant/components/sia/config_flow.py | 6 ++---- homeassistant/components/somfy_mylink/config_flow.py | 7 ++----- .../components/speedtestdotnet/config_flow.py | 5 ++--- homeassistant/components/tankerkoenig/config_flow.py | 5 ++--- homeassistant/components/unifi/config_flow.py | 8 +------- homeassistant/components/zha/config_flow.py | 2 -- homeassistant/components/zwave_js/config_flow.py | 5 ++--- 27 files changed, 39 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 5ea167090c6..fa43a3c4bcc 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -121,7 +121,6 @@ class AnthropicOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 0ebfa553f62..03a3f2ea1f8 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -41,7 +41,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> CastOptionsFlowHandler: """Get the options flow for this handler.""" - return CastOptionsFlowHandler(config_entry) + return CastOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -109,9 +109,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class CastOptionsFlowHandler(OptionsFlow): """Handle Google Cast options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Google Cast options flow.""" - self.config_entry = config_entry self.updated_config: dict[str, Any] = {} async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 3fb025b4d99..6332c56a08a 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -74,9 +74,11 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> DeconzOptionsFlowHandler: """Get the options flow for this handler.""" - return DeconzOptionsFlowHandler(config_entry) + return DeconzOptionsFlowHandler() def __init__(self) -> None: """Initialize the deCONZ config flow.""" @@ -299,11 +301,6 @@ class DeconzOptionsFlowHandler(OptionsFlow): gateway: DeconzHub - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize deCONZ options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 241f62bed69..2b27689bdaf 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -35,7 +35,7 @@ class DemoConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -45,11 +45,6 @@ class DemoConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 7b10cdfb64b..8bd238fd0e6 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -324,7 +324,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> GenericOptionsFlowHandler: """Get the options flow for this handler.""" - return GenericOptionsFlowHandler(config_entry) + return GenericOptionsFlowHandler() def check_for_existing(self, options: dict[str, Any]) -> bool: """Check whether an existing entry is using the same URLs.""" @@ -409,9 +409,8 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" - self.config_entry = config_entry self.preview_cam: dict[str, Any] = {} self.user_input: dict[str, Any] = {} diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index bccc7d1fb84..83eec25ed15 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -163,7 +163,6 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 4376ae793c0..c2b70de148c 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -113,7 +113,7 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> HERETravelTimeOptionsFlow: """Get the options flow.""" - return HERETravelTimeOptionsFlow(config_entry) + return HERETravelTimeOptionsFlow() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -297,9 +297,8 @@ class HERETravelTimeConfigFlow(ConfigFlow, domain=DOMAIN): class HERETravelTimeOptionsFlow(OptionsFlow): """Handle HERE Travel Time options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize HERE Travel Time options flow.""" - self.config_entry = config_entry self._config: dict[str, Any] = {} async def async_step_init( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index d6be2d1efab..a997954f4cc 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -182,7 +182,6 @@ class HiveOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None - self.config_entry = config_entry self.interval = config_entry.options.get(CONF_SCAN_INTERVAL, 120) async def async_step_init( diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index a63e365ead7..53db7774821 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -362,15 +362,14 @@ class HomeKitConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.hk_options: dict[str, Any] = {} self.included_cameras: list[str] = [] diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 3e1b98d9a38..536b8f18259 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -141,16 +141,14 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Options flow handler.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize HVV Departures options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) self.departure_filters: dict[str, Any] = {} async def async_step_init( diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index 9cc533f5cc5..567618a7680 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -1,5 +1,7 @@ """Config flow to configure iss component.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.config_entries import ( @@ -23,9 +25,9 @@ class ISSConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user(self, user_input=None) -> ConfigFlowResult: """Handle a flow initialized by the user.""" @@ -42,11 +44,6 @@ class ISSConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Config flow options handler for iss.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 69e81bf292d..d11fedac385 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -55,7 +55,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> KeeneticOptionsFlowHandler: """Get the options flow for this handler.""" - return KeeneticOptionsFlowHandler(config_entry) + return KeeneticOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -138,9 +138,8 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): class KeeneticOptionsFlowHandler(OptionsFlow): """Handle options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._interface_options: dict[str, str] = {} async def async_step_init( diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 4a71c600824..feeb7626577 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -770,7 +770,6 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - self.config_entry = config_entry super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] @callback diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index dd4319d566b..a1ba9ae0c61 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -171,8 +171,7 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.data = dict(self.config_entry.data) + self.data = dict(config_entry.data) self._all_region_codes_sorted: dict[str, str] = {} self.regions: dict[str, dict[str, Any]] = {} diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index b724dca1a81..36645278bae 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -141,10 +141,6 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -213,6 +209,6 @@ class NmapTrackerConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 65b8efaf525..1024a824c25 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -207,9 +207,8 @@ class OllamaOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.url: str = self.config_entry.data[CONF_URL] - self.model: str = self.config_entry.data[CONF_MODEL] + self.url: str = config_entry.data[CONF_URL] + self.model: str = config_entry.data[CONF_MODEL] async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index c6b8487ad0d..2a1764e6b5e 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -115,7 +115,6 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.config_entry = config_entry self.last_rendered_recommended = config_entry.options.get( CONF_RECOMMENDED, False ) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index fcd5751effb..22069310804 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import copy import logging from typing import TYPE_CHECKING, Any @@ -385,7 +384,6 @@ class PlexOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Plex options flow.""" - self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 6337431ecea..3ca7870b3cb 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -209,7 +209,7 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> PurpleAirOptionsFlowHandler: """Define the config flow to handle options.""" - return PurpleAirOptionsFlowHandler(config_entry) + return PurpleAirOptionsFlowHandler() async def async_step_by_coordinates( self, user_input: dict[str, Any] | None = None @@ -315,10 +315,9 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): class PurpleAirOptionsFlowHandler(OptionsFlow): """Handle a PurpleAir options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize.""" self._flow_data: dict[str, Any] = {} - self.config_entry = config_entry @property def settings_schema(self) -> vol.Schema: diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index 8f88c7c30a3..f7365d35414 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -220,7 +220,6 @@ class RiscoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" - self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} def _options_schema(self) -> vol.Schema: diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index cb451133d41..c421151f7bb 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -103,7 +103,7 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" - return SIAOptionsFlowHandler(config_entry) + return SIAOptionsFlowHandler() def __init__(self) -> None: """Initialize the config flow.""" @@ -179,10 +179,8 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize SIA options flow.""" - self.config_entry = config_entry - self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None self.accounts_todo: list = [] diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 705db43362e..f92c4909dd5 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from copy import deepcopy import logging from typing import Any @@ -122,16 +121,14 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() class OptionsFlowHandler(OptionsFlow): """Handle a option flow for somfy_mylink.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry - self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @callback diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index dc64448bbef..3bfd4eb6e4a 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -30,7 +30,7 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: SpeedTestConfigEntry, ) -> SpeedTestOptionsFlowHandler: """Get the options flow for this handler.""" - return SpeedTestOptionsFlowHandler(config_entry) + return SpeedTestOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -48,9 +48,8 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): class SpeedTestOptionsFlowHandler(OptionsFlow): """Handle SpeedTest options.""" - def __init__(self, config_entry: SpeedTestConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._servers: dict = {} async def async_step_init( diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b13bfa1fa36..509f293665d 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -74,7 +74,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -236,9 +236,8 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Handle an options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Initialize options flow.""" - self.config_entry = config_entry self._stations: dict[str, str] = {} async def async_step_init( diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index f36edc8a888..44969191fe6 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -38,7 +38,6 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac -from . import UnifiConfigEntry from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -82,7 +81,7 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): config_entry: ConfigEntry, ) -> UnifiOptionsFlowHandler: """Get the options flow for this handler.""" - return UnifiOptionsFlowHandler(config_entry) + return UnifiOptionsFlowHandler() def __init__(self) -> None: """Initialize the UniFi Network flow.""" @@ -248,11 +247,6 @@ class UnifiOptionsFlowHandler(OptionsFlow): hub: UnifiHub - def __init__(self, config_entry: UnifiConfigEntry) -> None: - """Initialize UniFi Network options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 20eb006eb74..1c7e0d105c4 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -680,8 +680,6 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" super().__init__() - self.config_entry = config_entry - self._radio_mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] self._radio_mgr.device_settings = config_entry.data[CONF_DEVICE] self._radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 7eb887c8dcf..36f208e18d5 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -366,7 +366,7 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -725,10 +725,9 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow): """Handle an options flow for Z-Wave JS.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self) -> None: """Set up the options flow.""" super().__init__() - self.config_entry = config_entry self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None From ff621d5bf3406213f87a09515cd5e74843145fd4 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 4 Nov 2024 14:45:20 +0100 Subject: [PATCH 084/511] Bump lcn-frontend to 0.2.1 (#129457) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8f499adabe0..6ce41a2d08d 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.0"] + "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52cbbe340c1..cea9be138dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1265,7 +1265,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa8c40a6bac..866d9de4cb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 From 41a81cbf1506a00d44cd8aa2807b6919e391c1cb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:48:28 +0100 Subject: [PATCH 085/511] Switch back to av 13.1.0 (#129699) --- .../components/generic/manifest.json | 2 +- homeassistant/components/stream/core.py | 8 ++- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/stream/recorder.py | 16 +++--- homeassistant/components/stream/worker.py | 50 +++++++++---------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 8 +-- requirements_test_all.txt | 8 +-- 8 files changed, 47 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b19d6d6293e..b02a8fa2520 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.4.0"] + "requirements": ["av==13.1.0", "Pillow==10.4.0"] } diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index bce16ff4c87..4184b23b9a0 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -27,8 +27,7 @@ from .const import ( ) if TYPE_CHECKING: - from av import Packet - from av.video.codeccontext import VideoCodecContext + from av import Packet, VideoCodecContext from homeassistant.components.camera import DynamicStreamSettings @@ -509,9 +508,8 @@ class KeyFrameConverter: frames = self._codec_context.decode(None) break except EOFError: - _LOGGER.debug("Codec context needs flushing, attempting to reopen") - self._codec_context.close() - self._codec_context.open() + _LOGGER.debug("Codec context needs flushing") + self._codec_context.flush_buffers() else: _LOGGER.debug("Unable to decode keyframe") return diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 00387d97b83..23494a06744 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "ha-av==10.1.1", "numpy==1.26.4"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==1.26.4"] } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index d28982ea30d..a24440e6d19 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -107,7 +107,7 @@ class RecorderOutput(StreamOutput): # Create output on first segment if not output: container_options: dict[str, str] = { - "video_track_timescale": str(int(1 / source_v.time_base)), + "video_track_timescale": str(int(1 / source_v.time_base)), # type: ignore[operator] "movflags": "frag_keyframe+empty_moov", "min_frag_duration": str(self.stream_settings.min_segment_duration), } @@ -132,21 +132,23 @@ class RecorderOutput(StreamOutput): last_stream_id = segment.stream_id pts_adjuster["video"] = int( (running_duration - source.start_time) - / (av.time_base * source_v.time_base) + / (av.time_base * source_v.time_base) # type: ignore[operator] ) if source_a: pts_adjuster["audio"] = int( (running_duration - source.start_time) - / (av.time_base * source_a.time_base) + / (av.time_base * source_a.time_base) # type: ignore[operator] ) # Remux video for packet in source.demux(): - if packet.dts is None: + if packet.pts is None: continue - packet.pts += pts_adjuster[packet.stream.type] - packet.dts += pts_adjuster[packet.stream.type] - packet.stream = output_v if packet.stream.type == "video" else output_a + packet.pts += pts_adjuster[packet.stream.type] # type: ignore[operator] + packet.dts += pts_adjuster[packet.stream.type] # type: ignore[operator] + stream = output_v if packet.stream.type == "video" else output_a + assert stream + packet.stream = stream output.mux(packet) running_duration += source.duration - source.start_time diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 42bfa13f13e..8c9bb1b8e9e 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -16,7 +16,6 @@ import av import av.audio import av.container import av.stream -import av.video from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util @@ -53,8 +52,8 @@ class StreamWorkerError(Exception): def redact_av_error_string(err: av.FFmpegError) -> str: """Return an error string with credentials redacted from the url.""" - parts = [str(err.type), err.strerror] - if err.filename is not None: + parts = [str(err.type), err.strerror] # type: ignore[attr-defined] + if err.filename: parts.append(redact_credentials(err.filename)) return ", ".join(parts) @@ -130,19 +129,19 @@ class StreamMuxer: _segment_start_dts: int _memory_file: BytesIO _av_output: av.container.OutputContainer - _output_video_stream: av.video.VideoStream + _output_video_stream: av.VideoStream _output_audio_stream: av.audio.AudioStream | None _segment: Segment | None # the following 2 member variables are used for Part formation _memory_file_pos: int - _part_start_dts: int + _part_start_dts: float def __init__( self, hass: HomeAssistant, - video_stream: av.video.VideoStream, + video_stream: av.VideoStream, audio_stream: av.audio.AudioStream | None, - audio_bsf: av.BitStreamFilter | None, + audio_bsf: str | None, stream_state: StreamState, stream_settings: StreamSettings, ) -> None: @@ -161,11 +160,11 @@ class StreamMuxer: self, memory_file: BytesIO, sequence: int, - input_vstream: av.video.VideoStream, + input_vstream: av.VideoStream, input_astream: av.audio.AudioStream | None, ) -> tuple[ av.container.OutputContainer, - av.video.VideoStream, + av.VideoStream, av.audio.AudioStream | None, ]: """Make a new av OutputContainer and add output streams.""" @@ -182,7 +181,7 @@ class StreamMuxer: # in test_durations "avoid_negative_ts": "make_non_negative", "fragment_index": str(sequence + 1), - "video_track_timescale": str(int(1 / input_vstream.time_base)), + "video_track_timescale": str(int(1 / input_vstream.time_base)), # type: ignore[operator] # Only do extra fragmenting if we are using ll_hls # Let ffmpeg do the work using frag_duration # Fragment durations may exceed the 15% allowed variance but it seems ok @@ -233,12 +232,11 @@ class StreamMuxer: output_astream = None if input_astream: if self._audio_bsf: - self._audio_bsf_context = self._audio_bsf.create() - self._audio_bsf_context.set_input_stream(input_astream) - output_astream = container.add_stream( - template=self._audio_bsf_context or input_astream - ) - return container, output_vstream, output_astream + self._audio_bsf_context = av.BitStreamFilterContext( + self._audio_bsf, input_astream + ) + output_astream = container.add_stream(template=input_astream) + return container, output_vstream, output_astream # type: ignore[return-value] def reset(self, video_dts: int) -> None: """Initialize a new stream segment.""" @@ -279,11 +277,11 @@ class StreamMuxer: self._part_has_keyframe |= packet.is_keyframe elif packet.stream == self._input_audio_stream: + assert self._output_audio_stream if self._audio_bsf_context: - self._audio_bsf_context.send(packet) - while packet := self._audio_bsf_context.recv(): - packet.stream = self._output_audio_stream - self._av_output.mux(packet) + for audio_packet in self._audio_bsf_context.filter(packet): + audio_packet.stream = self._output_audio_stream + self._av_output.mux(audio_packet) return packet.stream = self._output_audio_stream self._av_output.mux(packet) @@ -465,7 +463,7 @@ class TimestampValidator: """Validate the packet timestamp based on ordering within the stream.""" # Discard packets missing DTS. Terminate if too many are missing. if packet.dts is None: - if self._missing_dts >= MAX_MISSING_DTS: + if self._missing_dts >= MAX_MISSING_DTS: # type: ignore[unreachable] raise StreamWorkerError( f"No dts in {MAX_MISSING_DTS+1} consecutive packets" ) @@ -492,7 +490,7 @@ def is_keyframe(packet: av.Packet) -> Any: def get_audio_bitstream_filter( packets: Iterator[av.Packet], audio_stream: Any -) -> av.BitStreamFilterContext | None: +) -> str | None: """Return the aac_adtstoasc bitstream filter if ADTS AAC is detected.""" if not audio_stream: return None @@ -509,7 +507,7 @@ def get_audio_bitstream_filter( _LOGGER.debug( "ADTS AAC detected. Adding aac_adtstoaac bitstream filter" ) - return av.BitStreamFilter("aac_adtstoasc") + return "aac_adtstoasc" break return None @@ -547,7 +545,7 @@ def stream_worker( audio_stream = None # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: - audio_stream = None + audio_stream = None # type: ignore[unreachable] # Disable ll-hls for hls inputs if container.format.name == "hls": for field in fields(StreamSettings): @@ -562,8 +560,8 @@ def stream_worker( stream_state.diagnostics.set_value("audio_codec", audio_stream.name) dts_validator = TimestampValidator( - int(1 / video_stream.time_base), - int(1 / audio_stream.time_base) if audio_stream else 1, + int(1 / video_stream.time_base), # type: ignore[operator] + int(1 / audio_stream.time_base) if audio_stream else 1, # type: ignore[operator] ) container_packets = PeekIterator( filter(dts_validator.is_valid, container.demux((video_stream, audio_stream))) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 42bda4d3c40..aa8fecc73a5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,6 +13,7 @@ async-interrupt==1.2.0 async-upnp-client==0.41.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 +av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 bleak-retry-connector==3.6.0 @@ -27,7 +28,6 @@ cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.0.1b3 -ha-av==10.1.1 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.83.0 diff --git a/requirements_all.txt b/requirements_all.txt index cea9be138dc..10e4dd4fefb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -526,6 +526,10 @@ autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 +# homeassistant.components.generic +# homeassistant.components.stream +av==13.1.0 + # homeassistant.components.avea # avea==1.5.1 @@ -1064,10 +1068,6 @@ guppy3==3.1.4.post1 # homeassistant.components.iaqualink h2==4.1.0 -# homeassistant.components.generic -# homeassistant.components.stream -ha-av==10.1.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 866d9de4cb9..fb67a3f12ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,6 +481,10 @@ autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble automower-ble==0.2.0 +# homeassistant.components.generic +# homeassistant.components.stream +av==13.1.0 + # homeassistant.components.axis axis==63 @@ -902,10 +906,6 @@ guppy3==3.1.4.post1 # homeassistant.components.iaqualink h2==4.1.0 -# homeassistant.components.generic -# homeassistant.components.stream -ha-av==10.1.1 - # homeassistant.components.ffmpeg ha-ffmpeg==3.2.1 From 02750452dfd2f8392ea07e40c2a3ecef5f87e08d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 15:01:37 +0100 Subject: [PATCH 086/511] Update Spotify state after mutation (#129607) --- .../components/spotify/media_player.py | 29 +++++++++++++++++-- tests/components/spotify/conftest.py | 7 +++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index dce200bc598..7687936fe4c 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +import asyncio +from collections.abc import Awaitable, Callable, Coroutine import datetime as dt import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from spotifyaio import ( Device, @@ -63,6 +64,7 @@ REPEAT_MODE_MAPPING_TO_HA = { REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } +AFTER_REQUEST_SLEEP = 1 async def async_setup_entry( @@ -93,6 +95,19 @@ def ensure_item[_R]( return wrapper +def async_refresh_after[_T: SpotifyEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to yield and refresh after.""" + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + await func(self, *args, **kwargs) + await asyncio.sleep(AFTER_REQUEST_SLEEP) + await self.coordinator.async_refresh() + + return _async_wrap + + class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): """Representation of a Spotify controller.""" @@ -267,30 +282,37 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): return None return REPEAT_MODE_MAPPING_TO_HA.get(self.currently_playing.repeat_mode) + @async_refresh_after async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" await self.coordinator.client.set_volume(int(volume * 100)) + @async_refresh_after async def async_media_play(self) -> None: """Start or resume playback.""" await self.coordinator.client.start_playback() + @async_refresh_after async def async_media_pause(self) -> None: """Pause playback.""" await self.coordinator.client.pause_playback() + @async_refresh_after async def async_media_previous_track(self) -> None: """Skip to previous track.""" await self.coordinator.client.previous_track() + @async_refresh_after async def async_media_next_track(self) -> None: """Skip to next track.""" await self.coordinator.client.next_track() + @async_refresh_after async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self.coordinator.client.seek_track(int(position * 1000)) + @async_refresh_after async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -334,6 +356,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): await self.coordinator.client.start_playback(**kwargs) + @async_refresh_after async def async_select_source(self, source: str) -> None: """Select playback device.""" for device in self.devices.data: @@ -341,10 +364,12 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): await self.coordinator.client.transfer_playback(device.device_id) return + @async_refresh_after async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" await self.coordinator.client.set_shuffle(state=shuffle) + @async_refresh_after async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 5d86045e5a8..d3fc418f1cd 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -84,6 +84,13 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +async def patch_sleep() -> Generator[AsyncMock]: + """Fixture to setup credentials.""" + with patch("homeassistant.components.spotify.media_player.AFTER_REQUEST_SLEEP", 0): + yield + + @pytest.fixture def mock_spotify() -> Generator[AsyncMock]: """Mock the Spotify API.""" From d0c45b18573c80530f381fe467d673878b578839 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 4 Nov 2024 15:31:44 +0100 Subject: [PATCH 087/511] Bump python-bsblan to 1.2.1 (#129635) * Bump python-bsblan dependency to version 1.1.0 * Bump python-bsblan dependency to version 1.2.0 * Bump python-bsblan dependency to version 1.2.1 * Update test diagnostics snapshots to use numeric values and add error handling --- homeassistant/components/bsblan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bsblan/snapshots/test_diagnostics.ambr | 78 ++++++++++++++++--- 4 files changed, 70 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 5b10f46bf13..aa9c03abf4a 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==1.0.0"] + "requirements": ["python-bsblan==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 10e4dd4fefb..80db6a022d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2308,7 +2308,7 @@ python-awair==0.2.4 python-blockchain-api==0.0.2 # homeassistant.components.bsblan -python-bsblan==1.0.0 +python-bsblan==1.2.1 # homeassistant.components.clementine python-clementine-remote==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb67a3f12ca..324321456e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1859,7 +1859,7 @@ python-MotionMount==2.2.0 python-awair==0.2.4 # homeassistant.components.bsblan -python-bsblan==1.0.0 +python-bsblan==1.2.1 # homeassistant.components.ecobee python-ecobee-api==0.2.20 diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index e033b2417d2..9fabd373205 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -6,67 +6,103 @@ 'current_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp 1 actual value', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', - 'value': '18.6', + 'value': 18.6, }), 'outside_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Outside temp sensor local', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '6.1', + 'value': 6.1, }), }), 'state': dict({ 'current_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp 1 actual value', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', - 'value': '18.6', + 'value': 18.6, }), 'hvac_action': dict({ 'data_type': 1, 'desc': 'Raumtemp’begrenzung', + 'error': 0, 'name': 'Status heating circuit 1', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '', - 'value': '122', + 'value': 122, }), 'hvac_mode': dict({ 'data_type': 1, 'desc': 'Komfort', + 'error': 0, 'name': 'Operating mode', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', 'value': 'heat', }), 'hvac_mode2': dict({ 'data_type': 1, 'desc': 'Reduziert', + 'error': 0, 'name': 'Operating mode', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '2', + 'value': 2, }), 'room1_temp_setpoint_boost': dict({ 'data_type': 1, 'desc': 'Boost', + 'error': 0, 'name': 'Room 1 Temp Setpoint Boost', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '°C', 'value': '22.5', }), 'room1_thermostat_mode': dict({ 'data_type': 1, 'desc': 'Kein Bedarf', + 'error': 0, 'name': 'Raumthermostat 1', + 'precision': None, + 'readonly': 1, + 'readwrite': 0, 'unit': '', - 'value': '0', + 'value': 0, }), 'target_temperature': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temperature Comfort setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '18.5', + 'value': 18.5, }), }), }), @@ -80,21 +116,33 @@ 'controller_family': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Device family', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '211', + 'value': 211, }), 'controller_variant': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Device variant', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', - 'value': '127', + 'value': 127, }), 'device_identification': dict({ 'data_type': 7, 'desc': '', + 'error': 0, 'name': 'Gerte-Identifikation', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '', 'value': 'RVS21.831F/127', }), @@ -103,16 +151,24 @@ 'max_temp': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Summer/winter changeover temp heat circuit 1', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '20.0', + 'value': 20.0, }), 'min_temp': dict({ 'data_type': 0, 'desc': '', + 'error': 0, 'name': 'Room temp frost protection setpoint', + 'precision': None, + 'readonly': 0, + 'readwrite': 0, 'unit': '°C', - 'value': '8.0', + 'value': 8.0, }), }), }) From 7691991a93cdc598aa8cf2e95b69fbbedf8258ee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 08:33:15 -0600 Subject: [PATCH 088/511] Small cleanups to the websocket command phase (#129712) * Small cleanups to the websocket command phase - Remove unused argument - Avoid multiple NamedTuple property lookups * Update homeassistant/components/websocket_api/http.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Apply suggestions from code review * touch ups --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/websocket_api/http.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 29dc6113350..11aca19bab9 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -36,6 +36,8 @@ from .error import Disconnect from .messages import message_to_json_bytes from .util import describe_request +CLOSE_MSG_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING} + if TYPE_CHECKING: from .connection import ActiveConnection @@ -344,7 +346,7 @@ class WebSocketHandler: try: connection = await self._async_handle_auth_phase(auth, send_bytes_text) self._async_increase_writer_limit(writer) - await self._async_websocket_command_phase(connection, send_bytes_text) + await self._async_websocket_command_phase(connection) except asyncio.CancelledError: logger.debug("%s: Connection cancelled", self.description) raise @@ -454,9 +456,7 @@ class WebSocketHandler: writer._limit = 2**20 # noqa: SLF001 async def _async_websocket_command_phase( - self, - connection: ActiveConnection, - send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]], + self, connection: ActiveConnection ) -> None: """Handle the command phase of the websocket connection.""" wsock = self._wsock @@ -467,24 +467,26 @@ class WebSocketHandler: # Command phase while not wsock.closed: msg = await wsock.receive() + msg_type = msg.type + msg_data = msg.data - if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): + if msg_type in CLOSE_MSG_TYPES: break - if msg.type is WSMsgType.BINARY: - if len(msg.data) < 1: + if msg_type is WSMsgType.BINARY: + if len(msg_data) < 1: raise Disconnect("Received invalid binary message.") - handler = msg.data[0] - payload = msg.data[1:] + handler = msg_data[0] + payload = msg_data[1:] async_handle_binary(handler, payload) continue - if msg.type is not WSMsgType.TEXT: + if msg_type is not WSMsgType.TEXT: raise Disconnect("Received non-Text message.") try: - command_msg_data = json_loads(msg.data) + command_msg_data = json_loads(msg_data) except ValueError as ex: raise Disconnect("Received invalid JSON.") from ex From 4ac35d40cd47071a52207ca1ecb69c695a2e196c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 15:45:29 +0100 Subject: [PATCH 089/511] Fix create flow logic for single config entry integrations (#129807) * Fix create flow logic for single config entry integrations * Adjust MQTT test --- homeassistant/config_entries.py | 8 +++++++- tests/components/mqtt/test_config_flow.py | 2 +- tests/test_config_entries.py | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ec0a559c76f..f9e72a723a4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1278,7 +1278,13 @@ class ConfigEntriesFlowManager( # a single config entry, but which already has an entry if ( source not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} - and self.config_entries.async_has_entries(handler, include_ignore=False) + and ( + self.config_entries.async_has_entries(handler, include_ignore=False) + or ( + self.config_entries.async_has_entries(handler, include_ignore=True) + and source != SOURCE_USER + ) + ) and await _support_single_config_entry_only(self.hass, handler) ): return ConfigFlowResult( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5a95b9c5712..e99063b088b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -444,7 +444,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: ) assert result assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result.get("reason") == "single_instance_allowed" async def test_hassio_confirm( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 822dca559a8..700840eb90e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5729,6 +5729,14 @@ async def test_starting_config_flow_on_single_config_entry( None, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "not_implemented"}, ), + ( + {"source": config_entries.SOURCE_ZEROCONF}, + None, + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "single_instance_allowed", + }, + ), ], ) async def test_starting_config_flow_on_single_config_entry_2( From 365f8046ace7a4d7aa401fcf0aba54dd8347f3e3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:09:50 +0100 Subject: [PATCH 090/511] Use new helper properties in yeelight options flow (#129791) --- homeassistant/components/yeelight/config_flow.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 5438414ea61..7a3a0a2f100 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -58,9 +58,11 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowHandler: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return OptionsFlowHandler() def __init__(self) -> None: """Initialize the config flow.""" @@ -296,16 +298,12 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Handle a option flow for Yeelight.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize the option flow.""" - self._config_entry = config_entry - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - data = self._config_entry.data - options = self._config_entry.options + data = self.config_entry.data + options = self.config_entry.options detected_model = data.get(CONF_DETECTED_MODEL) model = options[CONF_MODEL] or detected_model From a5f3c434e079a24037052cd854ff06a67820ad51 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:46:38 +0100 Subject: [PATCH 091/511] Improve exceptions in habitica cast skill action (#129603) * Raise a different exception when entry not loaded * adjust type hints * move `get_config_entry` to services module --- homeassistant/components/habitica/services.py | 25 +++++++++++++------ .../components/habitica/strings.json | 5 +++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 8ca80ff63ad..440e2d4fb23 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -9,6 +9,7 @@ from typing import Any from aiohttp import ClientResponseError import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_NAME, CONF_NAME from homeassistant.core import ( HomeAssistant, @@ -54,6 +55,21 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema( ) +def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: + """Return config entry or raise if not found or not loaded.""" + if not (entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_found", + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Habitica integration.""" @@ -86,14 +102,7 @@ def async_setup_services(hass: HomeAssistant) -> None: async def cast_skill(call: ServiceCall) -> ServiceResponse: """Skill action.""" - entry: HabiticaConfigEntry | None - if not ( - entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY]) - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="entry_not_found", - ) + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) coordinator = entry.runtime_data skill = { "pickpocket": {"spellId": "pickPocket", "cost": "10 MP"}, diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 62b01260010..390dc3ba9ae 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -219,7 +219,10 @@ "message": "Unable to cast skill, your character does not have the skill or spell {skill}." }, "entry_not_found": { - "message": "The selected character is currently not configured or loaded in Home Assistant." + "message": "The selected character is not configured in Home Assistant." + }, + "entry_not_loaded": { + "message": "The selected character is currently not loaded or disabled in Home Assistant." }, "task_not_found": { "message": "Unable to cast skill, could not find the task {task}" From 400b377aa82016464bcd436c0e42f572b9ec5bd7 Mon Sep 17 00:00:00 2001 From: Jake Martin Date: Mon, 4 Nov 2024 15:55:02 +0000 Subject: [PATCH 092/511] Bump monzopy to 1.4.2 (#129726) * Bump monzopy to 1.4.0 * Bump to 1.4.2 --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/monzo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/monzo/manifest.json b/homeassistant/components/monzo/manifest.json index d9d17eb8abc..7038cecd7ea 100644 --- a/homeassistant/components/monzo/manifest.json +++ b/homeassistant/components/monzo/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/monzo", "iot_class": "cloud_polling", - "requirements": ["monzopy==1.3.2"] + "requirements": ["monzopy==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80db6a022d2..7e9e3810c69 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1385,7 +1385,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.1 # homeassistant.components.monzo -monzopy==1.3.2 +monzopy==1.4.2 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324321456e9..27712f44511 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1154,7 +1154,7 @@ moat-ble==0.1.1 moehlenhoff-alpha2==1.3.1 # homeassistant.components.monzo -monzopy==1.3.2 +monzopy==1.4.2 # homeassistant.components.mopeka mopeka-iot-ble==0.8.0 From 0c25252d9f7d2d5e5bc101712b6566df8d59a4e7 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 4 Nov 2024 11:20:15 -0500 Subject: [PATCH 093/511] Bump ayla-iot-unofficial to 1.4.3 (#129743) Upgrade to ayla-iot-unofficial v1.4.3 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 1c7b9b0b469..f7f3af8d037 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.2"] + "requirements": ["ayla-iot-unofficial==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e9e3810c69..522d81c2e0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -540,7 +540,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27712f44511..dbe3c7dd37b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -489,7 +489,7 @@ av==13.1.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From f141f5f9088c585052bdf42508c42dcb440c13ec Mon Sep 17 00:00:00 2001 From: Max Muth Date: Mon, 4 Nov 2024 17:26:12 +0100 Subject: [PATCH 094/511] Update codeowners of Fritz integration (#129595) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- CODEOWNERS | 4 ++-- homeassistant/components/fritz/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 99cfefa81c6..d039097fc82 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -496,8 +496,8 @@ build.json @home-assistant/supervisor /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 -/homeassistant/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 -/tests/components/fritz/ @mammuth @AaronDavidSchneider @chemelli74 @mib1185 +/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 +/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /homeassistant/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox_callmonitor/ @cdce8p diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 35250d9d34d..27aa42d9b2c 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -1,7 +1,7 @@ { "domain": "fritz", "name": "AVM FRITZ!Box Tools", - "codeowners": ["@mammuth", "@AaronDavidSchneider", "@chemelli74", "@mib1185"], + "codeowners": ["@AaronDavidSchneider", "@chemelli74", "@mib1185"], "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/fritz", From 0579d565dd90f71958fba6f4f28f181ee474a6b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:35:47 +0100 Subject: [PATCH 095/511] Fix incorrect description placeholders in azure event hub (#129803) --- homeassistant/components/azure_event_hub/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index 046851e6926..60ac9bff8cd 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -124,7 +124,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN): step_id=STEP_CONN_STRING, data_schema=CONN_STRING_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) @@ -144,7 +146,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN): step_id=STEP_SAS, data_schema=SAS_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) From f1a2c8be4bd6e4a3928c7c95024766f83caf0894 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 17:36:25 +0100 Subject: [PATCH 096/511] Stop recording of non-changing attributes in threshold (#129541) --- homeassistant/components/threshold/binary_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 5f1639ff2e1..da7d92f7051 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -151,6 +151,9 @@ class ThresholdSensor(BinarySensorEntity): """Representation of a Threshold sensor.""" _attr_should_poll = False + _unrecorded_attributes = frozenset( + {ATTR_ENTITY_ID, ATTR_HYSTERESIS, ATTR_LOWER, ATTR_TYPE, ATTR_UPPER} + ) def __init__( self, From 689260f581bb9b62652f1739d1258529d808a4b9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Nov 2024 11:37:14 -0500 Subject: [PATCH 097/511] Fix ESPHome dashboard check (#129812) --- homeassistant/components/esphome/manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index afbe109d5bc..007b4e791e1 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,8 +570,10 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( - device_info.name + elif ( + (dashboard := async_get_dashboard(hass)) + and dashboard.data + and dashboard.data.get(device_info.name) ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" From 2626a74840d7d625867c97e67dc57ac70b526282 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:00:31 +0100 Subject: [PATCH 098/511] Fix translations in honeywell (#129823) --- homeassistant/components/honeywell/strings.json | 3 +++ tests/components/honeywell/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index aa6e53620a5..a64f1a6fce0 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -16,6 +16,9 @@ } } }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py index b1c0b28f537..ed9c86f5e10 100644 --- a/tests/components/honeywell/test_config_flow.py +++ b/tests/components/honeywell/test_config_flow.py @@ -120,10 +120,6 @@ async def test_create_option_entry( } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.honeywell.config.abort.reauth_successful"], -) async def test_reauth_flow(hass: HomeAssistant) -> None: """Test a successful reauth flow.""" From a2a3f59e658fb308c5bc67f2968c1f28f1b02f80 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:01:39 +0100 Subject: [PATCH 099/511] Fix missing translation in jewish_calendar (#129822) --- homeassistant/components/jewish_calendar/strings.json | 3 ++- tests/components/jewish_calendar/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index e5367b5819e..1b7b86c0056 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -27,7 +27,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 2a490270fdf..dbd4ecd802d 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -168,10 +168,6 @@ async def test_options_reconfigure( ) -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.jewish_calendar.config.abort.reconfigure_successful"], -) async def test_reconfigure( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: From 6897b24c1093077a9ab7952b5e2c6c59fc768013 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:03:37 +0100 Subject: [PATCH 100/511] Fix translations in homeworks (#129824) --- homeassistant/components/homeworks/strings.json | 3 +++ tests/components/homeworks/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index a9dcab2f1e0..977e6be8afd 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "connection_error": "Could not connect to the controller.", "credentials_needed": "The controller needs credentials.", diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index cca09c10e70..e8c4ab15b3d 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -235,10 +235,6 @@ async def test_user_flow_cannot_connect( assert result["step_id"] == "user" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: @@ -326,10 +322,6 @@ async def test_reconfigure_flow_flow_duplicate( assert result["errors"] == {"base": "duplicated_host_port"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow_flow_no_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: From 9c8d8fef16dbffeaa8913c74f4c96e11161e7ad0 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Mon, 4 Nov 2024 09:06:45 -0800 Subject: [PATCH 101/511] Suggest area for NUT based on device location (#129770) --- homeassistant/components/nut/__init__.py | 5 ++++- tests/components/nut/test_init.py | 27 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 6bbe19e8f3c..b4e53c1380c 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -132,6 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: model=data.device_info.model, sw_version=data.device_info.firmware, serial_number=data.device_info.serial, + suggested_area=data.device_info.device_location, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -211,6 +212,7 @@ class NUTDeviceInfo: model: str | None = None firmware: str | None = None serial: str | None = None + device_location: str | None = None class PyNUTData: @@ -271,7 +273,8 @@ class PyNUTData: model = _model_from_status(self._status) firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) - return NUTDeviceInfo(manufacturer, model, firmware, serial) + device_location: str | None = self._status.get("device.location") + return NUTDeviceInfo(manufacturer, model, firmware, serial, device_location) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" diff --git a/tests/components/nut/test_init.py b/tests/components/nut/test_init.py index cd56c209a36..d5d85daa336 100644 --- a/tests/components/nut/test_init.py +++ b/tests/components/nut/test_init.py @@ -120,3 +120,30 @@ async def test_serial_number(hass: HomeAssistant) -> None: assert device_entry is not None assert device_entry.serial_number == mock_serial_number + + +async def test_device_location(hass: HomeAssistant) -> None: + """Test for suggested location on device.""" + mock_serial_number = "A00000000000" + mock_device_location = "XYZ Location" + await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={ + "ups.serial": mock_serial_number, + "device.location": mock_device_location, + }, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=[], + ) + + device_registry = dr.async_get(hass) + assert device_registry is not None + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_serial_number)} + ) + + assert device_entry is not None + assert device_entry.suggested_area == mock_device_location From 0278735dbfc4e64b146faed2e3ac3c997703e782 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 4 Nov 2024 12:07:11 -0500 Subject: [PATCH 102/511] Use translated errors in Russound RIO (#129820) --- homeassistant/components/russound_rio/__init__.py | 11 +++++++++-- homeassistant/components/russound_rio/entity.py | 7 ++++++- homeassistant/components/russound_rio/strings.json | 8 ++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index ba53f6794e3..784629ea0bc 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS +from .const import CONNECT_TIMEOUT, DOMAIN, RUSSOUND_RIO_EXCEPTIONS PLATFORMS = [Platform.MEDIA_PLAYER] @@ -43,7 +43,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() except RUSSOUND_RIO_EXCEPTIONS as err: - raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="entry_cannot_connect", + translation_placeholders={ + "host": host, + "port": port, + }, + ) from err entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 23b196ecb2f..0233305bb1f 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -26,7 +26,12 @@ def command[_EntityT: RussoundBaseEntity, **_P]( await func(self, *args, **kwargs) except RUSSOUND_RIO_EXCEPTIONS as exc: raise HomeAssistantError( - f"Error executing {func.__name__} on entity {self.entity_id}," + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={ + "function_name": func.__name__, + "entity_id": self.entity_id, + }, ) from exc return decorator diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index c105dcafae2..b8c29c08301 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -33,5 +33,13 @@ "title": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::title%]", "description": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::description%]" } + }, + "exceptions": { + "entry_cannot_connect": { + "message": "Error while connecting to {host}:{port}" + }, + "command_error": { + "message": "Error executing {function_name} on entity {entity_id}" + } } } From f6e36615d6d87b0752d7da907f083554c3b14469 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:39:39 +0000 Subject: [PATCH 103/511] Bump python-kasa to 0.7.7 (#129817) Bump tplink dependency python-kasa to 0.7.7 --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a79857e9e7e..cb8a55b3db2 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.7.6"] + "requirements": ["python-kasa[speedups]==0.7.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 522d81c2e0a..b35b82cf3c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2356,7 +2356,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbe3c7dd37b..5d2d1875c19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1883,7 +1883,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 From df796d432e2e7ef9f6c0ab3af5d54d196830cceb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 18:41:37 +0100 Subject: [PATCH 104/511] Remove all ice_servers on native sync WebRTC cameras (#129819) --- homeassistant/components/camera/__init__.py | 19 +++--- tests/components/camera/conftest.py | 75 ++++++++++++++++++++- tests/components/camera/test_init.py | 60 +---------------- tests/components/camera/test_webrtc.py | 23 +++++++ 4 files changed, 109 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 1feb7dffd3b..47d8b9dfbd0 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -827,16 +827,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - ice_servers = [ - server - for servers in self.hass.data.get(DATA_ICE_SERVERS, []) - for server in servers() - ] - config.configuration.ice_servers.extend(ice_servers) + if not self._webrtc_sync_offer: + # Until 2024.11, the frontend was not resolving any ice servers + # The async approach was added 2024.11 and new integrations need to use it + ice_servers = [ + server + for servers in self.hass.data.get(DATA_ICE_SERVERS, []) + for server in servers() + ] + config.configuration.ice_servers.extend(ice_servers) - config.get_candidates_upfront = ( - self._webrtc_sync_offer or self._legacy_webrtc_provider is not None - ) + config.get_candidates_upfront = self._legacy_webrtc_provider is not None return config diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index bec44704ec2..a88cd898e33 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -1,13 +1,14 @@ """Test helpers for camera.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -15,6 +16,15 @@ from homeassistant.setup import async_setup_component from .common import STREAM_SOURCE, WEBRTC_ANSWER +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) + @pytest.fixture(autouse=True) async def setup_homeassistant(hass: HomeAssistant) -> None: @@ -142,3 +152,66 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: return_value=STREAM_SOURCE, ) as mock_stream_source: yield mock_stream_source + + +@pytest.fixture +async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: + """Initialize a test camera with native sync WebRTC support.""" + + # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer + # and native support is checked by verify the function "async_handle_web_rtc_offer" was + # overwritten(implemented) or not + class MockCamera(camera.Camera): + """Mock Camera Entity.""" + + _attr_name = "Test" + _attr_supported_features: camera.CameraEntityFeature = ( + camera.CameraEntityFeature.STREAM + ) + _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC + + async def stream_source(self) -> str | None: + return STREAM_SOURCE + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + return WEBRTC_ANSWER + + domain = "test" + + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [camera.DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, camera.DOMAIN + ) + return True + + mock_integration( + hass, + MockModule( + domain, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + setup_test_component_platform( + hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + ) + mock_platform(hass, f"{domain}.config_flow", Mock()) + + with mock_config_flow(domain, ConfigFlow): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e7279f60848..0a173065564 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -25,7 +25,6 @@ from homeassistant.components.camera.const import ( ) from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STARTED, @@ -38,18 +37,12 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( - MockConfigEntry, - MockModule, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -986,62 +979,13 @@ async def test_camera_capabilities_hls( ) +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test WebRTC camera capabilities.""" - # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer - # Camera capabilities are determined by by checking if the function was overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: camera.CameraEntityFeature = ( - camera.CameraEntityFeature.STREAM - ) - - async def stream_source(self) -> str | None: - return STREAM_SOURCE - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - return WEBRTC_ANSWER - - domain = "test" - - entry = MockConfigEntry(domain=domain) - entry.add_to_hass(hass) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN) - return True - - mock_integration( - hass, - MockModule( - domain, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - setup_test_component_platform(hass, DOMAIN, [MockCamera()], from_config_entry=True) - mock_platform(hass, f"{domain}.config_flow", Mock()) - - with mock_config_flow(domain, ConfigFlow): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await _test_capabilities( hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 27c50848ebf..2970a41408c 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -393,6 +393,29 @@ async def test_ws_get_client_config( } +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +async def test_ws_get_client_config_sync_offer( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test get WebRTC client config, when camera is supporting sync offer.""" + await async_setup_component(hass, "camera", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + ) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "configuration": {}, + "getCandidatesUpfront": False, + } + + @pytest.mark.usefixtures("mock_camera_webrtc") async def test_ws_get_client_config_custom_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator From 7fd261347b72e7f17c02e518b127e49eaaa92835 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:49:19 +0100 Subject: [PATCH 105/511] Update charset-normalizer to 3.4.0 (#129821) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa8fecc73a5..ec1976c802c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -164,7 +164,7 @@ get-mac==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.2.0 +charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 36962ce1fe9..0f8354e1f60 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,7 +179,7 @@ get-mac==1000000000.0.0 # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. -charset-normalizer==3.2.0 +charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for # Roborock, NAM, Brother, and GIOS. From 81735b7b47959326b35312e38fd91fb07cd6a757 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:50:00 +0100 Subject: [PATCH 106/511] Use new helper properties in konnected options flow (#129778) --- homeassistant/components/konnected/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 3f1ef99c6fb..65dd7cf39b3 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -402,9 +402,10 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.entry = config_entry - self.model = self.entry.data[CONF_MODEL] - self.current_opt = self.entry.options or self.entry.data[CONF_DEFAULT_OPTIONS] + self.model = config_entry.data[CONF_MODEL] + self.current_opt = ( + config_entry.options or config_entry.data[CONF_DEFAULT_OPTIONS] + ) # as config proceeds we'll build up new options and then replace what's in the config entry self.new_opt: dict[str, Any] = {CONF_IO: {}} @@ -475,7 +476,7 @@ class OptionsFlowHandler(OptionsFlow): ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) @@ -511,7 +512,7 @@ class OptionsFlowHandler(OptionsFlow): ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) @@ -571,7 +572,7 @@ class OptionsFlowHandler(OptionsFlow): ), description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.model], - "host": self.entry.data[CONF_HOST], + "host": self.config_entry.data[CONF_HOST], }, errors=errors, ) From 8870b657d1815c6fd04559616c5b6116d3e5b464 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:54:22 +0100 Subject: [PATCH 107/511] Use new helper properties in hyperion options flow (#129777) --- .../components/hyperion/config_flow.py | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 161c531328d..b2b7dbdf531 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -424,24 +424,22 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> HyperionOptionsFlow: + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HyperionOptionsFlow: """Get the Hyperion Options flow.""" - return HyperionOptionsFlow(config_entry) + return HyperionOptionsFlow() class HyperionOptionsFlow(OptionsFlow): """Hyperion options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize a Hyperion options flow.""" - self._config_entry = config_entry - def _create_client(self) -> client.HyperionClient: """Create and connect a client instance.""" return create_hyperion_client( - self._config_entry.data[CONF_HOST], - self._config_entry.data[CONF_PORT], - token=self._config_entry.data.get(CONF_TOKEN), + self.config_entry.data[CONF_HOST], + self.config_entry.data[CONF_PORT], + token=self.config_entry.data.get(CONF_TOKEN), ) async def async_step_init( @@ -470,8 +468,7 @@ class HyperionOptionsFlow(OptionsFlow): return self.async_create_entry(title="", data=user_input) default_effect_show_list = list( - set(effects) - - set(self._config_entry.options.get(CONF_EFFECT_HIDE_LIST, [])) + set(effects) - set(self.config_entry.options.get(CONF_EFFECT_HIDE_LIST, [])) ) return self.async_show_form( @@ -480,7 +477,7 @@ class HyperionOptionsFlow(OptionsFlow): { vol.Optional( CONF_PRIORITY, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_PRIORITY, DEFAULT_PRIORITY ), ): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), From d180ff417dcdd56b02105d9136deec47969ba58f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:55:01 +0100 Subject: [PATCH 108/511] Cleanup deprecated OptionsFlowWithConfigEntry (part 3) (#129756) --- homeassistant/config_entries.py | 8 ++++++-- homeassistant/helpers/schema_config_entry_flow.py | 9 +++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f9e72a723a4..0682d46924d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3127,6 +3127,10 @@ class OptionsFlow(ConfigEntryBaseFlow): ) self._config_entry = value + def initialize_options(self, config_entry: ConfigEntry) -> None: + """Initialize the options to a mutable copy of the config entry options.""" + self._options = deepcopy(dict(config_entry.options)) + @property def options(self) -> dict[str, Any]: """Return a mutable copy of the config entry options. @@ -3135,7 +3139,7 @@ class OptionsFlow(ConfigEntryBaseFlow): can only be referenced after initialisation. """ if not hasattr(self, "_options"): - self._options = deepcopy(dict(self.config_entry.options)) + self.initialize_options(self.config_entry) return self._options @options.setter @@ -3161,7 +3165,7 @@ class OptionsFlowWithConfigEntry(OptionsFlow): "inherits from OptionsFlowWithConfigEntry, which is deprecated " "and will stop working in 2025.12", error_if_integration=False, - error_if_core=False, + error_if_core=True, ) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 7463c9945b2..58a44f9682d 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import HomeAssistant, callback, split_entity_id from homeassistant.data_entry_flow import UnknownHandler @@ -403,7 +402,7 @@ class SchemaConfigFlowHandler(ConfigFlow, ABC): ) -class SchemaOptionsFlowHandler(OptionsFlowWithConfigEntry): +class SchemaOptionsFlowHandler(OptionsFlow): """Handle a schema based options flow.""" def __init__( @@ -422,10 +421,8 @@ class SchemaOptionsFlowHandler(OptionsFlowWithConfigEntry): options, which is the union of stored options and user input from the options flow steps. """ - super().__init__(config_entry) - self._common_handler = SchemaCommonFlowHandler( - self, options_flow, self._options - ) + self.initialize_options(config_entry) + self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished for step in options_flow: From cc4fae10f5c7e58cd894b84fd72308b2feb9af44 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:55:49 +0100 Subject: [PATCH 109/511] Cleanup deprecated OptionsFlowWithConfigEntry (part 2) (#129754) --- homeassistant/components/androidtv/config_flow.py | 7 +++---- homeassistant/components/androidtv_remote/config_flow.py | 6 +++--- homeassistant/components/elevenlabs/config_flow.py | 6 ++---- homeassistant/components/onkyo/config_flow.py | 6 ++---- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index af6f1d14dcd..132ed96a96f 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -186,13 +186,12 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlowWithConfigEntry): +class OptionsFlowHandler(OptionsFlow): """Handle an option flow for Android Debug Bridge.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - + self.initialize_options(config_entry) self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) self._state_det_rules: dict[str, Any] = self.options.setdefault( CONF_STATE_DETECTION_RULES, {} diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 3512dd5ea65..962b1c09f1f 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithConfigEntry, + OptionsFlow, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -221,12 +221,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithConfigEntry): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): """Android TV Remote options flow.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) + self.initialize_options(config_entry) self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) self._conf_app_id: str | None = None diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index b596ec05b00..6419b1c973c 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -103,13 +102,12 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): return ElevenLabsOptionsFlow(config_entry) -class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): +class ElevenLabsOptionsFlow(OptionsFlow): """ElevenLabs options flow.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - self.api_key: str = self.config_entry.data[CONF_API_KEY] + self.api_key: str = config_entry.data[CONF_API_KEY] # id -> name self.voices: dict[str, str] = {} self.models: dict[str, str] = {} diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 4c5de362172..9ab01b3d904 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback @@ -323,13 +322,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): return OnkyoOptionsFlowHandler(config_entry) -class OnkyoOptionsFlowHandler(OptionsFlowWithConfigEntry): +class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - super().__init__(config_entry) - + self.initialize_options(config_entry) sources_store: dict[str, str] = self.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} self.options[OPTION_INPUT_SOURCES] = sources From 91157c21efb76e226510e8c83195214f73fc788d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:59:27 +0100 Subject: [PATCH 110/511] Reapply "Fix unused snapshots not triggering failure in CI" (#129311) --- .github/workflows/ci.yaml | 4 + tests/conftest.py | 8 +- tests/syrupy.py | 169 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02e8b4f180d..cae9795d715 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -949,6 +949,7 @@ jobs: --timeout=9 \ --durations=10 \ --numprocesses auto \ + --snapshot-details \ --dist=loadfile \ ${cov_params[@]} \ -o console_output_style=count \ @@ -1071,6 +1072,7 @@ jobs: -qq \ --timeout=20 \ --numprocesses 1 \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=10 \ @@ -1199,6 +1201,7 @@ jobs: -qq \ --timeout=9 \ --numprocesses 1 \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ @@ -1345,6 +1348,7 @@ jobs: -qq \ --timeout=9 \ --numprocesses auto \ + --snapshot-details \ ${cov_params[@]} \ -o console_output_style=count \ --durations=0 \ diff --git a/tests/conftest.py b/tests/conftest.py index 10c9a740256..c60018413e7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,7 @@ import pytest_socket import requests_mock import respx from syrupy.assertion import SnapshotAssertion +from syrupy.session import SnapshotSession from homeassistant import block_async_io from homeassistant.exceptions import ServiceNotFound @@ -92,7 +93,7 @@ from homeassistant.util.async_ import create_eager_task, get_scheduled_timer_han from homeassistant.util.json import json_loads from .ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS -from .syrupy import HomeAssistantSnapshotExtension +from .syrupy import HomeAssistantSnapshotExtension, override_syrupy_finish from .typing import ( ClientSessionGenerator, MockHAClientWebSocket, @@ -149,6 +150,11 @@ def pytest_configure(config: pytest.Config) -> None: if config.getoption("verbose") > 0: logging.getLogger().setLevel(logging.DEBUG) + # Override default finish to detect unused snapshots despite xdist + # Temporary workaround until it is finalised inside syrupy + # See https://github.com/syrupy-project/syrupy/pull/901 + SnapshotSession.finish = override_syrupy_finish + def pytest_runtest_setup() -> None: """Prepare pytest_socket and freezegun. diff --git a/tests/syrupy.py b/tests/syrupy.py index 268ee59243f..a3b3f763063 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -5,14 +5,22 @@ from __future__ import annotations from contextlib import suppress import dataclasses from enum import IntFlag +import json +import os from pathlib import Path from typing import Any import attr import attrs +import pytest +from syrupy.constants import EXIT_STATUS_FAIL_UNUSED +from syrupy.data import Snapshot, SnapshotCollection, SnapshotCollections from syrupy.extensions.amber import AmberDataSerializer, AmberSnapshotExtension from syrupy.location import PyTestLocation +from syrupy.report import SnapshotReport +from syrupy.session import ItemStatus, SnapshotSession from syrupy.types import PropertyFilter, PropertyMatcher, PropertyPath, SerializableData +from syrupy.utils import is_xdist_controller, is_xdist_worker import voluptuous as vol import voluptuous_serialize @@ -246,3 +254,164 @@ class HomeAssistantSnapshotExtension(AmberSnapshotExtension): """ test_dir = Path(test_location.filepath).parent return str(test_dir.joinpath("snapshots")) + + +# Classes and Methods to override default finish behavior in syrupy +# This is needed to handle the xdist plugin in pytest +# The default implementation does not handle the xdist plugin +# and will not work correctly when running tests in parallel +# with pytest-xdist. +# Temporary workaround until it is finalised inside syrupy +# See https://github.com/syrupy-project/syrupy/pull/901 + + +class _FakePytestObject: + """Fake object.""" + + def __init__(self, collected_item: dict[str, str]) -> None: + """Initialise fake object.""" + self.__module__ = collected_item["modulename"] + self.__name__ = collected_item["methodname"] + + +class _FakePytestItem: + """Fake pytest.Item object.""" + + def __init__(self, collected_item: dict[str, str]) -> None: + """Initialise fake pytest.Item object.""" + self.nodeid = collected_item["nodeid"] + self.name = collected_item["name"] + self.path = Path(collected_item["path"]) + self.obj = _FakePytestObject(collected_item) + + +def _serialize_collections(collections: SnapshotCollections) -> dict[str, Any]: + return { + k: [c.name for c in v] for k, v in collections._snapshot_collections.items() + } + + +def _serialize_report( + report: SnapshotReport, + collected_items: set[pytest.Item], + selected_items: dict[str, ItemStatus], +) -> dict[str, Any]: + return { + "discovered": _serialize_collections(report.discovered), + "created": _serialize_collections(report.created), + "failed": _serialize_collections(report.failed), + "matched": _serialize_collections(report.matched), + "updated": _serialize_collections(report.updated), + "used": _serialize_collections(report.used), + "_collected_items": [ + { + "nodeid": c.nodeid, + "name": c.name, + "path": str(c.path), + "modulename": c.obj.__module__, + "methodname": c.obj.__name__, + } + for c in list(collected_items) + ], + "_selected_items": { + key: status.value for key, status in selected_items.items() + }, + } + + +def _merge_serialized_collections( + collections: SnapshotCollections, json_data: dict[str, list[str]] +) -> None: + if not json_data: + return + for location, names in json_data.items(): + snapshot_collection = SnapshotCollection(location=location) + for name in names: + snapshot_collection.add(Snapshot(name)) + collections.update(snapshot_collection) + + +def _merge_serialized_report(report: SnapshotReport, json_data: dict[str, Any]) -> None: + _merge_serialized_collections(report.discovered, json_data["discovered"]) + _merge_serialized_collections(report.created, json_data["created"]) + _merge_serialized_collections(report.failed, json_data["failed"]) + _merge_serialized_collections(report.matched, json_data["matched"]) + _merge_serialized_collections(report.updated, json_data["updated"]) + _merge_serialized_collections(report.used, json_data["used"]) + for collected_item in json_data["_collected_items"]: + custom_item = _FakePytestItem(collected_item) + if not any( + t.nodeid == custom_item.nodeid and t.name == custom_item.nodeid + for t in report.collected_items + ): + report.collected_items.add(custom_item) + for key, selected_item in json_data["_selected_items"].items(): + if key in report.selected_items: + status = ItemStatus(selected_item) + if status != ItemStatus.NOT_RUN: + report.selected_items[key] = status + else: + report.selected_items[key] = ItemStatus(selected_item) + + +def override_syrupy_finish(self: SnapshotSession) -> int: + """Override the finish method to allow for custom handling.""" + exitstatus = 0 + self.flush_snapshot_write_queue() + self.report = SnapshotReport( + base_dir=self.pytest_session.config.rootpath, + collected_items=self._collected_items, + selected_items=self._selected_items, + assertions=self._assertions, + options=self.pytest_session.config.option, + ) + + needs_xdist_merge = self.update_snapshots or bool( + self.pytest_session.config.option.include_snapshot_details + ) + + if is_xdist_worker(): + if not needs_xdist_merge: + return exitstatus + with open(".pytest_syrupy_worker_count", "w", encoding="utf-8") as f: + f.write(os.getenv("PYTEST_XDIST_WORKER_COUNT")) + with open( + f".pytest_syrupy_{os.getenv("PYTEST_XDIST_WORKER")}_result", + "w", + encoding="utf-8", + ) as f: + json.dump( + _serialize_report( + self.report, self._collected_items, self._selected_items + ), + f, + indent=2, + ) + return exitstatus + if is_xdist_controller(): + return exitstatus + + if needs_xdist_merge: + worker_count = None + try: + with open(".pytest_syrupy_worker_count", encoding="utf-8") as f: + worker_count = f.read() + os.remove(".pytest_syrupy_worker_count") + except FileNotFoundError: + pass + + if worker_count: + for i in range(int(worker_count)): + with open(f".pytest_syrupy_gw{i}_result", encoding="utf-8") as f: + _merge_serialized_report(self.report, json.load(f)) + os.remove(f".pytest_syrupy_gw{i}_result") + + if self.report.num_unused: + if self.update_snapshots: + self.remove_unused_snapshots( + unused_snapshot_collections=self.report.unused, + used_snapshot_collections=self.report.used, + ) + elif not self.warn_unused_snapshots: + exitstatus |= EXIT_STATUS_FAIL_UNUSED + return exitstatus From ca0be3ec8a4fba97c51d7c63645e9537d84754bf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:16:22 +0100 Subject: [PATCH 111/511] Use coordinator async_setup in vizio (#129450) --- homeassistant/components/vizio/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/coordinator.py b/homeassistant/components/vizio/coordinator.py index 1930828b595..a7ca7d7f9ed 100644 --- a/homeassistant/components/vizio/coordinator.py +++ b/homeassistant/components/vizio/coordinator.py @@ -34,10 +34,9 @@ class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]] self.fail_threshold = 10 self.store = store - async def async_config_entry_first_refresh(self) -> None: + async def _async_setup(self) -> None: """Refresh data for the first time when a config entry is setup.""" self.data = await self.store.async_load() or APPS - await super().async_config_entry_first_refresh() async def _async_update_data(self) -> list[dict[str, Any]]: """Update data via library.""" From 6323a078e139b499b5957a2d07da94eb18c7b883 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:17:07 +0100 Subject: [PATCH 112/511] Set config_entry explicitly in wled coordinator (#129425) --- homeassistant/components/wled/coordinator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index cb39fde5e5a..8e2855e9f05 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -49,6 +49,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): super().__init__( hass, LOGGER, + config_entry=entry, name=DOMAIN, update_interval=SCAN_INTERVAL, ) @@ -133,6 +134,7 @@ class WLEDReleasesDataUpdateCoordinator(DataUpdateCoordinator[Releases]): super().__init__( hass, LOGGER, + config_entry=None, name=DOMAIN, update_interval=RELEASES_SCAN_INTERVAL, ) From b8f2583bc3b907efc105e1852b133f018f62ce38 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:17:53 +0100 Subject: [PATCH 113/511] Set config_entry explicitly in caldav coordinator (#129424) --- homeassistant/components/caldav/calendar.py | 6 +++++- .../components/caldav/coordinator.py | 21 ++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index d9ebe8e73fd..fb53947a723 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -109,6 +109,7 @@ async def async_setup_platform( entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, + None, calendar=calendar, days=days, include_all_day=True, @@ -126,6 +127,7 @@ async def async_setup_platform( entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) coordinator = CalDavUpdateCoordinator( hass, + None, calendar=calendar, days=days, include_all_day=False, @@ -152,6 +154,7 @@ async def async_setup_entry( async_generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass), CalDavUpdateCoordinator( hass, + entry, calendar=calendar, days=CONFIG_ENTRY_DEFAULT_DAYS, include_all_day=True, @@ -204,7 +207,8 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE if self._supports_offset: self._attr_extra_state_attributes = { "offset_reached": is_offset_reached( - self._event.start_datetime_local, self.coordinator.offset + self._event.start_datetime_local, + self.coordinator.offset, # type: ignore[arg-type] ) if self._event else False diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index 3a10b567167..eb09e3f5452 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -6,6 +6,9 @@ from datetime import date, datetime, time, timedelta from functools import partial import logging import re +from typing import TYPE_CHECKING + +import caldav from homeassistant.components.calendar import CalendarEvent, extract_offset from homeassistant.core import HomeAssistant @@ -14,6 +17,9 @@ from homeassistant.util import dt as dt_util from .api import get_attr_value +if TYPE_CHECKING: + from . import CalDavConfigEntry + _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -23,11 +29,20 @@ OFFSET = "!!" class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): """Class to utilize the calendar dav client object to get next event.""" - def __init__(self, hass, calendar, days, include_all_day, search): + def __init__( + self, + hass: HomeAssistant, + entry: CalDavConfigEntry | None, + calendar: caldav.Calendar, + days: int, + include_all_day: bool, + search: str | None, + ) -> None: """Set up how we are going to search the WebDav calendar.""" super().__init__( hass, _LOGGER, + config_entry=entry, name=f"CalDAV {calendar.name}", update_interval=MIN_TIME_BETWEEN_UPDATES, ) @@ -35,7 +50,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): self.days = days self.include_all_day = include_all_day self.search = search - self.offset = None + self.offset: timedelta | None = None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -109,7 +124,7 @@ class CalDavUpdateCoordinator(DataUpdateCoordinator[CalendarEvent | None]): _start_of_tomorrow = start_of_tomorrow if _start_of_today <= start_dt < _start_of_tomorrow: new_event = event.copy() - new_vevent = new_event.instance.vevent + new_vevent = new_event.instance.vevent # type: ignore[attr-defined] if hasattr(new_vevent, "dtend"): dur = new_vevent.dtend.value - new_vevent.dtstart.value new_vevent.dtend.value = start_dt + dur From 2052579efcd43e3f029aa1a00e30df51ce33d499 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:18:36 +0100 Subject: [PATCH 114/511] Set config_entry explicitly in todoist coordinator (#129421) --- homeassistant/components/todoist/__init__.py | 2 +- homeassistant/components/todoist/calendar.py | 2 +- homeassistant/components/todoist/coordinator.py | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 60c40b1c03c..2e30856d0df 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token = entry.data[CONF_TOKEN] api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + coordinator = TodoistCoordinator(hass, _LOGGER, entry, SCAN_INTERVAL, api, token) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 31470633cc6..62f9fafc02a 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -142,7 +142,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + coordinator = TodoistCoordinator(hass, _LOGGER, None, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b55680907ac..2f35741c5ab 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -6,6 +6,7 @@ import logging from todoist_api_python.api_async import TodoistAPIAsync from todoist_api_python.models import Label, Project, Section, Task +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,12 +18,19 @@ class TodoistCoordinator(DataUpdateCoordinator[list[Task]]): self, hass: HomeAssistant, logger: logging.Logger, + entry: ConfigEntry | None, update_interval: timedelta, api: TodoistAPIAsync, token: str, ) -> None: """Initialize the Todoist coordinator.""" - super().__init__(hass, logger, name="Todoist", update_interval=update_interval) + super().__init__( + hass, + logger, + config_entry=entry, + name="Todoist", + update_interval=update_interval, + ) self.api = api self._projects: list[Project] | None = None self._labels: list[Label] | None = None From 22f8f117fb40941b06f3794a9afe2f2ec773f403 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 19:22:12 +0100 Subject: [PATCH 115/511] Add basic testing framework to LG ThinQ (#127785) Co-authored-by: jangwon.lee Co-authored-by: Joostlek Co-authored-by: YunseonPark-LGE <34848373+YunseonPark-LGE@users.noreply.github.com> Co-authored-by: LG-ThinQ-Integration Co-authored-by: Franck Nijhof --- tests/components/lg_thinq/__init__.py | 14 +- tests/components/lg_thinq/conftest.py | 34 ++- .../fixtures/air_conditioner/device.json | 9 + .../fixtures/air_conditioner/profile.json | 154 +++++++++++++ .../fixtures/air_conditioner/status.json | 43 ++++ .../lg_thinq/snapshots/test_climate.ambr | 86 ++++++++ .../lg_thinq/snapshots/test_event.ambr | 55 +++++ .../lg_thinq/snapshots/test_number.ambr | 113 ++++++++++ .../lg_thinq/snapshots/test_sensor.ambr | 205 ++++++++++++++++++ tests/components/lg_thinq/test_climate.py | 29 +++ tests/components/lg_thinq/test_config_flow.py | 5 +- tests/components/lg_thinq/test_event.py | 29 +++ tests/components/lg_thinq/test_init.py | 26 +++ tests/components/lg_thinq/test_number.py | 29 +++ tests/components/lg_thinq/test_sensor.py | 29 +++ 15 files changed, 853 insertions(+), 7 deletions(-) create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/device.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/status.json create mode 100644 tests/components/lg_thinq/snapshots/test_climate.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_event.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_number.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_sensor.ambr create mode 100644 tests/components/lg_thinq/test_climate.py create mode 100644 tests/components/lg_thinq/test_event.py create mode 100644 tests/components/lg_thinq/test_init.py create mode 100644 tests/components/lg_thinq/test_number.py create mode 100644 tests/components/lg_thinq/test_sensor.py diff --git a/tests/components/lg_thinq/__init__.py b/tests/components/lg_thinq/__init__.py index 68ffb960f71..a5ba55ab1c9 100644 --- a/tests/components/lg_thinq/__init__.py +++ b/tests/components/lg_thinq/__init__.py @@ -1 +1,13 @@ -"""Tests for the lgthinq integration.""" +"""Tests for the LG ThinQ integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index cae2de61fa4..05cb3164137 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture def mock_thinq_api_response( @@ -45,6 +45,15 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.lg_thinq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_uuid() -> Generator[AsyncMock]: """Mock a uuid.""" @@ -59,22 +68,37 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api() -> Generator[AsyncMock]: +def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: """Mock a thinq api.""" with ( - patch("thinqconnect.ThinQApi", autospec=True) as mock_api, + patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, patch( "homeassistant.components.lg_thinq.config_flow.ThinQApi", new=mock_api, ), ): thinq_api = mock_api.return_value - thinq_api.async_get_device_list = AsyncMock( - return_value=mock_thinq_api_response(status=200, body={}) + thinq_api.async_get_device_list.return_value = [ + load_json_object_fixture("air_conditioner/device.json", DOMAIN) + ] + thinq_api.async_get_device_profile.return_value = load_json_object_fixture( + "air_conditioner/profile.json", DOMAIN + ) + thinq_api.async_get_device_status.return_value = load_json_object_fixture( + "air_conditioner/status.json", DOMAIN ) yield thinq_api +@pytest.fixture +def mock_thinq_mqtt_client() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch( + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True + ) as mock_api: + yield mock_api + + @pytest.fixture def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: """Mock an invalid thinq api.""" diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/device.json b/tests/components/lg_thinq/fixtures/air_conditioner/device.json new file mode 100644 index 00000000000..fb931c69929 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-2E247F93-B570-46A6-B827-920E9E10F966", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "PAC_910604_WW", + "alias": "Test air conditioner", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json new file mode 100644 index 00000000000..0d45dc5c9f4 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -0,0 +1,154 @@ +{ + "notification": { + "push": ["WATER_IS_FULL"] + }, + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["AIR_CLEAN", "COOL", "AIR_DRY"], + "w": ["AIR_CLEAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["LOW", "HIGH", "MID"], + "w": ["LOW", "HIGH", "MID"] + } + } + }, + "airQualitySensor": { + "PM1": { + "mode": ["r"], + "type": "number" + }, + "PM10": { + "mode": ["r"], + "type": "number" + }, + "PM2": { + "mode": ["r"], + "type": "number" + }, + "humidity": { + "mode": ["r"], + "type": "number" + }, + "monitoringEnabled": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["ON_WORKING", "ALWAYS"], + "w": ["ON_WORKING", "ALWAYS"] + } + }, + "oder": { + "mode": ["r"], + "type": "number" + }, + "totalPollution": { + "mode": ["r"], + "type": "number" + } + }, + "operation": { + "airCleanOperationMode": { + "mode": ["w"], + "type": "enum", + "value": { + "w": ["START", "STOP"] + } + }, + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "powerSave": { + "powerSaveEnabled": { + "mode": ["r", "w"], + "type": "boolean", + "value": { + "r": [false, true], + "w": [false, true] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "timer": { + "relativeHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeHourToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + } + } + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json new file mode 100644 index 00000000000..90d15d1ae16 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -0,0 +1,43 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStrength": "MID" + }, + "airQualitySensor": { + "PM1": 12, + "PM10": 7, + "PM2": 24, + "humidity": 40, + "monitoringEnabled": "ON_WORKING", + "totalPollution": 3, + "totalPollutionLevel": "GOOD" + }, + "filterInfo": { + "filterLifetime": 540, + "usedTime": 180 + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "powerSave": { + "powerSaveEnabled": false + }, + "sleepTimer": { + "relativeStopTimer": "UNSET" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "timer": { + "relativeStartTimer": "UNSET", + "relativeStopTimer": "UNSET", + "absoluteStartTimer": "SET", + "absoluteStopTimer": "UNSET", + "absoluteHourToStart": 13, + "absoluteMinuteToStart": 14 + } +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr new file mode 100644 index 00000000000..e9470c3de03 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_all_entities[climate.test_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_modes': list([ + 'air_clean', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner', + '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': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.test_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 40, + 'current_temperature': 25, + 'fan_mode': 'mid', + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'friendly_name': 'Test air conditioner', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_mode': None, + 'preset_modes': list([ + 'air_clean', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 19, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr new file mode 100644 index 00000000000..025f4496aeb --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_all_entities[event.test_air_conditioner_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'water_is_full', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_air_conditioner_notification', + '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': 'Notification', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[event.test_air_conditioner_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'water_is_full', + ]), + 'friendly_name': 'Test air conditioner Notification', + }), + 'context': , + 'entity_id': 'event.test_air_conditioner_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr new file mode 100644 index 00000000000..68f01854501 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..387df916eba --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test air conditioner Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Test air conditioner PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Test air conditioner PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Test air conditioner PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py new file mode 100644 index 00000000000..24ed3ad230d --- /dev/null +++ b/tests/components/lg_thinq/test_climate.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq climate platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index db0e2d29450..e7ee632810e 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -14,7 +14,10 @@ from tests.common import MockConfigEntry async def test_config_flow( - hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_uuid: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test that an thinq entry is normally created.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py new file mode 100644 index 00000000000..bea758cb943 --- /dev/null +++ b/tests/components/lg_thinq/test_event.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq event platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py new file mode 100644 index 00000000000..7da7e79fec0 --- /dev/null +++ b/tests/components/lg_thinq/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the LG ThinQ integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py new file mode 100644 index 00000000000..e578e4eba7a --- /dev/null +++ b/tests/components/lg_thinq/test_number.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py new file mode 100644 index 00000000000..02b91b4771b --- /dev/null +++ b/tests/components/lg_thinq/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From fc0547ccdf547d3e1f3eff2c6824d20a6bb2ab5d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 19:23:48 +0100 Subject: [PATCH 116/511] Pass the config entry explicitly in aemet coordinator (#128097) --- homeassistant/components/aemet/__init__.py | 15 ++------------- homeassistant/components/aemet/coordinator.py | 14 ++++++++++++++ homeassistant/components/aemet/diagnostics.py | 2 +- homeassistant/components/aemet/sensor.py | 3 +-- homeassistant/components/aemet/weather.py | 3 +-- 5 files changed, 19 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index e242d62a580..29bc044c67d 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,6 +1,5 @@ """The AEMET OpenData component.""" -from dataclasses import dataclass import logging from aemet_opendata.exceptions import AemetError, TownNotFound @@ -13,20 +12,10 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from .const import CONF_STATION_UPDATES, PLATFORMS -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, AemetData, WeatherUpdateCoordinator _LOGGER = logging.getLogger(__name__) -type AemetConfigEntry = ConfigEntry[AemetData] - - -@dataclass -class AemetData: - """Aemet runtime data.""" - - name: str - coordinator: WeatherUpdateCoordinator - async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" @@ -46,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AemetConfigEntry) -> boo except AemetError as err: raise ConfigEntryNotReady(err) from err - weather_coordinator = WeatherUpdateCoordinator(hass, aemet) + weather_coordinator = WeatherUpdateCoordinator(hass, entry, aemet) await weather_coordinator.async_config_entry_first_refresh() entry.runtime_data = AemetData(name=name, coordinator=weather_coordinator) diff --git a/homeassistant/components/aemet/coordinator.py b/homeassistant/components/aemet/coordinator.py index 8d179ccdb02..2e8534c7466 100644 --- a/homeassistant/components/aemet/coordinator.py +++ b/homeassistant/components/aemet/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from asyncio import timeout +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Final, cast @@ -19,6 +20,7 @@ from aemet_opendata.helpers import dict_nested_value from aemet_opendata.interface import AEMET from homeassistant.components.weather import Forecast +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +31,16 @@ _LOGGER = logging.getLogger(__name__) API_TIMEOUT: Final[int] = 120 WEATHER_UPDATE_INTERVAL = timedelta(minutes=10) +type AemetConfigEntry = ConfigEntry[AemetData] + + +@dataclass +class AemetData: + """Aemet runtime data.""" + + name: str + coordinator: WeatherUpdateCoordinator + class WeatherUpdateCoordinator(DataUpdateCoordinator): """Weather data update coordinator.""" @@ -36,6 +48,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + entry: AemetConfigEntry, aemet: AEMET, ) -> None: """Initialize coordinator.""" @@ -44,6 +57,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, + config_entry=entry, name=DOMAIN, update_interval=WEATHER_UPDATE_INTERVAL, ) diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index 2379bd34bc0..bc366fc6d44 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import AemetConfigEntry +from .coordinator import AemetConfigEntry TO_REDACT_CONFIG = [ CONF_API_KEY, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index e55344490aa..88eb34b6f84 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -55,7 +55,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import AemetConfigEntry from .const import ( ATTR_API_CONDITION, ATTR_API_FORECAST_CONDITION, @@ -87,7 +86,7 @@ from .const import ( ATTR_API_WIND_SPEED, CONDITIONS_MAP, ) -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .entity import AemetEntity diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index 341b81d71c4..a156652eadd 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -27,9 +27,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AemetConfigEntry from .const import CONDITIONS_MAP -from .coordinator import WeatherUpdateCoordinator +from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator from .entity import AemetEntity From 9fcf757021f6a7853b86ac36be32cd49a912505e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:35:35 +0100 Subject: [PATCH 117/511] Fix translations in landisgyr (#129831) --- .../components/landisgyr_heat_meter/strings.json | 3 +++ tests/components/landisgyr_heat_meter/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/strings.json b/homeassistant/components/landisgyr_heat_meter/strings.json index 4bae2490006..31f08ded79f 100644 --- a/homeassistant/components/landisgyr_heat_meter/strings.json +++ b/homeassistant/components/landisgyr_heat_meter/strings.json @@ -12,6 +12,9 @@ } } }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index 79088508e61..fe62d530719 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -101,10 +101,6 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" @@ -135,10 +131,6 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) @patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: From 7863927c3a322aca4fdde7a6e855d766d123ba24 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Nov 2024 19:39:46 +0100 Subject: [PATCH 118/511] Update frontend to 20241104.0 (#129829) --- 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 52eee7db199..89cd93227a4 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==20241031.0"] + "requirements": ["home-assistant-frontend==20241104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec1976c802c..c71bd19b3ee 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b35b82cf3c3..58739540311 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d2d1875c19..89619b18b89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From f408de4fc3e991dacc3ebf4adaa73fc6b51c38f4 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 4 Nov 2024 14:45:20 +0100 Subject: [PATCH 119/511] Bump lcn-frontend to 0.2.1 (#129457) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8f499adabe0..6ce41a2d08d 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.0"] + "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e12ef685beb..bfe9678e4c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1265,7 +1265,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 250d04e35ff..4c4862015b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.0 +lcn-frontend==0.2.1 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 From de453ab5c1d338755cb6cb9c401d9cdc8e0e3547 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:42:42 +0100 Subject: [PATCH 120/511] Add watchdog to monitor and respawn go2rtc server (#129497) --- homeassistant/components/go2rtc/__init__.py | 4 +- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/server.py | 113 +++++++++++++++++++- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_server.py | 97 +++++++++++++++++ 5 files changed, 210 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 0bf01490a47..c3e5971a53f 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -37,7 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN from .server import Server _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = "http://localhost:1984/" + url = DEFAULT_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index b0d52e4fd39..cb03e224e52 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,3 +4,4 @@ DOMAIN = "go2rtc" CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." +DEFAULT_URL = "http://localhost:1984/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index df4b5b7f13e..b2aa19d5275 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,17 +1,25 @@ """Go2rtc server.""" import asyncio +from contextlib import suppress import logging from tempfile import NamedTemporaryFile +from go2rtc_client import Go2RtcRestClient + from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_RESPAWN_COOLDOWN = 1 + # Default configuration for HA # - Api is listening only on localhost # - Disable rtsp listener @@ -29,6 +37,16 @@ webrtc: """ +class Go2RTCServerStartError(HomeAssistantError): + """Raised when server does not start.""" + + _message = "Go2rtc server didn't start correctly" + + +class Go2RTCWatchdogError(HomeAssistantError): + """Raised on watchdog error.""" + + def _create_temp_file(api_ip: str) -> str: """Create temporary config file.""" # Set delete=False to prevent the file from being deleted when the file is closed @@ -53,8 +71,17 @@ class Server: if enable_ui: # Listen on all interfaces for allowing access from all ips self._api_ip = "" + self._watchdog_task: asyncio.Task | None = None + self._watchdog_tasks: list[asyncio.Task] = [] async def start(self) -> None: + """Start the server.""" + await self._start() + self._watchdog_task = asyncio.create_task( + self._watchdog(), name="Go2rtc respawn" + ) + + async def _start(self) -> None: """Start the server.""" _LOGGER.debug("Starting go2rtc server") config_file = await self._hass.async_add_executor_job( @@ -82,8 +109,8 @@ class Server: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) - await self.stop() - raise HomeAssistantError("Go2rtc server didn't start correctly") from err + await self._stop() + raise Go2RTCServerStartError from err async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" @@ -95,17 +122,95 @@ class Server: if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + async def _watchdog(self) -> None: + """Keep respawning go2rtc servers. + + A new go2rtc server is spawned if the process terminates or the API + stops responding. + """ + while True: + try: + monitor_process_task = asyncio.create_task(self._monitor_process()) + self._watchdog_tasks.append(monitor_process_task) + monitor_process_task.add_done_callback(self._watchdog_tasks.remove) + monitor_api_task = asyncio.create_task(self._monitor_api()) + self._watchdog_tasks.append(monitor_api_task) + monitor_api_task.add_done_callback(self._watchdog_tasks.remove) + try: + await asyncio.gather(monitor_process_task, monitor_api_task) + except Go2RTCWatchdogError: + _LOGGER.debug("Caught Go2RTCWatchdogError") + for task in self._watchdog_tasks: + if task.done(): + if not task.cancelled(): + task.exception() + continue + task.cancel() + await asyncio.sleep(_RESPAWN_COOLDOWN) + try: + await self._stop() + _LOGGER.debug("Spawning new go2rtc server") + with suppress(Go2RTCServerStartError): + await self._start() + except Exception: + _LOGGER.exception( + "Unexpected error when restarting go2rtc server" + ) + except Exception: + _LOGGER.exception("Unexpected error in go2rtc server watchdog") + + async def _monitor_process(self) -> None: + """Raise if the go2rtc process terminates.""" + _LOGGER.debug("Monitoring go2rtc server process") + if self._process: + await self._process.wait() + _LOGGER.debug("go2rtc server terminated") + raise Go2RTCWatchdogError("Process ended") + + async def _monitor_api(self) -> None: + """Raise if the go2rtc process terminates.""" + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + + _LOGGER.debug("Monitoring go2rtc API") + try: + while True: + await client.streams.list() + await asyncio.sleep(10) + except Exception as err: + _LOGGER.debug("go2rtc API did not reply", exc_info=True) + raise Go2RTCWatchdogError("API error") from err + + async def _stop_watchdog(self) -> None: + """Handle watchdog stop request.""" + tasks: list[asyncio.Task] = [] + if watchdog_task := self._watchdog_task: + self._watchdog_task = None + tasks.append(watchdog_task) + watchdog_task.cancel() + for task in self._watchdog_tasks: + tasks.append(task) + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + async def stop(self) -> None: + """Stop the server and abort the watchdog task.""" + _LOGGER.debug("Server stop requested") + await self._stop_watchdog() + await self._stop() + + async def _stop(self) -> None: """Stop the server.""" if self._process: _LOGGER.debug("Stopping go2rtc server") process = self._process self._process = None - process.terminate() + with suppress(ProcessLookupError): + process.terminate() try: await asyncio.wait_for(process.wait(), timeout=_TERMINATE_TIMEOUT) except TimeoutError: _LOGGER.warning("Go2rtc server didn't terminate gracefully. Killing it") - process.kill() + with suppress(ProcessLookupError): + process.kill() else: _LOGGER.debug("Go2rtc server has been stopped") diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index b299c28c557..495d42114f1 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -18,6 +18,7 @@ def rest_client() -> Generator[AsyncMock]: patch( "homeassistant.components.go2rtc.Go2RtcRestClient", ) as mock_client, + patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value client.streams = Mock(spec_set=_StreamClient) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 42f3f5e098d..1410fbeb6c3 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -161,3 +161,100 @@ async def test_server_failed_to_start( stderr=subprocess.STDOUT, close_fds=False, ) + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_exit( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted when it exits.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_not_awaited() + + evt.set() + await asyncio.sleep(0.1) + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_process_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_api_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, +) -> None: + """Test that the server is restarted on error.""" + rest_client.streams.list.side_effect = Exception + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + await server.stop() + + +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_server_restart_error( + hass: HomeAssistant, + mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling when exception is raised during restart.""" + rest_client.streams.list.side_effect = Exception + mock_create_subprocess.return_value.terminate.side_effect = [Exception, None] + + await server.start() + mock_create_subprocess.assert_awaited_once() + mock_create_subprocess.reset_mock() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + mock_create_subprocess.assert_awaited_once() + + assert "Unexpected error when restarting go2rtc server" in caplog.text + + await server.stop() From 6e9834370678f9e913d26743760b5df077020f7c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 15:01:37 +0100 Subject: [PATCH 121/511] Update Spotify state after mutation (#129607) --- .../components/spotify/media_player.py | 29 +++++++++++++++++-- tests/components/spotify/conftest.py | 7 +++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index dce200bc598..7687936fe4c 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,10 +2,11 @@ from __future__ import annotations -from collections.abc import Callable +import asyncio +from collections.abc import Awaitable, Callable, Coroutine import datetime as dt import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Concatenate from spotifyaio import ( Device, @@ -63,6 +64,7 @@ REPEAT_MODE_MAPPING_TO_HA = { REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } +AFTER_REQUEST_SLEEP = 1 async def async_setup_entry( @@ -93,6 +95,19 @@ def ensure_item[_R]( return wrapper +def async_refresh_after[_T: SpotifyEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to yield and refresh after.""" + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + await func(self, *args, **kwargs) + await asyncio.sleep(AFTER_REQUEST_SLEEP) + await self.coordinator.async_refresh() + + return _async_wrap + + class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): """Representation of a Spotify controller.""" @@ -267,30 +282,37 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): return None return REPEAT_MODE_MAPPING_TO_HA.get(self.currently_playing.repeat_mode) + @async_refresh_after async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" await self.coordinator.client.set_volume(int(volume * 100)) + @async_refresh_after async def async_media_play(self) -> None: """Start or resume playback.""" await self.coordinator.client.start_playback() + @async_refresh_after async def async_media_pause(self) -> None: """Pause playback.""" await self.coordinator.client.pause_playback() + @async_refresh_after async def async_media_previous_track(self) -> None: """Skip to previous track.""" await self.coordinator.client.previous_track() + @async_refresh_after async def async_media_next_track(self) -> None: """Skip to next track.""" await self.coordinator.client.next_track() + @async_refresh_after async def async_media_seek(self, position: float) -> None: """Send seek command.""" await self.coordinator.client.seek_track(int(position * 1000)) + @async_refresh_after async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: @@ -334,6 +356,7 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): await self.coordinator.client.start_playback(**kwargs) + @async_refresh_after async def async_select_source(self, source: str) -> None: """Select playback device.""" for device in self.devices.data: @@ -341,10 +364,12 @@ class SpotifyMediaPlayer(SpotifyEntity, MediaPlayerEntity): await self.coordinator.client.transfer_playback(device.device_id) return + @async_refresh_after async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" await self.coordinator.client.set_shuffle(state=shuffle) + @async_refresh_after async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 5d86045e5a8..d3fc418f1cd 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -84,6 +84,13 @@ async def setup_credentials(hass: HomeAssistant) -> None: ) +@pytest.fixture(autouse=True) +async def patch_sleep() -> Generator[AsyncMock]: + """Fixture to setup credentials.""" + with patch("homeassistant.components.spotify.media_player.AFTER_REQUEST_SLEEP", 0): + yield + + @pytest.fixture def mock_spotify() -> Generator[AsyncMock]: """Mock the Spotify API.""" From bf196935f68f600f3116679a1948d079c913d783 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Sun, 3 Nov 2024 21:06:46 +0100 Subject: [PATCH 122/511] Add state class to precipitation_intensity in Aemet (#129670) Update sensor.py --- homeassistant/components/aemet/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 83d490f7fe2..e55344490aa 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -249,6 +249,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( name="Rain", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_RAIN_PROB, @@ -263,6 +264,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( name="Snow", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, ), AemetSensorEntityDescription( key=ATTR_API_SNOW_PROB, From ba3cfb5f8784a5246522a99a87c5008bc8da0d38 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Mon, 4 Nov 2024 11:20:15 -0500 Subject: [PATCH 123/511] Bump ayla-iot-unofficial to 1.4.3 (#129743) Upgrade to ayla-iot-unofficial v1.4.3 --- homeassistant/components/fujitsu_fglair/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 1c7b9b0b469..f7f3af8d037 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.4.2"] + "requirements": ["ayla-iot-unofficial==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index bfe9678e4c7..0b16de92d2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -536,7 +536,7 @@ automower-ble==0.2.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c4862015b8..a5d3166ed3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -485,7 +485,7 @@ automower-ble==0.2.0 axis==63 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.4.2 +ayla-iot-unofficial==1.4.3 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 From a4da2a9eb5a2ab5b30f9d31f2e225028e81d8cc4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 10:38:27 +0100 Subject: [PATCH 124/511] Use RTCIceCandidate instead of str for candidate (#129793) --- homeassistant/components/camera/__init__.py | 6 ++++-- homeassistant/components/camera/webrtc.py | 19 +++++++++++++---- homeassistant/components/go2rtc/__init__.py | 9 +++++--- tests/components/camera/test_init.py | 3 ++- tests/components/camera/test_webrtc.py | 23 ++++++++++++++------- tests/components/go2rtc/test_init.py | 7 ++++--- 6 files changed, 47 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 58826eb07ce..1feb7dffd3b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -20,7 +20,7 @@ from aiohttp import hdrs, web import attr from propcache import cached_property, under_cached_property import voluptuous as vol -from webrtc_models import RTCIceServer +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -840,7 +840,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return config - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle a WebRTC candidate.""" if self._webrtc_provider: await self._webrtc_provider.async_on_webrtc_candidate(session_id, candidate) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index aca2b8291f1..0612c96e40c 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -11,7 +11,7 @@ import logging from typing import TYPE_CHECKING, Any, Protocol import voluptuous as vol -from webrtc_models import RTCConfiguration, RTCIceServer +from webrtc_models import RTCConfiguration, RTCIceCandidate, RTCIceServer from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback @@ -78,7 +78,14 @@ class WebRTCAnswer(WebRTCMessage): class WebRTCCandidate(WebRTCMessage): """WebRTC candidate.""" - candidate: str + candidate: RTCIceCandidate + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the message.""" + return { + "type": self._get_type(), + "candidate": self.candidate.candidate, + } @dataclass(frozen=True) @@ -138,7 +145,9 @@ class CameraWebRTCProvider(ABC): """Handle the WebRTC offer and return the answer via the provided callback.""" @abstractmethod - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -319,7 +328,9 @@ async def ws_candidate( ) return - await camera.async_on_webrtc_candidate(msg["session_id"], msg["candidate"]) + await camera.async_on_webrtc_candidate( + msg["session_id"], RTCIceCandidate(msg["candidate"]) + ) connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index c3e5971a53f..013c094dc23 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -15,6 +15,7 @@ from go2rtc_client.ws import ( WsError, ) import voluptuous as vol +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( Camera, @@ -219,7 +220,7 @@ class WebRTCProvider(CameraWebRTCProvider): value: WebRTCMessage match message: case WebRTCCandidate(): - value = HAWebRTCCandidate(message.candidate) + value = HAWebRTCCandidate(RTCIceCandidate(message.candidate)) case WebRTCAnswer(): value = HAWebRTCAnswer(message.sdp) case WsError(): @@ -231,11 +232,13 @@ class WebRTCProvider(CameraWebRTCProvider): config = camera.async_get_webrtc_client_configuration() await ws_client.send(WebRTCOffer(offer_sdp, config.configuration.ice_servers)) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" if ws_client := self._sessions.get(session_id): - await ws_client.send(WebRTCCandidate(candidate)) + await ws_client.send(WebRTCCandidate(candidate.candidate)) else: _LOGGER.debug("Unknown session %s. Ignoring candidate", session_id) diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e0d4e38fb57..e7279f60848 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, AsyncMock, Mock, PropertyMock, mock_open, patch import pytest from syrupy.assertion import SnapshotAssertion +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera import ( @@ -960,7 +961,7 @@ async def _test_capabilities( send_message(WebRTCAnswer("answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ec096b5f37a..27c50848ebf 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from webrtc_models import RTCIceCandidate, RTCIceServer from homeassistant.components.camera import ( DATA_ICE_SERVERS, @@ -13,7 +14,6 @@ from homeassistant.components.camera import ( Camera, CameraEntityFeature, CameraWebRTCProvider, - RTCIceServer, StreamType, WebRTCAnswer, WebRTCCandidate, @@ -81,7 +81,9 @@ class SomeTestProvider(CameraWebRTCProvider): """ send_message(WebRTCAnswer(answer="answer")) - async def async_on_webrtc_candidate(self, session_id: str, candidate: str) -> None: + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: """Handle the WebRTC candidate.""" @callback @@ -503,7 +505,10 @@ async def test_websocket_webrtc_offer( @pytest.mark.parametrize( ("message", "expected_frontend_message"), [ - (WebRTCCandidate("candidate"), {"type": "candidate", "candidate": "candidate"}), + ( + WebRTCCandidate(RTCIceCandidate("candidate")), + {"type": "candidate", "candidate": "candidate"}, + ), ( WebRTCError("webrtc_offer_failed", "error"), {"type": "error", "code": "webrtc_offer_failed", "message": "error"}, @@ -989,7 +994,9 @@ async def test_ws_webrtc_candidate( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1039,7 +1046,9 @@ async def test_ws_webrtc_candidate_webrtc_provider( response = await client.receive_json() assert response["type"] == TYPE_RESULT assert response["success"] - mock_on_webrtc_candidate.assert_called_once_with(session_id, candidate) + mock_on_webrtc_candidate.assert_called_once_with( + session_id, RTCIceCandidate(candidate) + ) @pytest.mark.usefixtures("mock_camera_webrtc") @@ -1140,7 +1149,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: send_message(WebRTCAnswer(answer="answer")) async def async_on_webrtc_candidate( - self, session_id: str, candidate: str + self, session_id: str, candidate: RTCIceCandidate ) -> None: """Handle the WebRTC candidate.""" @@ -1150,7 +1159,7 @@ async def test_webrtc_provider_optional_interface(hass: HomeAssistant) -> None: await provider.async_handle_async_webrtc_offer( Mock(), "offer_sdp", "session_id", Mock() ) - await provider.async_on_webrtc_candidate("session_id", "candidate") + await provider.async_on_webrtc_candidate("session_id", RTCIceCandidate("candidate")) provider.async_close_session("session_id") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index c4a23731a93..1e73525fbe3 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -17,6 +17,7 @@ from go2rtc_client.ws import ( WsError, ) import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( DOMAIN as CAMERA_DOMAIN, @@ -379,7 +380,7 @@ async def message_callbacks( [ ( WebRTCCandidate("candidate"), - HAWebRTCCandidate("candidate"), + HAWebRTCCandidate(RTCIceCandidate("candidate")), ), ( WebRTCAnswer(ANSWER_SDP), @@ -415,7 +416,7 @@ async def test_on_candidate( session_id = "session_id" # Session doesn't exist - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) assert ( "homeassistant.components.go2rtc", logging.DEBUG, @@ -435,7 +436,7 @@ async def test_on_candidate( ) ws_client.reset_mock() - await camera.async_on_webrtc_candidate(session_id, "candidate") + await camera.async_on_webrtc_candidate(session_id, RTCIceCandidate("candidate")) ws_client.send.assert_called_once_with(WebRTCCandidate("candidate")) assert caplog.record_tuples == [] From 1ff0efc97b7282e158eb01bf43e94aaa44971403 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 10:33:08 +0100 Subject: [PATCH 125/511] Bump yt-dlp to 2024.11.04 (#129794) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 233fef3c7f3..3e4db5d5b04 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.10.22"], + "requirements": ["yt-dlp==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 0b16de92d2d..cb2f24bd998 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3051,7 +3051,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5d3166ed3b..484269c10d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2437,7 +2437,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.10.22 +yt-dlp==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From b1c9f83952b76916c8f4b787ff02b0b9997b9126 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 12:26:34 +0100 Subject: [PATCH 126/511] Fix stringification of discovered hassio uuid (#129797) --- homeassistant/components/hassio/discovery.py | 4 ++-- tests/components/hassio/test_discovery.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 6181fe4624c..b51b8e5a8f2 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -131,11 +131,11 @@ class HassIODiscovery(HomeAssistantView): config=data.config, name=addon_info.name, slug=data.addon, - uuid=str(data.uuid), + uuid=data.uuid.hex, ), discovery_key=discovery_flow.DiscoveryKey( domain=DOMAIN, - key=str(data.uuid), + key=data.uuid.hex, version=1, ), ) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index bb3a101d1f9..ba6338f84e2 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -91,7 +91,7 @@ async def test_hassio_discovery_startup( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -153,7 +153,7 @@ async def test_hassio_discovery_startup_done( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -203,7 +203,7 @@ async def test_hassio_discovery_webhook( }, name="Mosquitto Test", slug="mosquitto", - uuid=str(uuid), + uuid=uuid.hex, ) ) @@ -286,7 +286,7 @@ async def test_hassio_rediscover( ) expected_context = { - "discovery_key": DiscoveryKey(domain="hassio", key=str(uuid), version=1), + "discovery_key": DiscoveryKey(domain="hassio", key=uuid.hex, version=1), "source": config_entries.SOURCE_HASSIO, } From cb0b942db383ed2ce750bb8a3e97cf5154f61e70 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 12:34:00 +0100 Subject: [PATCH 127/511] Improve error handling in Spotify (#129799) --- .../components/spotify/coordinator.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py index 4a8c6885f9f..9e62d5f137e 100644 --- a/homeassistant/components/spotify/coordinator.py +++ b/homeassistant/components/spotify/coordinator.py @@ -75,7 +75,10 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): raise UpdateFailed("Error communicating with Spotify API") from err async def _async_update_data(self) -> SpotifyCoordinatorData: - current = await self.client.get_playback() + try: + current = await self.client.get_playback() + except SpotifyConnectionError as err: + raise UpdateFailed("Error communicating with Spotify API") from err if not current: return SpotifyCoordinatorData( current_playback=None, @@ -90,8 +93,17 @@ class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): audio_features: AudioFeatures | None = None if (item := current.item) is not None and item.type == ItemType.TRACK: if item.uri != self._currently_loaded_track: - self._currently_loaded_track = item.uri - audio_features = await self.client.get_audio_features(item.uri) + try: + audio_features = await self.client.get_audio_features(item.uri) + except SpotifyConnectionError: + _LOGGER.debug( + "Unable to load audio features for track '%s'. " + "Continuing without audio features", + item.uri, + ) + audio_features = None + else: + self._currently_loaded_track = item.uri else: audio_features = self.data.audio_features dj_playlist = False From 0f0f5fd0ab8fa864be264451e6f3499a2aa3cf82 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:35:47 +0100 Subject: [PATCH 128/511] Fix incorrect description placeholders in azure event hub (#129803) --- homeassistant/components/azure_event_hub/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index 046851e6926..60ac9bff8cd 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -124,7 +124,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN): step_id=STEP_CONN_STRING, data_schema=CONN_STRING_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) @@ -144,7 +146,9 @@ class AEHConfigFlow(ConfigFlow, domain=DOMAIN): step_id=STEP_SAS, data_schema=SAS_SCHEMA, errors=errors, - description_placeholders=self._data[CONF_EVENT_HUB_INSTANCE_NAME], + description_placeholders={ + "event_hub_instance_name": self._data[CONF_EVENT_HUB_INSTANCE_NAME] + }, last_step=True, ) From 7084b3b52c54a1bbb89ac4ebdcc4329673cc989c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 13:58:12 +0100 Subject: [PATCH 129/511] Update go2rtc stream if stream_source is not matching (#129804) --- homeassistant/components/go2rtc/__init__.py | 18 ++++++++++-------- tests/components/go2rtc/conftest.py | 3 ++- tests/components/go2rtc/test_init.py | 12 ++++++++++++ 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 013c094dc23..5be1dbc1a48 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -203,15 +203,17 @@ class WebRTCProvider(CameraWebRTCProvider): self._session, self._url, source=camera.entity_id ) + if not (stream_source := await camera.stream_source()): + send_message( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + ) + return + streams = await self._rest_client.streams.list() - if camera.entity_id not in streams: - if not (stream_source := await camera.stream_source()): - send_message( - WebRTCError( - "go2rtc_webrtc_offer_failed", "Camera has no stream source" - ) - ) - return + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): await self._rest_client.streams.add(camera.entity_id, stream_source) @callback diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 495d42114f1..87c68989fd2 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -21,7 +21,8 @@ def rest_client() -> Generator[AsyncMock]: patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): client = mock_client.return_value - client.streams = Mock(spec_set=_StreamClient) + client.streams = streams = Mock(spec_set=_StreamClient) + streams.list.return_value = {} client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 1e73525fbe3..847de248aaf 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -239,6 +239,18 @@ async def _test_setup_and_signaling( rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { From 93492924644ef1be9810707aa5580ea0cf5b2f8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 13:59:10 +0100 Subject: [PATCH 130/511] Fix aborting flows for single config entry integrations (#129805) --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e99c730145e..d7e6b34de0d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1446,6 +1446,7 @@ class ConfigEntriesFlowManager( or progress_unique_id == DEFAULT_DISCOVERY_UNIQUE_ID ): self.async_abort(progress_flow_id) + continue # Abort any flows in progress for the same handler # when integration allows only one config entry diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e0135657c2b..ec085a15866 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5741,8 +5741,20 @@ async def test_avoid_adding_second_config_entry_on_single_config_entry( assert result["translation_domain"] == HOMEASSISTANT_DOMAIN +@pytest.mark.parametrize( + ("flow_1_unique_id", "flow_2_unique_id"), + [ + (None, None), + ("very_unique", "very_unique"), + (None, config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ("very_unique", config_entries.DEFAULT_DISCOVERY_UNIQUE_ID), + ], +) async def test_in_progress_get_canceled_when_entry_is_created( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + flow_1_unique_id: str | None, + flow_2_unique_id: str | None, ) -> None: """Test that we abort all in progress flows when a new entry is created on a single instance only integration.""" integration = loader.Integration( @@ -5770,6 +5782,15 @@ async def test_in_progress_get_canceled_when_entry_is_created( if user_input is not None: return self.async_create_entry(title="Test Title", data=user_input) + await self.async_set_unique_id(flow_1_unique_id, raise_on_progress=False) + return self.async_show_form(step_id="user") + + async def async_step_zeroconfg(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(title="Test Title", data=user_input) + + await self.async_set_unique_id(flow_2_unique_id, raise_on_progress=False) return self.async_show_form(step_id="user") with ( From 6e93777f5469b969d1abb61da18b3f37799a99a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Nov 2024 15:45:29 +0100 Subject: [PATCH 131/511] Fix create flow logic for single config entry integrations (#129807) * Fix create flow logic for single config entry integrations * Adjust MQTT test --- homeassistant/config_entries.py | 12 +++++++++--- tests/components/mqtt/test_config_flow.py | 2 +- tests/test_config_entries.py | 8 ++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index d7e6b34de0d..9b5ffcf6fad 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1264,10 +1264,16 @@ class ConfigEntriesFlowManager( # Avoid starting a config flow on an integration that only supports # a single config entry, but which already has an entry + source = context["source"] if ( - context.get("source") - not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} - and self.config_entries.async_has_entries(handler, include_ignore=False) + source not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} + and ( + self.config_entries.async_has_entries(handler, include_ignore=False) + or ( + self.config_entries.async_has_entries(handler, include_ignore=True) + and source != SOURCE_USER + ) + ) and await _support_single_config_entry_only(self.hass, handler) ): return ConfigFlowResult( diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 5a95b9c5712..e99063b088b 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -444,7 +444,7 @@ async def test_hassio_ignored(hass: HomeAssistant) -> None: ) assert result assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + assert result.get("reason") == "single_instance_allowed" async def test_hassio_confirm( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ec085a15866..d0a9d5afb4b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5627,6 +5627,14 @@ async def test_starting_config_flow_on_single_config_entry( None, {"type": data_entry_flow.FlowResultType.ABORT, "reason": "not_implemented"}, ), + ( + {"source": config_entries.SOURCE_ZEROCONF}, + None, + { + "type": data_entry_flow.FlowResultType.ABORT, + "reason": "single_instance_allowed", + }, + ), ], ) async def test_starting_config_flow_on_single_config_entry_2( From 82868a85888be599a7495c6482eb6835e73818a6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Nov 2024 11:37:14 -0500 Subject: [PATCH 132/511] Fix ESPHome dashboard check (#129812) --- homeassistant/components/esphome/manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index afbe109d5bc..007b4e791e1 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -570,8 +570,10 @@ def _async_setup_device_registry( configuration_url = None if device_info.webserver_port > 0: configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif (dashboard := async_get_dashboard(hass)) and dashboard.data.get( - device_info.name + elif ( + (dashboard := async_get_dashboard(hass)) + and dashboard.data + and dashboard.data.get(device_info.name) ): configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" From 0b981f42bbb98369cfe3588fbc8b43a9fa4944d2 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 4 Nov 2024 17:39:39 +0000 Subject: [PATCH 133/511] Bump python-kasa to 0.7.7 (#129817) Bump tplink dependency python-kasa to 0.7.7 --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index a79857e9e7e..cb8a55b3db2 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -301,5 +301,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.7.6"] + "requirements": ["python-kasa[speedups]==0.7.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb2f24bd998..0cc08e633e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2353,7 +2353,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 484269c10d7..4b39c16bb98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1880,7 +1880,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.6 +python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay python-linkplay==0.0.17 From 6c75e0bee1939c6138f092414aff6df9102ed831 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 4 Nov 2024 18:41:37 +0100 Subject: [PATCH 134/511] Remove all ice_servers on native sync WebRTC cameras (#129819) --- homeassistant/components/camera/__init__.py | 19 +++--- tests/components/camera/conftest.py | 75 ++++++++++++++++++++- tests/components/camera/test_init.py | 60 +---------------- tests/components/camera/test_webrtc.py | 23 +++++++ 4 files changed, 109 insertions(+), 68 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 1feb7dffd3b..47d8b9dfbd0 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -827,16 +827,17 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - ice_servers = [ - server - for servers in self.hass.data.get(DATA_ICE_SERVERS, []) - for server in servers() - ] - config.configuration.ice_servers.extend(ice_servers) + if not self._webrtc_sync_offer: + # Until 2024.11, the frontend was not resolving any ice servers + # The async approach was added 2024.11 and new integrations need to use it + ice_servers = [ + server + for servers in self.hass.data.get(DATA_ICE_SERVERS, []) + for server in servers() + ] + config.configuration.ice_servers.extend(ice_servers) - config.get_candidates_upfront = ( - self._webrtc_sync_offer or self._legacy_webrtc_provider is not None - ) + config.get_candidates_upfront = self._legacy_webrtc_provider is not None return config diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index bec44704ec2..a88cd898e33 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -1,13 +1,14 @@ """Test helpers for camera.""" from collections.abc import AsyncGenerator, Generator -from unittest.mock import AsyncMock, PropertyMock, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest from homeassistant.components import camera from homeassistant.components.camera.const import StreamType from homeassistant.components.camera.webrtc import WebRTCAnswer, WebRTCSendMessage +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -15,6 +16,15 @@ from homeassistant.setup import async_setup_component from .common import STREAM_SOURCE, WEBRTC_ANSWER +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) + @pytest.fixture(autouse=True) async def setup_homeassistant(hass: HomeAssistant) -> None: @@ -142,3 +152,66 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: return_value=STREAM_SOURCE, ) as mock_stream_source: yield mock_stream_source + + +@pytest.fixture +async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: + """Initialize a test camera with native sync WebRTC support.""" + + # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer + # and native support is checked by verify the function "async_handle_web_rtc_offer" was + # overwritten(implemented) or not + class MockCamera(camera.Camera): + """Mock Camera Entity.""" + + _attr_name = "Test" + _attr_supported_features: camera.CameraEntityFeature = ( + camera.CameraEntityFeature.STREAM + ) + _attr_frontend_stream_type: camera.StreamType = camera.StreamType.WEB_RTC + + async def stream_source(self) -> str | None: + return STREAM_SOURCE + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + return WEBRTC_ANSWER + + domain = "test" + + entry = MockConfigEntry(domain=domain) + entry.add_to_hass(hass) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [camera.DOMAIN] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, camera.DOMAIN + ) + return True + + mock_integration( + hass, + MockModule( + domain, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + setup_test_component_platform( + hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + ) + mock_platform(hass, f"{domain}.config_flow", Mock()) + + with mock_config_flow(domain, ConfigFlow): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index e7279f60848..0a173065564 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -25,7 +25,6 @@ from homeassistant.components.camera.const import ( ) from homeassistant.components.camera.helper import get_camera_from_entity_id from homeassistant.components.websocket_api import TYPE_RESULT -from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STARTED, @@ -38,18 +37,12 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, WEBRTC_ANSWER, mock_turbo_jpeg +from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( - MockConfigEntry, - MockModule, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, ) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -986,62 +979,13 @@ async def test_camera_capabilities_hls( ) +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test WebRTC camera capabilities.""" - # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer - # Camera capabilities are determined by by checking if the function was overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: camera.CameraEntityFeature = ( - camera.CameraEntityFeature.STREAM - ) - - async def stream_source(self) -> str | None: - return STREAM_SOURCE - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - return WEBRTC_ANSWER - - domain = "test" - - entry = MockConfigEntry(domain=domain) - entry.add_to_hass(hass) - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload(config_entry, DOMAIN) - return True - - mock_integration( - hass, - MockModule( - domain, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - setup_test_component_platform(hass, DOMAIN, [MockCamera()], from_config_entry=True) - mock_platform(hass, f"{domain}.config_flow", Mock()) - - with mock_config_flow(domain, ConfigFlow): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await _test_capabilities( hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 27c50848ebf..2970a41408c 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -393,6 +393,29 @@ async def test_ws_get_client_config( } +@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +async def test_ws_get_client_config_sync_offer( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test get WebRTC client config, when camera is supporting sync offer.""" + await async_setup_component(hass, "camera", {}) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json_auto_id( + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + ) + msg = await client.receive_json() + + # Assert WebSocket response + assert msg["type"] == TYPE_RESULT + assert msg["success"] + assert msg["result"] == { + "configuration": {}, + "getCandidatesUpfront": False, + } + + @pytest.mark.usefixtures("mock_camera_webrtc") async def test_ws_get_client_config_custom_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator From 18d2ced045c3120cbaa98390b5e20bc43756fe1a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 18:03:37 +0100 Subject: [PATCH 135/511] Fix translations in homeworks (#129824) --- homeassistant/components/homeworks/strings.json | 3 +++ tests/components/homeworks/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json index a9dcab2f1e0..977e6be8afd 100644 --- a/homeassistant/components/homeworks/strings.json +++ b/homeassistant/components/homeworks/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "connection_error": "Could not connect to the controller.", "credentials_needed": "The controller needs credentials.", diff --git a/tests/components/homeworks/test_config_flow.py b/tests/components/homeworks/test_config_flow.py index cca09c10e70..e8c4ab15b3d 100644 --- a/tests/components/homeworks/test_config_flow.py +++ b/tests/components/homeworks/test_config_flow.py @@ -235,10 +235,6 @@ async def test_user_flow_cannot_connect( assert result["step_id"] == "user" -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: @@ -326,10 +322,6 @@ async def test_reconfigure_flow_flow_duplicate( assert result["errors"] == {"base": "duplicated_host_port"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.homeworks.config.abort.reconfigure_successful"], -) async def test_reconfigure_flow_flow_no_change( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_homeworks: MagicMock ) -> None: From 0bc6b8b0d46440ac71ad970ea6ab2f63ebcafb98 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Nov 2024 19:39:46 +0100 Subject: [PATCH 136/511] Update frontend to 20241104.0 (#129829) --- 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 52eee7db199..89cd93227a4 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==20241031.0"] + "requirements": ["home-assistant-frontend==20241104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fbb51b85d88..1a9edf42bd3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0cc08e633e2..e57ddf30435 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b39c16bb98..3b7d8fa1b5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241031.0 +home-assistant-frontend==20241104.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From 9fb3261f02d9553f6ba8561b50e58c3626b1eebc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:35:35 +0100 Subject: [PATCH 137/511] Fix translations in landisgyr (#129831) --- .../components/landisgyr_heat_meter/strings.json | 3 +++ tests/components/landisgyr_heat_meter/test_config_flow.py | 8 -------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/strings.json b/homeassistant/components/landisgyr_heat_meter/strings.json index 4bae2490006..31f08ded79f 100644 --- a/homeassistant/components/landisgyr_heat_meter/strings.json +++ b/homeassistant/components/landisgyr_heat_meter/strings.json @@ -12,6 +12,9 @@ } } }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index 79088508e61..fe62d530719 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -101,10 +101,6 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No } -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" @@ -135,10 +131,6 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.landisgyr_heat_meter.config.error.cannot_connect"], -) @patch(API_HEAT_METER_SERVICE) @patch("serial.tools.list_ports.comports", return_value=[mock_serial_port()]) async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: From 03e6a138962b1a21ce08a79572755bd08d206885 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Nov 2024 18:48:58 +0000 Subject: [PATCH 138/511] Bump version to 2024.11.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 57c31068b2f..c28f36f986e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 32abfd10c78..2e5b34e6ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b3" +version = "2024.11.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 90bd9bb626d4496b9c3772db7363a2cd73324b87 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:00 +0100 Subject: [PATCH 139/511] Fix translations in hydrawise (#129834) --- homeassistant/components/hydrawise/strings.json | 3 ++- tests/components/hydrawise/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index b6df36ad4ff..4d50f10bcb2 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -13,7 +13,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index e2eaaa51dc2..e85b1b9b249 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -93,10 +93,6 @@ async def test_form_connect_timeout( assert result2["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.hydrawise.config.error.invalid_auth"], -) async def test_form_not_authorized_error( hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User ) -> None: From 0b56ef5699a00608b969a469658258ac060a1f2f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:49 +0100 Subject: [PATCH 140/511] Fix translation in ovo energy (#129833) --- .../components/ovo_energy/strings.json | 7 ++++++- .../components/ovo_energy/test_config_flow.py | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index a9f7c9056b7..3dc11e3a601 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -1,10 +1,15 @@ { "config": { "flow_title": "{username}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "authorization_error": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { "user": { diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py index b6250a95492..cfe679a254a 100644 --- a/tests/components/ovo_energy/test_config_flow.py +++ b/tests/components/ovo_energy/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import patch import aiohttp -import pytest from homeassistant import config_entries from homeassistant.components.ovo_energy.const import CONF_ACCOUNT, DOMAIN @@ -121,10 +120,6 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None: assert result2["data"][CONF_ACCOUNT] == FIXTURE_USER_INPUT[CONF_ACCOUNT] -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.authorization_error"], -) async def test_reauth_authorization_error(hass: HomeAssistant) -> None: """Test we show user form on authorization error.""" mock_config = MockConfigEntry( @@ -150,10 +145,6 @@ async def test_reauth_authorization_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "authorization_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.connection_error"], -) async def test_reauth_connection_error(hass: HomeAssistant) -> None: """Test we show user form on connection error.""" mock_config = MockConfigEntry( @@ -181,15 +172,6 @@ async def test_reauth_connection_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "connection_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - [ - [ - "component.ovo_energy.config.abort.reauth_successful", - "component.ovo_energy.config.error.authorization_error", - ] - ], -) async def test_reauth_flow(hass: HomeAssistant) -> None: """Test reauth works.""" mock_config = MockConfigEntry( From 3584c710b96b9ccce8521ba4b4cd06a61e0c2af9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:13:56 -0600 Subject: [PATCH 141/511] Fix unifiprotect supported features being set too late (#129850) --- .../components/unifiprotect/camera.py | 25 +++---- tests/components/unifiprotect/test_camera.py | 69 ++++++++++++++++++- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 62c35d00171..ccf9bf1df0f 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -156,7 +156,8 @@ async def async_setup_entry( async_add_entities(_async_camera_entities(hass, entry, data)) -_EMPTY_CAMERA_FEATURES = CameraEntityFeature(0) +_DISABLE_FEATURE = CameraEntityFeature(0) +_ENABLE_FEATURE = CameraEntityFeature.STREAM class ProtectCamera(ProtectDeviceEntity, Camera): @@ -195,24 +196,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._attr_name = f"{camera_name} (insecure)" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure + # Set the stream source before finishing the init + # because async_added_to_hass is too late and camera + # integration uses async_internal_added_to_hass to access + # the stream source which is called before async_added_to_hass + self._async_set_stream_source() @callback def _async_set_stream_source(self) -> None: - disable_stream = self._disable_stream channel = self.channel - - if not channel.is_rtsp_enabled: - disable_stream = False - + enable_stream = not self._disable_stream and channel.is_rtsp_enabled rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url - - # _async_set_stream_source called by __init__ - # pylint: disable-next=attribute-defined-outside-init - self._stream_source = None if disable_stream else rtsp_url - if self._stream_source: - self._attr_supported_features = CameraEntityFeature.STREAM - else: - self._attr_supported_features = _EMPTY_CAMERA_FEATURES + source = rtsp_url if enable_stream else None + self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE + self._stream_source = source @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 75a0beb23d9..e86bc42f06c 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock +import pytest from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from uiprotect.exceptions import NvrError @@ -12,8 +13,13 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.camera import ( CameraEntityFeature, CameraState, + CameraWebRTCProvider, + RTCIceCandidate, + StreamType, + WebRTCSendMessage, async_get_image, async_get_stream_source, + async_register_webrtc_provider, ) from homeassistant.components.unifiprotect.const import ( ATTR_BITRATE, @@ -22,6 +28,7 @@ from homeassistant.components.unifiprotect.const import ( ATTR_HEIGHT, ATTR_WIDTH, DEFAULT_ATTRIBUTION, + DOMAIN, ) from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.const import ( @@ -31,11 +38,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .utils import ( + Camera, MockUFPFixture, adopt_devices, assert_entity_counts, @@ -46,6 +54,45 @@ from .utils import ( ) +class MockWebRTCProvider(CameraWebRTCProvider): + """WebRTC provider.""" + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return DOMAIN + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Return if this provider is supports the Camera as source.""" + return True + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback.""" + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" + + +@pytest.fixture +async def web_rtc_provider(hass: HomeAssistant) -> None: + """Fixture to enable WebRTC provider for camera entities.""" + await async_setup_component(hass, "camera", {}) + async_register_webrtc_provider(hass, MockWebRTCProvider()) + + def validate_default_camera_entity( hass: HomeAssistant, camera_obj: ProtectCamera, @@ -283,6 +330,26 @@ async def test_basic_setup( await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) +@pytest.mark.usefixtures("web_rtc_provider") +async def test_webrtc_support( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, +) -> None: + """Test webrtc support is available.""" + camera_high_only = camera_all.copy() + camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only.name = "Test Camera 1" + camera_high_only.channels[0].is_rtsp_enabled = True + camera_high_only.channels[1].is_rtsp_enabled = False + camera_high_only.channels[2].is_rtsp_enabled = False + await init_entry(hass, ufp, [camera_high_only]) + entity_id = validate_default_camera_entity(hass, camera_high_only, 0) + state = hass.states.get(entity_id) + assert state + assert StreamType.WEB_RTC in state.attributes["frontend_stream_type"] + + async def test_adopt( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: From e5263dc0c81e09d4b0cf4d79ecb49dc25af7159c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:43:22 -0600 Subject: [PATCH 142/511] Bump uiprotect to 6.4.0 (#129851) --- 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 4617a8aae80..85867b5c87c 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==6.3.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 58739540311..e9a335875f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2888,7 +2888,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.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 89619b18b89..fe5ce5673b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2301,7 +2301,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From e8c3539709dafbdd19109bc2b93b7a17867084c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 16:13:52 -0600 Subject: [PATCH 143/511] Disable SRTP for unifiprotect RTSPS stream (#129852) --- homeassistant/components/unifiprotect/camera.py | 4 +++- tests/components/unifiprotect/test_camera.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index ccf9bf1df0f..a40939be917 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -206,7 +206,9 @@ class ProtectCamera(ProtectDeviceEntity, Camera): def _async_set_stream_source(self) -> None: channel = self.channel enable_stream = not self._disable_stream and channel.is_rtsp_enabled - rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url + # SRTP disabled because go2rtc does not support it + # https://github.com/AlexxIT/go2rtc/#source-rtsp + rtsp_url = channel.rtsps_no_srtp_url if self._secure else channel.rtsp_url source = rtsp_url if enable_stream else None self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE self._stream_source = source diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index e86bc42f06c..379f443923a 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -196,7 +196,7 @@ async def validate_rtsps_camera_state( """Validate a camera's state.""" channel = camera_obj.channels[channel_id] - assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url + assert await async_get_stream_source(hass, entity_id) == channel.rtsps_no_srtp_url validate_common_camera_state(hass, channel, entity_id, features) From dafd54ba2b34a861dd8cd5cac25c19b493f4b020 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Nov 2024 03:34:40 +0100 Subject: [PATCH 144/511] Bump reolink-aio to 0.10.3 (#129841) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 282fe908e4c..5fd87c2ccb1 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.10.2"] + "requirements": ["reolink-aio==0.10.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9a335875f4..0c2eaebbd27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe5ce5673b8..78154cec9f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.rflink rflink==0.0.66 From 617e87e02ccc0748b805f915da4023fd70b2a33f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:56:47 +0100 Subject: [PATCH 145/511] Fix source mapping in Onkyo (#129716) * Fix source mapping * Fix copy paste --- .../components/onkyo/media_player.py | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 99f872e7fad..41e36a7f237 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -128,13 +128,27 @@ ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type InputLibValue = str | tuple[str, ...] -_cmds: dict[str, InputLibValue] = { - k: v["name"] - for k, v in { - **PYEISCP_COMMANDS["main"]["SLI"]["values"], - **PYEISCP_COMMANDS["zone2"]["SLZ"]["values"], - }.items() -} + +def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["SLI"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] + case "zone3": + cmds = PYEISCP_COMMANDS["zone3"]["SL3"] + case "zone4": + cmds = PYEISCP_COMMANDS["zone4"]["SL4"] + + result: dict[InputSource, InputLibValue] = {} + for k, v in cmds["values"].items(): + try: + source = InputSource(k) + except ValueError: + continue + result[source] = v["name"] + + return result async def async_setup_platform( @@ -147,16 +161,13 @@ async def async_setup_platform( host = config.get(CONF_HOST) source_mapping: dict[str, InputSource] = {} - for value, source_lib in _cmds.items(): - try: - source = InputSource(value) - except ValueError: - continue - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) + for zone in ZONES: + for source, source_lib in _input_lib_cmds(zone).items(): + if isinstance(source_lib, str): + source_mapping.setdefault(source_lib, source) + else: + for source_lib_single in source_lib: + source_mapping.setdefault(source_lib_single, source) sources: dict[InputSource, str] = {} for source_lib_single, source_name in config[CONF_SOURCES].items(): @@ -340,9 +351,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume - self._source_mapping = sources - self._reverse_mapping = {value: key for key, value in sources.items()} - self._lib_mapping = {_cmds[source.value]: source for source in InputSource} + self._name_mapping = sources + self._reverse_name_mapping = {value: key for key, value in sources.items()} + self._lib_mapping = _input_lib_cmds(zone) + self._reverse_lib_mapping = { + value: key for key, value in self._lib_mapping.items() + } self._attr_source_list = list(sources.values()) self._attr_extra_state_attributes = {} @@ -414,7 +428,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if self.source_list and source in self.source_list: - source_lib = _cmds[self._reverse_mapping[source].value] + source_lib = self._lib_mapping[self._reverse_name_mapping[source]] if isinstance(source_lib, str): source_lib_single = source_lib else: @@ -432,7 +446,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) -> None: """Play radio station by preset number.""" if self.source is not None: - source = self._reverse_mapping[self.source] + source = self._reverse_name_mapping[self.source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @@ -505,9 +519,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): @callback def _parse_source(self, source_lib: InputLibValue) -> None: - source = self._lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] + source = self._reverse_lib_mapping[source_lib] + if source in self._name_mapping: + self._attr_source = self._name_mapping[source] return source_meaning = source.value_meaning From 90ceebdf913143c0df5352f952890adee5a01419 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 5 Nov 2024 03:56:47 +0100 Subject: [PATCH 146/511] Fix source mapping in Onkyo (#129716) * Fix source mapping * Fix copy paste --- .../components/onkyo/media_player.py | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 99f872e7fad..41e36a7f237 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -128,13 +128,27 @@ ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo" type InputLibValue = str | tuple[str, ...] -_cmds: dict[str, InputLibValue] = { - k: v["name"] - for k, v in { - **PYEISCP_COMMANDS["main"]["SLI"]["values"], - **PYEISCP_COMMANDS["zone2"]["SLZ"]["values"], - }.items() -} + +def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]: + match zone: + case "main": + cmds = PYEISCP_COMMANDS["main"]["SLI"] + case "zone2": + cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] + case "zone3": + cmds = PYEISCP_COMMANDS["zone3"]["SL3"] + case "zone4": + cmds = PYEISCP_COMMANDS["zone4"]["SL4"] + + result: dict[InputSource, InputLibValue] = {} + for k, v in cmds["values"].items(): + try: + source = InputSource(k) + except ValueError: + continue + result[source] = v["name"] + + return result async def async_setup_platform( @@ -147,16 +161,13 @@ async def async_setup_platform( host = config.get(CONF_HOST) source_mapping: dict[str, InputSource] = {} - for value, source_lib in _cmds.items(): - try: - source = InputSource(value) - except ValueError: - continue - if isinstance(source_lib, str): - source_mapping.setdefault(source_lib, source) - else: - for source_lib_single in source_lib: - source_mapping.setdefault(source_lib_single, source) + for zone in ZONES: + for source, source_lib in _input_lib_cmds(zone).items(): + if isinstance(source_lib, str): + source_mapping.setdefault(source_lib, source) + else: + for source_lib_single in source_lib: + source_mapping.setdefault(source_lib_single, source) sources: dict[InputSource, str] = {} for source_lib_single, source_name in config[CONF_SOURCES].items(): @@ -340,9 +351,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self._volume_resolution = volume_resolution self._max_volume = max_volume - self._source_mapping = sources - self._reverse_mapping = {value: key for key, value in sources.items()} - self._lib_mapping = {_cmds[source.value]: source for source in InputSource} + self._name_mapping = sources + self._reverse_name_mapping = {value: key for key, value in sources.items()} + self._lib_mapping = _input_lib_cmds(zone) + self._reverse_lib_mapping = { + value: key for key, value in self._lib_mapping.items() + } self._attr_source_list = list(sources.values()) self._attr_extra_state_attributes = {} @@ -414,7 +428,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): async def async_select_source(self, source: str) -> None: """Select input source.""" if self.source_list and source in self.source_list: - source_lib = _cmds[self._reverse_mapping[source].value] + source_lib = self._lib_mapping[self._reverse_name_mapping[source]] if isinstance(source_lib, str): source_lib_single = source_lib else: @@ -432,7 +446,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): ) -> None: """Play radio station by preset number.""" if self.source is not None: - source = self._reverse_mapping[self.source] + source = self._reverse_name_mapping[self.source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: self._update_receiver("preset", media_id) @@ -505,9 +519,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): @callback def _parse_source(self, source_lib: InputLibValue) -> None: - source = self._lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] + source = self._reverse_lib_mapping[source_lib] + if source in self._name_mapping: + self._attr_source = self._name_mapping[source] return source_meaning = source.value_meaning From b6f875134efbf09d0e7ad03ce9e2cd205810472a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 3 Nov 2024 16:38:52 -0600 Subject: [PATCH 147/511] Add HassRespond intent (#129755) * Add HassHello intent * Rename to HassRespond * LLM's ignore HassRespond intent --- homeassistant/components/intent/__init__.py | 14 +++++++++++++- homeassistant/helpers/intent.py | 1 + homeassistant/helpers/llm.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 85fdf5c88c3..1322576f115 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -137,6 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: intent.async_register(hass, TimerStatusIntentHandler()) intent.async_register(hass, GetCurrentDateIntentHandler()) intent.async_register(hass, GetCurrentTimeIntentHandler()) + intent.async_register(hass, HelloIntentHandler()) return True @@ -364,7 +365,7 @@ class NevermindIntentHandler(intent.IntentHandler): description = "Cancels the current request and does nothing" async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: - """Doe not do anything, and produces an empty response.""" + """Do nothing and produces an empty response.""" return intent_obj.create_response() @@ -420,6 +421,17 @@ class GetCurrentTimeIntentHandler(intent.IntentHandler): return response +class HelloIntentHandler(intent.IntentHandler): + """Responds with no action.""" + + intent_type = intent.INTENT_RESPOND + description = "Returns the provided response with no action." + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Return the provided response, but take no action.""" + return intent_obj.create_response() + + async def _async_process_intent( hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol ) -> None: diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 6bd02b8660a..b38f769b302 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -56,6 +56,7 @@ INTENT_UNPAUSE_TIMER = "HassUnpauseTimer" INTENT_TIMER_STATUS = "HassTimerStatus" INTENT_GET_CURRENT_DATE = "HassGetCurrentDate" INTENT_GET_CURRENT_TIME = "HassGetCurrentTime" +INTENT_RESPOND = "HassRespond" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 39dff04fb7c..d322810b0ef 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -279,6 +279,7 @@ class AssistAPI(API): intent.INTENT_TOGGLE, intent.INTENT_GET_CURRENT_DATE, intent.INTENT_GET_CURRENT_TIME, + intent.INTENT_RESPOND, } def __init__(self, hass: HomeAssistant) -> None: From 9d261bab483ec4efb50803e23b18fd627bbb23ec Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:49 +0100 Subject: [PATCH 148/511] Fix translation in ovo energy (#129833) --- .../components/ovo_energy/strings.json | 7 ++++++- .../components/ovo_energy/test_config_flow.py | 18 ------------------ 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json index a9f7c9056b7..3dc11e3a601 100644 --- a/homeassistant/components/ovo_energy/strings.json +++ b/homeassistant/components/ovo_energy/strings.json @@ -1,10 +1,15 @@ { "config": { "flow_title": "{username}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "connection_error": "[%key:common::config_flow::error::cannot_connect%]", + "authorization_error": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { "user": { diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py index b6250a95492..cfe679a254a 100644 --- a/tests/components/ovo_energy/test_config_flow.py +++ b/tests/components/ovo_energy/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import patch import aiohttp -import pytest from homeassistant import config_entries from homeassistant.components.ovo_energy.const import CONF_ACCOUNT, DOMAIN @@ -121,10 +120,6 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None: assert result2["data"][CONF_ACCOUNT] == FIXTURE_USER_INPUT[CONF_ACCOUNT] -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.authorization_error"], -) async def test_reauth_authorization_error(hass: HomeAssistant) -> None: """Test we show user form on authorization error.""" mock_config = MockConfigEntry( @@ -150,10 +145,6 @@ async def test_reauth_authorization_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "authorization_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ovo_energy.config.error.connection_error"], -) async def test_reauth_connection_error(hass: HomeAssistant) -> None: """Test we show user form on connection error.""" mock_config = MockConfigEntry( @@ -181,15 +172,6 @@ async def test_reauth_connection_error(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "connection_error"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - [ - [ - "component.ovo_energy.config.abort.reauth_successful", - "component.ovo_energy.config.error.authorization_error", - ] - ], -) async def test_reauth_flow(hass: HomeAssistant) -> None: """Test reauth works.""" mock_config = MockConfigEntry( From b6345f8d074ceb61b906119974b656203505d7d2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Nov 2024 19:57:00 +0100 Subject: [PATCH 149/511] Fix translations in hydrawise (#129834) --- homeassistant/components/hydrawise/strings.json | 3 ++- tests/components/hydrawise/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hydrawise/strings.json b/homeassistant/components/hydrawise/strings.json index b6df36ad4ff..4d50f10bcb2 100644 --- a/homeassistant/components/hydrawise/strings.json +++ b/homeassistant/components/hydrawise/strings.json @@ -13,7 +13,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/tests/components/hydrawise/test_config_flow.py b/tests/components/hydrawise/test_config_flow.py index e2eaaa51dc2..e85b1b9b249 100644 --- a/tests/components/hydrawise/test_config_flow.py +++ b/tests/components/hydrawise/test_config_flow.py @@ -93,10 +93,6 @@ async def test_form_connect_timeout( assert result2["type"] is FlowResultType.CREATE_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.hydrawise.config.error.invalid_auth"], -) async def test_form_not_authorized_error( hass: HomeAssistant, mock_pydrawise: AsyncMock, user: User ) -> None: From e89ce215c6405e504c77846ce5c247d14bffa1c4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Nov 2024 03:34:40 +0100 Subject: [PATCH 150/511] Bump reolink-aio to 0.10.3 (#129841) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 282fe908e4c..5fd87c2ccb1 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.10.2"] + "requirements": ["reolink-aio==0.10.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e57ddf30435..48b9bc7a62c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2547,7 +2547,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b7d8fa1b5f..c3167ebc5bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2038,7 +2038,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.2 +reolink-aio==0.10.3 # homeassistant.components.rflink rflink==0.0.66 From 2982e733bc9a3ec417681ec68d164c81e0e62db0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:13:56 -0600 Subject: [PATCH 151/511] Fix unifiprotect supported features being set too late (#129850) --- .../components/unifiprotect/camera.py | 25 +++---- tests/components/unifiprotect/test_camera.py | 69 ++++++++++++++++++- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 62c35d00171..ccf9bf1df0f 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -156,7 +156,8 @@ async def async_setup_entry( async_add_entities(_async_camera_entities(hass, entry, data)) -_EMPTY_CAMERA_FEATURES = CameraEntityFeature(0) +_DISABLE_FEATURE = CameraEntityFeature(0) +_ENABLE_FEATURE = CameraEntityFeature.STREAM class ProtectCamera(ProtectDeviceEntity, Camera): @@ -195,24 +196,20 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._attr_name = f"{camera_name} (insecure)" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure + # Set the stream source before finishing the init + # because async_added_to_hass is too late and camera + # integration uses async_internal_added_to_hass to access + # the stream source which is called before async_added_to_hass + self._async_set_stream_source() @callback def _async_set_stream_source(self) -> None: - disable_stream = self._disable_stream channel = self.channel - - if not channel.is_rtsp_enabled: - disable_stream = False - + enable_stream = not self._disable_stream and channel.is_rtsp_enabled rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url - - # _async_set_stream_source called by __init__ - # pylint: disable-next=attribute-defined-outside-init - self._stream_source = None if disable_stream else rtsp_url - if self._stream_source: - self._attr_supported_features = CameraEntityFeature.STREAM - else: - self._attr_supported_features = _EMPTY_CAMERA_FEATURES + source = rtsp_url if enable_stream else None + self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE + self._stream_source = source @callback def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 75a0beb23d9..e86bc42f06c 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock +import pytest from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from uiprotect.exceptions import NvrError @@ -12,8 +13,13 @@ from uiprotect.websocket import WebsocketState from homeassistant.components.camera import ( CameraEntityFeature, CameraState, + CameraWebRTCProvider, + RTCIceCandidate, + StreamType, + WebRTCSendMessage, async_get_image, async_get_stream_source, + async_register_webrtc_provider, ) from homeassistant.components.unifiprotect.const import ( ATTR_BITRATE, @@ -22,6 +28,7 @@ from homeassistant.components.unifiprotect.const import ( ATTR_HEIGHT, ATTR_WIDTH, DEFAULT_ATTRIBUTION, + DOMAIN, ) from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.const import ( @@ -31,11 +38,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .utils import ( + Camera, MockUFPFixture, adopt_devices, assert_entity_counts, @@ -46,6 +54,45 @@ from .utils import ( ) +class MockWebRTCProvider(CameraWebRTCProvider): + """WebRTC provider.""" + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return DOMAIN + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Return if this provider is supports the Camera as source.""" + return True + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback.""" + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" + + +@pytest.fixture +async def web_rtc_provider(hass: HomeAssistant) -> None: + """Fixture to enable WebRTC provider for camera entities.""" + await async_setup_component(hass, "camera", {}) + async_register_webrtc_provider(hass, MockWebRTCProvider()) + + def validate_default_camera_entity( hass: HomeAssistant, camera_obj: ProtectCamera, @@ -283,6 +330,26 @@ async def test_basic_setup( await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) +@pytest.mark.usefixtures("web_rtc_provider") +async def test_webrtc_support( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, +) -> None: + """Test webrtc support is available.""" + camera_high_only = camera_all.copy() + camera_high_only.channels = [c.copy() for c in camera_all.channels] + camera_high_only.name = "Test Camera 1" + camera_high_only.channels[0].is_rtsp_enabled = True + camera_high_only.channels[1].is_rtsp_enabled = False + camera_high_only.channels[2].is_rtsp_enabled = False + await init_entry(hass, ufp, [camera_high_only]) + entity_id = validate_default_camera_entity(hass, camera_high_only, 0) + state = hass.states.get(entity_id) + assert state + assert StreamType.WEB_RTC in state.attributes["frontend_stream_type"] + + async def test_adopt( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ) -> None: From b830f83a34180e7b63365302861760bbb5601b46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 15:43:22 -0600 Subject: [PATCH 152/511] Bump uiprotect to 6.4.0 (#129851) --- 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 4617a8aae80..85867b5c87c 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==6.3.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.4.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 48b9bc7a62c..db81a1380a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2885,7 +2885,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.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 c3167ebc5bc..299295edf72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2298,7 +2298,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.3.2 +uiprotect==6.4.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 3a1502e2bb90fadb1150aecbd1ab5c51589305ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Nov 2024 16:13:52 -0600 Subject: [PATCH 153/511] Disable SRTP for unifiprotect RTSPS stream (#129852) --- homeassistant/components/unifiprotect/camera.py | 4 +++- tests/components/unifiprotect/test_camera.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index ccf9bf1df0f..a40939be917 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -206,7 +206,9 @@ class ProtectCamera(ProtectDeviceEntity, Camera): def _async_set_stream_source(self) -> None: channel = self.channel enable_stream = not self._disable_stream and channel.is_rtsp_enabled - rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url + # SRTP disabled because go2rtc does not support it + # https://github.com/AlexxIT/go2rtc/#source-rtsp + rtsp_url = channel.rtsps_no_srtp_url if self._secure else channel.rtsp_url source = rtsp_url if enable_stream else None self._attr_supported_features = _ENABLE_FEATURE if source else _DISABLE_FEATURE self._stream_source = source diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index e86bc42f06c..379f443923a 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -196,7 +196,7 @@ async def validate_rtsps_camera_state( """Validate a camera's state.""" channel = camera_obj.channels[channel_id] - assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url + assert await async_get_stream_source(hass, entity_id) == channel.rtsps_no_srtp_url validate_common_camera_state(hass, channel, entity_id, features) From c7b2ffbc8e12ec530d29b92e438562348aedd7f1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 03:00:18 +0000 Subject: [PATCH 154/511] Bump version to 2024.11.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 c28f36f986e..cee701c230e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 2e5b34e6ac9..b0d48ff2015 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b4" +version = "2024.11.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f7ce4ff25c4fbc8e32947ba580dc1c4dc7a9a9ec Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 5 Nov 2024 20:15:42 +1300 Subject: [PATCH 155/511] Update snapshot for lg thinq (#129856) update snapshot for lg thinq Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../lg_thinq/snapshots/test_sensor.ambr | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba..aa50ae5b03e 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,95 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From e1e731eb4828eaf3888afc11a930085b13d20833 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 08:56:58 +0100 Subject: [PATCH 156/511] Drop use of initialize_options in onkyo (#129869) * Drop use of initialize_options in onkyo * Apply suggestions from code review Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --------- Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- homeassistant/components/onkyo/config_flow.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 9ab01b3d904..623fa9b2a90 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -327,10 +327,8 @@ class OnkyoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - sources_store: dict[str, str] = self.options[OPTION_INPUT_SOURCES] - sources = {InputSource(k): v for k, v in sources_store.items()} - self.options[OPTION_INPUT_SOURCES] = sources + sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] + self._input_sources = {InputSource(k): v for k, v in sources_store.items()} async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -360,15 +358,12 @@ class OnkyoOptionsFlowHandler(OptionsFlow): ) ) - sources: dict[InputSource, str] = self.options[OPTION_INPUT_SOURCES] - for source in sources: - schema_dict[vol.Required(source.value_meaning, default=sources[source])] = ( + for source, source_name in self._input_sources.items(): + schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( TextSelector() ) - schema = vol.Schema(schema_dict) - return self.async_show_form( step_id="init", - data_schema=schema, + data_schema=vol.Schema(schema_dict), ) From 95eefbac20f683016367b76faed420369d675e58 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:01:29 +0100 Subject: [PATCH 157/511] Drop use of initialize_options in androidtv (#129854) * Drop use of initialize_options in androidtv * Initialize instance attribute in init method * Adjust --- homeassistant/components/androidtv/config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 132ed96a96f..a41a113268e 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -191,10 +191,9 @@ class OptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) - self._state_det_rules: dict[str, Any] = self.options.setdefault( - CONF_STATE_DETECTION_RULES, {} + self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) + self._state_det_rules: dict[str, Any] = dict( + config_entry.options.get(CONF_STATE_DETECTION_RULES, {}) ) self._conf_app_id: str | None = None self._conf_rule_id: str | None = None From 3858400a6f89f04942bb859bb7437a775b0a9f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 5 Nov 2024 10:10:23 +0100 Subject: [PATCH 158/511] Bump hass-nabucasa from 0.83.0 to 0.84.0 (#129873) --- 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 8d2b40ff8ba..4201cb1b2d4 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.83.0"], + "requirements": ["hass-nabucasa==0.84.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c71bd19b3ee..56155d53fd5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ fnv-hash-fast==1.0.2 go2rtc-client==0.0.1b3 ha-ffmpeg==3.2.1 habluetooth==3.6.0 -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241104.0 diff --git a/pyproject.toml b/pyproject.toml index 0c9c825e535..4a2857b5065 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "fnv-hash-fast==1.0.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.83.0", + "hass-nabucasa==0.84.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.27.2", diff --git a/requirements.txt b/requirements.txt index e90164ed272..a5beecec8ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==1.0.2 -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 httpx==0.27.2 home-assistant-bluetooth==1.13.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0c2eaebbd27..afd4de543fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78154cec9f6..abd88b11580 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -922,7 +922,7 @@ habitipy==0.3.3 habluetooth==3.6.0 # homeassistant.components.cloud -hass-nabucasa==0.83.0 +hass-nabucasa==0.84.0 # homeassistant.components.conversation hassil==1.7.4 From e6c20333b38d75cf7a542c8e320636b0ada14483 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:47:37 +0100 Subject: [PATCH 159/511] Remove dead code in translation checks (#129875) --- tests/components/conftest.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5bf393a8405..ba5d12afd01 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -528,21 +528,6 @@ async def _ensure_translation_exists( ignore_translations[full_key] = "used" return - key_parts = key.split(".") - # Ignore step data translations if title or description exists - if ( - len(key_parts) >= 3 - and key_parts[0] == "step" - and key_parts[2] == "data" - and ( - f"component.{component}.{category}.{key_parts[0]}.{key_parts[1]}.description" - in translations - or f"component.{component}.{category}.{key_parts[0]}.{key_parts[1]}.title" - in translations - ) - ): - return - pytest.fail( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" From fa3010016033e53e304edef30f4e8704b0bb146f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:55:40 +0100 Subject: [PATCH 160/511] Fix flaky tests in device_sun_light_trigger (#129871) --- tests/components/device_sun_light_trigger/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 1de0794b9ee..24996482916 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -177,6 +177,9 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test lights turn on when coming home after sun set.""" + # Ensure all setup tasks are done (avoid flaky tests) + await hass.async_block_till_done(wait_background_tasks=True) + device_1 = f"{DEVICE_TRACKER_DOMAIN}.device_1" device_2 = f"{DEVICE_TRACKER_DOMAIN}.device_2" From 80ff6dc6180070b1794fc99ee71bc49c0c277cda Mon Sep 17 00:00:00 2001 From: Alex Bush <45221249+KC3BZU@users.noreply.github.com> Date: Tue, 5 Nov 2024 04:56:34 -0500 Subject: [PATCH 161/511] Bump pyfibaro to 0.8.0 (#129846) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 39850672d06..d2a1186b05b 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.8"] + "requirements": ["pyfibaro==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index afd4de543fb..5f3fab24335 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1907,7 +1907,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abd88b11580..0e83f381730 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1536,7 +1536,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 From e9e20229a35acd09184a66c2654d33b6b6228bef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 10:57:03 +0100 Subject: [PATCH 162/511] Drop use of initialize_options in androidtv_remote (#129855) --- homeassistant/components/androidtv_remote/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 962b1c09f1f..3500e4ff47b 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -226,8 +226,7 @@ class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.initialize_options(config_entry) - self._apps: dict[str, Any] = self.options.setdefault(CONF_APPS, {}) + self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._conf_app_id: str | None = None @callback From af58b0c3b78f84b6029859dbeeda8aa210d9ad1a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 5 Nov 2024 11:05:20 +0100 Subject: [PATCH 163/511] Add reconfigure flow to yale_smart_alarm (#129536) --- .../yale_smart_alarm/config_flow.py | 76 ++++--- .../components/yale_smart_alarm/strings.json | 13 +- .../yale_smart_alarm/test_config_flow.py | 205 ++++++++++++++++++ 3 files changed, 267 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 9d653da7a7e..c71b7b33a08 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -25,7 +25,6 @@ from .const import ( DEFAULT_AREA_ID, DEFAULT_NAME, DOMAIN, - LOGGER, YALE_BASE_ERRORS, ) @@ -52,6 +51,18 @@ OPTIONS_SCHEMA = vol.Schema( ) +def validate_credentials(username: str, password: str) -> dict[str, Any]: + """Validate credentials.""" + errors: dict[str, str] = {} + try: + YaleSmartAlarmClient(username, password) + except AuthenticationError: + errors = {"base": "invalid_auth"} + except YALE_BASE_ERRORS: + errors = {"base": "cannot_connect"} + return errors + + class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yale integration.""" @@ -73,24 +84,16 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: reauth_entry = self._get_reauth_entry() username = reauth_entry.data[CONF_USERNAME] password = user_input[CONF_PASSWORD] - try: - await self.hass.async_add_executor_job( - YaleSmartAlarmClient, username, password - ) - except AuthenticationError as error: - LOGGER.error("Authentication failed. Check credentials %s", error) - errors = {"base": "invalid_auth"} - except YALE_BASE_ERRORS as error: - LOGGER.error("Connection to API failed %s", error) - errors = {"base": "cannot_connect"} - + errors = await self.hass.async_add_executor_job( + validate_credentials, username, password + ) if not errors: return self.async_update_reload_and_abort( reauth_entry, @@ -103,11 +106,42 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + errors: dict[str, str] = {} + + if user_input is not None: + reconfigure_entry = self._get_reconfigure_entry() + username = user_input[CONF_USERNAME] + + errors = await self.hass.async_add_executor_job( + validate_credentials, username, user_input[CONF_PASSWORD] + ) + if ( + username != reconfigure_entry.unique_id + and await self.async_set_unique_id(username) + ): + errors["base"] = "unique_id_exists" + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + unique_id=username, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: username = user_input[CONF_USERNAME] @@ -115,17 +149,9 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): name = DEFAULT_NAME area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) - try: - await self.hass.async_add_executor_job( - YaleSmartAlarmClient, username, password - ) - except AuthenticationError as error: - LOGGER.error("Authentication failed. Check credentials %s", error) - errors = {"base": "invalid_auth"} - except YALE_BASE_ERRORS as error: - LOGGER.error("Connection to API failed %s", error) - errors = {"base": "cannot_connect"} - + errors = await self.hass.async_add_executor_job( + validate_credentials, username, password + ) if not errors: await self.async_set_unique_id(username) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index cc837d7b7d7..7f940e1139e 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -2,11 +2,13 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unique_id_exists": "Another config entry with this username already exist" }, "step": { "user": { @@ -21,6 +23,13 @@ "data": { "password": "[%key:common::config_flow::data::password%]" } + }, + "reconfigure": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + } } } }, diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py index e325e259806..e5b59f79463 100644 --- a/tests/components/yale_smart_alarm/test_config_flow.py +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -239,6 +239,211 @@ async def test_reauth_flow_error( } +async def test_reconfigure(hass: HomeAssistant) -> None: + """Test reconfigure config flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + "area_id": "2", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "2", + } + + +async def test_reconfigure_username_exist(hass: HomeAssistant) -> None: + """Test reconfigure config flow abort other username already exist.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + entry2 = MockConfigEntry( + domain=DOMAIN, + unique_id="other-username", + data={ + "username": "other-username", + "password": "test-password", + "name": "Yale Smart Alarm 2", + "area_id": "1", + }, + version=2, + ) + entry2.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "other-username", + "password": "test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unique_id_exists"} + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "other-new-username", + "password": "test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "other-new-username", + "name": "Yale Smart Alarm", + "password": "test-password", + "area_id": "1", + } + + +@pytest.mark.parametrize( + ("sideeffect", "p_error"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (UnknownError, "cannot_connect"), + ], +) +async def test_reconfigure_flow_error( + hass: HomeAssistant, sideeffect: Exception, p_error: str +) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + version=2, + ) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=sideeffect, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "update-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["step_id"] == "reconfigure" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": p_error} + + with ( + patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + return_value="", + ), + patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert entry.data == { + "username": "test-username", + "name": "Yale Smart Alarm", + "password": "new-test-password", + "area_id": "1", + } + + async def test_options_flow(hass: HomeAssistant) -> None: """Test options config flow.""" entry = MockConfigEntry( From 8889464e04174504e4ab9b846a2d663b6335f03c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 11:09:10 +0100 Subject: [PATCH 164/511] Validate go2rtc server version (#129810) --- homeassistant/components/go2rtc/__init__.py | 14 +++- homeassistant/components/go2rtc/server.py | 6 +- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_init.py | 85 +++++++++++++++++++-- tests/components/go2rtc/test_server.py | 3 +- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 5be1dbc1a48..2bcdaddf739 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -5,7 +5,7 @@ import shutil from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Go2RtcRestClient -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( Go2RtcWsClient, ReceiveMessages, @@ -114,7 +114,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server = Server( hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) ) - await server.start() + try: + await server.start() + except Exception: # noqa: BLE001 + _LOGGER.warning("Could not start go2rtc server", exc_info=True) + return False async def on_stop(event: Event) -> None: await server.stop() @@ -143,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Validate the server URL try: client = Go2RtcRestClient(async_get_clientsession(hass), url) - await client.streams.list() + await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( @@ -151,6 +155,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False + except Go2RtcVersionError as err: + raise ConfigEntryNotReady( + f"The go2rtc server version is not supported, {err}" + ) from err except Exception as err: # noqa: BLE001 _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index b2aa19d5275..eff067416b3 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -112,6 +112,10 @@ class Server: await self._stop() raise Go2RTCServerStartError from err + # Check the server version + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + await client.validate_server_version() + async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" assert process.stdout is not None @@ -174,7 +178,7 @@ class Server: _LOGGER.debug("Monitoring go2rtc API") try: while True: - await client.streams.list() + await client.validate_server_version() await asyncio.sleep(10) except Exception as err: _LOGGER.debug("go2rtc API did not reply", exc_info=True) diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 87c68989fd2..42b363b2324 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -23,6 +23,7 @@ def rest_client() -> Generator[AsyncMock]: client = mock_client.return_value client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} + client.validate_server_version = AsyncMock() client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 847de248aaf..21d4d0a047e 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.models import Producer from go2rtc_client.ws import ( ReceiveMessages, @@ -494,6 +494,8 @@ ERR_CONNECT = "Could not connect to go2rtc instance" ERR_CONNECT_RETRY = ( "Could not connect to go2rtc instance on http://localhost:1984/; Retrying" ) +ERR_START_SERVER = "Could not start go2rtc server" +ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported" _INVALID_CONFIG = "Invalid config for 'go2rtc': " ERR_INVALID_URL = _INVALID_CONFIG + "invalid url" ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE @@ -526,8 +528,10 @@ async def test_non_user_setup_with_error( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ ({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), ( {DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}}, @@ -559,8 +563,6 @@ async def test_setup_with_setup_error( @pytest.mark.parametrize( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ - ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), - ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), ({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT), ], ) @@ -584,7 +586,7 @@ async def test_setup_with_setup_entry_error( assert expected_log_message in caplog.text -@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984/"}}]) @pytest.mark.parametrize( ("cause", "expected_config_entry_state", "expected_log_message"), [ @@ -598,7 +600,7 @@ async def test_setup_with_setup_entry_error( @pytest.mark.usefixtures( "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" ) -async def test_setup_with_retryable_setup_entry_error( +async def test_setup_with_retryable_setup_entry_error_custom_server( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, rest_client: AsyncMock, @@ -610,7 +612,78 @@ async def test_setup_with_retryable_setup_entry_error( """Test setup integration entry fails.""" go2rtc_error = Go2RtcClientError() go2rtc_error.__cause__ = cause - rest_client.streams.list.side_effect = go2rtc_error + rest_client.validate_server_version.side_effect = go2rtc_error + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("cause", "expected_config_entry_state", "expected_log_message"), + [ + (ClientConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (ServerConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (None, ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (Exception(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_retryable_setup_entry_error_default_server( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + has_go2rtc_entry: bool, + config: ConfigType, + cause: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + go2rtc_error = Go2RtcClientError() + go2rtc_error.__cause__ = cause + rest_client.validate_server_version.side_effect = go2rtc_error + assert not await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == has_go2rtc_entry + for config_entry in config_entries: + assert config_entry.state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("go2rtc_error", "expected_config_entry_state", "expected_log_message"), + [ + ( + Go2RtcVersionError("1.9.4", "1.9.5", "2.0.0"), + ConfigEntryState.SETUP_RETRY, + ERR_UNSUPPORTED_VERSION, + ), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_version_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + config: ConfigType, + go2rtc_error: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + rest_client.validate_server_version.side_effect = [None, go2rtc_error] assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) config_entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 1410fbeb6c3..fedf155baf5 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -47,6 +47,7 @@ def mock_tempfile() -> Generator[Mock]: ) async def test_server_run_success( mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, @@ -95,7 +96,7 @@ webrtc: @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( - mock_create_subprocess: MagicMock, server: Server + mock_create_subprocess: MagicMock, rest_client: AsyncMock, server: Server ) -> None: """Test server run where the process takes too long to terminate.""" # Start server thread From 72bcc6702f214752b36914831aadd09edb44d363 Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Tue, 5 Nov 2024 11:14:53 +0100 Subject: [PATCH 165/511] Add child lock for tplink thermostats (#129649) --- homeassistant/components/tplink/icons.json | 3 ++ homeassistant/components/tplink/strings.json | 3 ++ homeassistant/components/tplink/switch.py | 3 ++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 75d15373202..3a83349c613 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -68,6 +68,9 @@ "state": { "on": "mdi:sleep" } + }, + "child_lock": { + "default": "mdi:account-lock" } }, "sensor": { diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 66380434d32..e15f3cfba03 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -190,6 +190,9 @@ }, "fan_sleep_mode": { "name": "Fan sleep mode" + }, + "child_lock": { + "name": "Child lock" } }, "number": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 6d3e21d88c5..9ef58484ea8 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -48,6 +48,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="fan_sleep_mode", ), + TPLinkSwitchEntityDescription( + key="child_lock", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index d3526adec8a..f0cfcc92ea1 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -34,6 +34,11 @@ "type": "Switch", "category": "Config" }, + "child_lock": { + "value": true, + "type": "Switch", + "category": "Config" + }, "current_consumption": { "value": 5.23, "type": "Sensor", diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index 4354ea1905a..f6e9ad51410 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -173,6 +173,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_child_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_child_lock', + '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': 'Child lock', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': '123456789ABCDEFGH_child_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Child lock', + }), + 'context': , + 'entity_id': 'switch.my_device_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_fan_sleep_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 5fd1e23255e470995712b105b157ac2f92ef05a9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 11:52:11 +0100 Subject: [PATCH 166/511] Bump pynecil to 0.2.1 (#129843) --- homeassistant/components/iron_os/coordinator.py | 9 ++++----- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 32b6da13b57..699f5a01704 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -37,15 +37,14 @@ class IronOSLiveDataCoordinator(DataUpdateCoordinator[LiveDataResponse]): ) self.device = device - async def _async_setup(self) -> None: - """Set up the coordinator.""" - - self.device_info = await self.device.get_device_info() - async def _async_update_data(self) -> LiveDataResponse: """Fetch data from Device.""" try: + # device info is cached and won't be refetched on every + # coordinator refresh, only after the device has disconnected + # the device info is refetched + self.device_info = await self.device.get_device_info() return await self.device.get_live_data() except CommunicationError as e: diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 9fcb84e0f6a..4ec08a43b61 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/iron_os", "iot_class": "local_polling", "loggers": ["pynecil", "aiogithubapi"], - "requirements": ["pynecil==0.2.0", "aiogithubapi==24.6.0"] + "requirements": ["pynecil==0.2.1", "aiogithubapi==24.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5f3fab24335..484d6341a9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2084,7 +2084,7 @@ pymsteams==0.1.12 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.0 +pynecil==0.2.1 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e83f381730..656e3b1b63c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1680,7 +1680,7 @@ pymonoprice==0.4 pymysensors==0.24.0 # homeassistant.components.iron_os -pynecil==0.2.0 +pynecil==0.2.1 # homeassistant.components.netgear pynetgear==0.10.10 From 5eadfcc52439b352d84bb16856c4f6118e6c6a80 Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Tue, 5 Nov 2024 16:22:38 +0530 Subject: [PATCH 167/511] Adding new on values for Tuya Presence Detection Sensor (#129801) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a8c9157caa7..934f03336aa 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value="presence", + on_value={"presence", "small_move", "large_move"}, ), ), # Formaldehyde Detector From ae37c8cc7ac501166787e35f4486fa0da8f4db94 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 5 Nov 2024 05:53:01 -0500 Subject: [PATCH 168/511] Add repair for add-on boot fail (#129847) --- homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/issues.py | 2 + homeassistant/components/hassio/repairs.py | 12 ++- homeassistant/components/hassio/strings.json | 17 ++++ tests/components/hassio/test_repairs.py | 101 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 6e6c9006fca..b337017147b 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -103,6 +103,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" +ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 9c2152489d6..944bc99a6b9 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -36,6 +36,7 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, @@ -94,6 +95,7 @@ UNHEALTHY_REASONS = { # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_BOOT_FAIL, "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 082dbe38bee..0fcd96ace38 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -14,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from . import get_addons_info, get_issues_info from .const import ( + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, @@ -181,8 +182,8 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): return placeholders -class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): - """Handler for detached addon issue fixing flows.""" +class AddonIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for addon issue fixing flows.""" @property def description_placeholders(self) -> dict[str, str] | None: @@ -210,7 +211,10 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(issue_id) - if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: - return DetachedAddonIssueRepairFlow(issue_id) + if issue and issue.key in { + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_BOOT_FAIL, + }: + return AddonIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 8688934ee3d..09ed45bd5bc 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_addon_boot_fail": { + "title": "Add-on failed to start at boot", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", + "menu_options": { + "addon_execute_start": "Start", + "addon_disable_boot": "Disable" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, "issue_addon_detached_addon_missing": { "title": "Missing repository for an installed add-on", "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 907529ec9c4..f3ccb5948f1 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -868,3 +868,104 @@ async def test_supervisor_issue_detached_addon_removed( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_addon_boot_fail( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "boot_fail", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_start", + "context": "addon", + "reference": "test", + }, + { + "uuid": "1236", + "type": "disable_boot", + "context": "addon", + "reference": "test", + }, + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["addon_execute_start", "addon_execute_start"], + ["addon_disable_boot", "addon_disable_boot"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["addon_execute_start", "addon_disable_boot"], + "description_placeholders": { + "reference": "test", + "addon": "test", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "addon_execute_start"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) From 27dc82d7d033344d5c86fa3c1a6129d9a163847c Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:57:00 -0800 Subject: [PATCH 169/511] Add device model ID if provided by NUT (#124189) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index b4e53c1380c..169dbbbff5d 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -130,6 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: name=data.name.title(), manufacturer=data.device_info.manufacturer, model=data.device_info.model, + model_id=data.device_info.model_id, sw_version=data.device_info.firmware, serial_number=data.device_info.serial, suggested_area=data.device_info.device_location, @@ -210,6 +211,7 @@ class NUTDeviceInfo: manufacturer: str | None = None model: str | None = None + model_id: str | None = None firmware: str | None = None serial: str | None = None device_location: str | None = None @@ -271,10 +273,13 @@ class PyNUTData: manufacturer = _manufacturer_from_status(self._status) model = _model_from_status(self._status) + model_id: str | None = self._status.get("device.part") firmware = _firmware_from_status(self._status) serial = _serial_from_status(self._status) device_location: str | None = self._status.get("device.location") - return NUTDeviceInfo(manufacturer, model, firmware, serial, device_location) + return NUTDeviceInfo( + manufacturer, model, model_id, firmware, serial, device_location + ) async def _async_get_status(self) -> dict[str, str]: """Get the ups status from NUT.""" From 79901cede985830ab053c8945e253d7b39c61f8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:02:33 +0100 Subject: [PATCH 170/511] Drop initialize_options helper from OptionsFlow (#129870) --- homeassistant/config_entries.py | 6 +----- homeassistant/helpers/schema_config_entry_flow.py | 4 +++- tests/helpers/test_schema_config_entry_flow.py | 4 ++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0682d46924d..6a95707dcda 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3127,10 +3127,6 @@ class OptionsFlow(ConfigEntryBaseFlow): ) self._config_entry = value - def initialize_options(self, config_entry: ConfigEntry) -> None: - """Initialize the options to a mutable copy of the config entry options.""" - self._options = deepcopy(dict(config_entry.options)) - @property def options(self) -> dict[str, Any]: """Return a mutable copy of the config entry options. @@ -3139,7 +3135,7 @@ class OptionsFlow(ConfigEntryBaseFlow): can only be referenced after initialisation. """ if not hasattr(self, "_options"): - self.initialize_options(self.config_entry) + self._options = deepcopy(dict(self.config_entry.options)) return self._options @options.setter diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 58a44f9682d..b956a58398a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -421,7 +421,9 @@ class SchemaOptionsFlowHandler(OptionsFlow): options, which is the union of stored options and user input from the options flow steps. """ - self.initialize_options(config_entry) + # Although `self.options` is most likely unused, it is safer to keep both + # `self.options` and `self._common_handler.options` referring to the same object + self._options = copy.deepcopy(dict(config_entry.options)) self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 877e3762d3b..e67525253bc 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -648,6 +648,10 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: options_handler = hass.config_entries.options._progress[result["flow_id"]] assert options_handler._common_handler.flow_state == {"idx": None} + # Ensure that self.options and self._common_handler.options refer to the + # same mutable copy of the options + assert options_handler.options is options_handler._common_handler.options + # In step 1, flow state is updated with user input result = await hass.config_entries.options.async_configure( result["flow_id"], {"option1": "blublu"} From eafed2b86c030c68250e9f74fc1e2d32e90b68cf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 12:29:51 +0100 Subject: [PATCH 171/511] Append a 1 to all go2rtc ports to avoid port conflicts (#129881) --- homeassistant/components/go2rtc/__init__.py | 4 ++-- homeassistant/components/go2rtc/const.py | 3 ++- homeassistant/components/go2rtc/server.py | 17 +++++++++++------ tests/components/go2rtc/test_server.py | 5 +++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 2bcdaddf739..9ffe9e25f78 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL from .server import Server _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = DEFAULT_URL + url = HA_MANAGED_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index cb03e224e52..d33ae3e3897 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,4 +4,5 @@ DOMAIN = "go2rtc" CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." -DEFAULT_URL = "http://localhost:1984/" +HA_MANAGED_API_PORT = 11984 +HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index eff067416b3..6384cc5d49b 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -26,13 +26,14 @@ _RESPAWN_COOLDOWN = 1 # - Clear default ice servers _GO2RTC_CONFIG_FORMAT = r""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:{api_port}" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """ @@ -52,7 +53,11 @@ def _create_temp_file(api_ip: str) -> str: # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: - file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) + file.write( + _GO2RTC_CONFIG_FORMAT.format( + api_ip=api_ip, api_port=HA_MANAGED_API_PORT + ).encode() + ) return file.name @@ -113,7 +118,7 @@ class Server: raise Go2RTCServerStartError from err # Check the server version - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) await client.validate_server_version() async def _log_output(self, process: asyncio.subprocess.Process) -> None: @@ -173,7 +178,7 @@ class Server: async def _monitor_api(self) -> None: """Raise if the go2rtc process terminates.""" - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) _LOGGER.debug("Monitoring go2rtc API") try: diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index fedf155baf5..5b430d66641 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -71,13 +71,14 @@ async def test_server_run_success( mock_tempfile.write.assert_called_once_with( f""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:11984" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """.encode() ) From 15bf652f37fe492ed067682c159742a90a0f3316 Mon Sep 17 00:00:00 2001 From: Karl Beecken Date: Tue, 5 Nov 2024 12:30:48 +0100 Subject: [PATCH 172/511] Bump python-tado to 0.17.7 (#129842) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tado/fixtures/home.json | 47 +++++++++++++++++++++ tests/components/tado/util.py | 5 +++ 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/components/tado/fixtures/home.json diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index b0c00c888b7..652d51f0261 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.17.6"] + "requirements": ["python-tado==0.17.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 484d6341a9a..89114ef7724 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2405,7 +2405,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.6 +python-tado==0.17.7 # homeassistant.components.technove python-technove==1.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 656e3b1b63c..0a763845ded 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1926,7 +1926,7 @@ python-smarttub==0.0.36 python-songpal==0.16.2 # homeassistant.components.tado -python-tado==0.17.6 +python-tado==0.17.7 # homeassistant.components.technove python-technove==1.3.1 diff --git a/tests/components/tado/fixtures/home.json b/tests/components/tado/fixtures/home.json new file mode 100644 index 00000000000..3431c1c2471 --- /dev/null +++ b/tests/components/tado/fixtures/home.json @@ -0,0 +1,47 @@ +{ + "id": 1, + "name": "My Home", + "dateTimeZone": "Europe/Berlin", + "dateCreated": "2019-03-24T16:16:19.541Z", + "temperatureUnit": "CELSIUS", + "partner": null, + "simpleSmartScheduleEnabled": true, + "awayRadiusInMeters": 100.0, + "installationCompleted": true, + "incidentDetection": { "supported": true, "enabled": true }, + "generation": "PRE_LINE_X", + "zonesCount": 7, + "language": "de-DE", + "skills": ["AUTO_ASSIST"], + "christmasModeEnabled": true, + "showAutoAssistReminders": true, + "contactDetails": { + "name": "Max Mustermann", + "email": "max@example.com", + "phone": "+493023125431" + }, + "address": { + "addressLine1": "Musterstrasse 123", + "addressLine2": null, + "zipCode": "12345", + "city": "Berlin", + "state": null, + "country": "DEU" + }, + "geolocation": { "latitude": 52.0, "longitude": 13.0 }, + "consentGrantSkippable": true, + "enabledFeatures": [ + "EIQ_SETTINGS_AS_WEBVIEW", + "HIDE_BOILER_REPAIR_SERVICE", + "INTERCOM_ENABLED", + "MORE_AS_WEBVIEW", + "OWD_SETTINGS_AS_WEBVIEW", + "SETTINGS_OVERVIEW_AS_WEBVIEW" + ], + "isAirComfortEligible": true, + "isBalanceAcEligible": false, + "isEnergyIqEligible": true, + "isHeatSourceInstalled": false, + "isHeatPumpInstalled": false, + "supportsFlowTemperatureOptimization": false +} diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index de4fd515e5a..a76858ab98e 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,6 +20,7 @@ async def async_init_integration( mobile_devices_fixture = "tado/mobile_devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" + home_fixture = "tado/home.json" home_state_fixture = "tado/home_state.json" zones_fixture = "tado/zones.json" zone_states_fixture = "tado/zone_states.json" @@ -65,6 +66,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/me", text=load_fixture(me_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/", + text=load_fixture(home_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=load_fixture(weather_fixture), From 4c86102dafad5cd78006a05981da48cc012d92e7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Nov 2024 13:39:45 +0100 Subject: [PATCH 173/511] Add Reolink PTZ tilt position sensor (#129837) --- homeassistant/components/reolink/icons.json | 5 ++++- homeassistant/components/reolink/sensor.py | 11 ++++++++++- homeassistant/components/reolink/strings.json | 3 +++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 5815e165607..7f4a15ffe21 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -261,7 +261,10 @@ }, "sensor": { "ptz_pan_position": { - "default": "mdi:pan" + "default": "mdi:pan-horizontal" + }, + "ptz_tilt_position": { + "default": "mdi:pan-vertical" }, "battery_temperature": { "default": "mdi:thermometer" diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index c2fc815235e..80e58c3d5c2 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -58,7 +58,16 @@ SENSORS = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, value=lambda api, ch: api.ptz_pan_position(ch), - supported=lambda api, ch: api.supported(ch, "ptz_position"), + supported=lambda api, ch: api.supported(ch, "ptz_pan_position"), + ), + ReolinkSensorEntityDescription( + key="ptz_tilt_position", + cmd_key="GetPtzCurPos", + translation_key="ptz_tilt_position", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda api, ch: api.ptz_tilt_position(ch), + supported=lambda api, ch: api.supported(ch, "ptz_tilt_position"), ), ReolinkSensorEntityDescription( key="battery_percent", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 67fd5329e14..fbc88ed1b50 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -649,6 +649,9 @@ "ptz_pan_position": { "name": "PTZ pan position" }, + "ptz_tilt_position": { + "name": "PTZ tilt position" + }, "battery_temperature": { "name": "Battery temperature" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 33e9c78c550..71c5397fbd1 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -118,8 +118,8 @@ 'null': 2, }), 'GetPtzCurPos': dict({ - '0': 1, - 'null': 1, + '0': 2, + 'null': 2, }), 'GetPtzGuard': dict({ '0': 2, From 3a667bce8cb33dc609c4affa51acc87e26b351c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 14:05:04 +0100 Subject: [PATCH 174/511] Log go2rtc output with warning level on error (#129882) --- homeassistant/components/go2rtc/server.py | 13 ++++ tests/components/go2rtc/test_server.py | 89 +++++++++++++++++++---- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 6384cc5d49b..9be02d9a5d6 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,6 +1,7 @@ """Go2rtc server.""" import asyncio +from collections import deque from contextlib import suppress import logging from tempfile import NamedTemporaryFile @@ -18,6 +19,7 @@ _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_LOG_BUFFER_SIZE = 512 _RESPAWN_COOLDOWN = 1 # Default configuration for HA @@ -70,6 +72,7 @@ class Server: """Initialize the server.""" self._hass = hass self._binary = binary + self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() self._api_ip = _LOCALHOST_IP @@ -114,6 +117,7 @@ class Server: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) + self._log_server_output(logging.WARNING) await self._stop() raise Go2RTCServerStartError from err @@ -127,10 +131,17 @@ class Server: async for line in process.stdout: msg = line[:-1].decode().strip() + self._log_buffer.append(msg) _LOGGER.debug(msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + def _log_server_output(self, loglevel: int) -> None: + """Log captured process output, then clear the log buffer.""" + for line in list(self._log_buffer): # Copy the deque to avoid mutation error + _LOGGER.log(loglevel, line) + self._log_buffer.clear() + async def _watchdog(self) -> None: """Keep respawning go2rtc servers. @@ -158,6 +169,8 @@ class Server: await asyncio.sleep(_RESPAWN_COOLDOWN) try: await self._stop() + _LOGGER.warning("Go2rtc unexpectedly stopped, server log:") + self._log_server_output(logging.WARNING) _LOGGER.debug("Spawning new go2rtc server") with suppress(Go2RTCServerStartError): await self._start() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 5b430d66641..cda05fc4f2b 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -38,6 +38,42 @@ def mock_tempfile() -> Generator[Mock]: yield file +def _assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, + expect_logged: bool, +) -> None: + """Check server stdout was logged.""" + for entry in server_stdout: + assert ( + ( + "homeassistant.components.go2rtc.server", + loglevel, + entry, + ) + in caplog.record_tuples + ) is expect_logged + + +def assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, True) + + +def assert_server_output_not_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, False) + + @pytest.mark.parametrize( ("enable_ui", "api_ip"), [ @@ -83,17 +119,15 @@ webrtc: """.encode() ) - # Check that server read the log lines - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) await server.stop() mock_create_subprocess.return_value.terminate.assert_called_once() + # Verify go2rtc binary stdout was not logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( @@ -140,13 +174,9 @@ async def test_server_failed_to_start( ): await server.start() - # Verify go2rtc binary stdout was logged - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug and warning level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) + assert_server_output_logged(server_stdout, caplog, logging.WARNING) assert ( "homeassistant.components.go2rtc.server", @@ -169,8 +199,10 @@ async def test_server_failed_to_start( async def test_server_restart_process_exit( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted when it exits.""" evt = asyncio.Event() @@ -188,10 +220,16 @@ async def test_server_restart_process_exit( await hass.async_block_till_done() mock_create_subprocess.assert_not_awaited() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + evt.set() await asyncio.sleep(0.1) mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -199,8 +237,10 @@ async def test_server_restart_process_exit( async def test_server_restart_process_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] @@ -209,10 +249,16 @@ async def test_server_restart_process_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -220,8 +266,10 @@ async def test_server_restart_process_error( async def test_server_restart_api_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" rest_client.streams.list.side_effect = Exception @@ -230,10 +278,16 @@ async def test_server_restart_api_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -241,6 +295,7 @@ async def test_server_restart_api_error( async def test_server_restart_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, caplog: pytest.LogCaptureFixture, @@ -253,10 +308,16 @@ async def test_server_restart_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + assert "Unexpected error when restarting go2rtc server" in caplog.text await server.stop() From 8abbc4abbc439d0c4f0f16664067a08b7df07da1 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:13:48 +0100 Subject: [PATCH 175/511] Bump bimmer_connected to 0.16.4 (#129838) --- .../bmw_connected_drive/config_flow.py | 14 +++++- .../bmw_connected_drive/coordinator.py | 13 +++++- .../bmw_connected_drive/manifest.json | 2 +- .../bmw_connected_drive/strings.json | 6 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/test_config_flow.py | 35 ++++++++++++++- .../bmw_connected_drive/test_coordinator.py | 43 ++++++++++++++++++- 8 files changed, 109 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index cd43325f129..409bfdca6f1 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -7,7 +7,11 @@ from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError import voluptuous as vol @@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: await auth.login() + except MyBMWCaptchaMissingError as ex: + raise MissingCaptcha from ex except MyBMWAuthError as ex: raise InvalidAuth from ex except (MyBMWAPIError, RequestError) as ex: @@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), CONF_GCID: info.get(CONF_GCID), } + except MissingCaptcha: + errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 992e7dea6b2..d38b7ffacc2 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,12 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError from homeassistant.config_entries import ConfigEntry @@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() + except MyBMWCaptchaMissingError as err: + # If a captcha is required (user/password login flow), always trigger the reauth flow + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_captcha", + ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 6bc9027ac19..584eb1eebb5 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.3"] + "requirements": ["bimmer-connected[china]==0.16.4"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index fed71f85e35..0e7a4a32ef4 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -11,7 +11,8 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_captcha": "Captcha validation missing" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", @@ -200,6 +201,9 @@ "exceptions": { "invalid_poi": { "message": "Invalid data for point of interest: {poi_exception}" + }, + "missing_captcha": { + "message": "Login requires captcha validation" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 89114ef7724..6bd9afc33c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -576,7 +576,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a763845ded..f617bab52c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -510,7 +510,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9d4d15703f2..f57f1a304ac 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -4,8 +4,13 @@ from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError +import pytest from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN @@ -311,3 +316,31 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "account_mismatch" assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + TEST_REGION = "north_america" + + # Start flow and open form + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["errors"]["base"] == "missing_captcha" diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index b0f507bbfc2..774a85eb6da 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,13 +1,19 @@ """Test BMW coordinator.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import patch -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -122,3 +128,38 @@ async def test_init_reauth( f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the reauth form.""" + TEST_REGION = "north_america" + + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REGION] = TEST_REGION + config_entry = MockConfigEntry(**config_entry_fixure) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = config_entry.runtime_data.coordinator + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=10, seconds=1)) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + assert coordinator.last_exception.translation_key == "missing_captcha" From 4729b19dc6a90ca96bd67fe65fc1b01ca65a7df2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 14:44:37 +0100 Subject: [PATCH 176/511] Skip adding providers if the camera has native WebRTC (#129808) * Skip adding providers if the camera has native WebRTC * Update homeassistant/components/camera/__init__.py Co-authored-by: Martin Hjelmare * Implement suggestion * Add tests * Shorten test name * Fix test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/__init__.py | 40 ++++++++------ tests/components/camera/common.py | 50 +++++++++++++++++ tests/components/camera/conftest.py | 49 ++++++++++++++--- tests/components/camera/test_init.py | 20 ++++++- tests/components/camera/test_webrtc.py | 60 ++------------------- 5 files changed, 136 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 47d8b9dfbd0..b600eae02c7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -484,9 +484,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self._create_stream_lock: asyncio.Lock | None = None self._webrtc_provider: CameraWebRTCProvider | None = None self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None - self._webrtc_sync_offer = ( + self._supports_native_sync_webrtc = ( type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer ) + self._supports_native_async_webrtc = ( + type(self).async_handle_async_webrtc_offer + != Camera.async_handle_async_webrtc_offer + ) @cached_property def entity_picture(self) -> str: @@ -623,7 +627,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Integrations can override with a native WebRTC implementation. """ - if self._webrtc_sync_offer: + if self._supports_native_sync_webrtc: try: answer = await self.async_handle_web_rtc_offer(offer_sdp) except ValueError as ex: @@ -788,18 +792,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): providers or inputs to the state attributes change. """ old_provider = self._webrtc_provider - new_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_provider - ) - old_legacy_provider = self._legacy_webrtc_provider + new_provider = None new_legacy_provider = None - if new_provider is None: - # Only add the legacy provider if the new provider is not available - new_legacy_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_legacy_provider + + # Skip all providers if the camera has a native WebRTC implementation + if not ( + self._supports_native_sync_webrtc or self._supports_native_async_webrtc + ): + # Camera doesn't have a native WebRTC implementation + new_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_provider ) + if new_provider is None: + # Only add the legacy provider if the new provider is not available + new_legacy_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_legacy_provider + ) + if old_provider != new_provider or old_legacy_provider != new_legacy_provider: self._webrtc_provider = new_provider self._legacy_webrtc_provider = new_legacy_provider @@ -827,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - if not self._webrtc_sync_offer: + if not self._supports_native_sync_webrtc: # Until 2024.11, the frontend was not resolving any ice servers # The async approach was added 2024.11 and new integrations need to use it ice_servers = [ @@ -867,12 +878,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera capabilities.""" frontend_stream_types = set() if CameraEntityFeature.STREAM in self.supported_features_compat: - if ( - type(self).async_handle_web_rtc_offer - != Camera.async_handle_web_rtc_offer - or type(self).async_handle_async_webrtc_offer - != Camera.async_handle_async_webrtc_offer - ): + if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) else: diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index f7dcf46db01..569756c2640 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -6,6 +6,16 @@ components. Instead call the service directly. from unittest.mock import Mock +from webrtc_models import RTCIceCandidate + +from homeassistant.components.camera import ( + Camera, + CameraWebRTCProvider, + WebRTCAnswer, + WebRTCSendMessage, +) +from homeassistant.core import callback + EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -23,3 +33,43 @@ def mock_turbo_jpeg( mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG return mocked_turbo_jpeg + + +class SomeTestProvider(CameraWebRTCProvider): + """Test provider.""" + + def __init__(self) -> None: + """Initialize the provider.""" + self._is_supported = True + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return "some_test" + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Determine if the provider supports the stream source.""" + return self._is_supported + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback. + + Return value determines if the offer was handled successfully. + """ + send_message(WebRTCAnswer(answer="answer")) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index a88cd898e33..d6343959d41 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera.const import StreamType @@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -155,16 +156,15 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture -async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: - """Initialize a test camera with native sync WebRTC support.""" +async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: + """Initialize a test WebRTC cameras.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was # overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" + class BaseCamera(camera.Camera): + """Base Camera.""" - _attr_name = "Test" _attr_supported_features: camera.CameraEntityFeature = ( camera.CameraEntityFeature.STREAM ) @@ -173,9 +173,30 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: async def stream_source(self) -> str | None: return STREAM_SOURCE + class SyncCamera(BaseCamera): + """Mock Camera with native sync WebRTC support.""" + + _attr_name = "Sync" + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: return WEBRTC_ANSWER + class AsyncCamera(BaseCamera): + """Mock Camera with native async WebRTC support.""" + + _attr_name = "Async" + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + send_message(WebRTCAnswer(WEBRTC_ANSWER)) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle a WebRTC candidate.""" + # Do nothing + domain = "test" entry = MockConfigEntry(domain=domain) @@ -208,10 +229,24 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: ), ) setup_test_component_platform( - hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True ) mock_platform(hass, f"{domain}.config_flow", Mock()) with mock_config_flow(domain, ConfigFlow): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + +@pytest.fixture +async def register_test_provider( + hass: HomeAssistant, +) -> AsyncGenerator[SomeTestProvider]: + """Add WebRTC test provider.""" + await async_setup_component(hass, "camera", {}) + + provider = SomeTestProvider() + unsub = camera.async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + yield provider + unsub() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0a173065564..621ac8b7fb3 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -979,7 +979,7 @@ async def test_camera_capabilities_hls( ) -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -987,5 +987,21 @@ async def test_camera_capabilities_webrtc( """Test WebRTC camera capabilities.""" await _test_capabilities( - hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) + + +@pytest.mark.parametrize( + ("entity_id", "expect_native_async_webrtc"), + [("camera.sync", False), ("camera.async", True)], +) +@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider") +async def test_webrtc_provider_not_added_for_native_webrtc( + hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool +) -> None: + """Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support.""" + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj + assert camera_obj._webrtc_provider is None + assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc + assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 2970a41408c..f726eb29673 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -34,7 +34,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -51,46 +51,6 @@ HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u" TEST_INTEGRATION_DOMAIN = "test" -class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - def __init__(self) -> None: - """Initialize the provider.""" - self._is_supported = True - - @property - def domain(self) -> str: - """Return the integration domain of the provider.""" - return "some_test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return self._is_supported - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer via the provided callback. - - Return value determines if the offer was handled successfully. - """ - send_message(WebRTCAnswer(answer="answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate - ) -> None: - """Handle the WebRTC candidate.""" - - @callback - def async_close_session(self, session_id: str) -> None: - """Close the session.""" - - class Go2RTCProvider(SomeTestProvider): """go2rtc provider.""" @@ -179,20 +139,6 @@ async def init_test_integration( return test_camera -@pytest.fixture -async def register_test_provider( - hass: HomeAssistant, -) -> AsyncGenerator[SomeTestProvider]: - """Add WebRTC test provider.""" - await async_setup_component(hass, "camera", {}) - - provider = SomeTestProvider() - unsub = async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() - yield provider - unsub() - - @pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, @@ -393,7 +339,7 @@ async def test_ws_get_client_config( } -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config_sync_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -403,7 +349,7 @@ async def test_ws_get_client_config_sync_offer( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"} ) msg = await client.receive_json() From 6caa4baa007e160d673029c4d84eb0fb35980292 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 5 Nov 2024 14:58:25 +0100 Subject: [PATCH 177/511] Fix missing translation string in emoncms (#129859) --- homeassistant/components/emoncms/config_flow.py | 10 ++++++++-- homeassistant/components/emoncms/strings.json | 6 ++++++ tests/components/emoncms/test_config_flow.py | 11 +++-------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index fa684188713..e2e08217b3c 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -79,6 +79,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Initiate a flow via the UI.""" errors: dict[str, str] = {} + description_placeholders = {} if user_input is not None: self._async_abort_entries_match( @@ -91,7 +92,8 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] ) if not result[CONF_SUCCESS]: - errors["base"] = result[CONF_MESSAGE] + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} else: self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) self.url = user_input[CONF_URL] @@ -115,6 +117,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): user_input, ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_choose_feeds( @@ -177,6 +180,7 @@ class EmoncmsOptionsFlow(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} + description_placeholders = {} data = self.options if self.options else self.config_entry.data url = data[CONF_URL] api_key = data[CONF_API_KEY] @@ -184,7 +188,8 @@ class EmoncmsOptionsFlow(OptionsFlow): options: list = include_only_feeds result = await get_feed_list(self.hass, url, api_key) if not result[CONF_SUCCESS]: - errors["base"] = result[CONF_MESSAGE] + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} else: options = get_options(result[CONF_MESSAGE]) dropdown = {"options": options, "mode": "dropdown", "multiple": True} @@ -209,4 +214,5 @@ class EmoncmsOptionsFlow(OptionsFlow): } ), errors=errors, + description_placeholders=description_placeholders, ) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 4a700cc8981..e2b7602f6f2 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "api_error": "An error occured in the pyemoncms API : {details}" + }, "step": { "user": { "data": { @@ -19,6 +22,9 @@ } }, "options": { + "error": { + "api_error": "[%key:component::emoncms::config::error::api_error%]" + }, "step": { "init": { "data": { diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index b554466639e..43710967a01 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import AsyncMock -import pytest - from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL @@ -44,7 +42,7 @@ async def test_flow_import_failure( data=YAML, ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == EMONCMS_FAILURE["message"] + assert result["reason"] == "api_error" async def test_flow_import_already_configured( @@ -129,10 +127,6 @@ async def test_options_flow( assert config_entry.options == CONFIG_ENTRY -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.emoncms.options.error.failure"], -) async def test_options_flow_failure( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -144,6 +138,7 @@ async def test_options_flow_failure( await setup_integration(hass, config_entry) result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() - assert result["errors"]["base"] == "failure" + assert result["errors"]["base"] == "api_error" + assert result["description_placeholders"]["details"] == "failure" assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" From 69e3348cd79abc6b3ee86bb05edeff605fbc4a4e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 Nov 2024 08:01:45 -0600 Subject: [PATCH 178/511] Use different VAD thresholds for before and during voice command (#129848) * Use two VAD thresholds * Fix VoiceActivityTimeout class * Update homeassistant/components/assist_pipeline/audio_enhancer.py --------- Co-authored-by: Joost Lekkerkerker --- .../assist_pipeline/audio_enhancer.py | 16 ++-- .../components/assist_pipeline/pipeline.py | 10 ++- .../components/assist_pipeline/vad.py | 62 +++++++++----- tests/components/assist_pipeline/test_vad.py | 80 ++++++++++++------- 4 files changed, 108 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/assist_pipeline/audio_enhancer.py b/homeassistant/components/assist_pipeline/audio_enhancer.py index ff2b122187a..1fabc7790e7 100644 --- a/homeassistant/components/assist_pipeline/audio_enhancer.py +++ b/homeassistant/components/assist_pipeline/audio_enhancer.py @@ -22,8 +22,8 @@ class EnhancedAudioChunk: timestamp_ms: int """Timestamp relative to start of audio stream (milliseconds)""" - is_speech: bool | None - """True if audio chunk likely contains speech, False if not, None if unknown""" + speech_probability: float | None + """Probability that audio chunk contains speech (0-1), None if unknown""" class AudioEnhancer(ABC): @@ -70,27 +70,27 @@ class MicroVadSpeexEnhancer(AudioEnhancer): ) self.vad: MicroVad | None = None - self.threshold = 0.5 if self.is_vad_enabled: self.vad = MicroVad() - _LOGGER.debug("Initialized microVAD with threshold=%s", self.threshold) + _LOGGER.debug("Initialized microVAD") def enhance_chunk(self, audio: bytes, timestamp_ms: int) -> EnhancedAudioChunk: """Enhance 10ms chunk of PCM audio @ 16Khz with 16-bit mono samples.""" - is_speech: bool | None = None + speech_probability: float | None = None assert len(audio) == BYTES_PER_CHUNK if self.vad is not None: # Run VAD - speech_prob = self.vad.Process10ms(audio) - is_speech = speech_prob > self.threshold + speech_probability = self.vad.Process10ms(audio) if self.audio_processor is not None: # Run noise suppression and auto gain audio = self.audio_processor.Process10ms(audio).audio return EnhancedAudioChunk( - audio=audio, timestamp_ms=timestamp_ms, is_speech=is_speech + audio=audio, + timestamp_ms=timestamp_ms, + speech_probability=speech_probability, ) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a4255e37756..a55e23ae051 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -780,7 +780,9 @@ class PipelineRun: # speaking the voice command. audio_chunks_for_stt.extend( EnhancedAudioChunk( - audio=chunk_ts[0], timestamp_ms=chunk_ts[1], is_speech=False + audio=chunk_ts[0], + timestamp_ms=chunk_ts[1], + speech_probability=None, ) for chunk_ts in result.queued_audio ) @@ -827,7 +829,7 @@ class PipelineRun: if wake_word_vad is not None: chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate - if not wake_word_vad.process(chunk_seconds, chunk.is_speech): + if not wake_word_vad.process(chunk_seconds, chunk.speech_probability): raise WakeWordTimeoutError( code="wake-word-timeout", message="Wake word was not detected" ) @@ -955,7 +957,7 @@ class PipelineRun: if stt_vad is not None: chunk_seconds = (len(chunk.audio) // sample_width) / sample_rate - if not stt_vad.process(chunk_seconds, chunk.is_speech): + if not stt_vad.process(chunk_seconds, chunk.speech_probability): # Silence detected at the end of voice command self.process_event( PipelineEvent( @@ -1221,7 +1223,7 @@ class PipelineRun: yield EnhancedAudioChunk( audio=sub_chunk, timestamp_ms=timestamp_ms, - is_speech=None, # no VAD + speech_probability=None, # no VAD ) timestamp_ms += MS_PER_CHUNK diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 4782d14dee4..deae5b9b7b3 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -75,7 +75,7 @@ class AudioBuffer: class VoiceCommandSegmenter: """Segments an audio stream into voice commands.""" - speech_seconds: float = 0.3 + speech_seconds: float = 0.1 """Seconds of speech before voice command has started.""" command_seconds: float = 1.0 @@ -96,6 +96,12 @@ class VoiceCommandSegmenter: timed_out: bool = False """True a timeout occurred during voice command.""" + before_command_speech_threshold: float = 0.2 + """Probability threshold for speech before voice command.""" + + in_command_speech_threshold: float = 0.5 + """Probability threshold for speech during voice command.""" + _speech_seconds_left: float = 0.0 """Seconds left before considering voice command as started.""" @@ -124,7 +130,7 @@ class VoiceCommandSegmenter: self._reset_seconds_left = self.reset_seconds self.in_command = False - def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: """Process samples using external VAD. Returns False when command is done. @@ -142,7 +148,12 @@ class VoiceCommandSegmenter: self.timed_out = True return False + if speech_probability is None: + speech_probability = 0.0 + if not self.in_command: + # Before command + is_speech = speech_probability > self.before_command_speech_threshold if is_speech: self._reset_seconds_left = self.reset_seconds self._speech_seconds_left -= chunk_seconds @@ -160,24 +171,29 @@ class VoiceCommandSegmenter: if self._reset_seconds_left <= 0: self._speech_seconds_left = self.speech_seconds self._reset_seconds_left = self.reset_seconds - elif not is_speech: - # Silence in command - self._reset_seconds_left = self.reset_seconds - self._silence_seconds_left -= chunk_seconds - self._command_seconds_left -= chunk_seconds - if (self._silence_seconds_left <= 0) and (self._command_seconds_left <= 0): - # Command finished successfully - self.reset() - _LOGGER.debug("Voice command finished") - return False else: - # Speech in command. - # Reset silence counter if enough speech. - self._reset_seconds_left -= chunk_seconds - self._command_seconds_left -= chunk_seconds - if self._reset_seconds_left <= 0: - self._silence_seconds_left = self.silence_seconds + # In command + is_speech = speech_probability > self.in_command_speech_threshold + if not is_speech: + # Silence in command self._reset_seconds_left = self.reset_seconds + self._silence_seconds_left -= chunk_seconds + self._command_seconds_left -= chunk_seconds + if (self._silence_seconds_left <= 0) and ( + self._command_seconds_left <= 0 + ): + # Command finished successfully + self.reset() + _LOGGER.debug("Voice command finished") + return False + else: + # Speech in command. + # Reset silence counter if enough speech. + self._reset_seconds_left -= chunk_seconds + self._command_seconds_left -= chunk_seconds + if self._reset_seconds_left <= 0: + self._silence_seconds_left = self.silence_seconds + self._reset_seconds_left = self.reset_seconds return True @@ -226,6 +242,9 @@ class VoiceActivityTimeout: reset_seconds: float = 0.5 """Seconds of speech before resetting timeout.""" + speech_threshold: float = 0.5 + """Threshold for speech.""" + _silence_seconds_left: float = 0.0 """Seconds left before considering voice command as stopped.""" @@ -241,12 +260,15 @@ class VoiceActivityTimeout: self._silence_seconds_left = self.silence_seconds self._reset_seconds_left = self.reset_seconds - def process(self, chunk_seconds: float, is_speech: bool | None) -> bool: + def process(self, chunk_seconds: float, speech_probability: float | None) -> bool: """Process samples using external VAD. Returns False when timeout is reached. """ - if is_speech: + if speech_probability is None: + speech_probability = 0.0 + + if speech_probability > self.speech_threshold: # Speech self._reset_seconds_left -= chunk_seconds if self._reset_seconds_left <= 0: diff --git a/tests/components/assist_pipeline/test_vad.py b/tests/components/assist_pipeline/test_vad.py index fda26d2fb94..bd07601cd5d 100644 --- a/tests/components/assist_pipeline/test_vad.py +++ b/tests/components/assist_pipeline/test_vad.py @@ -16,7 +16,7 @@ def test_silence() -> None: segmenter = VoiceCommandSegmenter() # True return value indicates voice command has not finished - assert segmenter.process(_ONE_SECOND * 3, False) + assert segmenter.process(_ONE_SECOND * 3, 0.0) assert not segmenter.in_command @@ -26,15 +26,15 @@ def test_speech() -> None: segmenter = VoiceCommandSegmenter() # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # silence # False return value indicates voice command is finished - assert not segmenter.process(_ONE_SECOND, False) + assert not segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command @@ -112,19 +112,19 @@ def test_silence_seconds() -> None: segmenter = VoiceCommandSegmenter(silence_seconds=1.0) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # exactly enough silence now - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.in_command @@ -134,27 +134,27 @@ def test_silence_reset() -> None: segmenter = VoiceCommandSegmenter(silence_seconds=1.0, reset_seconds=0.5) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # "speech" - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # speech should reset silence detection - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.in_command # not enough silence to end - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.in_command # exactly enough silence now - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.in_command @@ -166,23 +166,23 @@ def test_speech_reset() -> None: ) # silence - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # not enough speech to start voice command - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.in_command # silence should reset speech detection - assert segmenter.process(_ONE_SECOND, False) + assert segmenter.process(_ONE_SECOND, 0.0) assert not segmenter.in_command # not enough speech to start voice command - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.in_command # exactly enough speech now - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.in_command @@ -193,18 +193,18 @@ def test_timeout() -> None: # not enough to time out assert not segmenter.timed_out - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) assert not segmenter.timed_out # enough to time out - assert not segmenter.process(_ONE_SECOND * 0.5, True) + assert not segmenter.process(_ONE_SECOND * 0.5, 1.0) assert segmenter.timed_out # flag resets with more audio - assert segmenter.process(_ONE_SECOND * 0.5, True) + assert segmenter.process(_ONE_SECOND * 0.5, 1.0) assert not segmenter.timed_out - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) assert segmenter.timed_out @@ -215,14 +215,38 @@ def test_command_seconds() -> None: command_seconds=3, speech_seconds=1, silence_seconds=1, reset_seconds=1 ) - assert segmenter.process(_ONE_SECOND, True) + assert segmenter.process(_ONE_SECOND, 1.0) # Silence counts towards total command length - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) # Enough to finish command now - assert segmenter.process(_ONE_SECOND, True) - assert segmenter.process(_ONE_SECOND * 0.5, False) + assert segmenter.process(_ONE_SECOND, 1.0) + assert segmenter.process(_ONE_SECOND * 0.5, 0.0) # Silence to finish - assert not segmenter.process(_ONE_SECOND * 0.5, False) + assert not segmenter.process(_ONE_SECOND * 0.5, 0.0) + + +def test_speech_thresholds() -> None: + """Test before/in command speech thresholds.""" + + segmenter = VoiceCommandSegmenter( + before_command_speech_threshold=0.2, + in_command_speech_threshold=0.5, + command_seconds=2, + speech_seconds=1, + silence_seconds=1, + ) + + # Not high enough probability to trigger command + assert segmenter.process(_ONE_SECOND, 0.1) + assert not segmenter.in_command + + # Triggers command + assert segmenter.process(_ONE_SECOND, 0.3) + assert segmenter.in_command + + # Now that same probability is considered silence. + # Finishes command. + assert not segmenter.process(_ONE_SECOND, 0.3) From 080e3d7a42c372b433c4d054c1abb62e3600fa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 5 Nov 2024 15:17:03 +0100 Subject: [PATCH 179/511] Removed stale translation and improved `set_setting` translation at Home Connect (#129878) --- homeassistant/components/home_connect/strings.json | 5 +---- tests/components/home_connect/test_number.py | 4 +++- tests/components/home_connect/test_time.py | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 9851c08d34b..eb57d822b15 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -37,11 +37,8 @@ "set_light_color": { "message": "Error while trying to set color of {entity_id}: {description}" }, - "set_light_effect": { - "message": "Error while trying to set effect of {entity_id}: {description}" - }, "set_setting": { - "message": "Error while trying to set \"{value}\" to \"{key}\" setting for {entity_id}: {description}" + "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" }, "turn_on": { "message": "Error while trying to turn on {entity_id} ({key}): {description}" diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index d822f791e40..f70e307cb41 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -161,7 +161,9 @@ async def test_number_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 2beab32c556..25ce39786a5 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -135,7 +135,9 @@ async def test_time_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, From 4e11ff05dec1c2c6179f917fc82f3653bf4403f2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 15:23:41 +0100 Subject: [PATCH 180/511] Use default package for yt-dlp (#129886) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3e4db5d5b04..ebfa79d7190 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.11.04"], + "requirements": ["yt-dlp[default]==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 6bd9afc33c0..07776b6399c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3054,7 +3054,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f617bab52c6..e0f127ac8bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2440,7 +2440,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From b76a94bd42c95496a365bea1805cad457e8b4890 Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 5 Nov 2024 15:34:25 +0100 Subject: [PATCH 181/511] Bump pypalazzetti to 0.1.10 (#129832) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 96edf86b43b..a1b25f563bf 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.6"] + "requirements": ["pypalazzetti==0.1.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 07776b6399c..99cd9ea7611 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f127ac8bc..ab28ebd9f2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1733,7 +1733,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.lcn pypck==0.7.24 From e562b6f42be357501acda349aa8ac6a33594c93e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 15:57:33 +0100 Subject: [PATCH 182/511] Map go2rtc log levels to Python log levels (#129894) --- homeassistant/components/go2rtc/server.py | 15 ++++- tests/components/go2rtc/test_server.py | 69 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 9be02d9a5d6..ed3b44aadf9 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -39,6 +39,16 @@ webrtc: ice_servers: [] """ +_LOG_LEVEL_MAP = { + "TRC": logging.DEBUG, + "DBG": logging.DEBUG, + "INF": logging.DEBUG, + "WRN": logging.WARNING, + "ERR": logging.WARNING, + "FTL": logging.ERROR, + "PNC": logging.ERROR, +} + class Go2RTCServerStartError(HomeAssistantError): """Raised when server does not start.""" @@ -132,7 +142,10 @@ class Server: async for line in process.stdout: msg = line[:-1].decode().strip() self._log_buffer.append(msg) - _LOGGER.debug(msg) + loglevel = logging.WARNING + if len(split_msg := msg.split(" ", 2)) == 3: + loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel) + _LOGGER.log(loglevel, msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index cda05fc4f2b..d810dbd88eb 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -195,6 +195,75 @@ async def test_server_failed_to_start( ) +@pytest.mark.parametrize( + ("server_stdout", "expected_loglevel"), + [ + ( + [ + "09:00:03.466 TRC [api] register path path=/", + "09:00:03.466 DBG build vcs.time=2024-10-28T19:47:55Z version=go1.23.2", + "09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5", + "09:00:03.467 INF [api] listen addr=127.0.0.1:1984", + "09:00:03.466 WRN warning message", + '09:00:03.466 ERR [api] listen error="listen tcp 127.0.0.1:11984: bind: address already in use"', + "09:00:03.466 FTL fatal message", + "09:00:03.466 PNC panic message", + "exit with signal: interrupt", # Example of stderr write + ], + [ + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.WARNING, + logging.WARNING, + logging.ERROR, + logging.ERROR, + logging.WARNING, + ], + ) + ], +) +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_log_level_mapping( + hass: HomeAssistant, + mock_create_subprocess: MagicMock, + server_stdout: list[str], + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, + expected_loglevel: list[int], +) -> None: + """Log level mapping.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + # Verify go2rtc binary stdout was logged with default level + for i, entry in enumerate(server_stdout): + assert ( + "homeassistant.components.go2rtc.server", + expected_loglevel[i], + entry, + ) in caplog.record_tuples + + evt.set() + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + + await server.stop() + + @patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) async def test_server_restart_process_exit( hass: HomeAssistant, From 5f36062ef339bc77a2fdb8997f4d2ae0bb198228 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 16:32:05 +0100 Subject: [PATCH 183/511] Remove timers from LG ThinQ (#129898) --- homeassistant/components/lg_thinq/sensor.py | 87 +----------------- .../lg_thinq/snapshots/test_sensor.ambr | 92 ------------------- 2 files changed, 1 insertion(+), 178 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 30d38685b3a..99b4df8176e 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -255,73 +255,9 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { translation_key=ThinQProperty.WATER_TYPE, ), } -TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { - TimerProperty.RELATIVE_TO_START: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START, - ), - TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START_WM, - ), - TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP, - ), - TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP_WM, - ), - TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - ), - TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_START, - translation_key=TimerProperty.ABSOLUTE_TO_START, - ), - TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_STOP, - translation_key=TimerProperty.ABSOLUTE_TO_STOP, - ), - TimerProperty.REMAIN: SensorEntityDescription( - key=TimerProperty.REMAIN, - translation_key=TimerProperty.REMAIN, - ), - TimerProperty.TARGET: SensorEntityDescription( - key=TimerProperty.TARGET, - translation_key=TimerProperty.TARGET, - ), - TimerProperty.RUNNING: SensorEntityDescription( - key=TimerProperty.RUNNING, - translation_key=TimerProperty.RUNNING, - ), - TimerProperty.TOTAL: SensorEntityDescription( - key=TimerProperty.TOTAL, - translation_key=TimerProperty.TOTAL, - ), - TimerProperty.LIGHT_START: SensorEntityDescription( - key=TimerProperty.LIGHT_START, - translation_key=TimerProperty.LIGHT_START, - ), - ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_STATE, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_STATE, - ), - ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_TOTAL, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, - ), -} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -332,9 +268,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -345,7 +278,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -361,7 +293,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -372,9 +303,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -385,10 +313,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), - DeviceType.HOOD: (TIMER_SENSOR_DESC[TimerProperty.REMAIN],), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], @@ -397,9 +322,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -408,15 +330,10 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = translation_key=ThinQProperty.TARGET_TEMPERATURE, ), ), - DeviceType.MICROWAVE_OVEN: ( - RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - ), + DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],), DeviceType.OVEN: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TARGET_TEMPERATURE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TARGET], ), DeviceType.PLANT_CULTIVATOR: ( LIGHT_SENSOR_DESC[ThinQProperty.BRIGHTNESS], @@ -427,7 +344,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], - TIMER_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -436,7 +352,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], - TIMER_SENSOR_DESC[TimerProperty.RUNNING], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index aa50ae5b03e..387df916eba 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,95 +203,3 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - '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': 'Schedule turn-off', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-off', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - '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': 'Schedule turn-on', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-on', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- From 00ea1cab9fdcc5588000fe6c2da60ab07da26395 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Nov 2024 19:22:12 +0100 Subject: [PATCH 184/511] Add basic testing framework to LG ThinQ (#127785) Co-authored-by: jangwon.lee Co-authored-by: Joostlek Co-authored-by: YunseonPark-LGE <34848373+YunseonPark-LGE@users.noreply.github.com> Co-authored-by: LG-ThinQ-Integration Co-authored-by: Franck Nijhof --- tests/components/lg_thinq/__init__.py | 14 +- tests/components/lg_thinq/conftest.py | 34 ++- .../fixtures/air_conditioner/device.json | 9 + .../fixtures/air_conditioner/profile.json | 154 +++++++++++++ .../fixtures/air_conditioner/status.json | 43 ++++ .../lg_thinq/snapshots/test_climate.ambr | 86 ++++++++ .../lg_thinq/snapshots/test_event.ambr | 55 +++++ .../lg_thinq/snapshots/test_number.ambr | 113 ++++++++++ .../lg_thinq/snapshots/test_sensor.ambr | 205 ++++++++++++++++++ tests/components/lg_thinq/test_climate.py | 29 +++ tests/components/lg_thinq/test_config_flow.py | 5 +- tests/components/lg_thinq/test_event.py | 29 +++ tests/components/lg_thinq/test_init.py | 26 +++ tests/components/lg_thinq/test_number.py | 29 +++ tests/components/lg_thinq/test_sensor.py | 29 +++ 15 files changed, 853 insertions(+), 7 deletions(-) create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/device.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/profile.json create mode 100644 tests/components/lg_thinq/fixtures/air_conditioner/status.json create mode 100644 tests/components/lg_thinq/snapshots/test_climate.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_event.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_number.ambr create mode 100644 tests/components/lg_thinq/snapshots/test_sensor.ambr create mode 100644 tests/components/lg_thinq/test_climate.py create mode 100644 tests/components/lg_thinq/test_event.py create mode 100644 tests/components/lg_thinq/test_init.py create mode 100644 tests/components/lg_thinq/test_number.py create mode 100644 tests/components/lg_thinq/test_sensor.py diff --git a/tests/components/lg_thinq/__init__.py b/tests/components/lg_thinq/__init__.py index 68ffb960f71..a5ba55ab1c9 100644 --- a/tests/components/lg_thinq/__init__.py +++ b/tests/components/lg_thinq/__init__.py @@ -1 +1,13 @@ -"""Tests for the lgthinq integration.""" +"""Tests for the LG ThinQ integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py index cae2de61fa4..05cb3164137 100644 --- a/tests/components/lg_thinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture def mock_thinq_api_response( @@ -45,6 +45,15 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.lg_thinq.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.fixture def mock_uuid() -> Generator[AsyncMock]: """Mock a uuid.""" @@ -59,22 +68,37 @@ def mock_uuid() -> Generator[AsyncMock]: @pytest.fixture -def mock_thinq_api() -> Generator[AsyncMock]: +def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: """Mock a thinq api.""" with ( - patch("thinqconnect.ThinQApi", autospec=True) as mock_api, + patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, patch( "homeassistant.components.lg_thinq.config_flow.ThinQApi", new=mock_api, ), ): thinq_api = mock_api.return_value - thinq_api.async_get_device_list = AsyncMock( - return_value=mock_thinq_api_response(status=200, body={}) + thinq_api.async_get_device_list.return_value = [ + load_json_object_fixture("air_conditioner/device.json", DOMAIN) + ] + thinq_api.async_get_device_profile.return_value = load_json_object_fixture( + "air_conditioner/profile.json", DOMAIN + ) + thinq_api.async_get_device_status.return_value = load_json_object_fixture( + "air_conditioner/status.json", DOMAIN ) yield thinq_api +@pytest.fixture +def mock_thinq_mqtt_client() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with patch( + "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True + ) as mock_api: + yield mock_api + + @pytest.fixture def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: """Mock an invalid thinq api.""" diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/device.json b/tests/components/lg_thinq/fixtures/air_conditioner/device.json new file mode 100644 index 00000000000..fb931c69929 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/device.json @@ -0,0 +1,9 @@ +{ + "deviceId": "MW2-2E247F93-B570-46A6-B827-920E9E10F966", + "deviceInfo": { + "deviceType": "DEVICE_AIR_CONDITIONER", + "modelName": "PAC_910604_WW", + "alias": "Test air conditioner", + "reportable": true + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/profile.json b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json new file mode 100644 index 00000000000..0d45dc5c9f4 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/profile.json @@ -0,0 +1,154 @@ +{ + "notification": { + "push": ["WATER_IS_FULL"] + }, + "property": { + "airConJobMode": { + "currentJobMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["AIR_CLEAN", "COOL", "AIR_DRY"], + "w": ["AIR_CLEAN", "COOL", "AIR_DRY"] + } + } + }, + "airFlow": { + "windStrength": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["LOW", "HIGH", "MID"], + "w": ["LOW", "HIGH", "MID"] + } + } + }, + "airQualitySensor": { + "PM1": { + "mode": ["r"], + "type": "number" + }, + "PM10": { + "mode": ["r"], + "type": "number" + }, + "PM2": { + "mode": ["r"], + "type": "number" + }, + "humidity": { + "mode": ["r"], + "type": "number" + }, + "monitoringEnabled": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["ON_WORKING", "ALWAYS"], + "w": ["ON_WORKING", "ALWAYS"] + } + }, + "oder": { + "mode": ["r"], + "type": "number" + }, + "totalPollution": { + "mode": ["r"], + "type": "number" + } + }, + "operation": { + "airCleanOperationMode": { + "mode": ["w"], + "type": "enum", + "value": { + "w": ["START", "STOP"] + } + }, + "airConOperationMode": { + "mode": ["r", "w"], + "type": "enum", + "value": { + "r": ["POWER_ON", "POWER_OFF"], + "w": ["POWER_ON", "POWER_OFF"] + } + } + }, + "powerSave": { + "powerSaveEnabled": { + "mode": ["r", "w"], + "type": "boolean", + "value": { + "r": [false, true], + "w": [false, true] + } + } + }, + "temperature": { + "coolTargetTemperature": { + "mode": ["w"], + "type": "range", + "value": { + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "currentTemperature": { + "mode": ["r"], + "type": "number" + }, + "targetTemperature": { + "mode": ["r", "w"], + "type": "range", + "value": { + "r": { + "max": 30, + "min": 18, + "step": 1 + }, + "w": { + "max": 30, + "min": 18, + "step": 1 + } + } + }, + "unit": { + "mode": ["r"], + "type": "enum", + "value": { + "r": ["C", "F"] + } + } + }, + "timer": { + "relativeHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeHourToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "relativeMinuteToStop": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteHourToStart": { + "mode": ["r", "w"], + "type": "number" + }, + "absoluteMinuteToStart": { + "mode": ["r", "w"], + "type": "number" + } + } + } +} diff --git a/tests/components/lg_thinq/fixtures/air_conditioner/status.json b/tests/components/lg_thinq/fixtures/air_conditioner/status.json new file mode 100644 index 00000000000..90d15d1ae16 --- /dev/null +++ b/tests/components/lg_thinq/fixtures/air_conditioner/status.json @@ -0,0 +1,43 @@ +{ + "airConJobMode": { + "currentJobMode": "COOL" + }, + "airFlow": { + "windStrength": "MID" + }, + "airQualitySensor": { + "PM1": 12, + "PM10": 7, + "PM2": 24, + "humidity": 40, + "monitoringEnabled": "ON_WORKING", + "totalPollution": 3, + "totalPollutionLevel": "GOOD" + }, + "filterInfo": { + "filterLifetime": 540, + "usedTime": 180 + }, + "operation": { + "airConOperationMode": "POWER_ON" + }, + "powerSave": { + "powerSaveEnabled": false + }, + "sleepTimer": { + "relativeStopTimer": "UNSET" + }, + "temperature": { + "currentTemperature": 25, + "targetTemperature": 19, + "unit": "C" + }, + "timer": { + "relativeStartTimer": "UNSET", + "relativeStopTimer": "UNSET", + "absoluteStartTimer": "SET", + "absoluteStopTimer": "UNSET", + "absoluteHourToStart": 13, + "absoluteMinuteToStart": 14 + } +} diff --git a/tests/components/lg_thinq/snapshots/test_climate.ambr b/tests/components/lg_thinq/snapshots/test_climate.ambr new file mode 100644 index 00000000000..e9470c3de03 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_climate.ambr @@ -0,0 +1,86 @@ +# serializer version: 1 +# name: test_all_entities[climate.test_air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_modes': list([ + 'air_clean', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_air_conditioner', + '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': None, + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_climate_air_conditioner', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[climate.test_air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 40, + 'current_temperature': 25, + 'fan_mode': 'mid', + 'fan_modes': list([ + 'low', + 'high', + 'mid', + ]), + 'friendly_name': 'Test air conditioner', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 30, + 'min_temp': 18, + 'preset_mode': None, + 'preset_modes': list([ + 'air_clean', + ]), + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 19, + }), + 'context': , + 'entity_id': 'climate.test_air_conditioner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_event.ambr b/tests/components/lg_thinq/snapshots/test_event.ambr new file mode 100644 index 00000000000..025f4496aeb --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_event.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_all_entities[event.test_air_conditioner_notification-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'water_is_full', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_air_conditioner_notification', + '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': 'Notification', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_notification', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[event.test_air_conditioner_notification-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'event_type': None, + 'event_types': list([ + 'water_is_full', + ]), + 'friendly_name': 'Test air conditioner Notification', + }), + 'context': , + 'entity_id': 'event.test_air_conditioner_notification', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_number.ambr b/tests/components/lg_thinq/snapshots/test_number.ambr new file mode 100644 index 00000000000..68f01854501 --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_stop', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_hour_to_start', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..387df916eba --- /dev/null +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -0,0 +1,205 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_air_conditioner_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test air conditioner Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Test air conditioner PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Test air conditioner PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_pm2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Test air conditioner PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24', + }) +# --- diff --git a/tests/components/lg_thinq/test_climate.py b/tests/components/lg_thinq/test_climate.py new file mode 100644 index 00000000000..24ed3ad230d --- /dev/null +++ b/tests/components/lg_thinq/test_climate.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq climate platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index db0e2d29450..e7ee632810e 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -14,7 +14,10 @@ from tests.common import MockConfigEntry async def test_config_flow( - hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_uuid: AsyncMock, + mock_setup_entry: AsyncMock, ) -> None: """Test that an thinq entry is normally created.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/lg_thinq/test_event.py b/tests/components/lg_thinq/test_event.py new file mode 100644 index 00000000000..bea758cb943 --- /dev/null +++ b/tests/components/lg_thinq/test_event.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq event platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.EVENT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_init.py b/tests/components/lg_thinq/test_init.py new file mode 100644 index 00000000000..7da7e79fec0 --- /dev/null +++ b/tests/components/lg_thinq/test_init.py @@ -0,0 +1,26 @@ +"""Tests for the LG ThinQ integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/lg_thinq/test_number.py b/tests/components/lg_thinq/test_number.py new file mode 100644 index 00000000000..e578e4eba7a --- /dev/null +++ b/tests/components/lg_thinq/test_number.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lg_thinq/test_sensor.py b/tests/components/lg_thinq/test_sensor.py new file mode 100644 index 00000000000..02b91b4771b --- /dev/null +++ b/tests/components/lg_thinq/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the LG Thinq sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_thinq_api: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.lg_thinq.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 3f5e395e2fac37b05a65bf2fc35dbfd801a5367d Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Tue, 5 Nov 2024 16:22:38 +0530 Subject: [PATCH 185/511] Adding new on values for Tuya Presence Detection Sensor (#129801) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a8c9157caa7..934f03336aa 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value="presence", + on_value={"presence", "small_move", "large_move"}, ), ), # Formaldehyde Detector From 89d3707cb73c9cf07ff771fbccf238fadce3bcca Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 14:44:37 +0100 Subject: [PATCH 186/511] Skip adding providers if the camera has native WebRTC (#129808) * Skip adding providers if the camera has native WebRTC * Update homeassistant/components/camera/__init__.py Co-authored-by: Martin Hjelmare * Implement suggestion * Add tests * Shorten test name * Fix test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/__init__.py | 40 ++++++++------ tests/components/camera/common.py | 50 +++++++++++++++++ tests/components/camera/conftest.py | 49 ++++++++++++++--- tests/components/camera/test_init.py | 20 ++++++- tests/components/camera/test_webrtc.py | 60 ++------------------- 5 files changed, 136 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 47d8b9dfbd0..b600eae02c7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -484,9 +484,13 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self._create_stream_lock: asyncio.Lock | None = None self._webrtc_provider: CameraWebRTCProvider | None = None self._legacy_webrtc_provider: CameraWebRTCLegacyProvider | None = None - self._webrtc_sync_offer = ( + self._supports_native_sync_webrtc = ( type(self).async_handle_web_rtc_offer != Camera.async_handle_web_rtc_offer ) + self._supports_native_async_webrtc = ( + type(self).async_handle_async_webrtc_offer + != Camera.async_handle_async_webrtc_offer + ) @cached_property def entity_picture(self) -> str: @@ -623,7 +627,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): Integrations can override with a native WebRTC implementation. """ - if self._webrtc_sync_offer: + if self._supports_native_sync_webrtc: try: answer = await self.async_handle_web_rtc_offer(offer_sdp) except ValueError as ex: @@ -788,18 +792,25 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): providers or inputs to the state attributes change. """ old_provider = self._webrtc_provider - new_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_provider - ) - old_legacy_provider = self._legacy_webrtc_provider + new_provider = None new_legacy_provider = None - if new_provider is None: - # Only add the legacy provider if the new provider is not available - new_legacy_provider = await self._async_get_supported_webrtc_provider( - async_get_supported_legacy_provider + + # Skip all providers if the camera has a native WebRTC implementation + if not ( + self._supports_native_sync_webrtc or self._supports_native_async_webrtc + ): + # Camera doesn't have a native WebRTC implementation + new_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_provider ) + if new_provider is None: + # Only add the legacy provider if the new provider is not available + new_legacy_provider = await self._async_get_supported_webrtc_provider( + async_get_supported_legacy_provider + ) + if old_provider != new_provider or old_legacy_provider != new_legacy_provider: self._webrtc_provider = new_provider self._legacy_webrtc_provider = new_legacy_provider @@ -827,7 +838,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the WebRTC client configuration and extend it with the registered ice servers.""" config = self._async_get_webrtc_client_configuration() - if not self._webrtc_sync_offer: + if not self._supports_native_sync_webrtc: # Until 2024.11, the frontend was not resolving any ice servers # The async approach was added 2024.11 and new integrations need to use it ice_servers = [ @@ -867,12 +878,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Return the camera capabilities.""" frontend_stream_types = set() if CameraEntityFeature.STREAM in self.supported_features_compat: - if ( - type(self).async_handle_web_rtc_offer - != Camera.async_handle_web_rtc_offer - or type(self).async_handle_async_webrtc_offer - != Camera.async_handle_async_webrtc_offer - ): + if self._supports_native_sync_webrtc or self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) else: diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py index f7dcf46db01..569756c2640 100644 --- a/tests/components/camera/common.py +++ b/tests/components/camera/common.py @@ -6,6 +6,16 @@ components. Instead call the service directly. from unittest.mock import Mock +from webrtc_models import RTCIceCandidate + +from homeassistant.components.camera import ( + Camera, + CameraWebRTCProvider, + WebRTCAnswer, + WebRTCSendMessage, +) +from homeassistant.core import callback + EMPTY_8_6_JPEG = b"empty_8_6" WEBRTC_ANSWER = "a=sendonly" STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -23,3 +33,43 @@ def mock_turbo_jpeg( mocked_turbo_jpeg.scale_with_quality.return_value = EMPTY_8_6_JPEG mocked_turbo_jpeg.encode.return_value = EMPTY_8_6_JPEG return mocked_turbo_jpeg + + +class SomeTestProvider(CameraWebRTCProvider): + """Test provider.""" + + def __init__(self) -> None: + """Initialize the provider.""" + self._is_supported = True + + @property + def domain(self) -> str: + """Return the integration domain of the provider.""" + return "some_test" + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Determine if the provider supports the stream source.""" + return self._is_supported + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback. + + Return value determines if the offer was handled successfully. + """ + send_message(WebRTCAnswer(answer="answer")) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle the WebRTC candidate.""" + + @callback + def async_close_session(self, session_id: str) -> None: + """Close the session.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index a88cd898e33..d6343959d41 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, Mock, PropertyMock, patch import pytest +from webrtc_models import RTCIceCandidate from homeassistant.components import camera from homeassistant.components.camera.const import StreamType @@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -155,16 +156,15 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture -async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: - """Initialize a test camera with native sync WebRTC support.""" +async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: + """Initialize a test WebRTC cameras.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was # overwritten(implemented) or not - class MockCamera(camera.Camera): - """Mock Camera Entity.""" + class BaseCamera(camera.Camera): + """Base Camera.""" - _attr_name = "Test" _attr_supported_features: camera.CameraEntityFeature = ( camera.CameraEntityFeature.STREAM ) @@ -173,9 +173,30 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: async def stream_source(self) -> str | None: return STREAM_SOURCE + class SyncCamera(BaseCamera): + """Mock Camera with native sync WebRTC support.""" + + _attr_name = "Sync" + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: return WEBRTC_ANSWER + class AsyncCamera(BaseCamera): + """Mock Camera with native async WebRTC support.""" + + _attr_name = "Async" + + async def async_handle_async_webrtc_offer( + self, offer_sdp: str, session_id: str, send_message: WebRTCSendMessage + ) -> None: + send_message(WebRTCAnswer(WEBRTC_ANSWER)) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Handle a WebRTC candidate.""" + # Do nothing + domain = "test" entry = MockConfigEntry(domain=domain) @@ -208,10 +229,24 @@ async def mock_camera_webrtc_native_sync_offer(hass: HomeAssistant) -> None: ), ) setup_test_component_platform( - hass, camera.DOMAIN, [MockCamera()], from_config_entry=True + hass, camera.DOMAIN, [SyncCamera(), AsyncCamera()], from_config_entry=True ) mock_platform(hass, f"{domain}.config_flow", Mock()) with mock_config_flow(domain, ConfigFlow): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + +@pytest.fixture +async def register_test_provider( + hass: HomeAssistant, +) -> AsyncGenerator[SomeTestProvider]: + """Add WebRTC test provider.""" + await async_setup_component(hass, "camera", {}) + + provider = SomeTestProvider() + unsub = camera.async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + yield provider + unsub() diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 0a173065564..621ac8b7fb3 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -979,7 +979,7 @@ async def test_camera_capabilities_hls( ) -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_camera_capabilities_webrtc( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -987,5 +987,21 @@ async def test_camera_capabilities_webrtc( """Test WebRTC camera capabilities.""" await _test_capabilities( - hass, hass_ws_client, "camera.test", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + hass, hass_ws_client, "camera.sync", {StreamType.WEB_RTC}, {StreamType.WEB_RTC} ) + + +@pytest.mark.parametrize( + ("entity_id", "expect_native_async_webrtc"), + [("camera.sync", False), ("camera.async", True)], +) +@pytest.mark.usefixtures("mock_test_webrtc_cameras", "register_test_provider") +async def test_webrtc_provider_not_added_for_native_webrtc( + hass: HomeAssistant, entity_id: str, expect_native_async_webrtc: bool +) -> None: + """Test that a WebRTC provider is not added to a camera when the camera has native WebRTC support.""" + camera_obj = get_camera_from_entity_id(hass, entity_id) + assert camera_obj + assert camera_obj._webrtc_provider is None + assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc + assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 2970a41408c..f726eb29673 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -34,7 +34,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from .common import STREAM_SOURCE, WEBRTC_ANSWER +from .common import STREAM_SOURCE, WEBRTC_ANSWER, SomeTestProvider from tests.common import ( MockConfigEntry, @@ -51,46 +51,6 @@ HLS_STREAM_SOURCE = "http://127.0.0.1/example.m3u" TEST_INTEGRATION_DOMAIN = "test" -class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - def __init__(self) -> None: - """Initialize the provider.""" - self._is_supported = True - - @property - def domain(self) -> str: - """Return the integration domain of the provider.""" - return "some_test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return self._is_supported - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer via the provided callback. - - Return value determines if the offer was handled successfully. - """ - send_message(WebRTCAnswer(answer="answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidate - ) -> None: - """Handle the WebRTC candidate.""" - - @callback - def async_close_session(self, session_id: str) -> None: - """Close the session.""" - - class Go2RTCProvider(SomeTestProvider): """go2rtc provider.""" @@ -179,20 +139,6 @@ async def init_test_integration( return test_camera -@pytest.fixture -async def register_test_provider( - hass: HomeAssistant, -) -> AsyncGenerator[SomeTestProvider]: - """Add WebRTC test provider.""" - await async_setup_component(hass, "camera", {}) - - provider = SomeTestProvider() - unsub = async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() - yield provider - unsub() - - @pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, @@ -393,7 +339,7 @@ async def test_ws_get_client_config( } -@pytest.mark.usefixtures("mock_camera_webrtc_native_sync_offer") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config_sync_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -403,7 +349,7 @@ async def test_ws_get_client_config_sync_offer( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.test"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.sync"} ) msg = await client.receive_json() From da0688ce8eab7b7ffb260d5726057927428e5c86 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 11:09:10 +0100 Subject: [PATCH 187/511] Validate go2rtc server version (#129810) --- homeassistant/components/go2rtc/__init__.py | 14 +++- homeassistant/components/go2rtc/server.py | 6 +- tests/components/go2rtc/conftest.py | 1 + tests/components/go2rtc/test_init.py | 85 +++++++++++++++++++-- tests/components/go2rtc/test_server.py | 3 +- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 5be1dbc1a48..2bcdaddf739 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -5,7 +5,7 @@ import shutil from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Go2RtcRestClient -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( Go2RtcWsClient, ReceiveMessages, @@ -114,7 +114,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: server = Server( hass, binary, enable_ui=config.get(DOMAIN, {}).get(CONF_DEBUG_UI, False) ) - await server.start() + try: + await server.start() + except Exception: # noqa: BLE001 + _LOGGER.warning("Could not start go2rtc server", exc_info=True) + return False async def on_stop(event: Event) -> None: await server.stop() @@ -143,7 +147,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Validate the server URL try: client = Go2RtcRestClient(async_get_clientsession(hass), url) - await client.streams.list() + await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( @@ -151,6 +155,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from err _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False + except Go2RtcVersionError as err: + raise ConfigEntryNotReady( + f"The go2rtc server version is not supported, {err}" + ) from err except Exception as err: # noqa: BLE001 _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index b2aa19d5275..eff067416b3 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -112,6 +112,10 @@ class Server: await self._stop() raise Go2RTCServerStartError from err + # Check the server version + client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + await client.validate_server_version() + async def _log_output(self, process: asyncio.subprocess.Process) -> None: """Log the output of the process.""" assert process.stdout is not None @@ -174,7 +178,7 @@ class Server: _LOGGER.debug("Monitoring go2rtc API") try: while True: - await client.streams.list() + await client.validate_server_version() await asyncio.sleep(10) except Exception as err: _LOGGER.debug("go2rtc API did not reply", exc_info=True) diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 87c68989fd2..42b363b2324 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -23,6 +23,7 @@ def rest_client() -> Generator[AsyncMock]: client = mock_client.return_value client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} + client.validate_server_version = AsyncMock() client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 847de248aaf..21d4d0a047e 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream -from go2rtc_client.exceptions import Go2RtcClientError +from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.models import Producer from go2rtc_client.ws import ( ReceiveMessages, @@ -494,6 +494,8 @@ ERR_CONNECT = "Could not connect to go2rtc instance" ERR_CONNECT_RETRY = ( "Could not connect to go2rtc instance on http://localhost:1984/; Retrying" ) +ERR_START_SERVER = "Could not start go2rtc server" +ERR_UNSUPPORTED_VERSION = "The go2rtc server version is not supported" _INVALID_CONFIG = "Invalid config for 'go2rtc': " ERR_INVALID_URL = _INVALID_CONFIG + "invalid url" ERR_EXCLUSIVE = _INVALID_CONFIG + DEBUG_UI_URL_MESSAGE @@ -526,8 +528,10 @@ async def test_non_user_setup_with_error( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ ({DEFAULT_CONFIG_DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {}}, None, False, ERR_URL_REQUIRED), ({DOMAIN: {}}, None, True, ERR_BINARY_NOT_FOUND), + ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_START_SERVER), ({DOMAIN: {CONF_URL: "invalid"}}, None, True, ERR_INVALID_URL), ( {DOMAIN: {CONF_URL: "http://localhost:1984", CONF_DEBUG_UI: True}}, @@ -559,8 +563,6 @@ async def test_setup_with_setup_error( @pytest.mark.parametrize( ("config", "go2rtc_binary", "is_docker_env", "expected_log_message"), [ - ({DEFAULT_CONFIG_DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), - ({DOMAIN: {}}, "/usr/bin/go2rtc", True, ERR_CONNECT), ({DOMAIN: {CONF_URL: "http://localhost:1984/"}}, None, True, ERR_CONNECT), ], ) @@ -584,7 +586,7 @@ async def test_setup_with_setup_entry_error( assert expected_log_message in caplog.text -@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984/"}}]) @pytest.mark.parametrize( ("cause", "expected_config_entry_state", "expected_log_message"), [ @@ -598,7 +600,7 @@ async def test_setup_with_setup_entry_error( @pytest.mark.usefixtures( "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" ) -async def test_setup_with_retryable_setup_entry_error( +async def test_setup_with_retryable_setup_entry_error_custom_server( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, rest_client: AsyncMock, @@ -610,7 +612,78 @@ async def test_setup_with_retryable_setup_entry_error( """Test setup integration entry fails.""" go2rtc_error = Go2RtcClientError() go2rtc_error.__cause__ = cause - rest_client.streams.list.side_effect = go2rtc_error + rest_client.validate_server_version.side_effect = go2rtc_error + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("cause", "expected_config_entry_state", "expected_log_message"), + [ + (ClientConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (ServerConnectionError(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (None, ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + (Exception(), ConfigEntryState.NOT_LOADED, ERR_START_SERVER), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_retryable_setup_entry_error_default_server( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + has_go2rtc_entry: bool, + config: ConfigType, + cause: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + go2rtc_error = Go2RtcClientError() + go2rtc_error.__cause__ = cause + rest_client.validate_server_version.side_effect = go2rtc_error + assert not await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == has_go2rtc_entry + for config_entry in config_entries: + assert config_entry.state == expected_config_entry_state + assert expected_log_message in caplog.text + + +@pytest.mark.parametrize("config", [{DOMAIN: {}}, {DEFAULT_CONFIG_DOMAIN: {}}]) +@pytest.mark.parametrize( + ("go2rtc_error", "expected_config_entry_state", "expected_log_message"), + [ + ( + Go2RtcVersionError("1.9.4", "1.9.5", "2.0.0"), + ConfigEntryState.SETUP_RETRY, + ERR_UNSUPPORTED_VERSION, + ), + ], +) +@pytest.mark.parametrize("has_go2rtc_entry", [True, False]) +@pytest.mark.usefixtures( + "mock_get_binary", "mock_go2rtc_entry", "mock_is_docker_env", "server" +) +async def test_setup_with_version_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + rest_client: AsyncMock, + config: ConfigType, + go2rtc_error: Exception, + expected_config_entry_state: ConfigEntryState, + expected_log_message: str, +) -> None: + """Test setup integration entry fails.""" + rest_client.validate_server_version.side_effect = [None, go2rtc_error] assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) config_entries = hass.config_entries.async_entries(DOMAIN) diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 1410fbeb6c3..fedf155baf5 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -47,6 +47,7 @@ def mock_tempfile() -> Generator[Mock]: ) async def test_server_run_success( mock_create_subprocess: AsyncMock, + rest_client: AsyncMock, server_stdout: list[str], server: Server, caplog: pytest.LogCaptureFixture, @@ -95,7 +96,7 @@ webrtc: @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( - mock_create_subprocess: MagicMock, server: Server + mock_create_subprocess: MagicMock, rest_client: AsyncMock, server: Server ) -> None: """Test server run where the process takes too long to terminate.""" # Start server thread From 496fc42b949ac4be29d15e114e85a0cd257a78ab Mon Sep 17 00:00:00 2001 From: dotvav Date: Tue, 5 Nov 2024 15:34:25 +0100 Subject: [PATCH 188/511] Bump pypalazzetti to 0.1.10 (#129832) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 96edf86b43b..a1b25f563bf 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.6"] + "requirements": ["pypalazzetti==0.1.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index db81a1380a4..8c0defe384a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2143,7 +2143,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 299295edf72..03cf6a0ea47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.6 +pypalazzetti==0.1.10 # homeassistant.components.lcn pypck==0.7.24 From 14023644ef4a324ed83376a90b02e9331d7a3e78 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 5 Nov 2024 14:13:48 +0100 Subject: [PATCH 189/511] Bump bimmer_connected to 0.16.4 (#129838) --- .../bmw_connected_drive/config_flow.py | 14 +++++- .../bmw_connected_drive/coordinator.py | 13 +++++- .../bmw_connected_drive/manifest.json | 2 +- .../bmw_connected_drive/strings.json | 6 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/test_config_flow.py | 35 ++++++++++++++- .../bmw_connected_drive/test_coordinator.py | 43 ++++++++++++++++++- 8 files changed, 109 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 37ff1eb374c..6803bbac600 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -7,7 +7,11 @@ from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError import voluptuous as vol @@ -54,6 +58,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: await auth.login() + except MyBMWCaptchaMissingError as ex: + raise MissingCaptcha from ex except MyBMWAuthError as ex: raise InvalidAuth from ex except (MyBMWAPIError, RequestError) as ex: @@ -98,6 +104,8 @@ class BMWConfigFlow(ConfigFlow, domain=DOMAIN): CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), CONF_GCID: info.get(CONF_GCID), } + except MissingCaptcha: + errors["base"] = "missing_captcha" except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -192,3 +200,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class MissingCaptcha(HomeAssistantError): + """Error to indicate the captcha token is missing.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 992e7dea6b2..d38b7ffacc2 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,12 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + GPSPosition, + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError from homeassistant.config_entries import ConfigEntry @@ -61,6 +66,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() + except MyBMWCaptchaMissingError as err: + # If a captcha is required (user/password login flow), always trigger the reauth flow + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="missing_captcha", + ) from err except MyBMWAuthError as err: # Allow one retry interval before raising AuthFailed to avoid flaky API issues if self.last_update_success: diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 6bc9027ac19..584eb1eebb5 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.3"] + "requirements": ["bimmer-connected[china]==0.16.4"] } diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json index fed71f85e35..0e7a4a32ef4 100644 --- a/homeassistant/components/bmw_connected_drive/strings.json +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -11,7 +11,8 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_captcha": "Captcha validation missing" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", @@ -200,6 +201,9 @@ "exceptions": { "invalid_poi": { "message": "Invalid data for point of interest: {poi_exception}" + }, + "missing_captcha": { + "message": "Login requires captcha validation" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 8c0defe384a..65cbbf31ae0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -572,7 +572,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03cf6a0ea47..6c3c1d30a15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.3 +bimmer-connected[china]==0.16.4 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 9d4d15703f2..f57f1a304ac 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -4,8 +4,13 @@ from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from httpx import RequestError +import pytest from homeassistant import config_entries from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN @@ -311,3 +316,31 @@ async def test_reconfigure_unique_id_abort(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "account_mismatch" assert config_entry.data == FIXTURE_COMPLETE_ENTRY + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_flow_not_set(hass: HomeAssistant) -> None: + """Test the external flow with captcha failing once and succeeding the second time.""" + + TEST_REGION = "north_america" + + # Start flow and open form + # Start flow and open form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Add login data + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication._login_row_na", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**FIXTURE_USER_INPUT, CONF_REGION: TEST_REGION}, + ) + assert result["errors"]["base"] == "missing_captcha" diff --git a/tests/components/bmw_connected_drive/test_coordinator.py b/tests/components/bmw_connected_drive/test_coordinator.py index b0f507bbfc2..774a85eb6da 100644 --- a/tests/components/bmw_connected_drive/test_coordinator.py +++ b/tests/components/bmw_connected_drive/test_coordinator.py @@ -1,13 +1,19 @@ """Test BMW coordinator.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import patch -from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from bimmer_connected.models import ( + MyBMWAPIError, + MyBMWAuthError, + MyBMWCaptchaMissingError, +) from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN +from homeassistant.const import CONF_REGION from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir @@ -122,3 +128,38 @@ async def test_init_reauth( f"config_entry_reauth_{BMW_DOMAIN}_{config_entry.entry_id}", ) assert reauth_issue.active is True + + +@pytest.mark.usefixtures("bmw_fixture") +async def test_captcha_reauth( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the reauth form.""" + TEST_REGION = "north_america" + + config_entry_fixure = deepcopy(FIXTURE_CONFIG_ENTRY) + config_entry_fixure["data"][CONF_REGION] = TEST_REGION + config_entry = MockConfigEntry(**config_entry_fixure) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = config_entry.runtime_data.coordinator + + assert coordinator.last_update_success is True + + freezer.tick(timedelta(minutes=10, seconds=1)) + with patch( + "bimmer_connected.account.MyBMWAccount.get_vehicles", + side_effect=MyBMWCaptchaMissingError( + "Missing hCaptcha token for North America login" + ), + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert coordinator.last_update_success is False + assert isinstance(coordinator.last_exception, ConfigEntryAuthFailed) is True + assert coordinator.last_exception.translation_key == "missing_captcha" From 8a20cd77a056ba526299afb0c496e0bbcb789629 Mon Sep 17 00:00:00 2001 From: Alex Bush <45221249+KC3BZU@users.noreply.github.com> Date: Tue, 5 Nov 2024 04:56:34 -0500 Subject: [PATCH 190/511] Bump pyfibaro to 0.8.0 (#129846) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 39850672d06..d2a1186b05b 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.8"] + "requirements": ["pyfibaro==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65cbbf31ae0..7c35e676906 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1904,7 +1904,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c3c1d30a15..4e9de12cb28 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1533,7 +1533,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.8 +pyfibaro==0.8.0 # homeassistant.components.fido pyfido==2.1.2 From 383f712d43e818c70d981ed8498baff3a38a1b1c Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 5 Nov 2024 05:53:01 -0500 Subject: [PATCH 191/511] Add repair for add-on boot fail (#129847) --- homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/issues.py | 2 + homeassistant/components/hassio/repairs.py | 12 ++- homeassistant/components/hassio/strings.json | 17 ++++ tests/components/hassio/test_repairs.py | 101 +++++++++++++++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 6e6c9006fca..b337017147b 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -103,6 +103,7 @@ PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" +ISSUE_KEY_ADDON_BOOT_FAIL = "issue_addon_boot_fail" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 9c2152489d6..944bc99a6b9 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -36,6 +36,7 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, @@ -94,6 +95,7 @@ UNHEALTHY_REASONS = { # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + ISSUE_KEY_ADDON_BOOT_FAIL, "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 082dbe38bee..0fcd96ace38 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -14,6 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from . import get_addons_info, get_issues_info from .const import ( + ISSUE_KEY_ADDON_BOOT_FAIL, ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_ADDON, @@ -181,8 +182,8 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): return placeholders -class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): - """Handler for detached addon issue fixing flows.""" +class AddonIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for addon issue fixing flows.""" @property def description_placeholders(self) -> dict[str, str] | None: @@ -210,7 +211,10 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(issue_id) - if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: - return DetachedAddonIssueRepairFlow(issue_id) + if issue and issue.key in { + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, + ISSUE_KEY_ADDON_BOOT_FAIL, + }: + return AddonIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 8688934ee3d..09ed45bd5bc 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_addon_boot_fail": { + "title": "Add-on failed to start at boot", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.", + "menu_options": { + "addon_execute_start": "Start", + "addon_disable_boot": "Disable" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, "issue_addon_detached_addon_missing": { "title": "Missing repository for an installed add-on", "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 907529ec9c4..f3ccb5948f1 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -868,3 +868,104 @@ async def test_supervisor_issue_detached_addon_removed( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +@pytest.mark.usefixtures("all_setup_requests") +async def test_supervisor_issue_addon_boot_fail( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "boot_fail", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_start", + "context": "addon", + "reference": "test", + }, + { + "uuid": "1236", + "type": "disable_boot", + "context": "addon", + "reference": "test", + }, + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["addon_execute_start", "addon_execute_start"], + ["addon_disable_boot", "addon_disable_boot"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["addon_execute_start", "addon_disable_boot"], + "description_placeholders": { + "reference": "test", + "addon": "test", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "addon_execute_start"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) From d671341864cdb68d0373b370c7d8405cc320101b Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Tue, 5 Nov 2024 20:15:42 +1300 Subject: [PATCH 192/511] Update snapshot for lg thinq (#129856) update snapshot for lg thinq Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../lg_thinq/snapshots/test_sensor.ambr | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index 387df916eba..aa50ae5b03e 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,3 +203,95 @@ 'state': '24', }) # --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + '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': 'Schedule turn-off', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-off', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + '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': 'Schedule turn-on', + 'platform': 'lg_thinq', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test air conditioner Schedule turn-on', + }), + 'context': , + 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- From b71c4377f6cb511d2dc4c15fd549e8ee8bde750e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 5 Nov 2024 15:17:03 +0100 Subject: [PATCH 193/511] Removed stale translation and improved `set_setting` translation at Home Connect (#129878) --- homeassistant/components/home_connect/strings.json | 5 +---- tests/components/home_connect/test_number.py | 4 +++- tests/components/home_connect/test_time.py | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 9851c08d34b..eb57d822b15 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -37,11 +37,8 @@ "set_light_color": { "message": "Error while trying to set color of {entity_id}: {description}" }, - "set_light_effect": { - "message": "Error while trying to set effect of {entity_id}: {description}" - }, "set_setting": { - "message": "Error while trying to set \"{value}\" to \"{key}\" setting for {entity_id}: {description}" + "message": "Error while trying to assign the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}" }, "turn_on": { "message": "Error while trying to turn on {entity_id} ({key}): {description}" diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index d822f791e40..f70e307cb41 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -161,7 +161,9 @@ async def test_number_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 2beab32c556..25ce39786a5 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -135,7 +135,9 @@ async def test_time_entity_error( with pytest.raises(HomeConnectError): getattr(problematic_appliance, mock_attr)() - with pytest.raises(ServiceValidationError, match=r"Error.*set.*setting.*"): + with pytest.raises( + ServiceValidationError, match=r"Error.*assign.*value.*to.*setting.*" + ): await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, From 25a05eb1567da4c0dcb4af9da7f786cec7aa9212 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 12:29:51 +0100 Subject: [PATCH 194/511] Append a 1 to all go2rtc ports to avoid port conflicts (#129881) --- homeassistant/components/go2rtc/__init__.py | 4 ++-- homeassistant/components/go2rtc/const.py | 3 ++- homeassistant/components/go2rtc/server.py | 17 +++++++++++------ tests/components/go2rtc/test_server.py | 5 +++-- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 2bcdaddf739..9ffe9e25f78 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -38,7 +38,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DEFAULT_URL, DOMAIN +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL from .server import Server _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) - url = DEFAULT_URL + url = HA_MANAGED_URL hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index cb03e224e52..d33ae3e3897 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -4,4 +4,5 @@ DOMAIN = "go2rtc" CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." -DEFAULT_URL = "http://localhost:1984/" +HA_MANAGED_API_PORT = 11984 +HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index eff067416b3..6384cc5d49b 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DEFAULT_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -26,13 +26,14 @@ _RESPAWN_COOLDOWN = 1 # - Clear default ice servers _GO2RTC_CONFIG_FORMAT = r""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:{api_port}" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """ @@ -52,7 +53,11 @@ def _create_temp_file(api_ip: str) -> str: # Set delete=False to prevent the file from being deleted when the file is closed # Linux is clearing tmp folder on reboot, so no need to delete it manually with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: - file.write(_GO2RTC_CONFIG_FORMAT.format(api_ip=api_ip).encode()) + file.write( + _GO2RTC_CONFIG_FORMAT.format( + api_ip=api_ip, api_port=HA_MANAGED_API_PORT + ).encode() + ) return file.name @@ -113,7 +118,7 @@ class Server: raise Go2RTCServerStartError from err # Check the server version - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) await client.validate_server_version() async def _log_output(self, process: asyncio.subprocess.Process) -> None: @@ -173,7 +178,7 @@ class Server: async def _monitor_api(self) -> None: """Raise if the go2rtc process terminates.""" - client = Go2RtcRestClient(async_get_clientsession(self._hass), DEFAULT_URL) + client = Go2RtcRestClient(async_get_clientsession(self._hass), HA_MANAGED_URL) _LOGGER.debug("Monitoring go2rtc API") try: diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index fedf155baf5..5b430d66641 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -71,13 +71,14 @@ async def test_server_run_success( mock_tempfile.write.assert_called_once_with( f""" api: - listen: "{api_ip}:1984" + listen: "{api_ip}:11984" rtsp: # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:8554" + listen: "127.0.0.1:18554" webrtc: + listen: ":18555/tcp" ice_servers: [] """.encode() ) From 6e2f36b6d413fede6cd4888d2ec5027d051d3570 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 14:05:04 +0100 Subject: [PATCH 195/511] Log go2rtc output with warning level on error (#129882) --- homeassistant/components/go2rtc/server.py | 13 ++++ tests/components/go2rtc/test_server.py | 89 +++++++++++++++++++---- 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 6384cc5d49b..9be02d9a5d6 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -1,6 +1,7 @@ """Go2rtc server.""" import asyncio +from collections import deque from contextlib import suppress import logging from tempfile import NamedTemporaryFile @@ -18,6 +19,7 @@ _TERMINATE_TIMEOUT = 5 _SETUP_TIMEOUT = 30 _SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr=" _LOCALHOST_IP = "127.0.0.1" +_LOG_BUFFER_SIZE = 512 _RESPAWN_COOLDOWN = 1 # Default configuration for HA @@ -70,6 +72,7 @@ class Server: """Initialize the server.""" self._hass = hass self._binary = binary + self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE) self._process: asyncio.subprocess.Process | None = None self._startup_complete = asyncio.Event() self._api_ip = _LOCALHOST_IP @@ -114,6 +117,7 @@ class Server: except TimeoutError as err: msg = "Go2rtc server didn't start correctly" _LOGGER.exception(msg) + self._log_server_output(logging.WARNING) await self._stop() raise Go2RTCServerStartError from err @@ -127,10 +131,17 @@ class Server: async for line in process.stdout: msg = line[:-1].decode().strip() + self._log_buffer.append(msg) _LOGGER.debug(msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() + def _log_server_output(self, loglevel: int) -> None: + """Log captured process output, then clear the log buffer.""" + for line in list(self._log_buffer): # Copy the deque to avoid mutation error + _LOGGER.log(loglevel, line) + self._log_buffer.clear() + async def _watchdog(self) -> None: """Keep respawning go2rtc servers. @@ -158,6 +169,8 @@ class Server: await asyncio.sleep(_RESPAWN_COOLDOWN) try: await self._stop() + _LOGGER.warning("Go2rtc unexpectedly stopped, server log:") + self._log_server_output(logging.WARNING) _LOGGER.debug("Spawning new go2rtc server") with suppress(Go2RTCServerStartError): await self._start() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index 5b430d66641..cda05fc4f2b 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -38,6 +38,42 @@ def mock_tempfile() -> Generator[Mock]: yield file +def _assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, + expect_logged: bool, +) -> None: + """Check server stdout was logged.""" + for entry in server_stdout: + assert ( + ( + "homeassistant.components.go2rtc.server", + loglevel, + entry, + ) + in caplog.record_tuples + ) is expect_logged + + +def assert_server_output_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, True) + + +def assert_server_output_not_logged( + server_stdout: list[str], + caplog: pytest.LogCaptureFixture, + loglevel: int, +) -> None: + """Check server stdout was logged.""" + _assert_server_output_logged(server_stdout, caplog, loglevel, False) + + @pytest.mark.parametrize( ("enable_ui", "api_ip"), [ @@ -83,17 +119,15 @@ webrtc: """.encode() ) - # Check that server read the log lines - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) await server.stop() mock_create_subprocess.return_value.terminate.assert_called_once() + # Verify go2rtc binary stdout was not logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + @pytest.mark.usefixtures("mock_tempfile") async def test_server_timeout_on_stop( @@ -140,13 +174,9 @@ async def test_server_failed_to_start( ): await server.start() - # Verify go2rtc binary stdout was logged - for entry in server_stdout: - assert ( - "homeassistant.components.go2rtc.server", - logging.DEBUG, - entry, - ) in caplog.record_tuples + # Verify go2rtc binary stdout was logged with debug and warning level + assert_server_output_logged(server_stdout, caplog, logging.DEBUG) + assert_server_output_logged(server_stdout, caplog, logging.WARNING) assert ( "homeassistant.components.go2rtc.server", @@ -169,8 +199,10 @@ async def test_server_failed_to_start( async def test_server_restart_process_exit( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted when it exits.""" evt = asyncio.Event() @@ -188,10 +220,16 @@ async def test_server_restart_process_exit( await hass.async_block_till_done() mock_create_subprocess.assert_not_awaited() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + evt.set() await asyncio.sleep(0.1) mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -199,8 +237,10 @@ async def test_server_restart_process_exit( async def test_server_restart_process_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" mock_create_subprocess.return_value.wait.side_effect = [Exception, None, None, None] @@ -209,10 +249,16 @@ async def test_server_restart_process_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -220,8 +266,10 @@ async def test_server_restart_process_error( async def test_server_restart_api_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, + caplog: pytest.LogCaptureFixture, ) -> None: """Test that the server is restarted on error.""" rest_client.streams.list.side_effect = Exception @@ -230,10 +278,16 @@ async def test_server_restart_api_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + await server.stop() @@ -241,6 +295,7 @@ async def test_server_restart_api_error( async def test_server_restart_error( hass: HomeAssistant, mock_create_subprocess: AsyncMock, + server_stdout: list[str], rest_client: AsyncMock, server: Server, caplog: pytest.LogCaptureFixture, @@ -253,10 +308,16 @@ async def test_server_restart_error( mock_create_subprocess.assert_awaited_once() mock_create_subprocess.reset_mock() + # Verify go2rtc binary stdout was not yet logged with warning level + assert_server_output_not_logged(server_stdout, caplog, logging.WARNING) + await asyncio.sleep(0.1) await hass.async_block_till_done() mock_create_subprocess.assert_awaited_once() + # Verify go2rtc binary stdout was logged with warning level + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + assert "Unexpected error when restarting go2rtc server" in caplog.text await server.stop() From 030aebb97f57d6df526bb873ebfb64d7adc7fe8e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 15:23:41 +0100 Subject: [PATCH 196/511] Use default package for yt-dlp (#129886) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3e4db5d5b04..ebfa79d7190 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2024.11.04"], + "requirements": ["yt-dlp[default]==2024.11.04"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 7c35e676906..a9128c7cad9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3051,7 +3051,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e9de12cb28..a0a043b22e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2437,7 +2437,7 @@ youless-api==2.1.2 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp==2024.11.04 +yt-dlp[default]==2024.11.04 # homeassistant.components.zamg zamg==0.3.6 From 14875a11011652a50ca18a3293a176492c626232 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 15:57:33 +0100 Subject: [PATCH 197/511] Map go2rtc log levels to Python log levels (#129894) --- homeassistant/components/go2rtc/server.py | 15 ++++- tests/components/go2rtc/test_server.py | 69 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 9be02d9a5d6..ed3b44aadf9 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -39,6 +39,16 @@ webrtc: ice_servers: [] """ +_LOG_LEVEL_MAP = { + "TRC": logging.DEBUG, + "DBG": logging.DEBUG, + "INF": logging.DEBUG, + "WRN": logging.WARNING, + "ERR": logging.WARNING, + "FTL": logging.ERROR, + "PNC": logging.ERROR, +} + class Go2RTCServerStartError(HomeAssistantError): """Raised when server does not start.""" @@ -132,7 +142,10 @@ class Server: async for line in process.stdout: msg = line[:-1].decode().strip() self._log_buffer.append(msg) - _LOGGER.debug(msg) + loglevel = logging.WARNING + if len(split_msg := msg.split(" ", 2)) == 3: + loglevel = _LOG_LEVEL_MAP.get(split_msg[1], loglevel) + _LOGGER.log(loglevel, msg) if not self._startup_complete.is_set() and _SUCCESSFUL_BOOT_MESSAGE in msg: self._startup_complete.set() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index cda05fc4f2b..d810dbd88eb 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -195,6 +195,75 @@ async def test_server_failed_to_start( ) +@pytest.mark.parametrize( + ("server_stdout", "expected_loglevel"), + [ + ( + [ + "09:00:03.466 TRC [api] register path path=/", + "09:00:03.466 DBG build vcs.time=2024-10-28T19:47:55Z version=go1.23.2", + "09:00:03.466 INF go2rtc platform=linux/amd64 revision=780f378 version=1.9.5", + "09:00:03.467 INF [api] listen addr=127.0.0.1:1984", + "09:00:03.466 WRN warning message", + '09:00:03.466 ERR [api] listen error="listen tcp 127.0.0.1:11984: bind: address already in use"', + "09:00:03.466 FTL fatal message", + "09:00:03.466 PNC panic message", + "exit with signal: interrupt", # Example of stderr write + ], + [ + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.DEBUG, + logging.WARNING, + logging.WARNING, + logging.ERROR, + logging.ERROR, + logging.WARNING, + ], + ) + ], +) +@patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) +async def test_log_level_mapping( + hass: HomeAssistant, + mock_create_subprocess: MagicMock, + server_stdout: list[str], + rest_client: AsyncMock, + server: Server, + caplog: pytest.LogCaptureFixture, + expected_loglevel: list[int], +) -> None: + """Log level mapping.""" + evt = asyncio.Event() + + async def wait_event() -> None: + await evt.wait() + + mock_create_subprocess.return_value.wait.side_effect = wait_event + + await server.start() + + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + # Verify go2rtc binary stdout was logged with default level + for i, entry in enumerate(server_stdout): + assert ( + "homeassistant.components.go2rtc.server", + expected_loglevel[i], + entry, + ) in caplog.record_tuples + + evt.set() + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + assert_server_output_logged(server_stdout, caplog, logging.WARNING) + + await server.stop() + + @patch("homeassistant.components.go2rtc.server._RESPAWN_COOLDOWN", 0) async def test_server_restart_process_exit( hass: HomeAssistant, From cc30d34e87c2683a03674c1b295f925512f1cd27 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 16:32:05 +0100 Subject: [PATCH 198/511] Remove timers from LG ThinQ (#129898) --- homeassistant/components/lg_thinq/sensor.py | 87 +----------------- .../lg_thinq/snapshots/test_sensor.ambr | 92 ------------------- 2 files changed, 1 insertion(+), 178 deletions(-) diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 30d38685b3a..99b4df8176e 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -255,73 +255,9 @@ WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { translation_key=ThinQProperty.WATER_TYPE, ), } -TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { - TimerProperty.RELATIVE_TO_START: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START, - ), - TimerProperty.RELATIVE_TO_START_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_START, - translation_key=TimerProperty.RELATIVE_TO_START_WM, - ), - TimerProperty.RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP, - ), - TimerProperty.RELATIVE_TO_STOP_WM: SensorEntityDescription( - key=TimerProperty.RELATIVE_TO_STOP, - translation_key=TimerProperty.RELATIVE_TO_STOP_WM, - ), - TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP: SensorEntityDescription( - key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - translation_key=TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP, - ), - TimerProperty.ABSOLUTE_TO_START: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_START, - translation_key=TimerProperty.ABSOLUTE_TO_START, - ), - TimerProperty.ABSOLUTE_TO_STOP: SensorEntityDescription( - key=TimerProperty.ABSOLUTE_TO_STOP, - translation_key=TimerProperty.ABSOLUTE_TO_STOP, - ), - TimerProperty.REMAIN: SensorEntityDescription( - key=TimerProperty.REMAIN, - translation_key=TimerProperty.REMAIN, - ), - TimerProperty.TARGET: SensorEntityDescription( - key=TimerProperty.TARGET, - translation_key=TimerProperty.TARGET, - ), - TimerProperty.RUNNING: SensorEntityDescription( - key=TimerProperty.RUNNING, - translation_key=TimerProperty.RUNNING, - ), - TimerProperty.TOTAL: SensorEntityDescription( - key=TimerProperty.TOTAL, - translation_key=TimerProperty.TOTAL, - ), - TimerProperty.LIGHT_START: SensorEntityDescription( - key=TimerProperty.LIGHT_START, - translation_key=TimerProperty.LIGHT_START, - ), - ThinQProperty.ELAPSED_DAY_STATE: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_STATE, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_STATE, - ), - ThinQProperty.ELAPSED_DAY_TOTAL: SensorEntityDescription( - key=ThinQProperty.ELAPSED_DAY_TOTAL, - native_unit_of_measurement=UnitOfTime.DAYS, - translation_key=ThinQProperty.ELAPSED_DAY_TOTAL, - ), -} WASHER_SENSORS: tuple[SensorEntityDescription, ...] = ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ) DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = { DeviceType.AIR_CONDITIONER: ( @@ -332,9 +268,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_LIFETIME], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER_FAN: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -345,7 +278,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.AIR_PURIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], @@ -361,7 +293,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.COOKTOP: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], POWER_SENSOR_DESC[ThinQProperty.POWER_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], ), DeviceType.DEHUMIDIFIER: ( JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], @@ -372,9 +303,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = PREFERENCE_SENSOR_DESC[ThinQProperty.RINSE_LEVEL], PREFERENCE_SENSOR_DESC[ThinQProperty.SOFTENING_LEVEL], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TOTAL], ), DeviceType.DRYER: WASHER_SENSORS, DeviceType.HOME_BREW: ( @@ -385,10 +313,7 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = RECIPE_SENSOR_DESC[ThinQProperty.FLAVOR_INFO], RECIPE_SENSOR_DESC[ThinQProperty.BEER_REMAIN], RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_STATE], - TIMER_SENSOR_DESC[ThinQProperty.ELAPSED_DAY_TOTAL], ), - DeviceType.HOOD: (TIMER_SENSOR_DESC[TimerProperty.REMAIN],), DeviceType.HUMIDIFIER: ( AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM1], AIR_QUALITY_SENSOR_DESC[ThinQProperty.PM2], @@ -397,9 +322,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = AIR_QUALITY_SENSOR_DESC[ThinQProperty.TEMPERATURE], AIR_QUALITY_SENSOR_DESC[ThinQProperty.MONITORING_ENABLED], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], - TIMER_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_STOP], - TIMER_SENSOR_DESC[TimerProperty.SLEEP_TIMER_RELATIVE_TO_STOP], ), DeviceType.KIMCHI_REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -408,15 +330,10 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = translation_key=ThinQProperty.TARGET_TEMPERATURE, ), ), - DeviceType.MICROWAVE_OVEN: ( - RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - ), + DeviceType.MICROWAVE_OVEN: (RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],), DeviceType.OVEN: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TARGET_TEMPERATURE], - TIMER_SENSOR_DESC[TimerProperty.REMAIN], - TIMER_SENSOR_DESC[TimerProperty.TARGET], ), DeviceType.PLANT_CULTIVATOR: ( LIGHT_SENSOR_DESC[ThinQProperty.BRIGHTNESS], @@ -427,7 +344,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = TEMPERATURE_SENSOR_DESC[ThinQProperty.DAY_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.NIGHT_TARGET_TEMPERATURE], TEMPERATURE_SENSOR_DESC[ThinQProperty.TEMPERATURE_STATE], - TIMER_SENSOR_DESC[TimerProperty.LIGHT_START], ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], @@ -436,7 +352,6 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] = DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], - TIMER_SENSOR_DESC[TimerProperty.RUNNING], ), DeviceType.STICK_CLEANER: ( BATTERY_SENSOR_DESC[ThinQProperty.BATTERY_PERCENT], diff --git a/tests/components/lg_thinq/snapshots/test_sensor.ambr b/tests/components/lg_thinq/snapshots/test_sensor.ambr index aa50ae5b03e..387df916eba 100644 --- a/tests/components/lg_thinq/snapshots/test_sensor.ambr +++ b/tests/components/lg_thinq/snapshots/test_sensor.ambr @@ -203,95 +203,3 @@ 'state': '24', }) # --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - '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': 'Schedule turn-off', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_stop', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-off', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - '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': 'Schedule turn-on', - 'platform': 'lg_thinq', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'MW2-2E247F93-B570-46A6-B827-920E9E10F966_relative_to_start', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[sensor.test_air_conditioner_schedule_turn_on-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test air conditioner Schedule turn-on', - }), - 'context': , - 'entity_id': 'sensor.test_air_conditioner_schedule_turn_on', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- From c85eb6bf8ecd5d80a58c8b772d1383516876868e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Nov 2024 16:51:05 +0100 Subject: [PATCH 199/511] Bump version to 2024.11.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 cee701c230e..a21b128f414 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 b0d48ff2015..a289448d87a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b5" +version = "2024.11.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9253fa4471a5dfa1591a7741cf59d4c57cbd9a06 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:01:38 +0100 Subject: [PATCH 200/511] Add binary sensor platform to Habitica integration (#129613) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/habitica/__init__.py | 1 + .../components/habitica/binary_sensor.py | 85 +++++++++++++++++++ homeassistant/components/habitica/icons.json | 8 ++ .../components/habitica/strings.json | 5 ++ .../fixtures/quest_invitation_off.json | 64 ++++++++++++++ tests/components/habitica/fixtures/user.json | 6 ++ .../snapshots/test_binary_sensor.ambr | 48 +++++++++++ .../components/habitica/test_binary_sensor.py | 80 +++++++++++++++++ 8 files changed, 297 insertions(+) create mode 100644 homeassistant/components/habitica/binary_sensor.py create mode 100644 tests/components/habitica/fixtures/quest_invitation_off.json create mode 100644 tests/components/habitica/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/habitica/test_binary_sensor.py diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 502f52609dd..5843e14d63e 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -30,6 +30,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CALENDAR, Platform.SENSOR, diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py new file mode 100644 index 00000000000..bc79370ea63 --- /dev/null +++ b/homeassistant/components/habitica/binary_sensor.py @@ -0,0 +1,85 @@ +"""Binary sensor platform for Habitica integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ASSETS_URL +from .entity import HabiticaBase +from .types import HabiticaConfigEntry + + +@dataclass(kw_only=True, frozen=True) +class HabiticaBinarySensorEntityDescription(BinarySensorEntityDescription): + """Habitica Binary Sensor Description.""" + + value_fn: Callable[[dict[str, Any]], bool | None] + entity_picture: Callable[[dict[str, Any]], str | None] + + +class HabiticaBinarySensor(StrEnum): + """Habitica Entities.""" + + PENDING_QUEST = "pending_quest" + + +def get_scroll_image_for_pending_quest_invitation(user: dict[str, Any]) -> str | None: + """Entity picture for pending quest invitation.""" + if user["party"]["quest"].get("key") and user["party"]["quest"]["RSVPNeeded"]: + return f"inventory_quest_scroll_{user["party"]["quest"]["key"]}.png" + return None + + +BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] = ( + HabiticaBinarySensorEntityDescription( + key=HabiticaBinarySensor.PENDING_QUEST, + translation_key=HabiticaBinarySensor.PENDING_QUEST, + value_fn=lambda user: user["party"]["quest"]["RSVPNeeded"], + entity_picture=get_scroll_image_for_pending_quest_invitation, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HabiticaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the habitica binary sensors.""" + + coordinator = config_entry.runtime_data + + async_add_entities( + HabiticaBinarySensorEntity(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class HabiticaBinarySensorEntity(HabiticaBase, BinarySensorEntity): + """Representation of a Habitica binary sensor.""" + + entity_description: HabiticaBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """If the binary sensor is on.""" + return self.entity_description.value_fn(self.coordinator.data.user) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if entity_picture := self.entity_description.entity_picture( + self.coordinator.data.user + ): + return f"{ASSETS_URL}{entity_picture}" + return None diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 617f08a4e58..0698b85afe1 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -135,6 +135,14 @@ "on": "mdi:sleep" } } + }, + "binary_sensor": { + "pending_quest": { + "default": "mdi:script-outline", + "state": { + "on": "mdi:script-text-outline" + } + } } }, "services": { diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 390dc3ba9ae..45824c484e9 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -38,6 +38,11 @@ } }, "entity": { + "binary_sensor": { + "pending_quest": { + "name": "Pending quest invitation" + } + }, "button": { "run_cron": { "name": "Start my day" diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json new file mode 100644 index 00000000000..f862a85c7c4 --- /dev/null +++ b/tests/components/habitica/fixtures/quest_invitation_off.json @@ -0,0 +1,64 @@ +{ + "data": { + "api_user": "test-api-user", + "profile": { "name": "test-user" }, + "stats": { + "buffs": { + "str": 0, + "int": 0, + "per": 0, + "con": 0, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "hp": 0, + "mp": 50.89999999999998, + "exp": 737, + "gp": 137.62587214609795, + "lvl": 38, + "class": "wizard", + "maxHealth": 50, + "maxMP": 166, + "toNextLevel": 880, + "points": 5 + }, + "preferences": { + "sleep": false, + "automaticAllocation": true, + "disableClasses": false + }, + "flags": { + "classSelected": true + }, + "tasksOrder": { + "rewards": ["5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b"], + "todos": [ + "88de7cd9-af2b-49ce-9afd-bf941d87336b", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "1aa3137e-ef72-4d1f-91ee-41933602f438", + "86ea2475-d1b5-4020-bdcc-c188c7996afa" + ], + "dailys": [ + "f21fa608-cfc6-4413-9fc7-0eb1b48ca43a", + "bc1d1855-b2b8-4663-98ff-62e7b763dfc4", + "e97659e0-2c42-4599-a7bb-00282adc410d", + "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", + "f2c85972-1a19-4426-bc6d-ce3337b9d99f", + "2c6d136c-a1c3-4bef-b7c4-fa980784b1e1" + ], + "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] + }, + "party": { + "quest": { + "RSVPNeeded": false, + "key": null + } + }, + "needsCron": true, + "lastCron": "2024-09-21T22:01:55.586Z" + } +} diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index a10ce354f44..818f4ed4eda 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -52,6 +52,12 @@ ], "habits": ["1d147de6-5c02-4740-8e2f-71d3015a37f4"] }, + "party": { + "quest": { + "RSVPNeeded": true, + "key": "dustbunnies" + } + }, "needsCron": true, "lastCron": "2024-09-21T22:01:55.586Z" } diff --git a/tests/components/habitica/snapshots/test_binary_sensor.ambr b/tests/components/habitica/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..c18f8f551c9 --- /dev/null +++ b/tests/components/habitica/snapshots/test_binary_sensor.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_binary_sensors[binary_sensor.test_user_pending_quest_invitation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_user_pending_quest_invitation', + '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': 'Pending quest invitation', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_pending_quest', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.test_user_pending_quest_invitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://habitica-assets.s3.amazonaws.com/mobileApp/images/inventory_quest_scroll_dustbunnies.png', + 'friendly_name': 'test-user Pending quest invitation', + }), + 'context': , + 'entity_id': 'binary_sensor.test_user_pending_quest_invitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py new file mode 100644 index 00000000000..5b19cd008bf --- /dev/null +++ b/tests/components/habitica/test_binary_sensor.py @@ -0,0 +1,80 @@ +"""Tests for the Habitica binary sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.habitica.const import ASSETS_URL, DEFAULT_URL, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def binary_sensor_only() -> Generator[None]: + """Enable only the binarty sensor platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_habitica") +async def test_binary_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the Habitica binary sensor platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("fixture", "entity_state", "entity_picture"), + [ + ("user", STATE_ON, f"{ASSETS_URL}inventory_quest_scroll_dustbunnies.png"), + ("quest_invitation_off", STATE_OFF, None), + ], +) +async def test_pending_quest_states( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + fixture: str, + entity_state: str, + entity_picture: str | None, +) -> None: + """Test states of pending quest sensor.""" + + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/user", + json=load_json_object_fixture(f"{fixture}.json", DOMAIN), + ) + aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert ( + state := hass.states.get("binary_sensor.test_user_pending_quest_invitation") + ) + assert state.state == entity_state + assert state.attributes.get("entity_picture") == entity_picture From ed56e5d631d193083b39d8608703d80290311f6d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 11:02:44 -0500 Subject: [PATCH 201/511] Change Ollama default to llama3.2 (#129901) --- homeassistant/components/ollama/const.py | 64 +++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 6152b223d6d..69c0a3d6296 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -24,8 +24,12 @@ MAX_HISTORY_SECONDS = 60 * 60 # 1 hour MODEL_NAMES = [ # https://ollama.com/library "alfred", "all-minilm", + "aya-expanse", "aya", "bakllava", + "bespoke-minicheck", + "bge-large", + "bge-m3", "codebooga", "codegeex4", "codegemma", @@ -33,18 +37,19 @@ MODEL_NAMES = [ # https://ollama.com/library "codeqwen", "codestral", "codeup", - "command-r", "command-r-plus", + "command-r", "dbrx", - "deepseek-coder", "deepseek-coder-v2", + "deepseek-coder", "deepseek-llm", + "deepseek-v2.5", "deepseek-v2", - "dolphincoder", "dolphin-llama3", "dolphin-mistral", "dolphin-mixtral", "dolphin-phi", + "dolphincoder", "duckdb-nsql", "everythinglm", "falcon", @@ -55,74 +60,97 @@ MODEL_NAMES = [ # https://ollama.com/library "glm4", "goliath", "granite-code", + "granite3-dense", + "granite3-guardian" "granite3-moe", + "hermes3", "internlm2", - "llama2", + "llama-guard3", + "llama-pro", "llama2-chinese", "llama2-uncensored", - "llama3", + "llama2", "llama3-chatqa", "llama3-gradient", "llama3-groq-tool-use", - "llama-pro", - "llava", + "llama3.1", + "llama3.2", + "llama3", "llava-llama3", "llava-phi3", + "llava", "magicoder", "mathstral", "meditron", "medllama2", "megadolphin", - "mistral", - "mistrallite", + "minicpm-v", + "mistral-large", "mistral-nemo", "mistral-openorca", + "mistral-small", + "mistral", + "mistrallite", "mixtral", "moondream", "mxbai-embed-large", + "nemotron-mini", + "nemotron", "neural-chat", "nexusraven", "nomic-embed-text", "notus", "notux", "nous-hermes", - "nous-hermes2", "nous-hermes2-mixtral", + "nous-hermes2", "nuextract", + "open-orca-platypus2", "openchat", "openhermes", - "open-orca-platypus2", - "orca2", "orca-mini", + "orca2", + "paraphrase-multilingual", "phi", + "phi3.5", "phi3", "phind-codellama", "qwen", + "qwen2-math", + "qwen2.5-coder", + "qwen2.5", "qwen2", + "reader-lm", + "reflection", "samantha-mistral", + "shieldgemma", + "smollm", + "smollm2", "snowflake-arctic-embed", + "solar-pro", "solar", "sqlcoder", "stable-beluga", "stable-code", - "stablelm2", "stablelm-zephyr", + "stablelm2", "starcoder", "starcoder2", "starling-lm", "tinydolphin", "tinyllama", "vicuna", + "wizard-math", + "wizard-vicuna-uncensored", + "wizard-vicuna", "wizardcoder", + "wizardlm-uncensored", "wizardlm", "wizardlm2", - "wizardlm-uncensored", - "wizard-math", - "wizard-vicuna", - "wizard-vicuna-uncensored", "xwinlm", "yarn-llama2", "yarn-mistral", + "yi-coder", "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.1:latest" +DEFAULT_MODEL = "llama3.2:latest" From 05e76105ad0dd28653701c7900fb70d3928d9b7a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 17:12:05 +0100 Subject: [PATCH 202/511] Improve improv BLE error handling (#129902) --- .../components/improv_ble/config_flow.py | 18 ++++++++++++++---- tests/components/improv_ble/__init__.py | 19 +++++++++++++++++++ .../components/improv_ble/test_config_flow.py | 18 ++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index f38f4830ace..05dd1de449a 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -120,12 +120,22 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): assert self._discovery_info is not None service_data = self._discovery_info.service_data - improv_service_data = ImprovServiceData.from_bytes( - service_data[SERVICE_DATA_UUID] - ) + try: + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + except improv_ble_errors.InvalidCommand as err: + _LOGGER.warning( + "Aborting improv flow, device %s sent invalid improv data: '%s'", + self._discovery_info.address, + service_data[SERVICE_DATA_UUID].hex(), + ) + raise AbortFlow("invalid_improv_data") from err + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device is already provisioned: %s", + "Aborting improv flow, device %s is already provisioned: %s", + self._discovery_info.address, improv_service_data.state, ) raise AbortFlow("already_provisioned") diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py index 41ea98cda7b..521d0881443 100644 --- a/tests/components/improv_ble/__init__.py +++ b/tests/components/improv_ble/__init__.py @@ -25,6 +25,25 @@ IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( ) +BAD_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, + tx_power=-127, +) + + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", address="AA:BB:CC:DD:EE:F0", diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 640a931bee5..2df4be2ba7d 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from . import ( + BAD_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO, NOT_IMPROV_BLE_DISCOVERY_INFO, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, @@ -649,3 +650,20 @@ async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "provision" assert result["errors"] == {"base": error} + + +async def test_provision_fails_invalid_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test bluetooth flow with error due to invalid data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=BAD_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_improv_data" + assert ( + "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + in caplog.text + ) From 611a952232c650def4cf979805c8f685859774e2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 18:39:10 +0100 Subject: [PATCH 203/511] Prevent update entity becoming unavailable on device disconnect in IronOS (#129840) * Don't render update entity unavailable when Pinecil device disconnects * fixes --- homeassistant/components/iron_os/update.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index bae9ccd4c6c..786ba86f730 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -92,4 +92,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.firmware_update.last_update_success + return ( + self.installed_version is not None + and self.firmware_update.last_update_success + ) From c54ed53a818728807786f52c8eb789da445ed8db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 19:51:20 +0100 Subject: [PATCH 204/511] Remove usage of options property in OptionsFlow (part 1) (#129895) * Remove usage of options property in OptionsFlow * Improve --- .../components/analytics_insights/config_flow.py | 2 +- homeassistant/components/androidtv/config_flow.py | 2 +- homeassistant/components/elevenlabs/config_flow.py | 2 +- homeassistant/components/feedreader/config_flow.py | 4 +++- homeassistant/components/fritz/config_flow.py | 7 +++---- homeassistant/components/lamarzocco/config_flow.py | 2 +- homeassistant/components/opensky/config_flow.py | 8 ++------ .../components/pvpc_hourly_pricing/config_flow.py | 14 ++++++-------- homeassistant/components/roku/config_flow.py | 2 +- homeassistant/components/roomba/config_flow.py | 5 +++-- homeassistant/components/sql/config_flow.py | 4 ++-- .../components/trafikverket_train/config_flow.py | 2 +- homeassistant/components/upnp/config_flow.py | 2 +- .../components/vodafone_station/config_flow.py | 2 +- homeassistant/components/wled/config_flow.py | 2 +- homeassistant/components/workday/config_flow.py | 13 ++++++------- homeassistant/components/youtube/config_flow.py | 2 +- 17 files changed, 35 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index 0212f208436..c36755f5403 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -212,6 +212,6 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): ), }, ), - self.options, + self.config_entry.options, ), ) diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index a41a113268e..afaba5175da 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -235,7 +235,7 @@ class OptionsFlowHandler(OptionsFlow): SelectOptionDict(value=k, label=v) for k, v in apps_list.items() ] rules = [RULES_NEW_ID, *self._state_det_rules] - options = self.options + options = self.config_entry.options data_schema = vol.Schema( { diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 6419b1c973c..227150a0f4e 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -168,7 +168,7 @@ class ElevenLabsOptionsFlow(OptionsFlow): vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, } ), - self.options, + self.config_entry.options, ) async def async_step_voice_settings( diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 1a19f612e7e..b902d48a1c8 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -163,7 +163,9 @@ class FeedReaderOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_MAX_ENTRIES, - default=self.options.get(CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES), + default=self.config_entry.options.get( + CONF_MAX_ENTRIES, DEFAULT_MAX_ENTRIES + ), ): cv.positive_int, } ) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 38e86519a01..ec9ffdd7554 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -405,19 +405,18 @@ class FritzBoxToolsOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) + options = self.config_entry.options data_schema = vol.Schema( { vol.Optional( CONF_CONSIDER_HOME, - default=self.options.get( + default=options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)), vol.Optional( CONF_OLD_DISCOVERY, - default=self.options.get( - CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY - ), + default=options.get(CONF_OLD_DISCOVERY, DEFAULT_CONF_OLD_DISCOVERY), ): bool, } ) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index bcb55a19275..4fadd3a9a32 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -359,7 +359,7 @@ class LmOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_USE_BLUETOOTH, - default=self.options.get(CONF_USE_BLUETOOTH, True), + default=self.config_entry.options.get(CONF_USE_BLUETOOTH, True), ): cv.boolean, } ) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index f0f599628cb..867a4781265 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_PASSWORD, CONF_RADIUS, CONF_USERNAME, @@ -112,10 +111,7 @@ class OpenSkyOptionsFlowHandler(OptionsFlow): except OpenSkyUnauthenticatedError: errors["base"] = "invalid_auth" if not errors: - return self.async_create_entry( - title=self.options.get(CONF_NAME, "OpenSky"), - data=user_input, - ) + return self.async_create_entry(data=user_input) return self.async_show_form( step_id="init", @@ -130,6 +126,6 @@ class OpenSkyOptionsFlowHandler(OptionsFlow): vol.Optional(CONF_CONTRIBUTING_USER, default=False): bool, } ), - user_input or self.options, + user_input or self.config_entry.options, ), ) diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index af80c40b75b..3c6b510004a 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -199,7 +199,7 @@ class PVPCOptionsFlowHandler(OptionsFlow): ) # Fill options with entry data - api_token = self.options.get( + api_token = self.config_entry.options.get( CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) ) return self.async_show_form( @@ -229,13 +229,11 @@ class PVPCOptionsFlowHandler(OptionsFlow): ) # Fill options with entry data - power = self.options.get(ATTR_POWER, self.config_entry.data[ATTR_POWER]) - power_valley = self.options.get( - ATTR_POWER_P3, self.config_entry.data[ATTR_POWER_P3] - ) - api_token = self.options.get( - CONF_API_TOKEN, self.config_entry.data.get(CONF_API_TOKEN) - ) + options = self.config_entry.options + data = self.config_entry.data + power = options.get(ATTR_POWER, data[ATTR_POWER]) + power_valley = options.get(ATTR_POWER_P3, data[ATTR_POWER_P3]) + api_token = options.get(CONF_API_TOKEN, data.get(CONF_API_TOKEN)) use_api_token = api_token is not None schema = vol.Schema( { diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index a99c475f515..18e3b3ed68a 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -186,7 +186,7 @@ class RokuOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_PLAY_MEDIA_APP_ID, - default=self.options.get( + default=self.config_entry.options.get( CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID ), ): str, diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index a53f0ac857f..e48d2d91139 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -310,17 +310,18 @@ class RoombaOptionsFlowHandler(OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) + options = self.config_entry.options return self.async_show_form( step_id="init", data_schema=vol.Schema( { vol.Optional( CONF_CONTINUOUS, - default=self.options.get(CONF_CONTINUOUS, DEFAULT_CONTINUOUS), + default=options.get(CONF_CONTINUOUS, DEFAULT_CONTINUOUS), ): bool, vol.Optional( CONF_DELAY, - default=self.options.get(CONF_DELAY, DEFAULT_DELAY), + default=options.get(CONF_DELAY, DEFAULT_DELAY), ): int, } ), diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 9f0614fae89..4fe04f2401c 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -223,7 +223,7 @@ class SQLOptionsFlowHandler(OptionsFlow): db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - name = self.options.get(CONF_NAME, self.config_entry.title) + name = self.config_entry.options.get(CONF_NAME, self.config_entry.title) try: query = validate_sql_select(query) @@ -275,7 +275,7 @@ class SQLOptionsFlowHandler(OptionsFlow): return self.async_show_form( step_id="init", data_schema=self.add_suggested_values_to_schema( - OPTIONS_SCHEMA, user_input or self.options + OPTIONS_SCHEMA, user_input or self.config_entry.options ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index b3b8180a08d..f498a7b0d0e 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -247,7 +247,7 @@ class TVTrainOptionsFlowHandler(OptionsFlow): step_id="init", data_schema=self.add_suggested_values_to_schema( vol.Schema(OPTION_SCHEMA), - user_input or self.options, + user_input or self.config_entry.options, ), errors=errors, ) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 5f1fdbee88f..41e481fa58c 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -314,7 +314,7 @@ class UpnpOptionsFlowHandler(OptionsFlow): { vol.Optional( CONFIG_ENTRY_FORCE_POLL, - default=self.options.get( + default=self.config_entry.options.get( CONFIG_ENTRY_FORCE_POLL, DEFAULT_CONFIG_ENTRY_FORCE_POLL ), ): bool, diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 288ebeb9a07..7a80244f8d6 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -159,7 +159,7 @@ class VodafoneStationOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_CONSIDER_HOME, - default=self.options.get( + default=self.config_entry.options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ), ): vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900)) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 67f2f60d13e..812a0500d1a 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -135,7 +135,7 @@ class WLEDOptionsFlowHandler(OptionsFlow): { vol.Optional( CONF_KEEP_MAIN_LIGHT, - default=self.options.get( + default=self.config_entry.options.get( CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ), ): bool, diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 759cc13aecf..4d93fccb1a7 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -320,7 +320,7 @@ class WorkdayOptionsFlowHandler(OptionsFlow): errors: dict[str, str] = {} if user_input is not None: - combined_input: dict[str, Any] = {**self.options, **user_input} + combined_input: dict[str, Any] = {**self.config_entry.options, **user_input} if CONF_PROVINCE not in user_input: # Province not present, delete old value (if present) too combined_input.pop(CONF_PROVINCE, None) @@ -357,23 +357,22 @@ class WorkdayOptionsFlowHandler(OptionsFlow): else: return self.async_create_entry(data=combined_input) + options = self.config_entry.options schema: vol.Schema = await self.hass.async_add_executor_job( add_province_and_language_to_schema, DATA_SCHEMA_OPT, - self.options.get(CONF_COUNTRY), + options.get(CONF_COUNTRY), ) - new_schema = self.add_suggested_values_to_schema( - schema, user_input or self.options - ) + new_schema = self.add_suggested_values_to_schema(schema, user_input or options) LOGGER.debug("Errors have occurred in options %s", errors) return self.async_show_form( step_id="init", data_schema=new_schema, errors=errors, description_placeholders={ - "name": self.options[CONF_NAME], - "country": self.options.get(CONF_COUNTRY), + "name": options[CONF_NAME], + "country": options.get(CONF_COUNTRY), }, ) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index d03beffdb49..48336422585 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -194,6 +194,6 @@ class YouTubeOptionsFlowHandler(OptionsFlow): ), } ), - self.options, + self.config_entry.options, ), ) From 1e42a38473c0ff2927aa8fe8e80627e4ecf8c47a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 5 Nov 2024 19:53:05 +0100 Subject: [PATCH 205/511] Remove usage of options property in OptionsFlow (part 2) (#129897) --- homeassistant/components/axis/config_flow.py | 3 +-- homeassistant/components/deconz/config_flow.py | 3 +-- homeassistant/components/iss/config_flow.py | 3 +-- homeassistant/components/kitchen_sink/config_flow.py | 7 +------ 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 5026f7e7ab6..592b1e2d41f 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -284,8 +284,7 @@ class AxisOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the Axis device stream options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) schema = {} diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 6332c56a08a..ed54701f656 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -312,8 +312,7 @@ class DeconzOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the deconz devices options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) schema_options = {} for option, default in ( diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index 567618a7680..eaf01a6d094 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -47,8 +47,7 @@ class OptionsFlowHandler(OptionsFlow): async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - self.options.update(user_input) - return self.async_create_entry(title="", data=self.options) + return self.async_create_entry(data=self.config_entry.options | user_input) return self.async_show_form( step_id="init", diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 74e738a0e04..019d1dddcad 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -68,8 +68,7 @@ class OptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: - self.options.update(user_input) - return await self._update_options() + return self.async_create_entry(data=self.config_entry.options | user_input) return self.async_show_form( step_id="options_1", @@ -95,7 +94,3 @@ class OptionsFlowHandler(OptionsFlow): } ), ) - - async def _update_options(self) -> ConfigFlowResult: - """Update config entry options.""" - return self.async_create_entry(title="", data=self.options) From 83a1b06b560703ec723254afe57878fc795bad29 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 Nov 2024 18:59:43 +0000 Subject: [PATCH 206/511] Set friendly name of utility meter select entity when configured through YAML (#128267) * set select friendly name in YAML * backward compatibility added * clean * cleaner backward compatibility approach * don't introduce default unique_id * split test according to review --- .../components/utility_meter/select.py | 24 ++++--- tests/components/utility_meter/test_select.py | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index d5b1206d046..5815ce7ec95 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -36,9 +36,9 @@ async def async_setup_entry( ) tariff_select = TariffSelect( - name, - tariffs, - unique_id, + name=name, + tariffs=tariffs, + unique_id=unique_id, device_info=device_info, ) async_add_entities([tariff_select]) @@ -62,13 +62,15 @@ async def async_setup_platform( conf_meter_unique_id: str | None = hass.data[DATA_UTILITY][meter].get( CONF_UNIQUE_ID ) + conf_meter_name = hass.data[DATA_UTILITY][meter].get(CONF_NAME, meter) async_add_entities( [ TariffSelect( - meter, - discovery_info[CONF_TARIFFS], - conf_meter_unique_id, + name=conf_meter_name, + tariffs=discovery_info[CONF_TARIFFS], + yaml_slug=meter, + unique_id=conf_meter_unique_id, ) ] ) @@ -82,12 +84,16 @@ class TariffSelect(SelectEntity, RestoreEntity): def __init__( self, name, - tariffs, - unique_id, + tariffs: list[str], + *, + yaml_slug: str | None = None, + unique_id: str | None = None, device_info: DeviceInfo | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name + if yaml_slug: # Backwards compatibility with YAML configuration entries + self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id self._attr_device_info = device_info self._current_tariff: str | None = None diff --git a/tests/components/utility_meter/test_select.py b/tests/components/utility_meter/test_select.py index 61f6cbe75b9..1f54f3b500a 100644 --- a/tests/components/utility_meter/test_select.py +++ b/tests/components/utility_meter/test_select.py @@ -3,10 +3,72 @@ from homeassistant.components.utility_meter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +async def test_select_entity_name_config_entry( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + config_entry_config = { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + } + + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + +async def test_select_entity_name_yaml( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + yaml_config = { + "utility_meter": { + "energy_bill": { + "name": "Energy bill", + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + "unique_id": "1234abcd", + } + } + } + + assert await async_setup_component(hass, DOMAIN, yaml_config) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 94db78a0be3bb1e2a3301d54d82ede66af4de03f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 6 Nov 2024 05:04:55 +1000 Subject: [PATCH 207/511] Add signing support to Tesla Fleet (#128407) * Add command signing * wip * Update tests * requirements * Add test --- .../components/tesla_fleet/__init__.py | 17 ++++++++-- .../components/tesla_fleet/button.py | 2 -- .../components/tesla_fleet/climate.py | 4 +-- homeassistant/components/tesla_fleet/cover.py | 10 +++--- .../components/tesla_fleet/entity.py | 8 ----- .../components/tesla_fleet/media_player.py | 2 +- .../components/tesla_fleet/strings.json | 3 -- tests/components/tesla_fleet/conftest.py | 10 ++++++ .../snapshots/test_media_player.ambr | 4 +-- tests/components/tesla_fleet/test_button.py | 32 ++++++++++++++++++- tests/components/tesla_fleet/test_init.py | 20 ++++++++++++ tests/components/tesla_fleet/test_switch.py | 27 ---------------- 12 files changed, 85 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 4cd8c5c7142..70db4a183aa 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -5,7 +5,12 @@ from typing import Final from aiohttp.client_exceptions import ClientResponseError import jwt -from tesla_fleet_api import EnergySpecific, TeslaFleetApi, VehicleSpecific +from tesla_fleet_api import ( + EnergySpecific, + TeslaFleetApi, + VehicleSigned, + VehicleSpecific, +) from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( InvalidRegion, @@ -126,7 +131,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - # Remove the protobuff 'cached_data' that we do not use to save memory product.pop("cached_data", None) vin = product["vin"] - api = VehicleSpecific(tesla.vehicle, vin) + signing = product["command_signing"] == "required" + if signing: + if not tesla.private_key: + await tesla.get_private_key("config/tesla_fleet.key") + api = VehicleSigned(tesla.vehicle, vin) + else: + api = VehicleSpecific(tesla.vehicle, vin) coordinator = TeslaFleetVehicleDataCoordinator(hass, api, product) await coordinator.async_config_entry_first_refresh() @@ -145,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - coordinator=coordinator, vin=vin, device=device, - signing=product["command_signing"] == "required", + signing=signing, ) ) elif "energy_site_id" in product and hasattr(tesla, "energy"): diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py index 87cd95576d2..aea0f91a97c 100644 --- a/homeassistant/components/tesla_fleet/button.py +++ b/homeassistant/components/tesla_fleet/button.py @@ -70,8 +70,6 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles for description in DESCRIPTIONS if Scope.VEHICLE_CMDS in entry.runtime_data.scopes - and (not vehicle.signing or description.key == "wake") - # Wake doesn't need signing ) diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index 6199ee112b5..9a1533a688f 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -84,7 +84,7 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): ) -> None: """Initialize the climate.""" - self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + self.read_only = Scope.VEHICLE_CMDS not in scopes if self.read_only: self._attr_supported_features = ClimateEntityFeature(0) @@ -231,7 +231,7 @@ class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEn """Initialize the cabin overheat climate entity.""" # Scopes - self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + self.read_only = Scope.VEHICLE_CMDS not in scopes # Supported Features if self.read_only: diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 4e49e24b689..2a14c4f039b 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -57,7 +57,7 @@ class TeslaFleetWindowEntity(TeslaFleetVehicleEntity, CoverEntity): self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -111,7 +111,7 @@ class TeslaFleetChargePortEntity(TeslaFleetVehicleEntity, CoverEntity): self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -144,7 +144,7 @@ class TeslaFleetFrontTrunkEntity(TeslaFleetVehicleEntity, CoverEntity): self.scoped = Scope.VEHICLE_CMDS in scopes self._attr_supported_features = CoverEntityFeature.OPEN - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -172,7 +172,7 @@ class TeslaFleetRearTrunkEntity(TeslaFleetVehicleEntity, CoverEntity): self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: @@ -216,7 +216,7 @@ class TeslaFleetSunroofEntity(TeslaFleetVehicleEntity, CoverEntity): super().__init__(vehicle, "vehicle_state_sun_roof_state") self.scoped = Scope.VEHICLE_CMDS in scopes - if not self.scoped or self.vehicle.signing: + if not self.scoped: self._attr_supported_features = CoverEntityFeature(0) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 60230cd881d..0ee41b5e322 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -123,14 +123,6 @@ class TeslaFleetVehicleEntity(TeslaFleetEntity): """Wake up the vehicle if its asleep.""" await wake_up_vehicle(self.vehicle) - def raise_for_read_only(self, scope: Scope) -> None: - """Raise an error if no command signing or a scope is not available.""" - if self.vehicle.signing: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="command_signing" - ) - super().raise_for_read_only(scope) - class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Live entities.""" diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py index 0a1d18c3407..455c990077d 100644 --- a/homeassistant/components/tesla_fleet/media_player.py +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -64,7 +64,7 @@ class TeslaFleetMediaEntity(TeslaFleetVehicleEntity, MediaPlayerEntity): """Initialize the media player entity.""" super().__init__(data, "media") self.scoped = scoped - if not scoped and data.signing: + if not scoped: self._attr_supported_features = MediaPlayerEntityFeature(0) def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 942824c5043..fe5cd06c1ef 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -504,9 +504,6 @@ "command_no_reason": { "message": "Command was unsuccessful but did not return a reason why." }, - "command_signing": { - "message": "Vehicle requires command signing. Please see documentation for more details." - }, "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index cc580212233..0dc5d87984f 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -167,3 +167,13 @@ def mock_request(): return_value=COMMAND_OK, ) as mock_request: yield mock_request + + +@pytest.fixture(autouse=True) +def mock_signed_command() -> Generator[AsyncMock]: + """Mock Tesla Fleet Api signed_command method.""" + with patch( + "homeassistant.components.tesla_fleet.VehicleSigned.signed_command", + return_value=COMMAND_OK, + ) as mock_signed_command: + yield mock_signed_command diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr index d6f3f3e4825..cc3018364a5 100644 --- a/tests/components/tesla_fleet/snapshots/test_media_player.ambr +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -105,7 +105,7 @@ 'original_name': 'Media player', 'platform': 'tesla_fleet', 'previous_unique_id': None, - 'supported_features': , + 'supported_features': 0, 'translation_key': 'media', 'unique_id': 'LRWXF7EK4KC700000-media', 'unit_of_measurement': None, @@ -123,7 +123,7 @@ 'media_position': 1.0, 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'source': 'Audible', - 'supported_features': , + 'supported_features': , 'volume_level': 0.16129355359011466, }), 'context': , diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index addba00b93d..07fdc962be9 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -1,13 +1,16 @@ """Test the Tesla Fleet button platform.""" -from unittest.mock import patch +from copy import deepcopy +from unittest.mock import AsyncMock, patch import pytest from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import NotOnWhitelistFault from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import assert_entities, setup_platform @@ -63,3 +66,30 @@ async def test_press( blocking=True, ) command.assert_called_once() + + +async def test_press_signing_error( + hass: HomeAssistant, normal_config_entry: MockConfigEntry, mock_products: AsyncMock +) -> None: + """Test pressing a button with a signing error.""" + # Enable Signing + new_product = deepcopy(mock_products.return_value) + new_product["response"][0]["command_signing"] = "required" + mock_products.return_value = new_product + + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", + side_effect=NotOnWhitelistFault, + ), + pytest.raises(HomeAssistantError) as error, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ["button.test_flash_lights"]}, + blocking=True, + ) + assert error.from_exception(NotOnWhitelistFault) diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 9dcac4ec388..7c17f986663 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -1,5 +1,6 @@ """Test the Tesla Fleet init.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch from aiohttp import RequestInfo @@ -404,3 +405,22 @@ async def test_init_region_issue_failed( await setup_platform(hass, normal_config_entry) mock_find_server.assert_called_once() assert normal_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_signing( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + mock_products: AsyncMock, +) -> None: + """Tests when a vehicle requires signing.""" + + # Make the vehicle require command signing + products = deepcopy(mock_products.return_value) + products["response"][0]["command_signing"] = "required" + mock_products.return_value = products + + with patch( + "homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key" + ) as mock_get_private_key: + await setup_platform(hass, normal_config_entry) + mock_get_private_key.assert_called_once() diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py index 5cf812439a5..fba4fc05cc4 100644 --- a/tests/components/tesla_fleet/test_switch.py +++ b/tests/components/tesla_fleet/test_switch.py @@ -1,6 +1,5 @@ """Test the tesla_fleet switch platform.""" -from copy import deepcopy from unittest.mock import AsyncMock, patch import pytest @@ -166,29 +165,3 @@ async def test_switch_no_scope( {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, blocking=True, ) - - -async def test_switch_no_signing( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - normal_config_entry: MockConfigEntry, - mock_products: AsyncMock, -) -> None: - """Tests that the switch entities are correct.""" - - # Make the vehicle require command signing - products = deepcopy(mock_products.return_value) - products["response"][0]["command_signing"] = "required" - mock_products.return_value = products - - await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) - with pytest.raises( - ServiceValidationError, - match="Vehicle requires command signing. Please see documentation for more details", - ): - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, - blocking=True, - ) From 7fefa5c2359400896a7459573b6226fcbf456707 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 5 Nov 2024 20:25:15 +0100 Subject: [PATCH 208/511] Update frontend to 20241105.0 (#129906) --- 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 89cd93227a4..ff399512c8b 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==20241104.0"] + "requirements": ["home-assistant-frontend==20241105.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 56155d53fd5..e0465ea6c0e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 99cd9ea7611..713498f60aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab28ebd9f2d..8bce16ef628 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From 79de1d9ed4b9374125cfd5303b4c0f9397735578 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 5 Nov 2024 20:26:22 +0100 Subject: [PATCH 209/511] Bump holidays to 0.60 (#129909) --- 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 9bb5bd9968e..8c64f492d42 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.59", "babel==2.15.0"] + "requirements": ["holidays==0.60", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c9a65a473bd..b02db734729 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.59"] + "requirements": ["holidays==0.60"] } diff --git a/requirements_all.txt b/requirements_all.txt index 713498f60aa..a414ec12d4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bce16ef628..1fca9957ff4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 From c355a53485a8aa5462bb0aa284ccfe9b640ea6b6 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 Nov 2024 18:59:43 +0000 Subject: [PATCH 210/511] Set friendly name of utility meter select entity when configured through YAML (#128267) * set select friendly name in YAML * backward compatibility added * clean * cleaner backward compatibility approach * don't introduce default unique_id * split test according to review --- .../components/utility_meter/select.py | 24 ++++--- tests/components/utility_meter/test_select.py | 62 +++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index d5b1206d046..5815ce7ec95 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -6,7 +6,7 @@ import logging from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.device_registry import DeviceInfo @@ -36,9 +36,9 @@ async def async_setup_entry( ) tariff_select = TariffSelect( - name, - tariffs, - unique_id, + name=name, + tariffs=tariffs, + unique_id=unique_id, device_info=device_info, ) async_add_entities([tariff_select]) @@ -62,13 +62,15 @@ async def async_setup_platform( conf_meter_unique_id: str | None = hass.data[DATA_UTILITY][meter].get( CONF_UNIQUE_ID ) + conf_meter_name = hass.data[DATA_UTILITY][meter].get(CONF_NAME, meter) async_add_entities( [ TariffSelect( - meter, - discovery_info[CONF_TARIFFS], - conf_meter_unique_id, + name=conf_meter_name, + tariffs=discovery_info[CONF_TARIFFS], + yaml_slug=meter, + unique_id=conf_meter_unique_id, ) ] ) @@ -82,12 +84,16 @@ class TariffSelect(SelectEntity, RestoreEntity): def __init__( self, name, - tariffs, - unique_id, + tariffs: list[str], + *, + yaml_slug: str | None = None, + unique_id: str | None = None, device_info: DeviceInfo | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name + if yaml_slug: # Backwards compatibility with YAML configuration entries + self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id self._attr_device_info = device_info self._current_tariff: str | None = None diff --git a/tests/components/utility_meter/test_select.py b/tests/components/utility_meter/test_select.py index 61f6cbe75b9..1f54f3b500a 100644 --- a/tests/components/utility_meter/test_select.py +++ b/tests/components/utility_meter/test_select.py @@ -3,10 +3,72 @@ from homeassistant.components.utility_meter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +async def test_select_entity_name_config_entry( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + config_entry_config = { + "cycle": "none", + "delta_values": False, + "name": "Energy bill", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + } + + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=config_entry_config, + title=config_entry_config["name"], + ) + + utility_meter_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + +async def test_select_entity_name_yaml( + hass: HomeAssistant, +) -> None: + """Test for Utility Meter select platform.""" + + yaml_config = { + "utility_meter": { + "energy_bill": { + "name": "Energy bill", + "source": "sensor.energy", + "tariffs": ["peak", "offpeak"], + "unique_id": "1234abcd", + } + } + } + + assert await async_setup_component(hass, DOMAIN, yaml_config) + + await hass.async_block_till_done() + + state = hass.states.get("select.energy_bill") + assert state is not None + assert state.attributes.get("friendly_name") == "Energy bill" + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From e1ef1063fe65d71498a255241523fd93254566d0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 5 Nov 2024 18:39:10 +0100 Subject: [PATCH 211/511] Prevent update entity becoming unavailable on device disconnect in IronOS (#129840) * Don't render update entity unavailable when Pinecil device disconnects * fixes --- homeassistant/components/iron_os/update.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py index bae9ccd4c6c..786ba86f730 100644 --- a/homeassistant/components/iron_os/update.py +++ b/homeassistant/components/iron_os/update.py @@ -92,4 +92,7 @@ class IronOSUpdate(IronOSBaseEntity, UpdateEntity): @property def available(self) -> bool: """Return if entity is available.""" - return super().available and self.firmware_update.last_update_success + return ( + self.installed_version is not None + and self.firmware_update.last_update_success + ) From eb3371beef78924555fa204e9b1a5270e7740e53 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 11:02:44 -0500 Subject: [PATCH 212/511] Change Ollama default to llama3.2 (#129901) --- homeassistant/components/ollama/const.py | 64 +++++++++++++++++------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 6152b223d6d..69c0a3d6296 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -24,8 +24,12 @@ MAX_HISTORY_SECONDS = 60 * 60 # 1 hour MODEL_NAMES = [ # https://ollama.com/library "alfred", "all-minilm", + "aya-expanse", "aya", "bakllava", + "bespoke-minicheck", + "bge-large", + "bge-m3", "codebooga", "codegeex4", "codegemma", @@ -33,18 +37,19 @@ MODEL_NAMES = [ # https://ollama.com/library "codeqwen", "codestral", "codeup", - "command-r", "command-r-plus", + "command-r", "dbrx", - "deepseek-coder", "deepseek-coder-v2", + "deepseek-coder", "deepseek-llm", + "deepseek-v2.5", "deepseek-v2", - "dolphincoder", "dolphin-llama3", "dolphin-mistral", "dolphin-mixtral", "dolphin-phi", + "dolphincoder", "duckdb-nsql", "everythinglm", "falcon", @@ -55,74 +60,97 @@ MODEL_NAMES = [ # https://ollama.com/library "glm4", "goliath", "granite-code", + "granite3-dense", + "granite3-guardian" "granite3-moe", + "hermes3", "internlm2", - "llama2", + "llama-guard3", + "llama-pro", "llama2-chinese", "llama2-uncensored", - "llama3", + "llama2", "llama3-chatqa", "llama3-gradient", "llama3-groq-tool-use", - "llama-pro", - "llava", + "llama3.1", + "llama3.2", + "llama3", "llava-llama3", "llava-phi3", + "llava", "magicoder", "mathstral", "meditron", "medllama2", "megadolphin", - "mistral", - "mistrallite", + "minicpm-v", + "mistral-large", "mistral-nemo", "mistral-openorca", + "mistral-small", + "mistral", + "mistrallite", "mixtral", "moondream", "mxbai-embed-large", + "nemotron-mini", + "nemotron", "neural-chat", "nexusraven", "nomic-embed-text", "notus", "notux", "nous-hermes", - "nous-hermes2", "nous-hermes2-mixtral", + "nous-hermes2", "nuextract", + "open-orca-platypus2", "openchat", "openhermes", - "open-orca-platypus2", - "orca2", "orca-mini", + "orca2", + "paraphrase-multilingual", "phi", + "phi3.5", "phi3", "phind-codellama", "qwen", + "qwen2-math", + "qwen2.5-coder", + "qwen2.5", "qwen2", + "reader-lm", + "reflection", "samantha-mistral", + "shieldgemma", + "smollm", + "smollm2", "snowflake-arctic-embed", + "solar-pro", "solar", "sqlcoder", "stable-beluga", "stable-code", - "stablelm2", "stablelm-zephyr", + "stablelm2", "starcoder", "starcoder2", "starling-lm", "tinydolphin", "tinyllama", "vicuna", + "wizard-math", + "wizard-vicuna-uncensored", + "wizard-vicuna", "wizardcoder", + "wizardlm-uncensored", "wizardlm", "wizardlm2", - "wizardlm-uncensored", - "wizard-math", - "wizard-vicuna", - "wizard-vicuna-uncensored", "xwinlm", "yarn-llama2", "yarn-mistral", + "yi-coder", "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.1:latest" +DEFAULT_MODEL = "llama3.2:latest" From 734ebc1adbf0c738f5520144e8b8c8161c357279 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Nov 2024 17:12:05 +0100 Subject: [PATCH 213/511] Improve improv BLE error handling (#129902) --- .../components/improv_ble/config_flow.py | 18 ++++++++++++++---- tests/components/improv_ble/__init__.py | 19 +++++++++++++++++++ .../components/improv_ble/test_config_flow.py | 18 ++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index f38f4830ace..05dd1de449a 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -120,12 +120,22 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN): assert self._discovery_info is not None service_data = self._discovery_info.service_data - improv_service_data = ImprovServiceData.from_bytes( - service_data[SERVICE_DATA_UUID] - ) + try: + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + except improv_ble_errors.InvalidCommand as err: + _LOGGER.warning( + "Aborting improv flow, device %s sent invalid improv data: '%s'", + self._discovery_info.address, + service_data[SERVICE_DATA_UUID].hex(), + ) + raise AbortFlow("invalid_improv_data") from err + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Aborting improv flow, device is already provisioned: %s", + "Aborting improv flow, device %s is already provisioned: %s", + self._discovery_info.address, improv_service_data.state, ) raise AbortFlow("already_provisioned") diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py index 41ea98cda7b..521d0881443 100644 --- a/tests/components/improv_ble/__init__.py +++ b/tests/components/improv_ble/__init__.py @@ -25,6 +25,25 @@ IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( ) +BAD_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x00\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, + tx_power=-127, +) + + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( name="00123456", address="AA:BB:CC:DD:EE:F0", diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 640a931bee5..2df4be2ba7d 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType from . import ( + BAD_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO, NOT_IMPROV_BLE_DISCOVERY_INFO, PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, @@ -649,3 +650,20 @@ async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "provision" assert result["errors"] == {"base": error} + + +async def test_provision_fails_invalid_data( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test bluetooth flow with error due to invalid data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=BAD_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "invalid_improv_data" + assert ( + "Aborting improv flow, device AA:BB:CC:DD:EE:F0 sent invalid improv data: '000000000000'" + in caplog.text + ) From 82c2422990a1b9a57c9200ace9311d09ca7dd063 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 5 Nov 2024 20:25:15 +0100 Subject: [PATCH 214/511] Update frontend to 20241105.0 (#129906) --- 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 89cd93227a4..ff399512c8b 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==20241104.0"] + "requirements": ["home-assistant-frontend==20241105.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1a9edf42bd3..ca938f22d15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 home-assistant-intents==2024.10.30 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a9128c7cad9..5264d0b166b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0a043b22e5..5b01fb7df7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.59 # homeassistant.components.frontend -home-assistant-frontend==20241104.0 +home-assistant-frontend==20241105.0 # homeassistant.components.conversation home-assistant-intents==2024.10.30 From f5555df9904889f40ebcbb780e0ef14e6f51d83f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 5 Nov 2024 20:26:22 +0100 Subject: [PATCH 215/511] Bump holidays to 0.60 (#129909) --- 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 9bb5bd9968e..8c64f492d42 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.59", "babel==2.15.0"] + "requirements": ["holidays==0.60", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c9a65a473bd..b02db734729 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.59"] + "requirements": ["holidays==0.60"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5264d0b166b..94325ca4f96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b01fb7df7a..d9c6be1f074 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.59 +holidays==0.60 # homeassistant.components.frontend home-assistant-frontend==20241105.0 From 211ce43127d58dd9b4ddb9d765b78f0adbfa00bc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Nov 2024 20:33:48 +0100 Subject: [PATCH 216/511] Bump version to 2024.11.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 a21b128f414..b0b4339a4c5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 a289448d87a..2053f5b81b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b6" +version = "2024.11.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 6ecdbb677f8774f99c25576f7fd416ec40ce1a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 5 Nov 2024 19:03:26 -0100 Subject: [PATCH 217/511] Bump huawei-lte-api to 1.10.0 (#129911) --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 908092ba2ca..6720d6718ef 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["huawei_lte_api.Session"], "requirements": [ - "huawei-lte-api==1.9.3", + "huawei-lte-api==1.10.0", "stringcase==1.2.0", "url-normalize==1.4.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index a414ec12d4b..23ebdb07f4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1142,7 +1142,7 @@ horimote==0.4.1 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.9.3 +huawei-lte-api==1.10.0 # homeassistant.components.huum huum==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fca9957ff4..fca0717b4aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -965,7 +965,7 @@ homematicip==1.1.2 httplib2==0.20.4 # homeassistant.components.huawei_lte -huawei-lte-api==1.9.3 +huawei-lte-api==1.10.0 # homeassistant.components.huum huum==0.7.10 From 9e0445747232cf95f00be91995570d0ea04210be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 21:04:58 +0100 Subject: [PATCH 218/511] Bump spotifyaio to 0.8.4 (#129899) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 2d86083d49c..9a52a4cf36a 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.3"], + "requirements": ["spotifyaio==0.8.4"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 23ebdb07f4d..2d17ef36437 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fca0717b4aa..aee62d587c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 From 89a9c2ec24b8e62035046d10885e4d416c21ebb6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 22:18:41 +0100 Subject: [PATCH 219/511] Disable uv cache (#129912) --- Dockerfile | 3 ++- script/hassfest/docker.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2f6a400e0d1..b6d571f308e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME=240000 \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 1f6c19e6593..083cdaba1a9 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -20,7 +20,8 @@ FROM ${{BUILD_FROM}} # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME={timeout} \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU From 901457e7aa03114b6327acaf3b3c23f245b4bcb2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 Nov 2024 15:22:49 -0600 Subject: [PATCH 220/511] Bump intents and add HassRespond test (#129830) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_default_agent.py | 13 ++++++++++++- tests/components/intent/test_init.py | 11 +++++++++++ 7 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ce0849f9514..2c446ac5d70 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.4", "home-assistant-intents==2024.10.30"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e0465ea6c0e..68ac451a9f0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241105.0 -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 2d17ef36437..b62776a533c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee62d587c8..b937d8afa0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index cd53c25ffc6..1e948c2982a 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.10.30 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index e06ba8b4750..14a9b0ca88c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -431,7 +431,7 @@ async def test_shopping_list_add_item(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") -async def test_nevermind_item(hass: HomeAssistant) -> None: +async def test_nevermind_intent(hass: HomeAssistant) -> None: """Test HassNevermind intent through the default agent.""" result = await conversation.async_converse(hass, "nevermind", None, Context()) assert result.response.intent is not None @@ -441,6 +441,17 @@ async def test_nevermind_item(hass: HomeAssistant) -> None: assert not result.response.speech +@pytest.mark.usefixtures("init_components") +async def test_respond_intent(hass: HomeAssistant) -> None: + """Test HassRespond intent through the default agent.""" + result = await conversation.async_converse(hass, "hello", None, Context()) + assert result.response.intent is not None + assert result.response.intent.intent_type == intent.INTENT_RESPOND + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello from Home Assistant." + + @pytest.mark.usefixtures("init_components") async def test_device_area_context( hass: HomeAssistant, diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 659ca16c0bb..20c0f9d8d44 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -455,3 +455,14 @@ async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> No "HassSetPosition", {"name": {"value": "test light"}, "position": {"value": 100}}, ) + + +async def test_intents_with_no_responses(hass: HomeAssistant) -> None: + """Test intents that should not return a response during handling.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + # The "respond" intent gets its response text from home-assistant-intents + for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): + response = await intent.async_handle(hass, "test", intent_name, {}) + assert not response.speech From 64e84e2aa0c88522d9cdde5b7c58cdb06a536f8a Mon Sep 17 00:00:00 2001 From: kingal123 <70146605+kingal123@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:23:14 +0000 Subject: [PATCH 221/511] Update pylutron to 0.2.16 (#129653) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 5dbf3c45f2a..82bdfad4774 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.15"], + "requirements": ["pylutron==0.2.16"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index b62776a533c..f0860a099bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2045,7 +2045,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b937d8afa0f..df577c2834a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1650,7 +1650,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 From 5f13db2356bd270a247e57df05fa8563b160da1b Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Nov 2024 00:05:05 +0100 Subject: [PATCH 222/511] Bump reolink_aio to 0.10.4 (#129914) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5fd87c2ccb1..23a46c5e1c9 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.10.3"] + "requirements": ["reolink-aio==0.10.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0860a099bb..322d8feb611 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2550,7 +2550,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df577c2834a..26bdb41b5b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.rflink rflink==0.0.66 From a927312fb557d98c18afbc7fd1a9ba2a55c6070d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 22:36:26 -0500 Subject: [PATCH 223/511] Ensure all template names are strings (#129921) --- homeassistant/components/template/template_entity.py | 6 ++++-- tests/components/template/test_sensor.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3e70e1c3546..f5b84b1ad7a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -535,13 +535,15 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module ) if self._entity_picture_template is not None: self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template + "_attr_entity_picture", self._entity_picture_template, cv.string ) if ( self._friendly_name_template is not None and not self._friendly_name_template.is_static ): - self.add_template_attribute("_attr_name", self._friendly_name_template) + self.add_template_attribute( + "_attr_name", self._friendly_name_template, cv.string + ) @callback def async_start_preview( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 5a7521f98c7..929a890ab38 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components import sensor, template from homeassistant.components.template.sensor import TriggerSensorEntity from homeassistant.const import ( ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, ATTR_ICON, EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -983,6 +984,7 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( "test": { "value_template": "{{ 1 }}", "entity_picture_template": "{{ ((states.sensor.test.attributes['entity_picture'] or 0) | int) + 1 }}", + "friendly_name_template": "{{ ((states.sensor.test.attributes['friendly_name'] or 0) | int) + 1 }}", }, }, } @@ -1007,7 +1009,8 @@ async def test_self_referencing_entity_picture_loop( state = hass.states.get("sensor.test") assert int(state.state) == 1 - assert state.attributes[ATTR_ENTITY_PICTURE] == 2 + assert state.attributes[ATTR_ENTITY_PICTURE] == "3" + assert state.attributes[ATTR_FRIENDLY_NAME] == "3" await hass.async_block_till_done() assert int(state.state) == 1 From f88bc008e5c8ad7cc00bbc8a247dd07485eff7c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Nov 2024 08:13:41 +0100 Subject: [PATCH 224/511] Bump actions/attest-build-provenance from 1.4.3 to 1.4.4 (#129924) --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index e359ed59cf0..7c08df39000 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3 + uses: actions/attest-build-provenance@ef244123eb79f2f7a7e75d99086184180e6d0018 # v1.4.4 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 184cbfea23eb73ab9cc29e343284589a8274de2f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 08:14:54 +0100 Subject: [PATCH 225/511] Use read-only options in lastfm options flow (#129928) Use read-only options in lstfm options flow --- homeassistant/components/lastfm/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index d460792f7c8..0e1f680dd63 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -163,24 +163,25 @@ class LastFmOptionsFlowHandler(OptionsFlow): ) -> ConfigFlowResult: """Initialize form.""" errors: dict[str, str] = {} + options = self.config_entry.options if user_input is not None: users, errors = validate_lastfm_users( - self.options[CONF_API_KEY], user_input[CONF_USERS] + options[CONF_API_KEY], user_input[CONF_USERS] ) user_input[CONF_USERS] = users if not errors: return self.async_create_entry( title="LastFM", data={ - **self.options, + **options, CONF_USERS: user_input[CONF_USERS], }, ) - if self.options[CONF_MAIN_USER]: + if options[CONF_MAIN_USER]: try: main_user, _ = get_lastfm_user( - self.options[CONF_API_KEY], - self.options[CONF_MAIN_USER], + options[CONF_API_KEY], + options[CONF_MAIN_USER], ) friends_response = await self.hass.async_add_executor_job( main_user.get_friends @@ -206,6 +207,6 @@ class LastFmOptionsFlowHandler(OptionsFlow): ), } ), - user_input or self.options, + user_input or options, ), ) From 2eb2bdd61558760439240205f448b6eb7befa252 Mon Sep 17 00:00:00 2001 From: Nicholas Romyn <13968908+nromyn@users.noreply.github.com> Date: Wed, 6 Nov 2024 02:25:18 -0500 Subject: [PATCH 226/511] Consolidating async_add_entities into one call in Ecobee (#129917) * Consolidating async_add_entities into one call. * changing to comprehension. --- homeassistant/components/ecobee/switch.py | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py index 67be78fb21d..89ee433c072 100644 --- a/homeassistant/components/ecobee/switch.py +++ b/homeassistant/components/ecobee/switch.py @@ -31,25 +31,26 @@ async def async_setup_entry( """Set up the ecobee thermostat switch entity.""" data: EcobeeData = hass.data[DOMAIN] - async_add_entities( - [ - EcobeeVentilator20MinSwitch( - data, - index, - (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) - or dt_util.get_default_time_zone(), - ) + entities: list[SwitchEntity] = [ + EcobeeVentilator20MinSwitch( + data, + index, + (await dt_util.async_get_time_zone(thermostat["location"]["timeZone"])) + or dt_util.get_default_time_zone(), + ) + for index, thermostat in enumerate(data.ecobee.thermostats) + if thermostat["settings"]["ventilatorType"] != "none" + ] + + entities.extend( + ( + EcobeeSwitchAuxHeatOnly(data, index) for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["ventilatorType"] != "none" - ], - update_before_add=True, + if thermostat["settings"]["hasHeatPump"] + ) ) - async_add_entities( - EcobeeSwitchAuxHeatOnly(data, index) - for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["hasHeatPump"] - ) + async_add_entities(entities, update_before_add=True) class EcobeeVentilator20MinSwitch(EcobeeBaseEntity, SwitchEntity): From 5679b061d2986bfe4dee46ab0556fb823b02e4f8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 10:07:10 +0100 Subject: [PATCH 227/511] Fix native sync WebRTC offer (#129931) --- homeassistant/components/camera/__init__.py | 5 ++++- tests/components/camera/test_webrtc.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b600eae02c7..67c2432129f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -848,7 +848,10 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ] config.configuration.ice_servers.extend(ice_servers) - config.get_candidates_upfront = self._legacy_webrtc_provider is not None + config.get_candidates_upfront = ( + self._supports_native_sync_webrtc + or self._legacy_webrtc_provider is not None + ) return config diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index f726eb29673..7a1df556c20 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -358,7 +358,7 @@ async def test_ws_get_client_config_sync_offer( assert msg["success"] assert msg["result"] == { "configuration": {}, - "getCandidatesUpfront": False, + "getCandidatesUpfront": True, } From 33016c29770de12ea62e9df701be86c56a345b33 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 10:37:55 +0100 Subject: [PATCH 228/511] Use new helper properties in netatmo options flow (#129781) * Use new helper properties in netatmo options flow * Update homeassistant/components/netatmo/config_flow.py * Apply suggestions from code review * Improve * Keep options * Simplify --- homeassistant/components/netatmo/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 0da4d6f16b7..d853694ffea 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -101,7 +101,6 @@ class NetatmoOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Netatmo options flow.""" - self.config_entry = config_entry self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) From 648c3d500b922d77deeaf947fa25dc7591be0adb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:35 +0100 Subject: [PATCH 229/511] Bump spotifyaio to 0.8.5 (#129938) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 9a52a4cf36a..8cf8d735553 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.4"], + "requirements": ["spotifyaio==0.8.5"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 322d8feb611..3f602f592d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2710,7 +2710,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26bdb41b5b0..63f7db8a212 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2162,7 +2162,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 From 25eb7173bf5d3a25c2c9a09fdf5cfd3cef6f001e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:59 +0100 Subject: [PATCH 230/511] Write squeezebox player state after query (#129939) --- homeassistant/components/squeezebox/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 6037017dd1e..19cd1e36910 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -535,6 +535,7 @@ class SqueezeBoxMediaPlayerEntity( all_params.extend(parameters) self._query_result = await self._player.async_query(*all_params) _LOGGER.debug("call_query got result %s", self._query_result) + self.async_write_ha_state() async def async_join_players(self, group_members: list[str]) -> None: """Add other Squeezebox players to this player's sync group. From 4dbf3359c11a3a2d2c8eb5cb449ecf3ab066d9a5 Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Wed, 6 Nov 2024 16:13:41 +0530 Subject: [PATCH 231/511] Adding "peaceful" status as on value to Tuya Presence Sensor (#129925) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 934f03336aa..12661a26fd1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value={"presence", "small_move", "large_move"}, + on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), # Formaldehyde Detector From 370d7d6bdfa707e30c3c7f321b02691b29468cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 6 Nov 2024 11:44:54 +0100 Subject: [PATCH 232/511] Bump pyTibber to 0.30.4 (#129844) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/services.py | 12 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/test_services.py | 96 +++++-------------- 5 files changed, 29 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index ac46141d974..205bc1352eb 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.3"] + "requirements": ["pyTibber==0.30.4"] } diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 87268186285..72943a0215a 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -47,17 +47,13 @@ async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResp for tibber_home in tibber_connection.get_homes(only_active=True): home_nickname = tibber_home.name - price_info = tibber_home.info["viewer"]["home"]["currentSubscription"][ - "priceInfo" - ] price_data = [ { - "start_time": price["startsAt"], - "price": price["total"], - "level": price["level"], + "start_time": starts_at, + "price": price, + "level": tibber_home.price_level.get(starts_at), } - for key in ("today", "tomorrow") - for price in price_info[key] + for starts_at, price in tibber_home.price_total.items() ] selected_data = [ diff --git a/requirements_all.txt b/requirements_all.txt index 3f602f592d7..2be7bb32ff2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63f7db8a212..c589b664ff1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index 49f9e5e451b..dc6f5d2789d 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -20,84 +20,32 @@ def generate_mock_home_data(): mock_homes = [ MagicMock( name="first_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), MagicMock( name="second_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), ] From f6f89bd807e26417cf43f36abf6cd961a7b44bab Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 6 Nov 2024 11:52:00 +0100 Subject: [PATCH 233/511] Update Bang & Olufsen source list as availability changes (#129910) --- .../components/bang_olufsen/const.py | 36 ++++++++++--------- .../components/bang_olufsen/media_player.py | 9 ++--- .../components/bang_olufsen/websocket.py | 11 ++++++ tests/components/bang_olufsen/conftest.py | 6 ++-- tests/components/bang_olufsen/const.py | 1 + .../bang_olufsen/test_media_player.py | 32 +++++++++++++++++ 6 files changed, 70 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index caa4cef8a13..1e06f153cdb 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -21,41 +21,57 @@ class BangOlufsenSource: name="Audio Streamer", id="uriStreamer", is_seekable=False, + is_enabled=True, + is_playable=True, ) BLUETOOTH: Final[Source] = Source( name="Bluetooth", id="bluetooth", is_seekable=False, + is_enabled=True, + is_playable=True, ) CHROMECAST: Final[Source] = Source( name="Chromecast built-in", id="chromeCast", is_seekable=False, + is_enabled=True, + is_playable=True, ) LINE_IN: Final[Source] = Source( name="Line-In", id="lineIn", is_seekable=False, + is_enabled=True, + is_playable=True, ) SPDIF: Final[Source] = Source( name="Optical", id="spdif", is_seekable=False, + is_enabled=True, + is_playable=True, ) NET_RADIO: Final[Source] = Source( name="B&O Radio", id="netRadio", is_seekable=False, + is_enabled=True, + is_playable=True, ) DEEZER: Final[Source] = Source( name="Deezer", id="deezer", is_seekable=True, + is_enabled=True, + is_playable=True, ) TIDAL: Final[Source] = Source( name="Tidal", id="tidal", is_seekable=True, + is_enabled=True, + is_playable=True, ) @@ -170,20 +186,6 @@ VALID_MEDIA_TYPES: Final[tuple] = ( MediaType.CHANNEL, ) -# Sources on the device that should not be selectable by the user -HIDDEN_SOURCE_IDS: Final[tuple] = ( - "airPlay", - "bluetooth", - "chromeCast", - "generator", - "local", - "dlna", - "qplay", - "wpl", - "pl", - "beolink", - "usbIn", -) # Fallback sources to use in case of API failure. FALLBACK_SOURCES: Final[SourceArray] = SourceArray( @@ -191,7 +193,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="uriStreamer", is_enabled=True, - is_playable=False, + is_playable=True, name="Audio Streamer", type=SourceTypeEnum(value="uriStreamer"), is_seekable=False, @@ -199,7 +201,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="bluetooth", is_enabled=True, - is_playable=False, + is_playable=True, name="Bluetooth", type=SourceTypeEnum(value="bluetooth"), is_seekable=False, @@ -207,7 +209,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="spotify", is_enabled=True, - is_playable=False, + is_playable=True, name="Spotify Connect", type=SourceTypeEnum(value="spotify"), is_seekable=True, diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 31f821683d4..e8108ee2cf7 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -70,7 +70,6 @@ from .const import ( CONNECTION_STATUS, DOMAIN, FALLBACK_SOURCES, - HIDDEN_SOURCE_IDS, VALID_MEDIA_TYPES, BangOlufsenMediaType, BangOlufsenSource, @@ -169,6 +168,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, + WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change, @@ -243,7 +243,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if queue_settings.shuffle is not None: self._attr_shuffle = queue_settings.shuffle - async def _async_update_sources(self) -> None: + async def _async_update_sources(self, _: Source | None = None) -> None: """Get sources for the specific product.""" # Audio sources @@ -270,10 +270,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._audio_sources = { source.id: source.name for source in cast(list[Source], sources.items) - if source.is_enabled - and source.id - and source.name - and source.id not in HIDDEN_SOURCE_IDS + if source.is_enabled and source.id and source.name and source.is_playable } # Some sources are not Beolink expandable, meaning that they can't be joined by diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 3519fcd9a48..94b84189ccc 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -63,6 +63,9 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_playback_progress_notifications( self.on_playback_progress_notification ) + self._client.get_playback_source_notifications( + self.on_playback_source_notification + ) self._client.get_playback_state_notifications( self.on_playback_state_notification ) @@ -157,6 +160,14 @@ class BangOlufsenWebsocket(BangOlufsenBase): notification, ) + def on_playback_source_notification(self, notification: Source) -> None: + """Send playback_source dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}", + notification, + ) + def on_source_change_notification(self, notification: Source) -> None: """Send source_change dispatch.""" async_dispatcher_send( diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index a644b395c69..6c19a29c1da 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -124,7 +124,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.get_available_sources = AsyncMock() client.get_available_sources.return_value = SourceArray( items=[ - # Is in the HIDDEN_SOURCE_IDS constant, so should not be user selectable + # Is not playable, so should not be user selectable Source( name="AirPlay", id="airPlay", @@ -137,14 +137,16 @@ def mock_mozart_client() -> Generator[AsyncMock]: id="tidal", is_enabled=True, is_multiroom_available=True, + is_playable=True, ), Source( name="Line-In", id="lineIn", is_enabled=True, is_multiroom_available=False, + is_playable=True, ), - # Is disabled, so should not be user selectable + # Is disabled and not playable, so should not be user selectable Source( name="Powerlink", id="pl", diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 7f2e52cfc87..3769aef5cd3 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -130,6 +130,7 @@ TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ "Audio Streamer", + "Bluetooth", "Spotify Connect", "Line-In", "Optical", diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 844e9bfe61b..8f23af9e04a 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -10,6 +10,7 @@ from mozart_api.models import ( PlayQueueSettings, RenderingState, Source, + SourceArray, WebsocketNotificationTag, ) import pytest @@ -195,6 +196,37 @@ async def test_async_update_sources_remote( assert mock_mozart_client.get_remote_menu.call_count == 2 +async def test_async_update_sources_availability( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the playback_source WebSocket event updates available playback sources.""" + # Remove video sources to simplify test + mock_mozart_client.get_remote_menu.return_value = {} + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + playback_source_callback = ( + mock_mozart_client.get_playback_source_notifications.call_args[0][0] + ) + + assert mock_mozart_client.get_available_sources.call_count == 1 + + # Add a source that is available and playable + mock_mozart_client.get_available_sources.return_value = SourceArray( + items=[BangOlufsenSource.TIDAL] + ) + + # Send playback_source. The source is not actually used, so its attributes don't matter + playback_source_callback(Source()) + + assert mock_mozart_client.get_available_sources.call_count == 2 + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name] + + async def test_async_update_playback_metadata( hass: HomeAssistant, mock_mozart_client: AsyncMock, From 25449b424fe6a938e287de1637be2165a456fe5d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 12:05:23 +0100 Subject: [PATCH 234/511] Bump go2rtc-client to 0.0.1b4 (#129942) --- homeassistant/components/go2rtc/__init__.py | 5 ++++- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 12 ++++++++---- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 9ffe9e25f78..a07a62305f2 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -222,7 +222,10 @@ class WebRTCProvider(CameraWebRTCProvider): if (stream := streams.get(camera.entity_id)) is None or not any( stream_source == producer.url for producer in stream.producers ): - await self._rest_client.streams.add(camera.entity_id, stream_source) + await self._rest_client.streams.add( + camera.entity_id, + [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], + ) @callback def on_messages(message: ReceiveMessages) -> None: diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index b30b7cb1cc1..e69140a51db 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b3"], + "requirements": ["go2rtc-client==0.0.1b4"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 68ac451a9f0..aeaa4aa7dcd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2be7bb32ff2..3ac09644b5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c589b664ff1..d8b4a50c254 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 21d4d0a047e..61b0ca97406 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -237,24 +237,28 @@ async def _test_setup_and_signaling( await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) + entity_id: Stream([Producer("rtsp://different", [])]) } receive_message_callback.reset_mock() ws_client.reset_mock() await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) + entity_id: Stream([Producer("rtsp://stream", [])]) } receive_message_callback.reset_mock() From a7ba4bd086960672fa40fe3f54be81e7306ece14 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:09:05 +0100 Subject: [PATCH 235/511] Use read-only options in emoncms options flow (#129926) * Use read-only options in emoncms options flow * Don't store URL and API_KEY in entry options --- .../components/emoncms/config_flow.py | 20 ++++++++++--------- homeassistant/components/emoncms/sensor.py | 9 +++++---- tests/components/emoncms/test_config_flow.py | 14 ++++++------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index e2e08217b3c..b294a5cd3d4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -72,7 +72,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> EmoncmsOptionsFlow: """Get the options flow for this handler.""" - return EmoncmsOptionsFlow() + return EmoncmsOptionsFlow(config_entry) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -175,18 +175,23 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize emoncms options flow.""" + self._url = config_entry.data[CONF_URL] + self._api_key = config_entry.data[CONF_API_KEY] + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" errors: dict[str, str] = {} description_placeholders = {} - data = self.options if self.options else self.config_entry.data - url = data[CONF_URL] - api_key = data[CONF_API_KEY] - include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) + include_only_feeds = self.config_entry.options.get( + CONF_ONLY_INCLUDE_FEEDID, + self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []), + ) options: list = include_only_feeds - result = await get_feed_list(self.hass, url, api_key) + result = await get_feed_list(self.hass, self._url, self._api_key) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} @@ -196,10 +201,7 @@ class EmoncmsOptionsFlow(OptionsFlow): if user_input: include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] return self.async_create_entry( - title=sensor_name(url), data={ - CONF_URL: url, - CONF_API_KEY: api_key, CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, }, ) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 4add7c9625d..d8dec12800a 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -138,10 +138,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the emoncms sensors.""" - config = entry.options if entry.options else entry.data - name = sensor_name(config[CONF_URL]) - exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) - include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) + name = sensor_name(entry.data[CONF_URL]) + exclude_feeds = entry.data.get(CONF_EXCLUDE_FEEDID) + include_only_feeds = entry.options.get( + CONF_ONLY_INCLUDE_FEEDID, entry.data.get(CONF_ONLY_INCLUDE_FEEDID) + ) if exclude_feeds is None and include_only_feeds is None: return diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 43710967a01..b3afc714c59 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -97,10 +97,6 @@ async def test_user_flow( assert len(mock_setup_entry.mock_calls) == 1 -USER_OPTIONS = { - CONF_ONLY_INCLUDE_FEEDID: ["1"], -} - CONFIG_ENTRY = { CONF_API_KEY: "my_api_key", CONF_ONLY_INCLUDE_FEEDID: ["1"], @@ -116,15 +112,19 @@ async def test_options_flow( ) -> None: """Options flow - success test.""" await setup_integration(hass, config_entry) + assert config_entry.options == {} result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input=USER_OPTIONS, + user_input={ + CONF_ONLY_INCLUDE_FEEDID: ["1"], + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == CONFIG_ENTRY - assert config_entry.options == CONFIG_ENTRY + assert config_entry.options == { + CONF_ONLY_INCLUDE_FEEDID: ["1"], + } async def test_options_flow_failure( From 2c1db109866d40eb9ed1945a7f5aa2218501b0a1 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 6 Nov 2024 13:10:23 +0100 Subject: [PATCH 236/511] Map "stop" to MediaPlayerState.IDLE in bluesound integration (#129904) Co-authored-by: Joost Lekkerkerker --- .../components/bluesound/media_player.py | 13 ++++++------ .../components/bluesound/test_media_player.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 20cf51ff2f9..1d46af2cc4b 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -364,12 +364,13 @@ class BluesoundPlayer(MediaPlayerEntity): if self.is_grouped and not self.is_master: return MediaPlayerState.IDLE - status = self._status.state - if status in ("pause", "stop"): - return MediaPlayerState.PAUSED - if status in ("stream", "play"): - return MediaPlayerState.PLAYING - return MediaPlayerState.IDLE + match self._status.state: + case "pause": + return MediaPlayerState.PAUSED + case "stream" | "play": + return MediaPlayerState.PLAYING + case _: + return MediaPlayerState.IDLE @property def media_title(self) -> str | None: diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 966f3117650..894528265e1 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -130,6 +130,26 @@ async def test_attributes_set( assert state == snapshot(exclude=props("media_position_updated_at")) +async def test_stop_maps_to_idle( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player stop maps to idle.""" + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), state="stop" + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.player_name1111").state == MediaPlayerState.IDLE + ) + + async def test_status_updated( hass: HomeAssistant, setup_config_entry: None, From 27e81fe0edc2fa8f6156cf4f8a69f03ecfd7bd55 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:23:43 +0100 Subject: [PATCH 237/511] Improve error messages in Habitica (#129948) Improve error messages --- homeassistant/components/habitica/coordinator.py | 4 ++-- homeassistant/components/habitica/strings.json | 4 ++-- tests/components/habitica/test_button.py | 4 ++-- tests/components/habitica/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 4e949b703fb..cce2c684ba8 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -59,9 +59,9 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.debug("Currently rate limited, skipping update") + _LOGGER.debug("Rate limit exceeded, will try again later") return self.data - raise UpdateFailed(f"Error communicating with API: {error}") from error + raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error return HabiticaData(user=user_response, tasks=tasks_response) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 45824c484e9..f7d2f20b8f9 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -209,10 +209,10 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Currently rate limited, try again later" + "message": "Rate limit exceeded, try again later" }, "service_call_unallowed": { - "message": "Unable to carry out this action, because the required conditions are not met" + "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { "message": "Unable to connect to Habitica, try again later" diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index e7eda1609c8..6bd62f3a58e 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -207,7 +207,7 @@ async def test_button_press( [ ( HTTPStatus.TOO_MANY_REQUESTS, - "Currently rate limited", + "Rate limit exceeded, try again later", ServiceValidationError, ), ( @@ -217,7 +217,7 @@ async def test_button_press( ), ( HTTPStatus.UNAUTHORIZED, - "Unable to carry out this action", + "Unable to complete action, the required conditions are not met", ServiceValidationError, ), ], diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 0ee2d872954..fd8a18b2d44 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -165,4 +165,4 @@ async def test_coordinator_rate_limited( async_fire_time_changed(hass) await hass.async_block_till_done() - assert "Currently rate limited, skipping update" in caplog.text + assert "Rate limit exceeded, will try again later" in caplog.text From c6cb2884f444e480dcb87e693d8680a8f4e19b2a Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Wed, 6 Nov 2024 13:40:17 +0100 Subject: [PATCH 238/511] Add motion sensor setting to tplink (#129393) --- homeassistant/components/tplink/icons.json | 6 +++ homeassistant/components/tplink/strings.json | 3 ++ homeassistant/components/tplink/switch.py | 3 ++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_switch.ambr | 46 +++++++++++++++++++ 5 files changed, 63 insertions(+) diff --git a/homeassistant/components/tplink/icons.json b/homeassistant/components/tplink/icons.json index 3a83349c613..0abd68543c5 100644 --- a/homeassistant/components/tplink/icons.json +++ b/homeassistant/components/tplink/icons.json @@ -71,6 +71,12 @@ }, "child_lock": { "default": "mdi:account-lock" + }, + "pir_enabled": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } } }, "sensor": { diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index e15f3cfba03..8e5118c2720 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -193,6 +193,9 @@ }, "child_lock": { "name": "Child lock" + }, + "pir_enabled": { + "name": "Motion sensor" } }, "number": { diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index 9ef58484ea8..c9285d86ba6 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -51,6 +51,9 @@ SWITCH_DESCRIPTIONS: tuple[TPLinkSwitchEntityDescription, ...] = ( TPLinkSwitchEntityDescription( key="child_lock", ), + TPLinkSwitchEntityDescription( + key="pir_enabled", + ), ) SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS} diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index f0cfcc92ea1..f60132fd2c2 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -39,6 +39,11 @@ "type": "Switch", "category": "Config" }, + "pir_enabled": { + "value": true, + "type": "Switch", + "category": "Config" + }, "current_consumption": { "value": 5.23, "type": "Sensor", diff --git a/tests/components/tplink/snapshots/test_switch.ambr b/tests/components/tplink/snapshots/test_switch.ambr index f6e9ad51410..36c630474c8 100644 --- a/tests/components/tplink/snapshots/test_switch.ambr +++ b/tests/components/tplink/snapshots/test_switch.ambr @@ -311,6 +311,52 @@ 'state': 'on', }) # --- +# name: test_states[switch.my_device_motion_sensor-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.my_device_motion_sensor', + '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': 'Motion sensor', + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pir_enabled', + 'unique_id': '123456789ABCDEFGH_pir_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.my_device_motion_sensor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'my_device Motion sensor', + }), + 'context': , + 'entity_id': 'switch.my_device_motion_sensor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.my_device_smooth_transitions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 96de4b3828c1ec3f17e7573e58a846ef43a6a647 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 6 Nov 2024 22:40:37 +1000 Subject: [PATCH 239/511] Improve history coordinator in Teslemetry (#128235) --- homeassistant/components/teslemetry/__init__.py | 17 +++++++++++------ homeassistant/components/teslemetry/entity.py | 2 ++ homeassistant/components/teslemetry/models.py | 2 +- homeassistant/components/teslemetry/sensor.py | 3 +-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b884f9bbc5c..aa1d2b42660 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -135,11 +135,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes: site_id = product["energy_site_id"] - if not ( - product["components"]["battery"] - or product["components"]["solar"] - or "wall_connectors" in product["components"] - ): + powerwall = ( + product["components"]["battery"] or product["components"]["solar"] + ) + wall_connector = "wall_connectors" in product["components"] + if not powerwall and not wall_connector: LOGGER.debug( "Skipping Energy Site %s as it has no components", site_id, @@ -162,7 +162,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - info_coordinator=TeslemetryEnergySiteInfoCoordinator( hass, api, product ), - history_coordinator=TeslemetryEnergyHistoryCoordinator(hass, api), + history_coordinator=( + TeslemetryEnergyHistoryCoordinator(hass, api) + if powerwall + else None + ), id=site_id, device=device, ) @@ -185,6 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - *( energysite.history_coordinator.async_config_entry_first_refresh() for energysite in energysites + if energysite.history_coordinator ), ) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index ca40d4d00ce..d14f3a42734 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -175,6 +175,8 @@ class TeslemetryEnergyHistoryEntity(TeslemetryEntity): ) -> None: """Initialize common aspects of a Teslemetry Energy Site Info entity.""" + assert data.history_coordinator + self.api = data.api self._attr_unique_id = f"{data.id}-{key}" self._attr_device_info = data.device diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index 7f8bd37425a..d3969b30a7c 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -49,6 +49,6 @@ class TeslemetryEnergyData: api: EnergySpecific live_coordinator: TeslemetryEnergySiteLiveCoordinator info_coordinator: TeslemetryEnergySiteInfoCoordinator - history_coordinator: TeslemetryEnergyHistoryCoordinator + history_coordinator: TeslemetryEnergyHistoryCoordinator | None id: int device: DeviceInfo diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index ba7d930fcd0..95876cc2cf9 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -482,8 +482,7 @@ async def async_setup_entry( TeslemetryEnergyHistorySensorEntity(energysite, description) for energysite in entry.runtime_data.energysites for description in ENERGY_HISTORY_DESCRIPTIONS - if energysite.info_coordinator.data.get("components_battery") - or energysite.info_coordinator.data.get("components_solar") + if energysite.history_coordinator ), ) ) From 57d1001603b6df3f604f35344dc94dda936c8388 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 6 Nov 2024 15:19:58 +0200 Subject: [PATCH 240/511] Move Jewish Calendar to runtime data (#129609) --- .../components/jewish_calendar/__init__.py | 39 +++++++++--------- .../jewish_calendar/binary_sensor.py | 10 ++--- .../components/jewish_calendar/entity.py | 40 +++++++++++-------- .../components/jewish_calendar/sensor.py | 17 +++----- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index fd238e8d615..4598cf7cd91 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -7,12 +7,11 @@ from functools import partial from hdate import Location import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, CONF_LATITUDE, - CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, CONF_TIME_ZONE, @@ -36,6 +35,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .entity import JewishCalendarConfigEntry, JewishCalendarData from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -120,7 +120,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: """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) @@ -143,13 +145,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) ) - 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, - } + config_entry.runtime_data = JewishCalendarData( + language, + diaspora, + location, + candle_lighting_offset, + havdalah_offset, + ) # Update unique ID to be unrelated to user defined options old_prefix = get_unique_prefix( @@ -163,7 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + async def update_listener( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry + ) -> None: # Trigger update of states for all platforms await hass.config_entries.async_reload(config_entry.entry_id) @@ -171,16 +175,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: JewishCalendarConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) @callback diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 060650ee25c..9fd1371f8a8 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -14,15 +14,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN -from .entity import JewishCalendarEntity +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @dataclass(frozen=True) @@ -63,14 +61,12 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: JewishCalendarConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Jewish Calendar binary sensors.""" - entry = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - JewishCalendarBinarySensor(config_entry, entry, description) + JewishCalendarBinarySensor(config_entry, description) for description in BINARY_SENSORS ) diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index c11925df954..ad5ac8e2137 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,18 +1,27 @@ """Entity representing a Jewish Calendar sensor.""" -from typing import Any +from dataclasses import dataclass + +from hdate import Location from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE, CONF_LOCATION from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription -from .const import ( - CONF_CANDLE_LIGHT_MINUTES, - CONF_DIASPORA, - CONF_HAVDALAH_OFFSET_MINUTES, - DOMAIN, -) +from .const import DOMAIN + +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: str + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int class JewishCalendarEntity(Entity): @@ -22,8 +31,7 @@ class JewishCalendarEntity(Entity): def __init__( self, - config_entry: ConfigEntry, - data: dict[str, Any], + config_entry: JewishCalendarConfigEntry, description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" @@ -32,10 +40,10 @@ class JewishCalendarEntity(Entity): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, - name=config_entry.title, ) - self._location = data[CONF_LOCATION] - self._hebrew = data[CONF_LANGUAGE] == "hebrew" - self._candle_lighting_offset = data[CONF_CANDLE_LIGHT_MINUTES] - self._havdalah_offset = data[CONF_HAVDALAH_OFFSET_MINUTES] - self._diaspora = data[CONF_DIASPORA] + data = config_entry.runtime_data + self._location = data.location + self._hebrew = data.language == "hebrew" + self._candle_lighting_offset = data.candle_lighting_offset + self._havdalah_offset = data.havdalah_offset + self._diaspora = data.diaspora diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 87b4375b8b2..c32647af07c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -14,15 +14,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -from .const import DOMAIN -from .entity import JewishCalendarEntity +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) @@ -169,17 +167,15 @@ TIME_SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: JewishCalendarConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Jewish calendar sensors .""" - entry = hass.data[DOMAIN][config_entry.entry_id] sensors = [ - JewishCalendarSensor(config_entry, entry, description) - for description in INFO_SENSORS + JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] sensors.extend( - JewishCalendarTimeSensor(config_entry, entry, description) + JewishCalendarTimeSensor(config_entry, description) for description in TIME_SENSORS ) @@ -193,12 +189,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity): def __init__( self, - config_entry: ConfigEntry, - data: dict[str, Any], + config_entry: JewishCalendarConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize the Jewish calendar sensor.""" - super().__init__(config_entry, data, description) + super().__init__(config_entry, description) self._attrs: dict[str, str] = {} async def async_update(self) -> None: From 29fa7f827a62772ceaf01f8e2867f5658719f629 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Nov 2024 14:20:14 +0100 Subject: [PATCH 241/511] Fix audit-licenses check for multiple Python versions [ci] (#129951) --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cae9795d715..b4c1ad8a74d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -622,13 +622,13 @@ jobs: steps: - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - - name: Set up Python ${{ env.DEFAULT_PYTHON }} + - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v5.3.0 with: - python-version: ${{ env.DEFAULT_PYTHON }} + python-version: ${{ matrix.python-version }} check-latest: true - - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment + - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv uses: actions/cache/restore@v4.1.2 with: @@ -823,7 +823,7 @@ jobs: fail-fast: false matrix: python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - name: Split tests for full run Python ${{ matrix.python-version }} + name: Split tests for full run steps: - name: Install additional OS dependencies run: | From 0430e6794e0fbe5d5b5757b88119b076f32340f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 6 Nov 2024 14:44:17 +0100 Subject: [PATCH 242/511] Delete binary door deprecation issue on unload at Home Connect (#129947) --- .../components/home_connect/binary_sensor.py | 12 +++++++++++- tests/components/home_connect/test_binary_sensor.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 935aae5cbda..f044a3fdfb4 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .api import HomeConnectDevice from .const import ( @@ -206,3 +210,9 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): "items": "\n".join([f"- {item}" for item in items]), }, ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" + ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 9b3e6e8bd02..b564b003af6 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -152,6 +152,7 @@ async def test_create_issue( """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" get_appliances.return_value = [appliance] + issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( hass, @@ -196,6 +197,11 @@ async def test_create_issue( assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue( - DOMAIN, f"deprecated_binary_common_door_sensor_{entity_id}" - ) + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 0ca4f3e1ba547e32841585faddd5ebf3831c080c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 14:52:21 +0100 Subject: [PATCH 243/511] Bump go2rtc-client to 0.0.1b5 (#129952) --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index e69140a51db..4a4f5eb1c2f 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b4"], + "requirements": ["go2rtc-client==0.0.1b5"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aeaa4aa7dcd..94e32d1ff18 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3ac09644b5d..17994cd5c56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8b4a50c254..8b272ad4cd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 61b0ca97406..18a46fdd4d1 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -244,7 +244,7 @@ async def _test_setup_and_signaling( # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different", [])]) + entity_id: Stream([Producer("rtsp://different")]) } receive_message_callback.reset_mock() @@ -258,7 +258,7 @@ async def _test_setup_and_signaling( # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream", [])]) + entity_id: Stream([Producer("rtsp://stream")]) } receive_message_callback.reset_mock() From 29ba14081693e025c8c30bbb771aab0a322852f9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Nov 2024 14:53:59 +0100 Subject: [PATCH 244/511] Update frontend to 20241106.0 (#129953) --- 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 ff399512c8b..2df14df4523 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==20241105.0"] + "requirements": ["home-assistant-frontend==20241106.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 94e32d1ff18..9a6aca1ce10 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 17994cd5c56..37bbdcb2ac3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b272ad4cd3..00b4c722c0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 From 7ce74cb5ec9c21a26acb6d84dc6e4f113f00d4a0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:14:59 +0100 Subject: [PATCH 245/511] Use read-only options in onkyo options flow (#129929) --- homeassistant/components/onkyo/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 623fa9b2a90..a8ced6fae64 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -343,7 +343,9 @@ class OnkyoOptionsFlowHandler(OptionsFlow): return self.async_create_entry( data={ - OPTION_VOLUME_RESOLUTION: self.options[OPTION_VOLUME_RESOLUTION], + OPTION_VOLUME_RESOLUTION: self.config_entry.options[ + OPTION_VOLUME_RESOLUTION + ], OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: sources_store, } @@ -351,7 +353,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow): schema_dict: dict[Any, Selector] = {} - max_volume: float = self.options[OPTION_MAX_VOLUME] + max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( NumberSelector( NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) From 51d694884830cf16d98a749fba8066ee7bed0435 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:15:35 +0100 Subject: [PATCH 246/511] Use read-only options in google cloud options flow (#129927) --- homeassistant/components/google_cloud/config_flow.py | 4 ++-- homeassistant/components/google_cloud/helpers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index 8b8fd751df9..fa6c952022b 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -169,7 +169,7 @@ class GoogleCloudOptionsFlowHandler(OptionsFlow): ) ), **tts_options_schema( - self.options, voices, from_config_flow=True + self.config_entry.options, voices, from_config_flow=True ).schema, vol.Optional( CONF_STT_MODEL, @@ -182,6 +182,6 @@ class GoogleCloudOptionsFlowHandler(OptionsFlow): ), } ), - self.options, + self.config_entry.options, ), ) diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index 3c614156132..f6e89fae7fa 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -52,7 +52,7 @@ async def async_tts_voices( def tts_options_schema( - config_options: dict[str, Any], + config_options: Mapping[str, Any], voices: dict[str, list[str]], from_config_flow: bool = False, ) -> vol.Schema: From adf836d9ac07eda0b8e5a2fd034b28ce01fba5ef Mon Sep 17 00:00:00 2001 From: kingal123 <70146605+kingal123@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:23:14 +0000 Subject: [PATCH 247/511] Update pylutron to 0.2.16 (#129653) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index 5dbf3c45f2a..82bdfad4774 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/lutron", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.2.15"], + "requirements": ["pylutron==0.2.16"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 94325ca4f96..711a7c5d22d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2042,7 +2042,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9c6be1f074..ae5ebaec332 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1647,7 +1647,7 @@ pylitterbot==2023.5.0 pylutron-caseta==0.21.1 # homeassistant.components.lutron -pylutron==0.2.15 +pylutron==0.2.16 # homeassistant.components.mailgun pymailgunner==1.4 From 48d9df89accbcb8f5b3e5db1537879af787a27b8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 5 Nov 2024 15:22:49 -0600 Subject: [PATCH 248/511] Bump intents and add HassRespond test (#129830) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/test_default_agent.py | 13 ++++++++++++- tests/components/intent/test_init.py | 11 +++++++++++ 7 files changed, 28 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index ce0849f9514..2c446ac5d70 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.4", "home-assistant-intents==2024.10.30"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ca938f22d15..2b8360d8a15 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241105.0 -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 711a7c5d22d..e34b0497bcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae5ebaec332..7f9b44c5a53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ holidays==0.60 home-assistant-frontend==20241105.0 # homeassistant.components.conversation -home-assistant-intents==2024.10.30 +home-assistant-intents==2024.11.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5f32b5a38c1..f54849ee12b 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.1 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.10.30 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index e06ba8b4750..14a9b0ca88c 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -431,7 +431,7 @@ async def test_shopping_list_add_item(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("init_components") -async def test_nevermind_item(hass: HomeAssistant) -> None: +async def test_nevermind_intent(hass: HomeAssistant) -> None: """Test HassNevermind intent through the default agent.""" result = await conversation.async_converse(hass, "nevermind", None, Context()) assert result.response.intent is not None @@ -441,6 +441,17 @@ async def test_nevermind_item(hass: HomeAssistant) -> None: assert not result.response.speech +@pytest.mark.usefixtures("init_components") +async def test_respond_intent(hass: HomeAssistant) -> None: + """Test HassRespond intent through the default agent.""" + result = await conversation.async_converse(hass, "hello", None, Context()) + assert result.response.intent is not None + assert result.response.intent.intent_type == intent.INTENT_RESPOND + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == "Hello from Home Assistant." + + @pytest.mark.usefixtures("init_components") async def test_device_area_context( hass: HomeAssistant, diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 659ca16c0bb..20c0f9d8d44 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -455,3 +455,14 @@ async def test_set_position_intent_unsupported_domain(hass: HomeAssistant) -> No "HassSetPosition", {"name": {"value": "test light"}, "position": {"value": 100}}, ) + + +async def test_intents_with_no_responses(hass: HomeAssistant) -> None: + """Test intents that should not return a response during handling.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, "intent", {}) + + # The "respond" intent gets its response text from home-assistant-intents + for intent_name in (intent.INTENT_NEVERMIND, intent.INTENT_RESPOND): + response = await intent.async_handle(hass, "test", intent_name, {}) + assert not response.speech From dea31e574461983e21eec6c8659dcaad6d8fe97f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:38:24 +0100 Subject: [PATCH 249/511] Ensure that all files in a folder are in the same test bucket (#129946) --- script/split_tests.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/script/split_tests.py b/script/split_tests.py index e124f722552..c64de46a068 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -49,16 +49,27 @@ class BucketHolder: test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests ) for tests in sorted_tests: - print(f"{tests.total_tests:>{digits}} tests in {tests.path}") if tests.added_to_bucket: # Already added to bucket continue + print(f"{tests.total_tests:>{digits}} tests in {tests.path}") smallest_bucket = min(self._buckets, key=lambda x: x.total_tests) + is_file = isinstance(tests, TestFile) if ( smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket - ) or isinstance(tests, TestFile): + ) or is_file: smallest_bucket.add(tests) + # Ensure all files from the same folder are in the same bucket + # to ensure that syrupy correctly identifies unused snapshots + if is_file: + for other_test in tests.parent.children.values(): + if other_test is tests or isinstance(other_test, TestFolder): + continue + print( + f"{other_test.total_tests:>{digits}} tests in {other_test.path} (same bucket)" + ) + smallest_bucket.add(other_test) # verify that all tests are added to a bucket if not test_folder.added_to_bucket: @@ -79,6 +90,7 @@ class TestFile: total_tests: int path: Path added_to_bucket: bool = field(default=False, init=False) + parent: TestFolder | None = field(default=None, init=False) def add_to_bucket(self) -> None: """Add test file to bucket.""" @@ -125,6 +137,7 @@ class TestFolder: def add_test_file(self, file: TestFile) -> None: """Add test file to folder.""" path = file.path + file.parent = self relative_path = path.relative_to(self.path) if not relative_path.parts: raise ValueError("Path is not a child of this folder") From f55e13bde46d2d1ebce60b2ab33ed6dcca660d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 6 Nov 2024 11:44:54 +0100 Subject: [PATCH 250/511] Bump pyTibber to 0.30.4 (#129844) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/services.py | 12 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tibber/test_services.py | 96 +++++-------------- 5 files changed, 29 insertions(+), 85 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index ac46141d974..205bc1352eb 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.3"] + "requirements": ["pyTibber==0.30.4"] } diff --git a/homeassistant/components/tibber/services.py b/homeassistant/components/tibber/services.py index 87268186285..72943a0215a 100644 --- a/homeassistant/components/tibber/services.py +++ b/homeassistant/components/tibber/services.py @@ -47,17 +47,13 @@ async def __get_prices(call: ServiceCall, *, hass: HomeAssistant) -> ServiceResp for tibber_home in tibber_connection.get_homes(only_active=True): home_nickname = tibber_home.name - price_info = tibber_home.info["viewer"]["home"]["currentSubscription"][ - "priceInfo" - ] price_data = [ { - "start_time": price["startsAt"], - "price": price["total"], - "level": price["level"], + "start_time": starts_at, + "price": price, + "level": tibber_home.price_level.get(starts_at), } - for key in ("today", "tomorrow") - for price in price_info[key] + for starts_at, price in tibber_home.price_total.items() ] selected_data = [ diff --git a/requirements_all.txt b/requirements_all.txt index e34b0497bcd..0102b49fea5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1735,7 +1735,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f9b44c5a53..225d1547ba8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1412,7 +1412,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.3 +pyTibber==0.30.4 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/tests/components/tibber/test_services.py b/tests/components/tibber/test_services.py index 49f9e5e451b..dc6f5d2789d 100644 --- a/tests/components/tibber/test_services.py +++ b/tests/components/tibber/test_services.py @@ -20,84 +20,32 @@ def generate_mock_home_data(): mock_homes = [ MagicMock( name="first_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), MagicMock( name="second_home", - info={ - "viewer": { - "home": { - "currentSubscription": { - "priceInfo": { - "today": [ - { - "startsAt": START_TIME.isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - START_TIME + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.36914, - "level": "VERY_EXPENSIVE", - }, - ], - "tomorrow": [ - { - "startsAt": tomorrow.isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - { - "startsAt": ( - tomorrow + dt.timedelta(hours=1) - ).isoformat(), - "total": 0.46914, - "level": "VERY_EXPENSIVE", - }, - ], - } - } - } - } + price_total={ + START_TIME.isoformat(): 0.36914, + (START_TIME + dt.timedelta(hours=1)).isoformat(): 0.36914, + tomorrow.isoformat(): 0.46914, + (tomorrow + dt.timedelta(hours=1)).isoformat(): 0.46914, + }, + price_level={ + START_TIME.isoformat(): "VERY_EXPENSIVE", + (START_TIME + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", + tomorrow.isoformat(): "VERY_EXPENSIVE", + (tomorrow + dt.timedelta(hours=1)).isoformat(): "VERY_EXPENSIVE", }, ), ] From 399c53a57e500648366e066c5e917e1e69993dd2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Nov 2024 21:04:58 +0100 Subject: [PATCH 251/511] Bump spotifyaio to 0.8.4 (#129899) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 2d86083d49c..9a52a4cf36a 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.3"], + "requirements": ["spotifyaio==0.8.4"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0102b49fea5..b4a8a9d2cf5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 225d1547ba8..c6f63b6762b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.3 +spotifyaio==0.8.4 # homeassistant.components.sql sqlparse==0.5.0 From bdc17621ee645d34ef5e1d6e913bb4cbd7c53f71 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 6 Nov 2024 13:10:23 +0100 Subject: [PATCH 252/511] Map "stop" to MediaPlayerState.IDLE in bluesound integration (#129904) Co-authored-by: Joost Lekkerkerker --- .../components/bluesound/media_player.py | 13 ++++++------ .../components/bluesound/test_media_player.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 20cf51ff2f9..1d46af2cc4b 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -364,12 +364,13 @@ class BluesoundPlayer(MediaPlayerEntity): if self.is_grouped and not self.is_master: return MediaPlayerState.IDLE - status = self._status.state - if status in ("pause", "stop"): - return MediaPlayerState.PAUSED - if status in ("stream", "play"): - return MediaPlayerState.PLAYING - return MediaPlayerState.IDLE + match self._status.state: + case "pause": + return MediaPlayerState.PAUSED + case "stream" | "play": + return MediaPlayerState.PLAYING + case _: + return MediaPlayerState.IDLE @property def media_title(self) -> str | None: diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 966f3117650..894528265e1 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -130,6 +130,26 @@ async def test_attributes_set( assert state == snapshot(exclude=props("media_position_updated_at")) +async def test_stop_maps_to_idle( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player stop maps to idle.""" + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), state="stop" + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + assert ( + hass.states.get("media_player.player_name1111").state == MediaPlayerState.IDLE + ) + + async def test_status_updated( hass: HomeAssistant, setup_config_entry: None, From 0c9f30364c5e99bc31a81fbb48623952bd5c1a3f Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 6 Nov 2024 11:52:00 +0100 Subject: [PATCH 253/511] Update Bang & Olufsen source list as availability changes (#129910) --- .../components/bang_olufsen/const.py | 36 ++++++++++--------- .../components/bang_olufsen/media_player.py | 9 ++--- .../components/bang_olufsen/websocket.py | 11 ++++++ tests/components/bang_olufsen/conftest.py | 6 ++-- tests/components/bang_olufsen/const.py | 1 + .../bang_olufsen/test_media_player.py | 32 +++++++++++++++++ 6 files changed, 70 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index caa4cef8a13..1e06f153cdb 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -21,41 +21,57 @@ class BangOlufsenSource: name="Audio Streamer", id="uriStreamer", is_seekable=False, + is_enabled=True, + is_playable=True, ) BLUETOOTH: Final[Source] = Source( name="Bluetooth", id="bluetooth", is_seekable=False, + is_enabled=True, + is_playable=True, ) CHROMECAST: Final[Source] = Source( name="Chromecast built-in", id="chromeCast", is_seekable=False, + is_enabled=True, + is_playable=True, ) LINE_IN: Final[Source] = Source( name="Line-In", id="lineIn", is_seekable=False, + is_enabled=True, + is_playable=True, ) SPDIF: Final[Source] = Source( name="Optical", id="spdif", is_seekable=False, + is_enabled=True, + is_playable=True, ) NET_RADIO: Final[Source] = Source( name="B&O Radio", id="netRadio", is_seekable=False, + is_enabled=True, + is_playable=True, ) DEEZER: Final[Source] = Source( name="Deezer", id="deezer", is_seekable=True, + is_enabled=True, + is_playable=True, ) TIDAL: Final[Source] = Source( name="Tidal", id="tidal", is_seekable=True, + is_enabled=True, + is_playable=True, ) @@ -170,20 +186,6 @@ VALID_MEDIA_TYPES: Final[tuple] = ( MediaType.CHANNEL, ) -# Sources on the device that should not be selectable by the user -HIDDEN_SOURCE_IDS: Final[tuple] = ( - "airPlay", - "bluetooth", - "chromeCast", - "generator", - "local", - "dlna", - "qplay", - "wpl", - "pl", - "beolink", - "usbIn", -) # Fallback sources to use in case of API failure. FALLBACK_SOURCES: Final[SourceArray] = SourceArray( @@ -191,7 +193,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="uriStreamer", is_enabled=True, - is_playable=False, + is_playable=True, name="Audio Streamer", type=SourceTypeEnum(value="uriStreamer"), is_seekable=False, @@ -199,7 +201,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="bluetooth", is_enabled=True, - is_playable=False, + is_playable=True, name="Bluetooth", type=SourceTypeEnum(value="bluetooth"), is_seekable=False, @@ -207,7 +209,7 @@ FALLBACK_SOURCES: Final[SourceArray] = SourceArray( Source( id="spotify", is_enabled=True, - is_playable=False, + is_playable=True, name="Spotify Connect", type=SourceTypeEnum(value="spotify"), is_seekable=True, diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 31f821683d4..e8108ee2cf7 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -70,7 +70,6 @@ from .const import ( CONNECTION_STATUS, DOMAIN, FALLBACK_SOURCES, - HIDDEN_SOURCE_IDS, VALID_MEDIA_TYPES, BangOlufsenMediaType, BangOlufsenSource, @@ -169,6 +168,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, + WebsocketNotification.PLAYBACK_SOURCE: self._async_update_sources, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, WebsocketNotification.SOURCE_CHANGE: self._async_update_source_change, @@ -243,7 +243,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if queue_settings.shuffle is not None: self._attr_shuffle = queue_settings.shuffle - async def _async_update_sources(self) -> None: + async def _async_update_sources(self, _: Source | None = None) -> None: """Get sources for the specific product.""" # Audio sources @@ -270,10 +270,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._audio_sources = { source.id: source.name for source in cast(list[Source], sources.items) - if source.is_enabled - and source.id - and source.name - and source.id not in HIDDEN_SOURCE_IDS + if source.is_enabled and source.id and source.name and source.is_playable } # Some sources are not Beolink expandable, meaning that they can't be joined by diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 3519fcd9a48..94b84189ccc 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -63,6 +63,9 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_playback_progress_notifications( self.on_playback_progress_notification ) + self._client.get_playback_source_notifications( + self.on_playback_source_notification + ) self._client.get_playback_state_notifications( self.on_playback_state_notification ) @@ -157,6 +160,14 @@ class BangOlufsenWebsocket(BangOlufsenBase): notification, ) + def on_playback_source_notification(self, notification: Source) -> None: + """Send playback_source dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.PLAYBACK_SOURCE}", + notification, + ) + def on_source_change_notification(self, notification: Source) -> None: """Send source_change dispatch.""" async_dispatcher_send( diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index a644b395c69..6c19a29c1da 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -124,7 +124,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.get_available_sources = AsyncMock() client.get_available_sources.return_value = SourceArray( items=[ - # Is in the HIDDEN_SOURCE_IDS constant, so should not be user selectable + # Is not playable, so should not be user selectable Source( name="AirPlay", id="airPlay", @@ -137,14 +137,16 @@ def mock_mozart_client() -> Generator[AsyncMock]: id="tidal", is_enabled=True, is_multiroom_available=True, + is_playable=True, ), Source( name="Line-In", id="lineIn", is_enabled=True, is_multiroom_available=False, + is_playable=True, ), - # Is disabled, so should not be user selectable + # Is disabled and not playable, so should not be user selectable Source( name="Powerlink", id="pl", diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 7f2e52cfc87..3769aef5cd3 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -130,6 +130,7 @@ TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ "Audio Streamer", + "Bluetooth", "Spotify Connect", "Line-In", "Optical", diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 844e9bfe61b..8f23af9e04a 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -10,6 +10,7 @@ from mozart_api.models import ( PlayQueueSettings, RenderingState, Source, + SourceArray, WebsocketNotificationTag, ) import pytest @@ -195,6 +196,37 @@ async def test_async_update_sources_remote( assert mock_mozart_client.get_remote_menu.call_count == 2 +async def test_async_update_sources_availability( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the playback_source WebSocket event updates available playback sources.""" + # Remove video sources to simplify test + mock_mozart_client.get_remote_menu.return_value = {} + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + playback_source_callback = ( + mock_mozart_client.get_playback_source_notifications.call_args[0][0] + ) + + assert mock_mozart_client.get_available_sources.call_count == 1 + + # Add a source that is available and playable + mock_mozart_client.get_available_sources.return_value = SourceArray( + items=[BangOlufsenSource.TIDAL] + ) + + # Send playback_source. The source is not actually used, so its attributes don't matter + playback_source_callback(Source()) + + assert mock_mozart_client.get_available_sources.call_count == 2 + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name] + + async def test_async_update_playback_metadata( hass: HomeAssistant, mock_mozart_client: AsyncMock, From 399011552bdbc2fb20773c95f06de636b519ac77 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 5 Nov 2024 22:18:41 +0100 Subject: [PATCH 254/511] Disable uv cache (#129912) --- Dockerfile | 3 ++- script/hassfest/docker.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2f6a400e0d1..b6d571f308e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,8 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME=240000 \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 1f6c19e6593..083cdaba1a9 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -20,7 +20,8 @@ FROM ${{BUILD_FROM}} # Synchronize with homeassistant/core.py:async_stop ENV \ S6_SERVICES_GRACETIME={timeout} \ - UV_SYSTEM_PYTHON=true + UV_SYSTEM_PYTHON=true \ + UV_NO_CACHE=true ARG QEMU_CPU From 995aab83471e6427a12cc097e29fba21b63a229c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Nov 2024 00:05:05 +0100 Subject: [PATCH 255/511] Bump reolink_aio to 0.10.4 (#129914) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5fd87c2ccb1..23a46c5e1c9 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.10.3"] + "requirements": ["reolink-aio==0.10.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4a8a9d2cf5..6f05ce42280 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2547,7 +2547,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6f63b6762b..d0cd110240e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2038,7 +2038,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.3 +reolink-aio==0.10.4 # homeassistant.components.rflink rflink==0.0.66 From 26d8d5343a8f5820e9cb82a6fc26c749750b1cba Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Nov 2024 22:36:26 -0500 Subject: [PATCH 256/511] Ensure all template names are strings (#129921) --- homeassistant/components/template/template_entity.py | 6 ++++-- tests/components/template/test_sensor.py | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3e70e1c3546..f5b84b1ad7a 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -535,13 +535,15 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module ) if self._entity_picture_template is not None: self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template + "_attr_entity_picture", self._entity_picture_template, cv.string ) if ( self._friendly_name_template is not None and not self._friendly_name_template.is_static ): - self.add_template_attribute("_attr_name", self._friendly_name_template) + self.add_template_attribute( + "_attr_name", self._friendly_name_template, cv.string + ) @callback def async_start_preview( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 5a7521f98c7..929a890ab38 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components import sensor, template from homeassistant.components.template.sensor import TriggerSensorEntity from homeassistant.const import ( ATTR_ENTITY_PICTURE, + ATTR_FRIENDLY_NAME, ATTR_ICON, EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -983,6 +984,7 @@ async def test_self_referencing_sensor_with_icon_and_picture_entity_loop( "test": { "value_template": "{{ 1 }}", "entity_picture_template": "{{ ((states.sensor.test.attributes['entity_picture'] or 0) | int) + 1 }}", + "friendly_name_template": "{{ ((states.sensor.test.attributes['friendly_name'] or 0) | int) + 1 }}", }, }, } @@ -1007,7 +1009,8 @@ async def test_self_referencing_entity_picture_loop( state = hass.states.get("sensor.test") assert int(state.state) == 1 - assert state.attributes[ATTR_ENTITY_PICTURE] == 2 + assert state.attributes[ATTR_ENTITY_PICTURE] == "3" + assert state.attributes[ATTR_FRIENDLY_NAME] == "3" await hass.async_block_till_done() assert int(state.state) == 1 From 361e0d4fc74c70d197bf342a33148cb5a4f9508d Mon Sep 17 00:00:00 2001 From: Kunal Aggarwal Date: Wed, 6 Nov 2024 16:13:41 +0530 Subject: [PATCH 257/511] Adding "peaceful" status as on value to Tuya Presence Sensor (#129925) --- homeassistant/components/tuya/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 934f03336aa..12661a26fd1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -151,7 +151,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, - on_value={"presence", "small_move", "large_move"}, + on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), # Formaldehyde Detector From 232a6868ffd4c80bc25dad50f071780d811784ed Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 10:07:10 +0100 Subject: [PATCH 258/511] Fix native sync WebRTC offer (#129931) --- homeassistant/components/camera/__init__.py | 5 ++++- tests/components/camera/test_webrtc.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b600eae02c7..67c2432129f 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -848,7 +848,10 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ] config.configuration.ice_servers.extend(ice_servers) - config.get_candidates_upfront = self._legacy_webrtc_provider is not None + config.get_candidates_upfront = ( + self._supports_native_sync_webrtc + or self._legacy_webrtc_provider is not None + ) return config diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index f726eb29673..7a1df556c20 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -358,7 +358,7 @@ async def test_ws_get_client_config_sync_offer( assert msg["success"] assert msg["result"] == { "configuration": {}, - "getCandidatesUpfront": False, + "getCandidatesUpfront": True, } From 9cd46c7f036742fe090755d850df5e389b90638c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:35 +0100 Subject: [PATCH 259/511] Bump spotifyaio to 0.8.5 (#129938) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 9a52a4cf36a..8cf8d735553 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.4"], + "requirements": ["spotifyaio==0.8.5"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6f05ce42280..a6f9239802b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2707,7 +2707,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0cd110240e..fdd14fc91de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.4 +spotifyaio==0.8.5 # homeassistant.components.sql sqlparse==0.5.0 From 4b9524c5c169d1eb7fbe0267791ec9e54aa08926 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 11:32:59 +0100 Subject: [PATCH 260/511] Write squeezebox player state after query (#129939) --- homeassistant/components/squeezebox/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 6037017dd1e..19cd1e36910 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -535,6 +535,7 @@ class SqueezeBoxMediaPlayerEntity( all_params.extend(parameters) self._query_result = await self._player.async_query(*all_params) _LOGGER.debug("call_query got result %s", self._query_result) + self.async_write_ha_state() async def async_join_players(self, group_members: list[str]) -> None: """Add other Squeezebox players to this player's sync group. From 22b5071c26cab907fa63555952c7a205f9b81ddf Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 12:05:23 +0100 Subject: [PATCH 261/511] Bump go2rtc-client to 0.0.1b4 (#129942) --- homeassistant/components/go2rtc/__init__.py | 5 ++++- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 12 ++++++++---- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 9ffe9e25f78..a07a62305f2 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -222,7 +222,10 @@ class WebRTCProvider(CameraWebRTCProvider): if (stream := streams.get(camera.entity_id)) is None or not any( stream_source == producer.url for producer in stream.producers ): - await self._rest_client.streams.add(camera.entity_id, stream_source) + await self._rest_client.streams.add( + camera.entity_id, + [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], + ) @callback def on_messages(message: ReceiveMessages) -> None: diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index b30b7cb1cc1..e69140a51db 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b3"], + "requirements": ["go2rtc-client==0.0.1b4"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b8360d8a15..cb9a5c8f868 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 ha-av==10.1.1 ha-ffmpeg==3.2.1 habluetooth==3.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index a6f9239802b..e4d391204b0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -986,7 +986,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdd14fc91de..d5134ac24f1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -836,7 +836,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b3 +go2rtc-client==0.0.1b4 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 21d4d0a047e..61b0ca97406 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -237,24 +237,28 @@ async def _test_setup_and_signaling( await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) + entity_id: Stream([Producer("rtsp://different", [])]) } receive_message_callback.reset_mock() ws_client.reset_mock() await test() - rest_client.streams.add.assert_called_once_with(entity_id, "rtsp://stream") + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) + entity_id: Stream([Producer("rtsp://stream", [])]) } receive_message_callback.reset_mock() From dfc3423c83f3d3e6d6bc0f75acdd3507bd76e298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 6 Nov 2024 14:44:17 +0100 Subject: [PATCH 262/511] Delete binary door deprecation issue on unload at Home Connect (#129947) --- .../components/home_connect/binary_sensor.py | 12 +++++++++++- tests/components/home_connect/test_binary_sensor.py | 12 +++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 935aae5cbda..f044a3fdfb4 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from .api import HomeConnectDevice from .const import ( @@ -206,3 +210,9 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): "items": "\n".join([f"- {item}" for item in items]), }, ) + + async def async_will_remove_from_hass(self) -> None: + """Call when entity will be removed from hass.""" + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}" + ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 9b3e6e8bd02..b564b003af6 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -152,6 +152,7 @@ async def test_create_issue( """Test we create an issue when an automation or script is using a deprecated entity.""" entity_id = "binary_sensor.washer_door" get_appliances.return_value = [appliance] + issue_id = f"deprecated_binary_common_door_sensor_{entity_id}" assert await async_setup_component( hass, @@ -196,6 +197,11 @@ async def test_create_issue( assert scripts_with_entity(hass, entity_id)[0] == "script.test" assert len(issue_registry.issues) == 1 - assert issue_registry.async_get_issue( - DOMAIN, f"deprecated_binary_common_door_sensor_{entity_id}" - ) + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 795384ca2d34709147fc446a79bf851c6f17a1ec Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 13:23:43 +0100 Subject: [PATCH 263/511] Improve error messages in Habitica (#129948) Improve error messages --- homeassistant/components/habitica/coordinator.py | 4 ++-- homeassistant/components/habitica/strings.json | 4 ++-- tests/components/habitica/test_button.py | 4 ++-- tests/components/habitica/test_init.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 4e949b703fb..cce2c684ba8 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -59,9 +59,9 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: - _LOGGER.debug("Currently rate limited, skipping update") + _LOGGER.debug("Rate limit exceeded, will try again later") return self.data - raise UpdateFailed(f"Error communicating with API: {error}") from error + raise UpdateFailed(f"Unable to connect to Habitica: {error}") from error return HabiticaData(user=user_response, tasks=tasks_response) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 62b01260010..690cdab09ad 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -204,10 +204,10 @@ "message": "Unable to create new to-do `{name}` for Habitica, please try again" }, "setup_rate_limit_exception": { - "message": "Currently rate limited, try again later" + "message": "Rate limit exceeded, try again later" }, "service_call_unallowed": { - "message": "Unable to carry out this action, because the required conditions are not met" + "message": "Unable to complete action, the required conditions are not met" }, "service_call_exception": { "message": "Unable to connect to Habitica, try again later" diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index e7eda1609c8..6bd62f3a58e 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -207,7 +207,7 @@ async def test_button_press( [ ( HTTPStatus.TOO_MANY_REQUESTS, - "Currently rate limited", + "Rate limit exceeded, try again later", ServiceValidationError, ), ( @@ -217,7 +217,7 @@ async def test_button_press( ), ( HTTPStatus.UNAUTHORIZED, - "Unable to carry out this action", + "Unable to complete action, the required conditions are not met", ServiceValidationError, ), ], diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 0ee2d872954..fd8a18b2d44 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -165,4 +165,4 @@ async def test_coordinator_rate_limited( async_fire_time_changed(hass) await hass.async_block_till_done() - assert "Currently rate limited, skipping update" in caplog.text + assert "Rate limit exceeded, will try again later" in caplog.text From 401262c23de9422d391ad40ac5cd76a77ca3d326 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 14:52:21 +0100 Subject: [PATCH 264/511] Bump go2rtc-client to 0.0.1b5 (#129952) --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/go2rtc/test_init.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index e69140a51db..4a4f5eb1c2f 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b4"], + "requirements": ["go2rtc-client==0.0.1b5"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cb9a5c8f868..8032c9b1a3f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 ha-av==10.1.1 ha-ffmpeg==3.2.1 habluetooth==3.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index e4d391204b0..9dd7bb927ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -986,7 +986,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5134ac24f1..456ac820169 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -836,7 +836,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b4 +go2rtc-client==0.0.1b5 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 61b0ca97406..18a46fdd4d1 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -244,7 +244,7 @@ async def _test_setup_and_signaling( # Stream exists but the source is different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different", [])]) + entity_id: Stream([Producer("rtsp://different")]) } receive_message_callback.reset_mock() @@ -258,7 +258,7 @@ async def _test_setup_and_signaling( # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream", [])]) + entity_id: Stream([Producer("rtsp://stream")]) } receive_message_callback.reset_mock() From bc84fdc64ac0a45e2795ecf0f5924009e16b6bc8 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Nov 2024 14:53:59 +0100 Subject: [PATCH 265/511] Update frontend to 20241106.0 (#129953) --- 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 ff399512c8b..2df14df4523 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==20241105.0"] + "requirements": ["home-assistant-frontend==20241106.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8032c9b1a3f..2086f5d47fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.83.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 home-assistant-intents==2024.11.4 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9dd7bb927ce..4ec1271c34a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 456ac820169..ae79d4422a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241105.0 +home-assistant-frontend==20241106.0 # homeassistant.components.conversation home-assistant-intents==2024.11.4 From 3b840c684bb827743ad25492d244568d49a62f7a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Nov 2024 15:44:10 +0100 Subject: [PATCH 266/511] Bump version to 2024.11.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 b0b4339a4c5..5d120cdf27c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 2053f5b81b5..ce2e421bd1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b7" +version = "2024.11.0b8" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9f427893b135079183ac02e47fbf6e7c31de61f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 17:00:20 +0100 Subject: [PATCH 267/511] Remove deprecation issues for LCN once entities removed (#129955) --- homeassistant/components/lcn/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 1e29a36da4e..d0ce4815f19 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -15,7 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -115,6 +119,9 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): await self.device_connection.cancel_status_request_handler( self.setpoint_variable ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" @@ -201,6 +208,9 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" From fe0a822721cd777e2dfb216185c6a7f2d126c8be Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Nov 2024 17:37:23 +0100 Subject: [PATCH 268/511] Call async_refresh_providers when camera entity feature changes (#129941) --- homeassistant/components/camera/__init__.py | 20 +++++++++ tests/components/camera/conftest.py | 2 +- tests/components/camera/test_init.py | 49 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 67c2432129f..6d65ea255c7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -472,6 +472,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_state: None = None # State is determined by is_on _attr_supported_features: CameraEntityFeature = CameraEntityFeature(0) + __supports_stream: CameraEntityFeature | None = None + def __init__(self) -> None: """Initialize a camera.""" self._cache: dict[str, Any] = {} @@ -783,6 +785,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -892,6 +897,21 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return CameraCapabilities(frontend_stream_types) + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine. + + Schedules async_refresh_providers if support of streams have changed. + """ + super().async_write_ha_state() + if self.__supports_stream != ( + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM + ): + self.__supports_stream = supports_stream + self._invalidate_camera_capabilities_cache() + self.hass.async_create_task(self.async_refresh_providers()) + class CameraView(HomeAssistantView): """Base CameraView.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index d6343959d41..f0c418711c7 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -157,7 +157,7 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: - """Initialize a test WebRTC cameras.""" + """Initialize test WebRTC cameras with native RTC support.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 621ac8b7fb3..32024694b7e 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1005,3 +1005,52 @@ async def test_webrtc_provider_not_added_for_native_webrtc( assert camera_obj._webrtc_provider is None assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_capabilities_changing_non_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, "camera.demo_camera") + assert ( + cam.supported_features + == camera.CameraEntityFeature.ON_OFF | camera.CameraEntityFeature.STREAM + ) + + await _test_capabilities( + hass, + hass_ws_client, + cam.entity_id, + {StreamType.HLS}, + {StreamType.HLS, StreamType.WEB_RTC}, + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("mock_test_webrtc_cameras") +@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"]) +async def test_camera_capabilities_changing_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_id: str, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, entity_id) + assert cam.supported_features == camera.CameraEntityFeature.STREAM + + await _test_capabilities( + hass, hass_ws_client, cam.entity_id, {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) From d4adb1f2980a2cfc04dccc222dad5f9885e2f912 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 17:59:04 +0100 Subject: [PATCH 269/511] Bump go2rtc-client to 0.1.0 (#129965) --- homeassistant/components/go2rtc/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/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 4a4f5eb1c2f..ea9308e5e18 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b5"], + "requirements": ["go2rtc-client==0.1.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9a6aca1ce10..15ce798ab90 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 ha-ffmpeg==3.2.1 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index 37bbdcb2ac3..ef79b8ad6b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00b4c722c0b..b3c05f3a524 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -840,7 +840,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 From c18d50910f67d66d4b6f921494d3c8592b8f2530 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Nov 2024 17:37:23 +0100 Subject: [PATCH 270/511] Call async_refresh_providers when camera entity feature changes (#129941) --- homeassistant/components/camera/__init__.py | 20 +++++++++ tests/components/camera/conftest.py | 2 +- tests/components/camera/test_init.py | 49 +++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 67c2432129f..6d65ea255c7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -472,6 +472,8 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): _attr_state: None = None # State is determined by is_on _attr_supported_features: CameraEntityFeature = CameraEntityFeature(0) + __supports_stream: CameraEntityFeature | None = None + def __init__(self) -> None: """Initialize a camera.""" self._cache: dict[str, Any] = {} @@ -783,6 +785,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() + self.__supports_stream = ( + self.supported_features_compat & CameraEntityFeature.STREAM + ) await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -892,6 +897,21 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return CameraCapabilities(frontend_stream_types) + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine. + + Schedules async_refresh_providers if support of streams have changed. + """ + super().async_write_ha_state() + if self.__supports_stream != ( + supports_stream := self.supported_features_compat + & CameraEntityFeature.STREAM + ): + self.__supports_stream = supports_stream + self._invalidate_camera_capabilities_cache() + self.hass.async_create_task(self.async_refresh_providers()) + class CameraView(HomeAssistantView): """Base CameraView.""" diff --git a/tests/components/camera/conftest.py b/tests/components/camera/conftest.py index d6343959d41..f0c418711c7 100644 --- a/tests/components/camera/conftest.py +++ b/tests/components/camera/conftest.py @@ -157,7 +157,7 @@ def mock_stream_source_fixture() -> Generator[AsyncMock]: @pytest.fixture async def mock_test_webrtc_cameras(hass: HomeAssistant) -> None: - """Initialize a test WebRTC cameras.""" + """Initialize test WebRTC cameras with native RTC support.""" # Cannot use the fixture mock_camera_web_rtc as it's mocking Camera.async_handle_web_rtc_offer # and native support is checked by verify the function "async_handle_web_rtc_offer" was diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 621ac8b7fb3..32024694b7e 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1005,3 +1005,52 @@ async def test_webrtc_provider_not_added_for_native_webrtc( assert camera_obj._webrtc_provider is None assert camera_obj._supports_native_sync_webrtc is not expect_native_async_webrtc assert camera_obj._supports_native_async_webrtc is expect_native_async_webrtc + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_camera_capabilities_changing_non_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, "camera.demo_camera") + assert ( + cam.supported_features + == camera.CameraEntityFeature.ON_OFF | camera.CameraEntityFeature.STREAM + ) + + await _test_capabilities( + hass, + hass_ws_client, + cam.entity_id, + {StreamType.HLS}, + {StreamType.HLS, StreamType.WEB_RTC}, + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("mock_test_webrtc_cameras") +@pytest.mark.parametrize(("entity_id"), ["camera.sync", "camera.async"]) +async def test_camera_capabilities_changing_native_support( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_id: str, +) -> None: + """Test WebRTC camera capabilities.""" + cam = get_camera_from_entity_id(hass, entity_id) + assert cam.supported_features == camera.CameraEntityFeature.STREAM + + await _test_capabilities( + hass, hass_ws_client, cam.entity_id, {StreamType.WEB_RTC}, {StreamType.WEB_RTC} + ) + + cam._attr_supported_features = camera.CameraEntityFeature(0) + cam.async_write_ha_state() + await hass.async_block_till_done() + + await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) From e5a28f4f254436f05144dcb8755094e1c2582e6b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Nov 2024 17:00:20 +0100 Subject: [PATCH 271/511] Remove deprecation issues for LCN once entities removed (#129955) --- homeassistant/components/lcn/binary_sensor.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 1e29a36da4e..d0ce4815f19 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -15,7 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -115,6 +119,9 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): await self.device_connection.cancel_status_request_handler( self.setpoint_variable ) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" @@ -201,6 +208,9 @@ class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) + async_delete_issue( + self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" + ) def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" From 7757423d18c047c548498be3213aa1979cb18de9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 6 Nov 2024 17:59:04 +0100 Subject: [PATCH 272/511] Bump go2rtc-client to 0.1.0 (#129965) --- homeassistant/components/go2rtc/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/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 4a4f5eb1c2f..ea9308e5e18 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.0.1b5"], + "requirements": ["go2rtc-client==0.1.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2086f5d47fc..b399c64d7e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -26,7 +26,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 ha-av==10.1.1 ha-ffmpeg==3.2.1 habluetooth==3.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4ec1271c34a..1e50a44c2dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -986,7 +986,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae79d4422a7..2a04ce2bf63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -836,7 +836,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.0.1b5 +go2rtc-client==0.1.0 # homeassistant.components.goalzero goalzero==0.2.2 From 782417528cfdec023b4a68eafe34e6eb62ceff79 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Nov 2024 18:25:29 +0100 Subject: [PATCH 273/511] Bump version to 2024.11.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 5d120cdf27c..af7b7768cec 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 ce2e421bd1f..e26ab16b965 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b8" +version = "2024.11.0b9" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From b808c0c5eb35a29f65b4149653d037c5da6ec3f6 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:15:25 +0100 Subject: [PATCH 274/511] Add state invitation to list access sensor in Bring integration (#129960) --- homeassistant/components/bring/icons.json | 3 +- homeassistant/components/bring/sensor.py | 2 +- homeassistant/components/bring/strings.json | 3 +- .../bring/fixtures/items_invitation.json | 44 +++++++++++++++++++ .../bring/fixtures/items_shared.json | 44 +++++++++++++++++++ .../bring/snapshots/test_sensor.ambr | 4 ++ tests/components/bring/test_sensor.py | 36 ++++++++++++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/components/bring/fixtures/items_invitation.json create mode 100644 tests/components/bring/fixtures/items_shared.json diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index 74c3b2e393b..c670ef87700 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -16,7 +16,8 @@ "list_access": { "default": "mdi:account-lock", "state": { - "shared": "mdi:account-group" + "shared": "mdi:account-group", + "invitation": "mdi:account-multiple-plus" } } }, diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 57ceb099535..746ed397e1b 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -79,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( translation_key=BringSensor.LIST_ACCESS, value_fn=lambda lst, _: lst["status"].lower(), entity_category=EntityCategory.DIAGNOSTIC, - options=["registered", "shared"], + options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, ), ) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 61121cdca60..9a93881b5d2 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -66,7 +66,8 @@ "name": "List access", "state": { "registered": "Private", - "shared": "Shared" + "shared": "Shared", + "invitation": "Invitation pending" } } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json new file mode 100644 index 00000000000..82ef623e439 --- /dev/null +++ b/tests/components/bring/fixtures/items_invitation.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "INVITATION", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json new file mode 100644 index 00000000000..9ac999729d3 --- /dev/null +++ b/tests/components/bring/fixtures/items_shared.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "SHARED", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index 513b4e6469e..97e1d1b4bd9 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -55,6 +55,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -92,6 +93,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , @@ -344,6 +346,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -381,6 +384,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index a36b0163165..974818ccedf 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -1,17 +1,18 @@ """Test for sensor platform of the Bring! integration.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -42,3 +43,34 @@ async def test_setup( await snapshot_platform( hass, entity_registry, snapshot, bring_config_entry.entry_id ) + + +@pytest.mark.parametrize( + ("fixture", "entity_state"), + [ + ("items_invitation", "invitation"), + ("items_shared", "shared"), + ("items", "registered"), + ], +) +async def test_list_access_states( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + fixture: str, + entity_state: str, +) -> None: + """Snapshot test states of list access sensor.""" + + mock_bring_client.get_list.return_value = load_json_object_fixture( + f"{fixture}.json", DOMAIN + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("sensor.einkauf_list_access")) + assert state.state == entity_state From e84d5fba117936bf014ad458c6409b695f0e677f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 6 Nov 2024 19:15:25 +0100 Subject: [PATCH 275/511] Add state invitation to list access sensor in Bring integration (#129960) --- homeassistant/components/bring/icons.json | 3 +- homeassistant/components/bring/sensor.py | 2 +- homeassistant/components/bring/strings.json | 3 +- .../bring/fixtures/items_invitation.json | 44 +++++++++++++++++++ .../bring/fixtures/items_shared.json | 44 +++++++++++++++++++ .../bring/snapshots/test_sensor.ambr | 4 ++ tests/components/bring/test_sensor.py | 36 ++++++++++++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/components/bring/fixtures/items_invitation.json create mode 100644 tests/components/bring/fixtures/items_shared.json diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index 74c3b2e393b..c670ef87700 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -16,7 +16,8 @@ "list_access": { "default": "mdi:account-lock", "state": { - "shared": "mdi:account-group" + "shared": "mdi:account-group", + "invitation": "mdi:account-multiple-plus" } } }, diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 57ceb099535..746ed397e1b 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -79,7 +79,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( translation_key=BringSensor.LIST_ACCESS, value_fn=lambda lst, _: lst["status"].lower(), entity_category=EntityCategory.DIAGNOSTIC, - options=["registered", "shared"], + options=["registered", "shared", "invitation"], device_class=SensorDeviceClass.ENUM, ), ) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 61121cdca60..9a93881b5d2 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -66,7 +66,8 @@ "name": "List access", "state": { "registered": "Private", - "shared": "Shared" + "shared": "Shared", + "invitation": "Invitation pending" } } } diff --git a/tests/components/bring/fixtures/items_invitation.json b/tests/components/bring/fixtures/items_invitation.json new file mode 100644 index 00000000000..82ef623e439 --- /dev/null +++ b/tests/components/bring/fixtures/items_invitation.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "INVITATION", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/fixtures/items_shared.json b/tests/components/bring/fixtures/items_shared.json new file mode 100644 index 00000000000..9ac999729d3 --- /dev/null +++ b/tests/components/bring/fixtures/items_shared.json @@ -0,0 +1,44 @@ +{ + "uuid": "77a151f8-77c4-47a3-8295-c750a0e69d4f", + "status": "SHARED", + "purchase": [ + { + "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", + "itemId": "Paprika", + "specification": "Rot", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + }, + { + "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", + "itemId": "Pouletbrüstli", + "specification": "Bio", + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] + } + ], + "recently": [ + { + "uuid": "fc8db30a-647e-4e6c-9d71-3b85d6a2d954", + "itemId": "Ananas", + "specification": "", + "attributes": [] + } + ] +} diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr index 513b4e6469e..97e1d1b4bd9 100644 --- a/tests/components/bring/snapshots/test_sensor.ambr +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -55,6 +55,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -92,6 +93,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , @@ -344,6 +346,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'config_entry_id': , @@ -381,6 +384,7 @@ 'options': list([ 'registered', 'shared', + 'invitation', ]), }), 'context': , diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py index a36b0163165..974818ccedf 100644 --- a/tests/components/bring/test_sensor.py +++ b/tests/components/bring/test_sensor.py @@ -1,17 +1,18 @@ """Test for sensor platform of the Bring! integration.""" from collections.abc import Generator -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bring.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_object_fixture, snapshot_platform @pytest.fixture(autouse=True) @@ -42,3 +43,34 @@ async def test_setup( await snapshot_platform( hass, entity_registry, snapshot, bring_config_entry.entry_id ) + + +@pytest.mark.parametrize( + ("fixture", "entity_state"), + [ + ("items_invitation", "invitation"), + ("items_shared", "shared"), + ("items", "registered"), + ], +) +async def test_list_access_states( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + fixture: str, + entity_state: str, +) -> None: + """Snapshot test states of list access sensor.""" + + mock_bring_client.get_list.return_value = load_json_object_fixture( + f"{fixture}.json", DOMAIN + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("sensor.einkauf_list_access")) + assert state.state == entity_state From 94c5c8f42e58e49c16ab316cbc4b2e8fec9a34ef Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Nov 2024 19:29:07 +0100 Subject: [PATCH 276/511] Bump version to 2024.11.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 af7b7768cec..2988834d3b0 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 11 -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 e26ab16b965..6b21d117d9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.11.0b9" +version = "2024.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5a24b670a27c7d0850be2f653129e83f0f032b2d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Nov 2024 19:32:23 +0100 Subject: [PATCH 277/511] Ran ruff --- tests/components/lamarzocco/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index a2f0b927437..e4e8d6ebafd 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, patch from lmcloud.exceptions import AuthFail, RequestNotSuccessful from lmcloud.models import LaMarzoccoDeviceInfo -import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN From 9a2a177b28aa27dc6679da3e2ca666aec395fedb Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 6 Nov 2024 21:46:08 +0000 Subject: [PATCH 278/511] Bump ring library ring-doorbell to 0.9.9 (#129966) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 4e0514ba7f9..63c47cb2979 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "silver", - "requirements": ["ring-doorbell==0.9.8"] + "requirements": ["ring-doorbell==0.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef79b8ad6b6..dc7d3416aaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2559,7 +2559,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.8 +ring-doorbell==0.9.9 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3c05f3a524..f3a8d6c2874 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ reolink-aio==0.10.4 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.8 +ring-doorbell==0.9.9 # homeassistant.components.roku rokuecp==0.19.3 From 53c486ccd1b2dfe5a3f60dd222b257d4516a73bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Nov 2024 15:59:31 -0600 Subject: [PATCH 279/511] Bump aiohttp to 3.11.0b3 (#129363) --- homeassistant/components/websocket_api/http.py | 8 +------- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/generic/test_camera.py | 4 +++- tests/components/websocket_api/test_auth.py | 2 +- tests/components/websocket_api/test_http.py | 6 +++--- 7 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 11aca19bab9..e7d57aebab6 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -330,13 +330,7 @@ class WebSocketHandler: if TYPE_CHECKING: assert writer is not None - # aiohttp 3.11.0 changed the method name from _send_frame to send_frame - if hasattr(writer, "send_frame"): - send_frame = writer.send_frame # pragma: no cover - else: - send_frame = writer._send_frame # noqa: SLF001 - - send_bytes_text = partial(send_frame, opcode=WSMsgType.TEXT) + send_bytes_text = partial(writer.send_frame, opcode=WSMsgType.TEXT) auth = AuthPhase( logger, hass, self._send_message, self._cancel, request, send_bytes_text ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 15ce798ab90..49d2f4f01cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.10 +aiohttp==3.11.0b3 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 4a2857b5065..282a4e51ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.10.10", + "aiohttp==3.11.0b3", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index a5beecec8ff..ef0a423467a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.10.10 +aiohttp==3.11.0b3 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 59ff513ccc9..d3ef0a39241 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -275,7 +275,9 @@ async def test_limit_refetch( with ( pytest.raises(aiohttp.ServerTimeoutError), - patch("asyncio.timeout", side_effect=TimeoutError()), + patch.object( + client.session._connector, "connect", side_effect=asyncio.TimeoutError + ), ): resp = await client.get("/api/camera_proxy/camera.config_test") diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 20a728cf3cd..d55d2f97017 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -293,6 +293,6 @@ async def test_auth_sending_unknown_type_disconnects( auth_msg = await ws.receive_json() assert auth_msg["type"] == TYPE_AUTH_REQUIRED - await ws._writer._send_frame(b"1" * 130, 0x30) + await ws._writer.send_frame(b"1" * 130, 0x30) auth_msg = await ws.receive() assert auth_msg.type == WSMsgType.close diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 2530d885942..03e30c11ee9 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -5,7 +5,7 @@ from datetime import timedelta from typing import Any, cast from unittest.mock import patch -from aiohttp import WSMsgType, WSServerHandshakeError, web +from aiohttp import ServerDisconnectedError, WSMsgType, web import pytest from homeassistant.components.websocket_api import ( @@ -374,7 +374,7 @@ async def test_prepare_fail_timeout( "homeassistant.components.websocket_api.http.web.WebSocketResponse.prepare", side_effect=(TimeoutError, web.WebSocketResponse.prepare), ), - pytest.raises(WSServerHandshakeError), + pytest.raises(ServerDisconnectedError), ): await hass_ws_client(hass) @@ -392,7 +392,7 @@ async def test_prepare_fail_connection_reset( "homeassistant.components.websocket_api.http.web.WebSocketResponse.prepare", side_effect=(ConnectionResetError, web.WebSocketResponse.prepare), ), - pytest.raises(WSServerHandshakeError), + pytest.raises(ServerDisconnectedError), ): await hass_ws_client(hass) From 03d5b18974f54f742fb0c1f9fa4970b7a7a23c0d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Nov 2024 23:28:01 +0100 Subject: [PATCH 280/511] Remove options property from OptionFlow (#129890) * Remove options property from OptionFlow * Update test_config_entries.py * Partial revert of "Remove deprecated property setters in option flows (#129773)" * Partial revert "Use new helper properties in crownstone options flow (#129774)" * Restore onewire init * Restore onvif * Restore roborock * Use deepcopy in onewire * Restore steam_online * Restore initial options property in OptionsFlowWithConfigEntry * re-add options property in SchemaOptionsFlowHandler * Restore test * Cleanup --- .../components/crownstone/config_flow.py | 5 +-- homeassistant/components/demo/config_flow.py | 6 +++- .../components/nmap_tracker/config_flow.py | 6 +++- .../components/onewire/config_flow.py | 7 +++- homeassistant/components/onvif/config_flow.py | 6 +++- homeassistant/components/plex/config_flow.py | 2 ++ .../components/roborock/config_flow.py | 7 +++- homeassistant/components/sia/config_flow.py | 5 +-- .../components/somfy_mylink/config_flow.py | 6 ++-- .../components/steam_online/config_flow.py | 6 +++- homeassistant/components/unifi/config_flow.py | 10 ++++-- homeassistant/config_entries.py | 28 +++------------ .../helpers/schema_config_entry_flow.py | 7 ++-- tests/test_config_entries.py | 35 ++----------------- 14 files changed, 63 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 4cfbb10a4bd..bf6e9204714 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -143,7 +143,7 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= config_entry: ConfigEntry, ) -> CrownstoneOptionsFlowHandler: """Return the Crownstone options.""" - return CrownstoneOptionsFlowHandler() + return CrownstoneOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the flow.""" @@ -210,9 +210,10 @@ class CrownstoneConfigFlowHandler(BaseCrownstoneFlowHandler, ConfigFlow, domain= class CrownstoneOptionsFlowHandler(BaseCrownstoneFlowHandler, OptionsFlow): """Handle Crownstone options.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Crownstone options.""" super().__init__(OPTIONS_FLOW, self.async_create_new_entry) + self.options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index 2b27689bdaf..53c1678aa81 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -35,7 +35,7 @@ class DemoConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Set the config entry up from yaml.""" @@ -45,6 +45,10 @@ class DemoConfigFlow(ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(OptionsFlow): """Handle options.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 36645278bae..e05150995aa 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -141,6 +141,10 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlow): """Handle a option flow for homekit.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,4 +215,4 @@ class NmapTrackerConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 3ee0563410c..abb4c884974 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy from typing import Any import voluptuous as vol @@ -104,7 +105,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OnewireOptionsFlowHandler: """Get the options flow for this handler.""" - return OnewireOptionsFlowHandler() + return OnewireOptionsFlowHandler(config_entry) class OnewireOptionsFlowHandler(OptionsFlow): @@ -125,6 +126,10 @@ class OnewireOptionsFlowHandler(OptionsFlow): current_device: str """Friendly name of the currently selected device.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 830f74b94e8..66e566af0bf 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -109,7 +109,7 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OnvifOptionsFlowHandler: """Get the options flow for this handler.""" - return OnvifOptionsFlowHandler() + return OnvifOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the ONVIF config flow.""" @@ -389,6 +389,10 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): class OnvifOptionsFlowHandler(OptionsFlow): """Handle ONVIF options.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize ONVIF options flow.""" + self.options = dict(config_entry.options) + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the ONVIF options.""" return await self.async_step_onvif_devices() diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 22069310804..ae7cbb12574 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy import logging from typing import TYPE_CHECKING, Any @@ -384,6 +385,7 @@ class PlexOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Plex options flow.""" + self.options = deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index e01bb904adf..200614b024e 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from copy import deepcopy import logging from typing import Any @@ -172,12 +173,16 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> RoborockOptionsFlowHandler: """Create the options flow.""" - return RoborockOptionsFlowHandler() + return RoborockOptionsFlowHandler(config_entry) class RoborockOptionsFlowHandler(OptionsFlow): """Handle an option flow for Roborock.""" + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index c421151f7bb..a23978145e7 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -103,7 +103,7 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" - return SIAOptionsFlowHandler() + return SIAOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the config flow.""" @@ -179,8 +179,9 @@ class SIAConfigFlow(ConfigFlow, domain=DOMAIN): class SIAOptionsFlowHandler(OptionsFlow): """Handle SIA options.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize SIA options flow.""" + self.options = deepcopy(dict(config_entry.options)) self.hub: SIAHub | None = None self.accounts_todo: list = [] diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index f92c4909dd5..c2d85160175 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import deepcopy import logging from typing import Any @@ -121,14 +122,15 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): config_entry: ConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler() + return OptionsFlowHandler(config_entry) class OptionsFlowHandler(OptionsFlow): """Handle a option flow for somfy_mylink.""" - def __init__(self) -> None: + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" + self.options = deepcopy(dict(config_entry.options)) self._target_id: str | None = None @callback diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 605f27edb19..69009fca8c4 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -42,7 +42,7 @@ class SteamFlowHandler(ConfigFlow, domain=DOMAIN): config_entry: SteamConfigEntry, ) -> SteamOptionsFlowHandler: """Get the options flow for this handler.""" - return SteamOptionsFlowHandler() + return SteamOptionsFlowHandler(config_entry) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -121,6 +121,10 @@ def _batch_ids(ids: list[str]) -> Iterator[list[str]]: class SteamOptionsFlowHandler(OptionsFlow): """Handle Steam client options.""" + def __init__(self, entry: SteamConfigEntry) -> None: + """Initialize options flow.""" + self.options = dict(entry.options) + async def async_step_init( self, user_input: dict[str, dict[str, str]] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 44969191fe6..63c8533aa2e 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -21,7 +21,6 @@ import voluptuous as vol from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -38,6 +37,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac +from . import UnifiConfigEntry from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, @@ -78,10 +78,10 @@ class UnifiFlowHandler(ConfigFlow, domain=UNIFI_DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: UnifiConfigEntry, ) -> UnifiOptionsFlowHandler: """Get the options flow for this handler.""" - return UnifiOptionsFlowHandler() + return UnifiOptionsFlowHandler(config_entry) def __init__(self) -> None: """Initialize the UniFi Network flow.""" @@ -247,6 +247,10 @@ class UnifiOptionsFlowHandler(OptionsFlow): hub: UnifiHub + def __init__(self, config_entry: UnifiConfigEntry) -> None: + """Initialize UniFi Network options flow.""" + self.options = dict(config_entry.options) + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 6a95707dcda..a13225c4dfe 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3060,7 +3060,6 @@ class OptionsFlowManager( class OptionsFlow(ConfigEntryBaseFlow): """Base class for config options flows.""" - _options: dict[str, Any] handler: str _config_entry: ConfigEntry @@ -3127,28 +3126,6 @@ class OptionsFlow(ConfigEntryBaseFlow): ) self._config_entry = value - @property - def options(self) -> dict[str, Any]: - """Return a mutable copy of the config entry options. - - Please note that this is not available inside `__init__` method, and - can only be referenced after initialisation. - """ - if not hasattr(self, "_options"): - self._options = deepcopy(dict(self.config_entry.options)) - return self._options - - @options.setter - def options(self, value: dict[str, Any]) -> None: - """Set the options value.""" - report( - "sets option flow options explicitly, which is deprecated " - "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, - ) - self._options = value - class OptionsFlowWithConfigEntry(OptionsFlow): """Base class for options flows with config entry and options.""" @@ -3164,6 +3141,11 @@ class OptionsFlowWithConfigEntry(OptionsFlow): error_if_core=True, ) + @property + def options(self) -> dict[str, Any]: + """Return a mutable copy of the config entry options.""" + return self._options + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index b956a58398a..af8c4c6402d 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -421,8 +421,6 @@ class SchemaOptionsFlowHandler(OptionsFlow): options, which is the union of stored options and user input from the options flow steps. """ - # Although `self.options` is most likely unused, it is safer to keep both - # `self.options` and `self._common_handler.options` referring to the same object self._options = copy.deepcopy(dict(config_entry.options)) self._common_handler = SchemaCommonFlowHandler(self, options_flow, self.options) self._async_options_flow_finished = async_options_flow_finished @@ -437,6 +435,11 @@ class SchemaOptionsFlowHandler(OptionsFlow): if async_setup_preview: setattr(self, "async_setup_preview", async_setup_preview) + @property + def options(self) -> dict[str, Any]: + """Return a mutable copy of the config entry options.""" + return self._options + @staticmethod def _async_step( step_id: str, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 700840eb90e..3e3f3b4c504 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5066,31 +5066,6 @@ async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} -@pytest.mark.usefixtures("mock_integration_frame") -@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_options_flow_options_not_mutated(hass: HomeAssistant) -> None: - """Test that OptionsFlow doesn't mutate entry options.""" - entry = MockConfigEntry( - domain="test", - data={"first": True}, - options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, - ) - entry.add_to_hass(hass) - - options_flow = config_entries.OptionsFlow() - options_flow.handler = entry.entry_id - options_flow.hass = hass - - options_flow.options["sub_dict"]["2"] = "two" - options_flow._options["sub_list"].append("two") - - assert options_flow._options == { - "sub_dict": {"1": "one", "2": "two"}, - "sub_list": ["one", "two"], - } - assert entry.options == {"sub_dict": {"1": "one"}, "sub_list": ["one"]} - - async def test_initializing_flows_canceled_on_shutdown( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -7466,6 +7441,7 @@ async def test_options_flow_config_entry( @pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -7493,10 +7469,7 @@ async def test_options_flow_deprecated_config_entry_setter( def __init__(self, entry) -> None: """Test initialisation.""" - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - self.config_entry = entry - with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): - self.options = entry.options + self.config_entry = entry async def async_step_init(self, user_input=None): """Test user step.""" @@ -7525,10 +7498,6 @@ async def test_options_flow_deprecated_config_entry_setter( "Detected that integration 'hue' sets option flow config_entry explicitly, " "which is deprecated and will stop working in 2025.12" in caplog.text ) - assert ( - "Detected that integration 'hue' sets option flow options explicitly, " - "which is deprecated and will stop working in 2025.12" in caplog.text - ) async def test_add_description_placeholder_automatically( From ed4f55406c47748b0989100ab1364a2640ad8e71 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 6 Nov 2024 19:33:51 -0500 Subject: [PATCH 281/511] Replace Supervisor resolution API calls with aiohasupervisor (#129599) * Replace Supervisor resolution API calls with aiohasupervisor * Use consistent types to avoid uuid issues * Fix mocking in http test * Changes from feedback * Put hass first * Fix typo --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/hassio/const.py | 14 - homeassistant/components/hassio/handler.py | 37 -- homeassistant/components/hassio/issues.py | 82 ++- homeassistant/components/hassio/repairs.py | 27 +- tests/components/conftest.py | 35 +- tests/components/hassio/test_binary_sensor.py | 14 +- tests/components/hassio/test_diagnostics.py | 14 +- tests/components/hassio/test_handler.py | 2 +- tests/components/hassio/test_init.py | 14 +- tests/components/hassio/test_issues.py | 372 +++++------ tests/components/hassio/test_repairs.py | 623 +++++++++--------- tests/components/hassio/test_sensor.py | 14 +- tests/components/hassio/test_update.py | 14 +- tests/components/hassio/test_websocket_api.py | 17 +- tests/components/http/test_ban.py | 13 +- tests/components/onboarding/test_views.py | 14 +- 16 files changed, 607 insertions(+), 699 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index b337017147b..82ce74832c2 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -137,17 +137,3 @@ class SupervisorEntityModel(StrEnum): CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" HOST = "Home Assistant Host" - - -class SupervisorIssueContext(StrEnum): - """Context for supervisor issues.""" - - ADDON = "addon" - CORE = "core" - DNS_SERVER = "dns_server" - MOUNT = "mount" - OS = "os" - PLUGIN = "plugin" - SUPERVISOR = "supervisor" - STORE = "store" - SYSTEM = "system" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index f69ee40293b..58f2aa8c144 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -91,15 +91,6 @@ async def async_create_backup( return await hassio.send_command(command, payload=payload, timeout=None) -@bind_hass -@_api_bool -async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> dict: - """Apply a suggestion from supervisor's resolution center.""" - hassio: HassIO = hass.data[DOMAIN] - command = f"/resolution/suggestion/{suggestion_uuid}" - return await hassio.send_command(command, timeout=None) - - @api_data async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Green.""" @@ -245,26 +236,6 @@ class HassIO: """ return self.send_command("/ingress/panels", method="get") - @api_data - def get_resolution_info(self) -> Coroutine: - """Return data for Supervisor resolution center. - - This method returns a coroutine. - """ - return self.send_command("/resolution/info", method="get") - - @api_data - def get_suggestions_for_issue( - self, issue_id: str - ) -> Coroutine[Any, Any, dict[str, Any]]: - """Return suggestions for issue from Supervisor resolution center. - - This method returns a coroutine. - """ - return self.send_command( - f"/resolution/issue/{issue_id}/suggestions", method="get" - ) - @_api_bool async def update_hass_api( self, http_config: dict[str, Any], refresh_token: RefreshToken @@ -304,14 +275,6 @@ class HassIO: "/supervisor/options", payload={"diagnostics": diagnostics} ) - @_api_bool - def apply_suggestion(self, suggestion_uuid: str) -> Coroutine: - """Apply a suggestion from supervisor's resolution center. - - This method returns a coroutine. - """ - return self.send_command(f"/resolution/suggestion/{suggestion_uuid}") - async def send_command( self, command: str, diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 944bc99a6b9..16697659077 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -7,6 +7,10 @@ from dataclasses import dataclass, field from datetime import datetime import logging from typing import Any, NotRequired, TypedDict +from uuid import UUID + +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ContextType, Issue as SupervisorIssue from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,12 +24,8 @@ from homeassistant.helpers.issue_registry import ( from .const import ( ATTR_DATA, ATTR_HEALTHY, - ATTR_ISSUES, - ATTR_SUGGESTIONS, ATTR_SUPPORTED, - ATTR_UNHEALTHY, ATTR_UNHEALTHY_REASONS, - ATTR_UNSUPPORTED, ATTR_UNSUPPORTED_REASONS, ATTR_UPDATE_KEY, ATTR_WS_EVENT, @@ -45,10 +45,9 @@ from .const import ( PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, - SupervisorIssueContext, ) from .coordinator import get_addons_info -from .handler import HassIO, HassioAPIError +from .handler import HassIO, get_supervisor_client ISSUE_KEY_UNHEALTHY = "unhealthy" ISSUE_KEY_UNSUPPORTED = "unsupported" @@ -120,9 +119,9 @@ class SuggestionDataType(TypedDict): class Suggestion: """Suggestion from Supervisor which resolves an issue.""" - uuid: str + uuid: UUID type: str - context: SupervisorIssueContext + context: ContextType reference: str | None = None @property @@ -134,9 +133,9 @@ class Suggestion: def from_dict(cls, data: SuggestionDataType) -> Suggestion: """Convert from dictionary representation.""" return cls( - uuid=data["uuid"], + uuid=UUID(data["uuid"]), type=data["type"], - context=SupervisorIssueContext(data["context"]), + context=ContextType(data["context"]), reference=data["reference"], ) @@ -155,9 +154,9 @@ class IssueDataType(TypedDict): class Issue: """Issue from Supervisor.""" - uuid: str + uuid: UUID type: str - context: SupervisorIssueContext + context: ContextType reference: str | None = None suggestions: list[Suggestion] = field(default_factory=list, compare=False) @@ -171,9 +170,9 @@ class Issue: """Convert from dictionary representation.""" suggestions: list[SuggestionDataType] = data.get("suggestions", []) return cls( - uuid=data["uuid"], + uuid=UUID(data["uuid"]), type=data["type"], - context=SupervisorIssueContext(data["context"]), + context=ContextType(data["context"]), reference=data["reference"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions @@ -190,7 +189,8 @@ class SupervisorIssues: self._client = client self._unsupported_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set() - self._issues: dict[str, Issue] = {} + self._issues: dict[UUID, Issue] = {} + self._supervisor_client = get_supervisor_client(hass) @property def unhealthy_reasons(self) -> set[str]: @@ -283,7 +283,7 @@ class SupervisorIssues: async_create_issue( self._hass, DOMAIN, - issue.uuid, + issue.uuid.hex, is_fixable=bool(issue.suggestions), severity=IssueSeverity.WARNING, translation_key=issue.key, @@ -292,19 +292,37 @@ class SupervisorIssues: self._issues[issue.uuid] = issue - async def add_issue_from_data(self, data: IssueDataType) -> None: + async def add_issue_from_data(self, data: SupervisorIssue) -> None: """Add issue from data to list after getting latest suggestions.""" try: - data["suggestions"] = ( - await self._client.get_suggestions_for_issue(data["uuid"]) - )[ATTR_SUGGESTIONS] - except HassioAPIError: + suggestions = ( + await self._supervisor_client.resolution.suggestions_for_issue( + data.uuid + ) + ) + except SupervisorError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", - data["uuid"], + data.uuid.hex, ) return - self.add_issue(Issue.from_dict(data)) + self.add_issue( + Issue( + uuid=data.uuid, + type=str(data.type), + context=data.context, + reference=data.reference, + suggestions=[ + Suggestion( + uuid=suggestion.uuid, + type=str(suggestion.type), + context=suggestion.context, + reference=suggestion.reference, + ) + for suggestion in suggestions + ], + ) + ) def remove_issue(self, issue: Issue) -> None: """Remove an issue from the list. Delete a repair if necessary.""" @@ -312,13 +330,13 @@ class SupervisorIssues: return if issue.key in ISSUE_KEYS_FOR_REPAIRS: - async_delete_issue(self._hass, DOMAIN, issue.uuid) + async_delete_issue(self._hass, DOMAIN, issue.uuid.hex) del self._issues[issue.uuid] def get_issue(self, issue_id: str) -> Issue | None: """Get issue from key.""" - return self._issues.get(issue_id) + return self._issues.get(UUID(issue_id)) async def setup(self) -> None: """Create supervisor events listener.""" @@ -331,8 +349,8 @@ class SupervisorIssues: async def _update(self, _: datetime | None = None) -> None: """Update issues from Supervisor resolution center.""" try: - data = await self._client.get_resolution_info() - except HassioAPIError as err: + data = await self._supervisor_client.resolution.info() + except SupervisorError as err: _LOGGER.error("Failed to update supervisor issues: %r", err) async_call_later( self._hass, @@ -340,18 +358,16 @@ class SupervisorIssues: HassJob(self._update, cancel_on_shutdown=True), ) return - self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) - self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) + self.unhealthy_reasons = set(data.unhealthy) + self.unsupported_reasons = set(data.unsupported) # Remove any cached issues that weren't returned - for issue_id in set(self._issues.keys()) - { - issue["uuid"] for issue in data[ATTR_ISSUES] - }: + for issue_id in set(self._issues) - {issue.uuid for issue in data.issues}: self.remove_issue(self._issues[issue_id]) # Add/update any issues that came back await asyncio.gather( - *[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]] + *[self.add_issue_from_data(issue) for issue in data.issues] ) @callback diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 0fcd96ace38..0e8122c08b9 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -6,6 +6,8 @@ from collections.abc import Callable, Coroutine from types import MethodType from typing import Any +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ContextType import voluptuous as vol from homeassistant.components.repairs import RepairsFlow @@ -20,9 +22,8 @@ from .const import ( PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, - SupervisorIssueContext, ) -from .handler import async_apply_suggestion +from .handler import get_supervisor_client from .issues import Issue, Suggestion HELP_URLS = { @@ -51,9 +52,10 @@ class SupervisorIssueRepairFlow(RepairsFlow): _data: dict[str, Any] | None = None _issue: Issue | None = None - def __init__(self, issue_id: str) -> None: + def __init__(self, hass: HomeAssistant, issue_id: str) -> None: """Initialize repair flow.""" self._issue_id = issue_id + self._supervisor_client = get_supervisor_client(hass) super().__init__() @property @@ -124,9 +126,12 @@ class SupervisorIssueRepairFlow(RepairsFlow): if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: return self._async_form_for_suggestion(suggestion) - if await async_apply_suggestion(self.hass, suggestion.uuid): - return self.async_create_entry(data={}) - return self.async_abort(reason="apply_suggestion_fail") + try: + await self._supervisor_client.resolution.apply_suggestion(suggestion.uuid) + except SupervisorError: + return self.async_abort(reason="apply_suggestion_fail") + + return self.async_create_entry(data={}) @staticmethod def _async_step( @@ -163,9 +168,9 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): if issue.key == self.issue.key or issue.type != self.issue.type: continue - if issue.context == SupervisorIssueContext.CORE: + if issue.context == ContextType.CORE: components.insert(0, "Home Assistant") - elif issue.context == SupervisorIssueContext.ADDON: + elif issue.context == ContextType.ADDON: components.append( next( ( @@ -210,11 +215,11 @@ async def async_create_fix_flow( supervisor_issues = get_issues_info(hass) issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: - return DockerConfigIssueRepairFlow(issue_id) + return DockerConfigIssueRepairFlow(hass, issue_id) if issue and issue.key in { ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_ADDON_BOOT_FAIL, }: - return AddonIssueRepairFlow(issue_id) + return AddonIssueRepairFlow(hass, issue_id) - return SupervisorIssueRepairFlow(issue_id) + return SupervisorIssueRepairFlow(hass, issue_id) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index ba5d12afd01..1ec656d44c5 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -8,7 +8,13 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch -from aiohasupervisor.models import Discovery, Repository, StoreAddon, StoreInfo +from aiohasupervisor.models import ( + Discovery, + Repository, + ResolutionInfo, + StoreAddon, + StoreInfo, +) import pytest from homeassistant.config_entries import ( @@ -473,6 +479,26 @@ def supervisor_is_connected_fixture(supervisor_client: AsyncMock) -> AsyncMock: return supervisor_client.supervisor.ping +@pytest.fixture(name="resolution_info") +def resolution_info_fixture(supervisor_client: AsyncMock) -> AsyncMock: + """Mock resolution info from supervisor.""" + supervisor_client.resolution.info.return_value = ResolutionInfo( + suggestions=[], + unsupported=[], + unhealthy=[], + issues=[], + checks=[], + ) + return supervisor_client.resolution.info + + +@pytest.fixture(name="resolution_suggestions_for_issue") +def resolution_suggestions_for_issue_fixture(supervisor_client: AsyncMock) -> AsyncMock: + """Mock suggestions by issue from supervisor resolution.""" + supervisor_client.resolution.suggestions_for_issue.return_value = [] + return supervisor_client.resolution.suggestions_for_issue + + @pytest.fixture(name="supervisor_client") def supervisor_client() -> Generator[AsyncMock]: """Mock the supervisor client.""" @@ -481,6 +507,7 @@ def supervisor_client() -> Generator[AsyncMock]: supervisor_client.discovery = AsyncMock() supervisor_client.homeassistant = AsyncMock() supervisor_client.os = AsyncMock() + supervisor_client.resolution = AsyncMock() supervisor_client.supervisor = AsyncMock() with ( patch( @@ -504,7 +531,11 @@ def supervisor_client() -> Generator[AsyncMock]: return_value=supervisor_client, ), patch( - "homeassistant.components.hassio.get_supervisor_client", + "homeassistant.components.hassio.issues.get_supervisor_client", + return_value=supervisor_client, + ), + patch( + "homeassistant.components.hassio.repairs.get_supervisor_client", return_value=supervisor_client, ), ): diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index c97be736248..9878dd67a21 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -25,6 +25,7 @@ def mock_all( store_info: AsyncMock, addon_changelog: AsyncMock, addon_stats: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -140,19 +141,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index c238d9d2a15..c95cde67b8a 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -24,6 +24,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -143,19 +144,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index e125e09ae7e..56f0dcb706c 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -208,7 +208,7 @@ async def test_api_ingress_panels( @pytest.mark.parametrize( ("api_call", "method", "payload"), [ - ("get_resolution_info", "GET", None), + ("get_network_info", "GET", None), ("update_diagnostics", "POST", True), ], ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 23259543478..5c11370ae74 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -67,6 +67,7 @@ def mock_all( addon_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -204,19 +205,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 1a3d3d83f95..7ce11a18fb5 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -4,11 +4,28 @@ from __future__ import annotations from collections.abc import Generator from datetime import timedelta -from http import HTTPStatus import os from typing import Any -from unittest.mock import ANY, patch +from unittest.mock import ANY, AsyncMock, patch +from uuid import UUID, uuid4 +from aiohasupervisor import ( + SupervisorBadRequestError, + SupervisorError, + SupervisorTimeoutError, +) +from aiohasupervisor.models import ( + Check, + CheckType, + ContextType, + Issue, + IssueType, + ResolutionInfo, + Suggestion, + SuggestionType, + UnhealthyReason, + UnsupportedReason, +) from freezegun.api import FrozenDateTimeFactory import pytest @@ -18,7 +35,6 @@ from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON -from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse from tests.typing import WebSocketGenerator @@ -36,49 +52,41 @@ def fixture_supervisor_environ() -> Generator[None]: def mock_resolution_info( - aioclient_mock: AiohttpClientMocker, - unsupported: list[str] | None = None, - unhealthy: list[str] | None = None, - issues: list[dict[str, str]] | None = None, - suggestion_result: str = "ok", + supervisor_client: AsyncMock, + unsupported: list[UnsupportedReason] | None = None, + unhealthy: list[UnhealthyReason] | None = None, + issues: list[Issue] | None = None, + suggestions_by_issue: dict[UUID, list[Suggestion]] | None = None, + suggestion_result: SupervisorError | None = None, ) -> None: """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": unsupported or [], - "unhealthy": unhealthy or [], - "suggestions": [], - "issues": [ - {k: v for k, v in issue.items() if k != "suggestions"} - for issue in issues - ] - if issues - else [], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, + supervisor_client.resolution.info.return_value = ResolutionInfo( + unsupported=unsupported or [], + unhealthy=unhealthy or [], + issues=issues or [], + suggestions=[ + suggestion + for issue_list in suggestions_by_issue.values() + for suggestion in issue_list + ] + if suggestions_by_issue + else [], + checks=[ + Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], ) - if issues: - suggestions_by_issue = { - issue["uuid"]: issue.get("suggestions", []) for issue in issues - } - for issue_uuid, suggestions in suggestions_by_issue.items(): - aioclient_mock.get( - f"http://127.0.0.1/resolution/issue/{issue_uuid}/suggestions", - json={"result": "ok", "data": {"suggestions": suggestions}}, - ) - for suggestion in suggestions: - aioclient_mock.post( - f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", - json={"result": suggestion_result}, - ) + if suggestions_by_issue: + + async def mock_suggestions_for_issue(uuid: UUID) -> list[Suggestion]: + """Mock of suggestions for issue api.""" + return suggestions_by_issue.get(uuid, []) + + supervisor_client.resolution.suggestions_for_issue.side_effect = ( + mock_suggestions_for_issue + ) + supervisor_client.resolution.apply_suggestion.side_effect = suggestion_result def assert_repair_in_list( @@ -134,11 +142,13 @@ def assert_issue_repair_in_list( @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test issues added for unhealthy systems.""" - mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) + mock_resolution_info( + supervisor_client, unhealthy=[UnhealthyReason.DOCKER, UnhealthyReason.SETUP] + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -156,11 +166,14 @@ async def test_unhealthy_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test issues added for unsupported systems.""" - mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) + mock_resolution_info( + supervisor_client, + unsupported=[UnsupportedReason.CONTENT_TRUST, UnsupportedReason.OS], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -180,11 +193,11 @@ async def test_unsupported_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test unhealthy issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -237,11 +250,11 @@ async def test_unhealthy_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test unsupported issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -294,21 +307,21 @@ async def test_unsupported_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_reset_issues_supervisor_restart( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """All issues reset on supervisor restart.""" mock_resolution_info( - aioclient_mock, - unsupported=["os"], - unhealthy=["docker"], + supervisor_client, + unsupported=[UnsupportedReason.OS], + unhealthy=[UnhealthyReason.DOCKER], issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - } + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(uuid := uuid4()), + ) ], ) @@ -325,15 +338,14 @@ async def test_reset_issues_supervisor_restart( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=uuid.hex, context="system", type_="reboot_required", fixable=False, reference=None, ) - aioclient_mock.clear_requests() - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) await client.send_json( { "id": 2, @@ -358,11 +370,15 @@ async def test_reset_issues_supervisor_restart( @pytest.mark.usefixtures("all_setup_requests") async def test_reasons_added_and_removed( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test an unsupported/unhealthy reasons being added and removed at same time.""" - mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + mock_resolution_info( + supervisor_client, + unsupported=[UnsupportedReason.OS], + unhealthy=[UnhealthyReason.DOCKER], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -376,9 +392,10 @@ async def test_reasons_added_and_removed( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") - aioclient_mock.clear_requests() mock_resolution_info( - aioclient_mock, unsupported=["content_trust"], unhealthy=["setup"] + supervisor_client, + unsupported=[UnsupportedReason.CONTENT_TRUST], + unhealthy=[UnhealthyReason.SETUP], ) await client.send_json( { @@ -408,12 +425,14 @@ async def test_reasons_added_and_removed( @pytest.mark.usefixtures("all_setup_requests") async def test_ignored_unsupported_skipped( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Unsupported reasons which have an identical unhealthy reason are ignored.""" mock_resolution_info( - aioclient_mock, unsupported=["privileged"], unhealthy=["privileged"] + supervisor_client, + unsupported=[UnsupportedReason.PRIVILEGED], + unhealthy=[UnhealthyReason.PRIVILEGED], ) result = await async_setup_component(hass, "hassio", {}) @@ -431,12 +450,14 @@ async def test_ignored_unsupported_skipped( @pytest.mark.usefixtures("all_setup_requests") async def test_new_unsupported_unhealthy_reason( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """New unsupported/unhealthy reasons result in a generic repair until next core update.""" mock_resolution_info( - aioclient_mock, unsupported=["fake_unsupported"], unhealthy=["fake_unhealthy"] + supervisor_client, + unsupported=["fake_unsupported"], + unhealthy=["fake_unhealthy"], ) result = await async_setup_component(hass, "hassio", {}) @@ -481,40 +502,43 @@ async def test_new_unsupported_unhealthy_reason( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test repairs added for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - }, - { - "uuid": "1235", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1236", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - } - ], - }, - { - "uuid": "1237", - "type": "should_not_be_repair", - "context": "os", - "reference": None, - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(uuid_issue1 := uuid4()), + ), + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(uuid_issue2 := uuid4()), + ), + Issue( + type="should_not_be_repair", + context=ContextType.OS, + reference=None, + uuid=uuid4(), + ), ], + suggestions_by_issue={ + uuid_issue2: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=uuid4(), + auto=False, + ) + ] + }, ) result = await async_setup_component(hass, "hassio", {}) @@ -528,7 +552,7 @@ async def test_supervisor_issues( assert len(msg["result"]["issues"]) == 2 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=uuid_issue1.hex, context="system", type_="reboot_required", fixable=False, @@ -536,7 +560,7 @@ async def test_supervisor_issues( ) assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1235", + uuid=uuid_issue2.hex, context="system", type_="multiple_data_disks", fixable=True, @@ -547,61 +571,33 @@ async def test_supervisor_issues( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_initial_failure( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + resolution_info: AsyncMock, + resolution_suggestions_for_issue: AsyncMock, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test issues manager retries after initial update failure.""" - responses = [ - AiohttpClientMockResponse( - method="get", - url="http://127.0.0.1/resolution/info", - status=HTTPStatus.BAD_REQUEST, - json={ - "result": "error", - "message": "System is not ready with state: setup", - }, - ), - AiohttpClientMockResponse( - method="get", - url="http://127.0.0.1/resolution/info", - status=HTTPStatus.OK, - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - }, - ], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, + resolution_info.side_effect = [ + SupervisorBadRequestError("System is not ready with state: setup"), + ResolutionInfo( + unsupported=[], + unhealthy=[], + suggestions=[], + issues=[ + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], + checks=[ + Check(enabled=True, slug=CheckType.SUPERVISOR_TRUST), + Check(enabled=True, slug=CheckType.FREE_SPACE), + ], ), ] - async def mock_responses(*args): - nonlocal responses - return responses.pop(0) - - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - side_effect=mock_responses, - ) - aioclient_mock.get( - "http://127.0.0.1/resolution/issue/1234/suggestions", - json={"result": "ok", "data": {"suggestions": []}}, - ) - with patch("homeassistant.components.hassio.issues.REQUEST_REFRESH_DELAY", new=0.1): result = await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() @@ -625,11 +621,11 @@ async def test_supervisor_issues_initial_failure( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_add_remove( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issues added and removed from dispatches.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -643,7 +639,7 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": (issue_uuid := uuid4().hex), "type": "reboot_required", "context": "system", "reference": None, @@ -661,7 +657,7 @@ async def test_supervisor_issues_add_remove( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="system", type_="reboot_required", fixable=False, @@ -675,13 +671,13 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": issue_uuid, "type": "reboot_required", "context": "system", "reference": None, "suggestions": [ { - "uuid": "1235", + "uuid": uuid4().hex, "type": "execute_reboot", "context": "system", "reference": None, @@ -701,7 +697,7 @@ async def test_supervisor_issues_add_remove( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="system", type_="reboot_required", fixable=True, @@ -715,7 +711,7 @@ async def test_supervisor_issues_add_remove( "data": { "event": "issue_removed", "data": { - "uuid": "1234", + "uuid": issue_uuid, "type": "reboot_required", "context": "system", "reference": None, @@ -736,37 +732,23 @@ async def test_supervisor_issues_add_remove( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_suggestions_fail( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, + resolution_suggestions_for_issue: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test failing to get suggestions for issue skips it.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - } - ], - "checks": [ - {"enabled": True, "slug": "supervisor_trust"}, - {"enabled": True, "slug": "free_space"}, - ], - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/resolution/issue/1234/suggestions", - exc=TimeoutError(), + mock_resolution_info( + supervisor_client, + issues=[ + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + ) + ], ) + resolution_suggestions_for_issue.side_effect = SupervisorTimeoutError result = await async_setup_component(hass, "hassio", {}) assert result @@ -782,11 +764,11 @@ async def test_supervisor_issues_suggestions_fail( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_remove_missing_issue_without_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test HA skips message to remove issue that it didn't know about (sync issue).""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -816,16 +798,12 @@ async def test_supervisor_remove_missing_issue_without_error( @pytest.mark.usefixtures("all_setup_requests") async def test_system_is_not_ready( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + resolution_info: AsyncMock, caplog: pytest.LogCaptureFixture, ) -> None: """Ensure hassio starts despite error.""" - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "", - "message": "System is not ready with state: setup", - }, + resolution_info.side_effect = SupervisorBadRequestError( + "System is not ready with state: setup" ) assert await async_setup_component(hass, "hassio", {}) @@ -838,11 +816,11 @@ async def test_system_is_not_ready( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issues_detached_addon_missing( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_ws_client: WebSocketGenerator, ) -> None: """Test supervisor issue for detached addon due to missing repository.""" - mock_resolution_info(aioclient_mock) + mock_resolution_info(supervisor_client) result = await async_setup_component(hass, "hassio", {}) assert result @@ -856,7 +834,7 @@ async def test_supervisor_issues_detached_addon_missing( "data": { "event": "issue_changed", "data": { - "uuid": "1234", + "uuid": (issue_uuid := uuid4().hex), "type": "detached_addon_missing", "context": "addon", "reference": "test", @@ -874,7 +852,7 @@ async def test_supervisor_issues_detached_addon_missing( assert len(msg["result"]["issues"]) == 1 assert_issue_repair_in_list( msg["result"]["issues"], - uuid="1234", + uuid=issue_uuid, context="addon", type_="detached_addon_missing", fixable=False, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index f3ccb5948f1..f8cac4e1a97 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -3,8 +3,17 @@ from collections.abc import Generator from http import HTTPStatus import os -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from uuid import uuid4 +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ( + ContextType, + Issue, + IssueType, + Suggestion, + SuggestionType, +) import pytest from homeassistant.core import HomeAssistant @@ -14,7 +23,6 @@ from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON from .test_issues import mock_resolution_info -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -28,34 +36,39 @@ def fixture_supervisor_environ() -> Generator[None]: @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1235", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - } - ], - }, + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(sugg_uuid := uuid4()), + auto=False, + ) + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -95,52 +108,53 @@ async def test_supervisor_issue_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_with_multiple_suggestions( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue with multiple suggestions.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": "test", - }, - { - "uuid": "1236", - "type": "test_type", - "context": "system", - "reference": "test", - }, - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference="test", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type="test_type", + context=ContextType.SYSTEM, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -189,52 +203,53 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1236" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confirmation( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue with multiple suggestions and choice requires confirmation.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": None, - }, - { - "uuid": "1236", - "type": "test_type", - "context": "system", - "reference": None, - }, - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type="test_type", + context=ContextType.SYSTEM, + reference=None, + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -302,46 +317,46 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_skip_confirmation( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test confirmation skipped for fix flow for supervisor issue with one suggestion.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "reboot_required", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reboot", - "context": "system", - "reference": None, - } - ], - }, + Issue( + type=IssueType.REBOOT_REQUIRED, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBOOT, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -381,53 +396,54 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_mount_failed_repair_flow_error( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test repair flow fails when repair fails to apply.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "mount_failed", - "context": "mount", - "reference": "backup_share", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reload", - "context": "mount", - "reference": "backup_share", - }, - { - "uuid": "1236", - "type": "execute_remove", - "context": "mount", - "reference": "backup_share", - }, - ], - }, + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(issue_uuid := uuid4()), + ), ], - suggestion_result=False, + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + ] + }, + suggestion_result=SupervisorError("boom"), ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -459,46 +475,52 @@ async def test_mount_failed_repair_flow_error( "description_placeholders": None, } - assert issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) @pytest.mark.usefixtures("all_setup_requests") async def test_mount_failed_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test repair flow for mount_failed issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "mount_failed", - "context": "mount", - "reference": "backup_share", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_reload", - "context": "mount", - "reference": "backup_share", - }, - { - "uuid": "1236", - "type": "execute_remove", - "context": "mount", - "reference": "backup_share", - }, - ], - }, + Issue( + type=IssueType.MOUNT_FAILED, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_RELOAD, + context=ContextType.MOUNT, + reference="backup_share", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.MOUNT, + reference="backup_share", + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -551,13 +573,8 @@ async def test_mount_failed_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -566,62 +583,69 @@ async def test_mount_failed_repair_flow( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_docker_config_repair_flow( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "docker_config", - "context": "system", - "reference": None, - "suggestions": [ - { - "uuid": "1235", - "type": "execute_rebuild", - "context": "system", - "reference": None, - } - ], - }, - { - "uuid": "1236", - "type": "docker_config", - "context": "core", - "reference": None, - "suggestions": [ - { - "uuid": "1237", - "type": "execute_rebuild", - "context": "core", - "reference": None, - } - ], - }, - { - "uuid": "1238", - "type": "docker_config", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1239", - "type": "execute_rebuild", - "context": "addon", - "reference": "test", - } - ], - }, + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.SYSTEM, + reference=None, + uuid=(issue1_uuid := uuid4()), + ), + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.CORE, + reference=None, + uuid=(issue2_uuid := uuid4()), + ), + Issue( + type=IssueType.DOCKER_CONFIG, + context=ContextType.ADDON, + reference="test", + uuid=(issue3_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue1_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.SYSTEM, + reference=None, + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ], + issue2_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.CORE, + reference=None, + uuid=uuid4(), + auto=False, + ), + ], + issue3_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REBUILD, + context=ContextType.ADDON, + reference="test", + uuid=uuid4(), + auto=False, + ), + ], + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue1_uuid.hex + ) assert repair_issue client = await hass_client() @@ -661,52 +685,53 @@ async def test_supervisor_issue_docker_config_repair_flow( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue1_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_repair_flow_multiple_data_disks( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for multiple data disks supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "multiple_data_disks", - "context": "system", - "reference": "/dev/sda1", - "suggestions": [ - { - "uuid": "1235", - "type": "rename_data_disk", - "context": "system", - "reference": "/dev/sda1", - }, - { - "uuid": "1236", - "type": "adopt_data_disk", - "context": "system", - "reference": "/dev/sda1", - }, - ], - }, + Issue( + type=IssueType.MULTIPLE_DATA_DISKS, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.RENAME_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=uuid4(), + auto=False, + ), + Suggestion( + type=SuggestionType.ADOPT_DATA_DISK, + context=ContextType.SYSTEM, + reference="/dev/sda1", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -774,13 +799,8 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1236" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -789,34 +809,39 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_detached_addon_removed( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "detached_addon_removed", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_remove", - "context": "addon", - "reference": "test", - } - ], - }, + Issue( + type=IssueType.DETACHED_ADDON_REMOVED, + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type=SuggestionType.EXECUTE_REMOVE, + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -861,13 +886,8 @@ async def test_supervisor_issue_detached_addon_removed( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) @pytest.mark.parametrize( @@ -876,40 +896,46 @@ async def test_supervisor_issue_detached_addon_removed( @pytest.mark.usefixtures("all_setup_requests") async def test_supervisor_issue_addon_boot_fail( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, + supervisor_client: AsyncMock, hass_client: ClientSessionGenerator, issue_registry: ir.IssueRegistry, ) -> None: """Test fix flow for supervisor issue.""" mock_resolution_info( - aioclient_mock, + supervisor_client, issues=[ - { - "uuid": "1234", - "type": "boot_fail", - "context": "addon", - "reference": "test", - "suggestions": [ - { - "uuid": "1235", - "type": "execute_start", - "context": "addon", - "reference": "test", - }, - { - "uuid": "1236", - "type": "disable_boot", - "context": "addon", - "reference": "test", - }, - ], - }, + Issue( + type="boot_fail", + context=ContextType.ADDON, + reference="test", + uuid=(issue_uuid := uuid4()), + ), ], + suggestions_by_issue={ + issue_uuid: [ + Suggestion( + type="execute_start", + context=ContextType.ADDON, + reference="test", + uuid=(sugg_uuid := uuid4()), + auto=False, + ), + Suggestion( + type="disable_boot", + context=ContextType.ADDON, + reference="test", + uuid=uuid4(), + auto=False, + ), + ] + }, ) assert await async_setup_component(hass, "hassio", {}) - repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + repair_issue = issue_registry.async_get_issue( + domain="hassio", issue_id=issue_uuid.hex + ) assert repair_issue client = await hass_client() @@ -962,10 +988,5 @@ async def test_supervisor_issue_addon_boot_fail( "description_placeholders": None, } - assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") - - assert aioclient_mock.mock_calls[-1][0] == "post" - assert ( - str(aioclient_mock.mock_calls[-1][1]) - == "http://127.0.0.1/resolution/suggestion/1235" - ) + assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex) + supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid) diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 1b58534d52f..7160a2cbf16 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -33,6 +33,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" _install_default_mocks(aioclient_mock) @@ -146,19 +147,6 @@ def _install_default_mocks(aioclient_mock: AiohttpClientMocker): aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 0d15eac48c5..c1775d6e0b4 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -29,6 +29,7 @@ def mock_all( store_info: AsyncMock, addon_stats: AsyncMock, addon_changelog: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -149,19 +150,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 1023baa89df..21e6b03678b 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -26,7 +26,9 @@ from tests.typing import WebSocketGenerator @pytest.fixture(autouse=True) def mock_all( - aioclient_mock: AiohttpClientMocker, supervisor_is_connected: AsyncMock + aioclient_mock: AiohttpClientMocker, + supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, ) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) @@ -67,19 +69,6 @@ def mock_all( aioclient_mock.get( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) @pytest.mark.usefixtures("hassio_env") diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 7ffd0263157..59011de0cfd 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -197,6 +197,7 @@ async def test_access_from_supervisor_ip( hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, hassio_env, + resolution_info: AsyncMock, ) -> None: """Test accessing to server from supervisor IP.""" app = web.Application() @@ -218,17 +219,7 @@ async def test_access_from_supervisor_ip( manager = app[KEY_BAN_MANAGER] - with patch( - "homeassistant.components.hassio.HassIO.get_resolution_info", - return_value={ - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - ): - assert await async_setup_component(hass, "hassio", {"hassio": {}}) + assert await async_setup_component(hass, "hassio", {"hassio": {}}) m_open = mock_open() diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 6df3951249b..35f6b7d739c 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -72,23 +72,11 @@ async def mock_supervisor_fixture( aioclient_mock: AiohttpClientMocker, store_info: AsyncMock, supervisor_is_connected: AsyncMock, + resolution_info: AsyncMock, ) -> AsyncGenerator[None]: """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) - aioclient_mock.get( - "http://127.0.0.1/resolution/info", - json={ - "result": "ok", - "data": { - "unsupported": [], - "unhealthy": [], - "suggestions": [], - "issues": [], - "checks": [], - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/network/info", json={ From bc964ce7f03a73e1e30276a2dfce02a6ec1f7ff0 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Thu, 7 Nov 2024 02:14:54 -0500 Subject: [PATCH 282/511] Update sense energy library to 0.13.3 (#129998) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index f1a01f9d7aa..d4889c0c5f5 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 72d1d045c9a..df2317c3a6c 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.13.2"] + "requirements": ["sense-energy==0.13.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc7d3416aaa..8baf6ef1731 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2626,7 +2626,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3a8d6c2874..0597a3174f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2093,7 +2093,7 @@ securetar==2024.2.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.13.2 +sense-energy==0.13.3 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 56212c6fa5f43624d93059a4d307b28e1a846f9f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 08:24:47 +0100 Subject: [PATCH 283/511] Update numpy to 2.1.2 and pandas to 2.2.3 (#129958) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 6 ++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 6 ++---- 9 files changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index caae9190bca..90fa6289b8d 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.26.4"] + "requirements": ["numpy==2.1.2"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 6142fa1349e..d589c117edd 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.26.4", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.1.2", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 23494a06744..304ef5bbf62 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==1.26.4"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.2"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 4f2b6f19285..906ce02f5b1 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.26.4", + "numpy==2.1.2", "Pillow==10.4.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 56b4b811171..b2f47738d4a 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.26.4"] + "requirements": ["numpy==2.1.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 49d2f4f01cf..54df8ccf1ab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,8 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.4 +numpy==2.1.2 +pandas~=2.2.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 @@ -170,9 +171,6 @@ charset-normalizer==3.4.0 # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 -# Musle wheels for pandas 2.2.0 cannot be build for any architecture. -pandas==2.1.4 - # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x chacha20poly1305-reuseable>=0.13.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8baf6ef1731..27b9c357b59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1491,7 +1491,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.4 +numpy==2.1.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0597a3174f7..3444b2b8558 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.4 +numpy==2.1.2 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0f8354e1f60..352b209c5fc 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -127,7 +127,8 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.4 +numpy==2.1.2 +pandas~=2.2.3 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 @@ -185,9 +186,6 @@ charset-normalizer==3.4.0 # Roborock, NAM, Brother, and GIOS. dacite>=1.7.0 -# Musle wheels for pandas 2.2.0 cannot be build for any architecture. -pandas==2.1.4 - # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x chacha20poly1305-reuseable>=0.13.0 From df16e6d0227ce9d949ac20261252a7142341a385 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 7 Nov 2024 01:29:44 -0600 Subject: [PATCH 284/511] Bump intents to 2024.11.6 (#129982) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2c446ac5d70..8b5c6ef173f 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.4", "home-assistant-intents==2024.11.4"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 54df8ccf1ab..e2b04c48b30 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.0 -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 27b9c357b59..fa9f83d4cbe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ holidays==0.60 home-assistant-frontend==20241106.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3444b2b8558..bfab4850799 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ holidays==0.60 home-assistant-frontend==20241106.0 # homeassistant.components.conversation -home-assistant-intents==2024.11.4 +home-assistant-intents==2024.11.6 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 1e948c2982a..61b623dc32b 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 2d2f55a4df9a16fca0e9c6a406985d3cbef4ea72 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 08:52:20 +0100 Subject: [PATCH 285/511] Report update_percentage in shelly update entity (#129382) Co-authored-by: Shay Levy --- homeassistant/components/shelly/update.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index fb586ae8b85..f22547acf50 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -238,7 +238,8 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) - self._ota_in_progress: bool | int = False + self._ota_in_progress = False + self._ota_progress_percentage: int | None = None self._attr_release_url = get_release_url( coordinator.device.gen, coordinator.model, description.beta ) @@ -256,11 +257,12 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): if self.in_progress is not False: event_type = event["event"] if event_type == OTA_BEGIN: - self._ota_in_progress = 0 + self._ota_progress_percentage = 0 elif event_type == OTA_PROGRESS: - self._ota_in_progress = event["progress_percent"] + self._ota_progress_percentage = event["progress_percent"] elif event_type in (OTA_ERROR, OTA_SUCCESS): self._ota_in_progress = False + self._ota_progress_percentage = None self.async_write_ha_state() @property @@ -278,10 +280,15 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): return self.installed_version @property - def in_progress(self) -> bool | int: + def in_progress(self) -> bool: """Update installation in progress.""" return self._ota_in_progress + @property + def update_percentage(self) -> int | None: + """Update installation progress.""" + return self._ota_progress_percentage + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: @@ -310,6 +317,7 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): await self.coordinator.async_shutdown_device_and_start_reauth() else: self._ota_in_progress = True + self._ota_progress_percentage = None LOGGER.debug("OTA update call for %s successful", self.coordinator.name) From a657b9bb8417cfbcd1c61713e5a45c799fb1d209 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 09:57:14 +0100 Subject: [PATCH 286/511] Add temporary package constraint on flexparser and pint to fix CI (#130016) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e2b04c48b30..5da579fa827 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -192,3 +192,8 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# latest pint 0.24.3 is not yet compatible with flexparser 0.4 +# https://github.com/hgrecco/pint/issues/1969 +flexparser==0.3.1 +pint==0.24.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 352b209c5fc..a71047fddc8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -207,6 +207,11 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 + +# latest pint 0.24.3 is not yet compatible with flexparser 0.4 +# https://github.com/hgrecco/pint/issues/1969 +flexparser==0.3.1 +pint==0.24.3 """ GENERATED_MESSAGE = ( From cb97f2f13ce263a8b7ce147b1ae8d635b26f8f0b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 7 Nov 2024 11:06:28 +0200 Subject: [PATCH 287/511] Bump zwave-js-server-python to 0.59.0 (#129482) --- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/services.py | 11 +++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_services.py | 5 ++--- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a37b3560526..e3f643486a0 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.58.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 969a235bb41..d1cb66ceafc 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -529,8 +529,15 @@ class ZWaveServices: for node_or_endpoint, result in get_valid_responses_from_results( nodes_or_endpoints_list, _results ): - zwave_value = result[0] - cmd_status = result[1] + if value_size is None: + # async_set_config_parameter still returns (Value, SetConfigParameterResult) + zwave_value = result[0] + cmd_status = result[1] + else: + # async_set_raw_config_parameter_value now returns just SetConfigParameterResult + cmd_status = result + zwave_value = f"parameter {property_or_property_name}" + if cmd_status.status == CommandStatus.ACCEPTED: msg = "Set configuration parameter %s on Node %s with value %s" else: diff --git a/requirements_all.txt b/requirements_all.txt index fa9f83d4cbe..685574a89b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3081,7 +3081,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.1 +zwave-js-server-python==0.59.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfab4850799..95703e6f030 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2455,7 +2455,7 @@ zeversolar==0.3.2 zha==0.0.37 # homeassistant.components.zwave_js -zwave-js-server-python==0.58.1 +zwave-js-server-python==0.59.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index ec13d0262f8..41477f18b97 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -497,13 +497,12 @@ async def test_set_config_parameter( caplog.clear() - config_value = aeotec_zw164_siren.values["2-112-0-32"] cmd_result = SetConfigParameterResult("accepted", {"status": 255}) # Test accepted return with patch( "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", - return_value=(config_value, cmd_result), + return_value=cmd_result, ) as mock_set_raw_config_parameter_value: await hass.services.async_call( DOMAIN, @@ -534,7 +533,7 @@ async def test_set_config_parameter( cmd_result.status = "queued" with patch( "homeassistant.components.zwave_js.services.Endpoint.async_set_raw_config_parameter_value", - return_value=(config_value, cmd_result), + return_value=cmd_result, ) as mock_set_raw_config_parameter_value: await hass.services.async_call( DOMAIN, From bbefa971d8c89793940a3e6804c2b39166573946 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 10:32:23 +0100 Subject: [PATCH 288/511] Add missing placeholder description to twitch (#130013) --- homeassistant/components/twitch/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/twitch/config_flow.py b/homeassistant/components/twitch/config_flow.py index dbaef59c236..ed196897c11 100644 --- a/homeassistant/components/twitch/config_flow.py +++ b/homeassistant/components/twitch/config_flow.py @@ -78,7 +78,10 @@ class OAuth2FlowHandler( reauth_entry = self._get_reauth_entry() self._abort_if_unique_id_mismatch( reason="wrong_account", - description_placeholders={"title": reauth_entry.title}, + description_placeholders={ + "title": reauth_entry.title, + "username": str(reauth_entry.unique_id), + }, ) new_channels = reauth_entry.options[CONF_CHANNELS] From 43c2658962b3db3e5a2bcb6c9971b895546c860a Mon Sep 17 00:00:00 2001 From: sean t Date: Thu, 7 Nov 2024 17:34:54 +0800 Subject: [PATCH 289/511] Bump agent-py to 0.0.24 (#130018) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/agent_dvr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json index 9a6c528c336..4ec14296363 100644 --- a/homeassistant/components/agent_dvr/manifest.json +++ b/homeassistant/components/agent_dvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/agent_dvr", "iot_class": "local_polling", "loggers": ["agent"], - "requirements": ["agent-py==0.0.23"] + "requirements": ["agent-py==0.0.24"] } diff --git a/requirements_all.txt b/requirements_all.txt index 685574a89b2..32e71aa083a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -152,7 +152,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95703e6f030..0c73e10df18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ advantage-air==0.4.4 afsapi==0.2.7 # homeassistant.components.agent_dvr -agent-py==0.0.23 +agent-py==0.0.24 # homeassistant.components.geo_json_events aio-geojson-generic-client==0.4 From 838ef0bb9f2ff7e42b4bd15ddf5be2a4df91367e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 7 Nov 2024 19:36:43 +1000 Subject: [PATCH 290/511] Fix Trunks in Teslemetry and Tesla Fleet (#129986) --- homeassistant/components/tesla_fleet/cover.py | 8 +------- homeassistant/components/teslemetry/cover.py | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py index 2a14c4f039b..f270734424f 100644 --- a/homeassistant/components/tesla_fleet/cover.py +++ b/homeassistant/components/tesla_fleet/cover.py @@ -177,13 +177,7 @@ class TeslaFleetRearTrunkEntity(TeslaFleetVehicleEntity, CoverEntity): def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 190f729d99f..8775da931d5 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -182,13 +182,7 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): def _async_update_attrs(self) -> None: """Update the entity attributes.""" - value = self._value - if value == CLOSED: - self._attr_is_closed = True - elif value == OPEN: - self._attr_is_closed = False - else: - self._attr_is_closed = None + self._attr_is_closed = self._value == CLOSED async def async_open_cover(self, **kwargs: Any) -> None: """Open rear trunk.""" From 2adbf7c9330220cef55864cade4154130be190e8 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Nov 2024 01:50:40 -0800 Subject: [PATCH 291/511] Bump google-nest-sdm to 6.1.4 (#130005) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- 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 976e870cc83..581113f0c96 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==6.1.3"] + "requirements": ["google-nest-sdm==6.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32e71aa083a..449fcba2f5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1015,7 +1015,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c73e10df18..04706cc0546 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -865,7 +865,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.3 +google-nest-sdm==6.1.4 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 49bf5db5ff7f80fb8bca6c27e8b590e9ecba98fc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 12:55:54 +0100 Subject: [PATCH 292/511] Update pytest warnings filter (#130027) --- pyproject.toml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 282a4e51ff7..a96cb3b405b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -486,10 +486,13 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources", # -- tracked upstream / open PRs + # - pyOpenSSL v24.2.1 # https://github.com/certbot/certbot/issues/9828 - v2.11.0 + # https://github.com/certbot/certbot/issues/9992 "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 - "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", + "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", + # - other # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 # https://github.com/foxel/python_ndms2_client/pull/8 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", @@ -526,6 +529,8 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + # https://github.com/cereal2nd/velbus-aio/pull/126 - >2024.10.0 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", # -- fixed for Python 3.13 # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2 @@ -549,7 +554,7 @@ filterwarnings = [ "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.8/pysnmp/smi/compiler.py#L23-L31 - v7.1.8 - 2024-10-15 + # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 @@ -579,7 +584,7 @@ filterwarnings = [ # - pkg_resources # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast", - # https://pypi.org/project/habitipy/ - v0.3.1 - 2019-01-14 / 2024-04-28 + # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data", @@ -587,14 +592,6 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom", - # https://pypi.org/project/velbus-aio/ - v2024.7.6 - 2024-07-31 - # https://github.com/Cereal2nd/velbus-aio/blob/2024.7.6/velbusaio/handler.py#L22 - "ignore:pkg_resources is deprecated as an API:DeprecationWarning:velbusaio.handler", - # - pyOpenSSL v24.2.1 - # https://pypi.org/project/acme/ - v2.11.0 - 2024-06-06 - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util", - # https://pypi.org/project/josepy/ - v1.14.0 - 2023-11-01 - "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util", # -- Python 3.13 # HomeAssistant @@ -608,7 +605,7 @@ filterwarnings = [ # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7 "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition", # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06 - # https://github.com/home-assistant-libs/voip-utils/blob/v0.2.0/voip_utils/rtp_audio.py#L3 + # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3 "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio", # -- Python 3.13 - unmaintained projects, last release about 2+ years From a3ba7803db895b5e083c7f7d84fd3bb0e70bad25 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:12:00 +0100 Subject: [PATCH 293/511] Add checks for translation placeholders (#129963) * Add checks for translation placeholders * Remove async * Apply suggestions from code review * Apply suggestions from code review * Apply suggestions from code review --- tests/components/conftest.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1ec656d44c5..00738cd252f 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Generator from importlib.util import find_spec from pathlib import Path +import string from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -542,17 +543,40 @@ def supervisor_client() -> Generator[AsyncMock]: yield supervisor_client +def _validate_translation_placeholders( + full_key: str, + translation: str, + description_placeholders: dict[str, str] | None, +) -> str | None: + """Raise if translation exists with missing placeholders.""" + tuples = list(string.Formatter().parse(translation)) + for _, placeholder, _, _ in tuples: + if placeholder is None: + continue + if ( + description_placeholders is None + or placeholder not in description_placeholders + ): + pytest.fail( + f"Description not found for placeholder `{placeholder}` in {full_key}" + ) + + async def _ensure_translation_exists( hass: HomeAssistant, ignore_translations: dict[str, StoreInfo], category: str, component: str, key: str, + description_placeholders: dict[str, str] | None, ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" translations = await async_get_translations(hass, "en", category, [component]) - if full_key in translations: + if (translation := translations.get(full_key)) is not None: + _validate_translation_placeholders( + full_key, translation, description_placeholders + ) return if full_key in ignore_translations: @@ -610,6 +634,7 @@ def check_config_translations(ignore_translations: str | list[str]) -> Generator category, component, f"error.{error}", + result["description_placeholders"], ) return result @@ -624,6 +649,7 @@ def check_config_translations(ignore_translations: str | list[str]) -> Generator category, component, f"abort.{result["reason"]}", + result["description_placeholders"], ) return result From 0e324c074a3d307bfc839f0cf4d36092c4466d4c Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:25:38 +0100 Subject: [PATCH 294/511] Bump PySuez to 1.3.1 (#129825) --- .../components/suez_water/config_flow.py | 10 +-- .../components/suez_water/coordinator.py | 90 ++++--------------- .../components/suez_water/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/suez_water/conftest.py | 35 ++++---- .../components/suez_water/test_config_flow.py | 84 ++++++++--------- tests/components/suez_water/test_init.py | 6 +- tests/components/suez_water/test_sensor.py | 8 +- 9 files changed, 88 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index 28b211dc808..a7ade642888 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -5,8 +5,7 @@ from __future__ import annotations import logging from typing import Any -from pysuez import SuezClient -from pysuez.client import PySuezError +from pysuez import PySuezError, SuezClient import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -26,7 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) -def validate_input(data: dict[str, Any]) -> None: +async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. @@ -36,9 +35,8 @@ def validate_input(data: dict[str, Any]) -> None: data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTER_ID], - provider=None, ) - if not client.check_credentials(): + if not await client.check_credentials(): raise InvalidAuth except PySuezError as ex: raise CannotConnect from ex @@ -58,7 +56,7 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(user_input[CONF_USERNAME]) self._abort_if_unique_id_configured() try: - await self.hass.async_add_executor_job(validate_input, user_input) + await validate_input(user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index adcbd39c01b..55f3ba348d4 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,39 +1,20 @@ """Suez water update coordinator.""" -import asyncio -from dataclasses import dataclass -from datetime import date - -from pysuez import SuezClient -from pysuez.client import PySuezError +from pysuez import AggregatedData, PySuezError, SuezClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import _LOGGER, HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN -@dataclass -class AggregatedSensorData: - """Hold suez water aggregated sensor data.""" - - value: float - current_month: dict[date, float] - previous_month: dict[date, float] - previous_year: dict[str, float] - current_year: dict[str, float] - history: dict[date, float] - highest_monthly_consumption: float - attribution: str - - -class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): +class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): """Suez water coordinator.""" - _sync_client: SuezClient + _suez_client: SuezClient config_entry: ConfigEntry def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: @@ -48,61 +29,22 @@ class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedSensorData]): ) async def _async_setup(self) -> None: - self._sync_client = await self.hass.async_add_executor_job(self._get_client) + self._suez_client = SuezClient( + username=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + counter_id=self.config_entry.data[CONF_COUNTER_ID], + ) + if not await self._suez_client.check_credentials(): + raise ConfigEntryError("Invalid credentials for suez water") - async def _async_update_data(self) -> AggregatedSensorData: + async def _async_update_data(self) -> AggregatedData: """Fetch data from API endpoint.""" - async with asyncio.timeout(30): - return await self.hass.async_add_executor_job(self._fetch_data) - - def _fetch_data(self) -> AggregatedSensorData: - """Fetch latest data from Suez.""" try: - self._sync_client.update() + data = await self._suez_client.fetch_aggregated_data() except PySuezError as err: + _LOGGER.exception(err) raise UpdateFailed( f"Suez coordinator error communicating with API: {err}" ) from err - current_month = {} - for item in self._sync_client.attributes["thisMonthConsumption"]: - current_month[item] = self._sync_client.attributes["thisMonthConsumption"][ - item - ] - previous_month = {} - for item in self._sync_client.attributes["previousMonthConsumption"]: - previous_month[item] = self._sync_client.attributes[ - "previousMonthConsumption" - ][item] - highest_monthly_consumption = self._sync_client.attributes[ - "highestMonthlyConsumption" - ] - previous_year = self._sync_client.attributes["lastYearOverAll"] - current_year = self._sync_client.attributes["thisYearOverAll"] - history = {} - for item in self._sync_client.attributes["history"]: - history[item] = self._sync_client.attributes["history"][item] - _LOGGER.debug("Retrieved consumption: " + str(self._sync_client.state)) - return AggregatedSensorData( - self._sync_client.state, - current_month, - previous_month, - previous_year, - current_year, - history, - highest_monthly_consumption, - self._sync_client.attributes["attribution"], - ) - - def _get_client(self) -> SuezClient: - try: - client = SuezClient( - username=self.config_entry.data[CONF_USERNAME], - password=self.config_entry.data[CONF_PASSWORD], - counter_id=self.config_entry.data[CONF_COUNTER_ID], - provider=None, - ) - if not client.check_credentials(): - raise ConfigEntryError - except PySuezError as ex: - raise ConfigEntryNotReady from ex - return client + _LOGGER.debug("Successfully fetched suez data") + return data diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index fa7f8f6461d..5eb05b9acb7 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], - "requirements": ["pysuezV2==0.2.2"] + "requirements": ["pysuezV2==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 449fcba2f5a..e1c224ad870 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2284,7 +2284,7 @@ pysqueezebox==0.10.0 pystiebeleltron==0.0.1.dev2 # homeassistant.components.suez_water -pysuezV2==0.2.2 +pysuezV2==1.3.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04706cc0546..68aec855ec5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1841,7 +1841,7 @@ pyspeex-noise==1.0.2 pysqueezebox==0.10.0 # homeassistant.components.suez_water -pysuezV2==0.2.2 +pysuezV2==1.3.1 # homeassistant.components.switchbee pyswitchbee==1.8.3 diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index bcb817a5025..0cbf16095bf 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -1,11 +1,12 @@ """Common fixtures for the Suez Water tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.components.suez_water.coordinator import AggregatedData from tests.common import MockConfigEntry @@ -37,7 +38,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="suez_client") -def mock_suez_client() -> Generator[MagicMock]: +def mock_suez_data() -> Generator[AsyncMock]: """Create mock for suez_water external api.""" with ( patch( @@ -48,28 +49,30 @@ def mock_suez_client() -> Generator[MagicMock]: new=mock_client, ), ): - client = mock_client.return_value - client.check_credentials.return_value = True - client.update.return_value = None - client.state = 160 - client.attributes = { - "thisMonthConsumption": { + suez_client = mock_client.return_value + suez_client.check_credentials.return_value = True + + result = AggregatedData( + value=160, + current_month={ "2024-01-01": 130, "2024-01-02": 145, }, - "previousMonthConsumption": { + previous_month={ "2024-12-01": 154, "2024-12-02": 166, }, - "highestMonthlyConsumption": 2558, - "lastYearOverAll": 1000, - "thisYearOverAll": 1500, - "history": { + current_year=1500, + previous_year=1000, + attribution="suez water mock test", + highest_monthly_consumption=2558, + history={ "2024-01-01": 130, "2024-01-02": 145, "2024-12-01": 154, "2024-12-02": 166, }, - "attribution": "suez water mock test", - } - yield client + ) + + suez_client.fetch_aggregated_data.return_value = result + yield suez_client diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index ddf7bcd3d80..766fd8c5fa5 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -1,8 +1,8 @@ """Test the Suez Water config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from pysuez.client import PySuezError +from pysuez.exception import PySuezError import pytest from homeassistant import config_entries @@ -15,7 +15,9 @@ from .conftest import MOCK_DATA from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -23,12 +25,11 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with patch("homeassistant.components.suez_water.config_flow.SuezClient"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -38,37 +39,28 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: async def test_form_invalid_auth( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock ) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.suez_water.config_flow.SuezClient.__init__", - return_value=None, - ), - patch( - "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", - return_value=False, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_auth"} - with patch("homeassistant.components.suez_water.config_flow.SuezClient"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) - await hass.async_block_till_done() + suez_client.check_credentials.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" @@ -104,32 +96,32 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: ("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] ) async def test_form_error( - hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + suez_client: AsyncMock, + error: str, ) -> None: """Test we handle errors.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch( - "homeassistant.components.suez_water.config_flow.SuezClient", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.side_effect = exception + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with patch( - "homeassistant.components.suez_water.config_flow.SuezClient", - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_DATA, - ) + suez_client.check_credentials.return_value = True + suez_client.check_credentials.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test-username" diff --git a/tests/components/suez_water/test_init.py b/tests/components/suez_water/test_init.py index b9a8875a8a1..78d086af38f 100644 --- a/tests/components/suez_water/test_init.py +++ b/tests/components/suez_water/test_init.py @@ -1,5 +1,7 @@ """Test Suez_water integration initialization.""" +from unittest.mock import AsyncMock + from homeassistant.components.suez_water.coordinator import PySuezError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -11,7 +13,7 @@ from tests.common import MockConfigEntry async def test_initialization_invalid_credentials( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that suez_water can't be loaded with invalid credentials.""" @@ -24,7 +26,7 @@ async def test_initialization_invalid_credentials( async def test_initialization_setup_api_error( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test that suez_water needs to retry loading if api failed to connect.""" diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index d3da159ee28..1cd40dff75b 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -1,6 +1,6 @@ """Test Suez_water sensor platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion @@ -20,7 +20,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat async def test_sensors_valid_state( hass: HomeAssistant, snapshot: SnapshotAssertion, - suez_client: MagicMock, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: @@ -34,7 +34,7 @@ async def test_sensors_valid_state( async def test_sensors_failed_update( hass: HomeAssistant, - suez_client, + suez_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: @@ -51,7 +51,7 @@ async def test_sensors_failed_update( assert entity_ids[0] assert state.state != STATE_UNAVAILABLE - suez_client.update.side_effect = PySuezError("Should fail to update") + suez_client.fetch_aggregated_data.side_effect = PySuezError("Should fail to update") freezer.tick(DATA_REFRESH_INTERVAL) async_fire_time_changed(hass) From c5e3ba536c385a6340433b4892defc8cf2881190 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 17:07:23 +0100 Subject: [PATCH 295/511] Don't create repairs asking user to remove duplicate ignored config entries (#130056) --- homeassistant/config_entries.py | 11 +++++++++++ tests/test_config_entries.py | 8 +++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a13225c4dfe..7209ad8cbca 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2437,6 +2437,17 @@ class ConfigEntries: for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 for unique_id, entries in unique_ids.items(): + # We might mutate the list of entries, so we need a copy to not mess up + # the index + entries = list(entries) + + # There's no need to raise an issue for ignored entries, we can + # safely remove them once we no longer allow unique id collisions. + # Iterate over a copy of the copy to allow mutating while iterating + for entry in list(entries): + if entry.source == SOURCE_IGNORE: + entries.remove(entry) + if len(entries) < 2: continue issue_id = f"{ISSUE_UNIQUE_ID_COLLISION}_{domain}_{unique_id}" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3e3f3b4c504..54008a394b5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7224,6 +7224,12 @@ async def test_unique_id_collision_issues( for _ in range(6): test3.append(MockConfigEntry(domain="test3", unique_id="not_unique")) await manager.async_add(test3[-1]) + # Add an ignored config entry + await manager.async_add( + MockConfigEntry( + domain="test2", unique_id="group_1", source=config_entries.SOURCE_IGNORE + ) + ) # Check we get one issue for domain test2 and one issue for domain test3 assert len(issue_registry.issues) == 2 @@ -7270,7 +7276,7 @@ async def test_unique_id_collision_issues( (HOMEASSISTANT_DOMAIN, "config_entry_unique_id_collision_test2_group_2"), } - # Remove the last test2 group2 duplicate, a new issue is created + # Remove the last test2 group2 duplicate, the issue is cleared await manager.async_remove(test2_group_2[1].entry_id) assert not issue_registry.issues From c1ecc13cb35ece9570743e84795e7dfd81d3a804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frank=20Wickstr=C3=B6m?= Date: Thu, 7 Nov 2024 18:18:36 +0200 Subject: [PATCH 296/511] Bump huum to 0.7.11 (#130047) * Update huum dependency 0.7.10 -> 0.7.11 This change includes an explicit MIT license for the package. * Remove huum from license exceptions list --- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 7629f529b91..cc393f3785f 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.10"] + "requirements": ["huum==0.7.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index e1c224ad870..3641d949e0d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.10 +huum==0.7.11 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68aec855ec5..2cc01f44c65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.10 +huum==0.7.11 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/script/licenses.py b/script/licenses.py index 4f5432ad519..f4d534365bc 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -188,7 +188,6 @@ EXCEPTIONS = { "crownstone-uart", # https://github.com/crownstone/crownstone-lib-python-uart/pull/12 "eliqonline", # https://github.com/molobrakos/eliqonline/pull/17 "enocean", # https://github.com/kipe/enocean/pull/142 - "huum", # https://github.com/frwickst/pyhuum/pull/8 "imutils", # https://github.com/PyImageSearch/imutils/pull/292 "iso4217", # Public domain "kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6 From ef767c2b9ffd3d636bc5a01cc7c51c823cff45db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:35:58 +0100 Subject: [PATCH 297/511] Improve tests for frame helper (#130046) * Improve tests for frame helper * Improve comments * Add ids * Apply suggestions from code review --- tests/conftest.py | 26 +++++++++-- tests/helpers/test_frame.py | 85 +++++++++++++++++++++++++++++++++++ tests/test_loader.py | 88 +++++++++++++++++++++---------------- 3 files changed, 157 insertions(+), 42 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c60018413e7..35b65c5653c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1772,10 +1772,30 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: @pytest.fixture -def mock_integration_frame() -> Generator[Mock]: - """Mock as if we're calling code from inside an integration.""" +def integration_frame_path() -> str: + """Return the path to the integration frame. + + Can be parametrized with + `@pytest.mark.parametrize("integration_frame_path", ["path_to_frame"])` + + - "custom_components/XYZ" for a custom integration + - "homeassistant/components/XYZ" for a core integration + - "homeassistant/XYZ" for core (no integration) + + Defaults to core component `hue` + """ + return "homeassistant/components/hue" + + +@pytest.fixture +def mock_integration_frame(integration_frame_path: str) -> Generator[Mock]: + """Mock where we are calling code from. + + Defaults to calling from `hue` core integration, and can be parametrized + with `integration_frame_path`. + """ correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", + filename=f"/home/paulus/{integration_frame_path}/light.py", lineno="23", line="self.light.is_on", ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index b3fbb0faaf4..1961bf14299 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,5 +1,6 @@ """Test the frame helper.""" +from typing import Any from unittest.mock import ANY, Mock, patch import pytest @@ -247,3 +248,87 @@ async def test_report_error_if_integration( ), ): frame.report("did a bad thing", error_if_integration=True) + + +@pytest.mark.parametrize( + ("integration_frame_path", "keywords", "expected_error", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", + {}, + True, + 0, + id="core default", + ), + pytest.param( + "homeassistant/components/test_core_integration", + {}, + False, + 1, + id="core integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {}, + False, + 1, + id="custom integration default", + ), + pytest.param( + "custom_components/test_integration_frame", + {"log_custom_component_only": True}, + False, + 1, + id="log_custom_component_only with custom integration", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"log_custom_component_only": True}, + False, + 0, + id="log_custom_component_only with core integration", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"error_if_core": False}, + False, + 1, + id="disable error_if_core", + ), + pytest.param( + "custom_components/test_integration_frame", + {"error_if_integration": True}, + True, + 1, + id="error_if_integration with custom integration", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"error_if_integration": True}, + True, + 1, + id="error_if_integration with core integration", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report( + caplog: pytest.LogCaptureFixture, + keywords: dict[str, Any], + expected_error: bool, + expected_log: int, +) -> None: + """Test report.""" + + what = "test_report_string" + + errored = False + try: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report(what, **keywords) + except RuntimeError: + errored = True + + assert errored == expected_error + + assert caplog.text.count(what) == expected_log diff --git a/tests/test_loader.py b/tests/test_loader.py index c4bcbed0107..57d3d6fa832 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -6,7 +6,7 @@ import pathlib import sys import threading from typing import Any -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, patch from awesomeversion import AwesomeVersion import pytest @@ -1295,26 +1295,29 @@ async def test_config_folder_not_in_path() -> None: import tests.testing_config.check_config_not_in_path # noqa: F401 -async def test_hass_components_use_reported( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock -) -> None: - """Test that use of hass.components is reported.""" - mock_integration_frame.filename = ( - "/home/paulus/homeassistant/custom_components/demo/light.py" - ) - integration_frame = frame.IntegrationFrame( - custom_integration=True, - frame=mock_integration_frame, - integration="test_integration_frame", - module="custom_components.test_integration_frame", - relative_filename="custom_components/test_integration_frame/__init__.py", - ) - - with ( - patch( - "homeassistant.helpers.frame.get_integration_frame", - return_value=integration_frame, +@pytest.mark.parametrize( + ("integration_frame_path", "expected"), + [ + pytest.param( + "custom_components/test_integration_frame", True, id="custom integration" ), + pytest.param( + "homeassistant/components/test_integration_frame", + False, + id="core integration", + ), + pytest.param("homeassistant/test_integration_frame", False, id="core"), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_hass_components_use_reported( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + expected: bool, +) -> None: + """Test whether use of hass.components is reported.""" + with ( patch( "homeassistant.components.http.start_http_server_and_save_config", return_value=None, @@ -1322,10 +1325,11 @@ async def test_hass_components_use_reported( ): await hass.components.http.start_http_server_and_save_config(hass, [], None) - assert ( + reported = ( "Detected that custom integration 'test_integration_frame'" " accesses hass.components.http. This is deprecated" ) in caplog.text + assert reported == expected async def test_async_get_component_preloads_config_and_config_flow( @@ -1987,24 +1991,29 @@ async def test_has_services(hass: HomeAssistant) -> None: assert integration.has_services is True -async def test_hass_helpers_use_reported( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock -) -> None: - """Test that use of hass.components is reported.""" - integration_frame = frame.IntegrationFrame( - custom_integration=True, - frame=mock_integration_frame, - integration="test_integration_frame", - module="custom_components.test_integration_frame", - relative_filename="custom_components/test_integration_frame/__init__.py", - ) - - with ( - patch.object(frame, "_REPORTED_INTEGRATIONS", new=set()), - patch( - "homeassistant.helpers.frame.get_integration_frame", - return_value=integration_frame, +@pytest.mark.parametrize( + ("integration_frame_path", "expected"), + [ + pytest.param( + "custom_components/test_integration_frame", True, id="custom integration" ), + pytest.param( + "homeassistant/components/test_integration_frame", + False, + id="core integration", + ), + pytest.param("homeassistant/test_integration_frame", False, id="core"), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +@patch.object(frame, "_REPORTED_INTEGRATIONS", set()) +async def test_hass_helpers_use_reported( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + expected: bool, +) -> None: + """Test whether use of hass.helpers is reported.""" + with ( patch( "homeassistant.helpers.aiohttp_client.async_get_clientsession", return_value=None, @@ -2012,10 +2021,11 @@ async def test_hass_helpers_use_reported( ): hass.helpers.aiohttp_client.async_get_clientsession() - assert ( + reported = ( "Detected that custom integration 'test_integration_frame' " "accesses hass.helpers.aiohttp_client. This is deprecated" ) in caplog.text + assert reported == expected async def test_manifest_json_fragment_round_trip(hass: HomeAssistant) -> None: From 536e6868923ae7956f06b90baeb8f5bb1f15dfb1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Nov 2024 17:38:10 +0100 Subject: [PATCH 298/511] Don't create repairs asking user to remove duplicate flipr config entries (#130058) * Don't create repairs asking user to remove duplicate flipr config entries * Improve comments --- homeassistant/config_entries.py | 13 +++++++++++- tests/test_config_entries.py | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7209ad8cbca..a41f4f24701 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2158,7 +2158,12 @@ class ConfigEntries: if unique_id is not UNDEFINED and entry.unique_id != unique_id: # Deprecated in 2024.11, should fail in 2025.11 if ( - unique_id is not None + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + entry.domain != "flipr" + and unique_id is not None and self.async_entry_for_domain_unique_id(entry.domain, unique_id) is not None ): @@ -2436,6 +2441,12 @@ class ConfigEntries: issues.add(issue.issue_id) for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001 + # flipr creates duplicates during migration, and asks users to + # remove the duplicate. We don't need warn about it here too. + # We should remove the special case for "flipr" in HA Core 2025.4, + # when the flipr migration period ends + if domain == "flipr": + continue for unique_id, entries in unique_ids.items(): # We might mutate the list of entries, so we need a copy to not mess up # the index diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 54008a394b5..df464f6af1b 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7195,6 +7195,41 @@ async def test_async_update_entry_unique_id_collision( assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) +@pytest.mark.parametrize("domain", ["flipr"]) +async def test_async_update_entry_unique_id_collision_allowed_domain( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, + domain: str, +) -> None: + """Test we warn when async_update_entry creates a unique_id collision. + + This tests we don't warn and don't create issues for domains which have + their own migration path. + """ + assert len(issue_registry.issues) == 0 + + entry1 = MockConfigEntry(domain=domain, unique_id=None) + entry2 = MockConfigEntry(domain=domain, unique_id="not none") + entry3 = MockConfigEntry(domain=domain, unique_id="very unique") + entry4 = MockConfigEntry(domain=domain, unique_id="also very unique") + entry1.add_to_manager(manager) + entry2.add_to_manager(manager) + entry3.add_to_manager(manager) + entry4.add_to_manager(manager) + + manager.async_update_entry(entry2, unique_id=None) + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + manager.async_update_entry(entry4, unique_id="very unique") + assert len(issue_registry.issues) == 0 + assert len(caplog.record_tuples) == 0 + + assert ("already in use") not in caplog.text + + async def test_unique_id_collision_issues( hass: HomeAssistant, manager: config_entries.ConfigEntries, From ee30520b572a244c01c6239e054ab936ff34eefd Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:16:01 +0100 Subject: [PATCH 299/511] Fix esphome mqtt discovery by handling case where payload is a empty string (#129969) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/config_flow.py | 3 +++ homeassistant/components/esphome/strings.json | 3 ++- tests/components/esphome/test_config_flow.py | 8 ++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 99dae2e68ab..cb892b314cd 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -257,6 +257,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: """Handle MQTT discovery.""" + if not discovery_info.payload: + return self.async_abort(reason="mqtt_missing_payload") + device_info = json_loads_object(discovery_info.payload) if "mac" not in device_info: return self.async_abort(reason="mqtt_missing_mac") diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index ec7e6f674b3..18a54772e30 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -8,7 +8,8 @@ "service_received": "Action received", "mqtt_missing_mac": "Missing MAC address in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.", - "mqtt_missing_ip": "Missing IP address in MQTT properties." + "mqtt_missing_ip": "Missing IP address in MQTT properties.", + "mqtt_missing_payload": "Missing MQTT Payload." }, "error": { "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 3051547bd43..0a389969c78 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1400,6 +1400,14 @@ async def test_discovery_mqtt_no_mac( await mqtt_discovery_test_abort(hass, "{}", "mqtt_missing_mac") +@pytest.mark.usefixtures("mock_zeroconf") +async def test_discovery_mqtt_empty_payload( + hass: HomeAssistant, mock_client, mock_setup_entry: None +) -> None: + """Test discovery aborted if MQTT payload is empty.""" + await mqtt_discovery_test_abort(hass, "", "mqtt_missing_payload") + + @pytest.mark.usefixtures("mock_zeroconf") async def test_discovery_mqtt_no_api( hass: HomeAssistant, mock_client, mock_setup_entry: None From a3b0909e3f1a41d35a0cfc16fc68eb69a07ce9da Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 7 Nov 2024 18:23:35 +0100 Subject: [PATCH 300/511] Add new frame helper to better distinguish custom and core integrations (#130025) * Add new frame helper to clarify options available * Adjust * Improve * Use report_usage in core * Add tests * Use is/is not Co-authored-by: J. Nick Koston * Use enum.auto() --------- Co-authored-by: J. Nick Koston --- homeassistant/core.py | 20 +++---- homeassistant/core_config.py | 8 +-- homeassistant/data_entry_flow.py | 6 +-- homeassistant/helpers/frame.py | 65 ++++++++++++++++++++--- homeassistant/loader.py | 20 ++++--- tests/helpers/test_frame.py | 91 ++++++++++++++++++++++++++++++++ 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index ab852056353..cdfb5570b44 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -656,12 +656,12 @@ class HomeAssistant: # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_add_job`, which is deprecated and will be removed in Home " "Assistant 2025.4; Please review " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if target is None: @@ -712,12 +712,12 @@ class HomeAssistant: # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_add_hass_job`, which is deprecated and will be removed in Home " "Assistant 2025.5; Please review " "https://developers.home-assistant.io/blog/2024/04/07/deprecate_add_hass_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) return self._async_add_hass_job(hassjob, *args, background=background) @@ -986,12 +986,12 @@ class HomeAssistant: # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_run_job`, which is deprecated and will be removed in Home " "Assistant 2025.4; Please review " "https://developers.home-assistant.io/blog/2024/03/13/deprecate_add_run_job" " for replacement options", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if asyncio.iscoroutine(target): @@ -1635,10 +1635,10 @@ class EventBus: # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_listen` with run_immediately, which is" " deprecated and will be removed in Home Assistant 2025.5", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) if event_filter is not None and not is_callback_check_partial(event_filter): @@ -1705,10 +1705,10 @@ class EventBus: # late import to avoid circular imports from .helpers import frame # pylint: disable=import-outside-toplevel - frame.report( + frame.report_usage( "calls `async_listen_once` with run_immediately, which is " "deprecated and will be removed in Home Assistant 2025.5", - error_if_core=False, + core_behavior=frame.ReportBehavior.LOG, ) one_time_listener: _OneTimeListener[_DataT] = _OneTimeListener( diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index 25f745f110c..5c773c57bc4 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -60,7 +60,7 @@ from .core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from .generated.currencies import HISTORIC_CURRENCIES from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report_usage from .helpers.storage import Store from .helpers.typing import UNDEFINED, UndefinedType from .util import dt as dt_util, location @@ -695,11 +695,11 @@ class Config: It will be removed in Home Assistant 2025.6. """ - report( + report_usage( "set the time zone using set_time_zone instead of async_set_time_zone" " which will stop working in Home Assistant 2025.6", - error_if_core=True, - error_if_integration=True, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.ERROR, ) if time_zone := dt_util.get_time_zone(time_zone_str): self.time_zone = time_zone_str diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 1fb6439a8c4..9d041c9b8d3 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -26,7 +26,7 @@ from .helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report_usage from .loader import async_suggest_report_issue from .util import uuid as uuid_util @@ -530,12 +530,12 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): if not isinstance(result["type"], FlowResultType): result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] - report( + report_usage( ( "does not use FlowResultType enum for data entry flow result type. " "This is deprecated and will stop working in Home Assistant 2025.1" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) if ( diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index fd7e014b2ff..eda98099713 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass +import enum import functools import linecache import logging @@ -144,24 +145,72 @@ def report( If error_if_integration is True, raise instead of log if an integration is found when unwinding the stack frame. """ + core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG + core_integration_behavior = ( + ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG + ) + custom_integration_behavior = core_integration_behavior + + if log_custom_component_only: + if core_behavior is ReportBehavior.LOG: + core_behavior = ReportBehavior.IGNORE + if core_integration_behavior is ReportBehavior.LOG: + core_integration_behavior = ReportBehavior.IGNORE + + report_usage( + what, + core_behavior=core_behavior, + core_integration_behavior=core_integration_behavior, + custom_integration_behavior=custom_integration_behavior, + exclude_integrations=exclude_integrations, + level=level, + ) + + +class ReportBehavior(enum.Enum): + """Enum for behavior on code usage.""" + + IGNORE = enum.auto() + """Ignore the code usage.""" + LOG = enum.auto() + """Log the code usage.""" + ERROR = enum.auto() + """Raise an error on code usage.""" + + +def report_usage( + what: str, + *, + core_behavior: ReportBehavior = ReportBehavior.ERROR, + core_integration_behavior: ReportBehavior = ReportBehavior.LOG, + custom_integration_behavior: ReportBehavior = ReportBehavior.LOG, + exclude_integrations: set[str] | None = None, + level: int = logging.WARNING, +) -> None: + """Report incorrect code usage. + + Similar to `report` but allows more fine-grained reporting. + """ try: integration_frame = get_integration_frame( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: msg = f"Detected code that {what}. Please report this issue." - if error_if_core: + if core_behavior is ReportBehavior.ERROR: raise RuntimeError(msg) from err - if not log_custom_component_only: + if core_behavior is ReportBehavior.LOG: _LOGGER.warning(msg, stack_info=True) return - if ( - error_if_integration - or not log_custom_component_only - or integration_frame.custom_integration - ): - _report_integration(what, integration_frame, level, error_if_integration) + integration_behavior = core_integration_behavior + if integration_frame.custom_integration: + integration_behavior = custom_integration_behavior + + if integration_behavior is not ReportBehavior.IGNORE: + _report_integration( + what, integration_frame, level, integration_behavior is ReportBehavior.ERROR + ) def _report_integration( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 221a2c7ce19..d2e04df04c4 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1556,16 +1556,18 @@ class Components: raise ImportError(f"Unable to load {comp_name}") # Local import to avoid circular dependencies - from .helpers.frame import report # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from .helpers.frame import ReportBehavior, report_usage - report( + report_usage( ( f"accesses hass.components.{comp_name}." " This is deprecated and will stop working in Home Assistant 2025.3, it" f" should be updated to import functions used from {comp_name} directly" ), - error_if_core=False, - log_custom_component_only=True, + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, ) wrapped = ModuleWrapper(self._hass, component) @@ -1585,16 +1587,18 @@ class Helpers: helper = importlib.import_module(f"homeassistant.helpers.{helper_name}") # Local import to avoid circular dependencies - from .helpers.frame import report # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel + from .helpers.frame import ReportBehavior, report_usage - report( + report_usage( ( f"accesses hass.helpers.{helper_name}." " This is deprecated and will stop working in Home Assistant 2025.5, it" f" should be updated to import functions used from {helper_name} directly" ), - error_if_core=False, - log_custom_component_only=True, + core_behavior=ReportBehavior.IGNORE, + core_integration_behavior=ReportBehavior.IGNORE, + custom_integration_behavior=ReportBehavior.LOG, ) wrapped = ModuleWrapper(self._hass, helper) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 1961bf14299..a2a4890810b 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -157,6 +157,97 @@ async def test_get_integration_logger_no_integration( assert logger.name == __name__ +@pytest.mark.parametrize( + ("integration_frame_path", "keywords", "expected_error", "expected_log"), + [ + pytest.param( + "homeassistant/test_core", + {}, + True, + 0, + id="core default", + ), + pytest.param( + "homeassistant/components/test_core_integration", + {}, + False, + 1, + id="core integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {}, + False, + 1, + id="custom integration default", + ), + pytest.param( + "custom_components/test_custom_integration", + {"custom_integration_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="custom integration ignore", + ), + pytest.param( + "custom_components/test_custom_integration", + {"custom_integration_behavior": frame.ReportBehavior.ERROR}, + True, + 1, + id="custom integration error", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"core_integration_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="core_integration_behavior ignore", + ), + pytest.param( + "homeassistant/components/test_integration_frame", + {"core_integration_behavior": frame.ReportBehavior.ERROR}, + True, + 1, + id="core_integration_behavior error", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"core_behavior": frame.ReportBehavior.IGNORE}, + False, + 0, + id="core_behavior ignore", + ), + pytest.param( + "homeassistant/test_integration_frame", + {"core_behavior": frame.ReportBehavior.LOG}, + False, + 1, + id="core_behavior log", + ), + ], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_report_usage( + caplog: pytest.LogCaptureFixture, + keywords: dict[str, Any], + expected_error: bool, + expected_log: int, +) -> None: + """Test report.""" + + what = "test_report_string" + + errored = False + try: + with patch.object(frame, "_REPORTED_INTEGRATIONS", set()): + frame.report_usage(what, **keywords) + except RuntimeError: + errored = True + + assert errored == expected_error + + assert caplog.text.count(what) == expected_log + + @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_prevent_flooding( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock From 8cae8edc5557828f97dd2f9938c3bafdda49d21b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Nov 2024 19:10:24 +0100 Subject: [PATCH 301/511] Remove temporary pint constraint (#130070) --- homeassistant/package_constraints.txt | 5 ----- script/gen_requirements_all.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5da579fa827..e2b04c48b30 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -192,8 +192,3 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 - -# latest pint 0.24.3 is not yet compatible with flexparser 0.4 -# https://github.com/hgrecco/pint/issues/1969 -flexparser==0.3.1 -pint==0.24.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a71047fddc8..352b209c5fc 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -207,11 +207,6 @@ tenacity!=8.4.0 # 5.0.0 breaks Timeout as a context manager # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 - -# latest pint 0.24.3 is not yet compatible with flexparser 0.4 -# https://github.com/hgrecco/pint/issues/1969 -flexparser==0.3.1 -pint==0.24.3 """ GENERATED_MESSAGE = ( From dac6271e01c6209b0e590be1acf644dcf0209cb4 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Thu, 7 Nov 2024 22:06:34 +0200 Subject: [PATCH 302/511] Add Switcher Lights support (#129494) * switcher lights integration * fix based on requested changes * Update light.py * switcher fix based on requested changes * fix linting * fix linting * Update light.py * Update light.py * Update homeassistant/components/switcher_kis/light.py * Update light.py --------- Co-authored-by: Shay Levy --- .../components/switcher_kis/light.py | 26 +++++---- tests/components/switcher_kis/consts.py | 56 +++++++++++++++++++ tests/components/switcher_kis/test_light.py | 41 +++++++++++--- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py index 4b6df6db6ed..bd87176bcf0 100644 --- a/homeassistant/components/switcher_kis/light.py +++ b/homeassistant/components/switcher_kis/light.py @@ -35,16 +35,20 @@ async def async_setup_entry( def async_add_light(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add light from Switcher device.""" entities: list[LightEntity] = [] - if ( - coordinator.data.device_type.category - == DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT + + if coordinator.data.device_type.category in ( + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, + DeviceCategory.LIGHT, ): - entities.extend(SwitcherDualLightEntity(coordinator, i) for i in range(2)) - if ( - coordinator.data.device_type.category - == DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT - ): - entities.append(SwitcherSingleLightEntity(coordinator, 0)) + number_of_lights = len(cast(SwitcherLight, coordinator.data).light) + if number_of_lights == 1: + entities.append(SwitcherSingleLightEntity(coordinator, 0)) + else: + entities.extend( + SwitcherMultiLightEntity(coordinator, i) + for i in range(number_of_lights) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -133,8 +137,8 @@ class SwitcherSingleLightEntity(SwitcherBaseLightEntity): self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" -class SwitcherDualLightEntity(SwitcherBaseLightEntity): - """Representation of a Switcher dual light entity.""" +class SwitcherMultiLightEntity(SwitcherBaseLightEntity): + """Representation of a Switcher multiple light entity.""" _attr_translation_key = "light" diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index ab0bef4e335..fe77ee0236b 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -5,6 +5,7 @@ from aioswitcher.device import ( DeviceType, ShutterDirection, SwitcherDualShutterSingleLight, + SwitcherLight, SwitcherPowerPlug, SwitcherShutter, SwitcherSingleShutterDualLight, @@ -23,18 +24,27 @@ DUMMY_DEVICE_ID3 = "bada77" DUMMY_DEVICE_ID4 = "bbd164" DUMMY_DEVICE_ID5 = "bcdb64" DUMMY_DEVICE_ID6 = "bcdc64" +DUMMY_DEVICE_ID7 = "bcdd64" +DUMMY_DEVICE_ID8 = "bcde64" +DUMMY_DEVICE_ID9 = "bcdf64" DUMMY_DEVICE_KEY1 = "18" DUMMY_DEVICE_KEY2 = "01" DUMMY_DEVICE_KEY3 = "12" DUMMY_DEVICE_KEY4 = "07" DUMMY_DEVICE_KEY5 = "15" DUMMY_DEVICE_KEY6 = "16" +DUMMY_DEVICE_KEY7 = "17" +DUMMY_DEVICE_KEY8 = "18" +DUMMY_DEVICE_KEY9 = "19" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME3 = "Breeze AB39" DUMMY_DEVICE_NAME4 = "Runner DD77" DUMMY_DEVICE_NAME5 = "RunnerS11 6CF5" DUMMY_DEVICE_NAME6 = "RunnerS12 A9BE" +DUMMY_DEVICE_NAME7 = "Light 36BB" +DUMMY_DEVICE_NAME8 = "Light 36CB" +DUMMY_DEVICE_NAME9 = "Light 36DB" DUMMY_DEVICE_PASSWORD = "12345678" DUMMY_ELECTRIC_CURRENT1 = 0.5 DUMMY_ELECTRIC_CURRENT2 = 12.8 @@ -44,18 +54,27 @@ DUMMY_IP_ADDRESS3 = "192.168.100.159" DUMMY_IP_ADDRESS4 = "192.168.100.160" DUMMY_IP_ADDRESS5 = "192.168.100.161" DUMMY_IP_ADDRESS6 = "192.168.100.162" +DUMMY_IP_ADDRESS7 = "192.168.100.163" +DUMMY_IP_ADDRESS8 = "192.168.100.164" +DUMMY_IP_ADDRESS9 = "192.168.100.165" DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA" DUMMY_MAC_ADDRESS4 = "A1:B2:C3:45:67:DB" DUMMY_MAC_ADDRESS5 = "A1:B2:C3:45:67:DC" DUMMY_MAC_ADDRESS6 = "A1:B2:C3:45:67:DD" +DUMMY_MAC_ADDRESS7 = "A1:B2:C3:45:67:DE" +DUMMY_MAC_ADDRESS8 = "A1:B2:C3:45:67:DF" +DUMMY_MAC_ADDRESS9 = "A1:B2:C3:45:67:DG" DUMMY_TOKEN_NEEDED1 = False DUMMY_TOKEN_NEEDED2 = False DUMMY_TOKEN_NEEDED3 = False DUMMY_TOKEN_NEEDED4 = False DUMMY_TOKEN_NEEDED5 = True DUMMY_TOKEN_NEEDED6 = True +DUMMY_TOKEN_NEEDED7 = True +DUMMY_TOKEN_NEEDED8 = True +DUMMY_TOKEN_NEEDED9 = True DUMMY_PHONE_ID = "1234" DUMMY_POWER_CONSUMPTION1 = 100 DUMMY_POWER_CONSUMPTION2 = 2780 @@ -75,6 +94,7 @@ DUMMY_USERNAME = "email" DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" DUMMY_LIGHT = [DeviceState.ON] DUMMY_LIGHT_2 = [DeviceState.ON, DeviceState.ON] +DUMMY_LIGHT_3 = [DeviceState.ON, DeviceState.ON, DeviceState.ON] DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, @@ -162,4 +182,40 @@ DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( DUMMY_REMOTE_ID, ) +DUMMY_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL01, + DeviceState.ON, + DUMMY_DEVICE_ID7, + DUMMY_DEVICE_KEY7, + DUMMY_IP_ADDRESS7, + DUMMY_MAC_ADDRESS7, + DUMMY_DEVICE_NAME7, + DUMMY_TOKEN_NEEDED7, + DUMMY_LIGHT, +) + +DUMMY_DUAL_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL02, + DeviceState.ON, + DUMMY_DEVICE_ID8, + DUMMY_DEVICE_KEY8, + DUMMY_IP_ADDRESS8, + DUMMY_MAC_ADDRESS8, + DUMMY_DEVICE_NAME8, + DUMMY_TOKEN_NEEDED8, + DUMMY_LIGHT_2, +) + +DUMMY_TRIPLE_LIGHT_DEVICE = SwitcherLight( + DeviceType.LIGHT_SL03, + DeviceState.ON, + DUMMY_DEVICE_ID9, + DUMMY_DEVICE_KEY9, + DUMMY_IP_ADDRESS9, + DUMMY_MAC_ADDRESS9, + DUMMY_DEVICE_NAME9, + DUMMY_TOKEN_NEEDED9, + DUMMY_LIGHT_3, +) + DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_light.py b/tests/components/switcher_kis/test_light.py index d360cb11291..60c851bf6a9 100644 --- a/tests/components/switcher_kis/test_light.py +++ b/tests/components/switcher_kis/test_light.py @@ -21,26 +21,43 @@ from homeassistant.util import slugify from . import init_integration from .consts import ( + DUMMY_DUAL_LIGHT_DEVICE as DEVICE4, DUMMY_DUAL_SHUTTER_SINGLE_LIGHT_DEVICE as DEVICE2, + DUMMY_LIGHT_DEVICE as DEVICE3, DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE, DUMMY_TOKEN as TOKEN, + DUMMY_TRIPLE_LIGHT_DEVICE as DEVICE5, DUMMY_USERNAME as USERNAME, ) ENTITY_ID = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_1" -ENTITY_ID2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_2" -ENTITY_ID3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE2.name)}" +ENTITY_ID_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE.name)}_light_2" +ENTITY_ID2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE2.name)}" +ENTITY_ID3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE3.name)}" +ENTITY_ID4 = f"{LIGHT_DOMAIN}.{slugify(DEVICE4.name)}_light_1" +ENTITY_ID4_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE4.name)}_light_2" +ENTITY_ID5 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_1" +ENTITY_ID5_2 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_2" +ENTITY_ID5_3 = f"{LIGHT_DOMAIN}.{slugify(DEVICE5.name)}_light_3" @pytest.mark.parametrize( ("device", "entity_id", "light_id", "device_state"), [ (DEVICE, ENTITY_ID, 0, [DeviceState.OFF, DeviceState.ON]), - (DEVICE, ENTITY_ID2, 1, [DeviceState.ON, DeviceState.OFF]), - (DEVICE2, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE, ENTITY_ID_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE2, ENTITY_ID2, 0, [DeviceState.OFF]), + (DEVICE3, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE4, ENTITY_ID4, 0, [DeviceState.OFF, DeviceState.ON]), + (DEVICE4, ENTITY_ID4_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE5, ENTITY_ID5, 0, [DeviceState.OFF, DeviceState.ON, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_2, 1, [DeviceState.ON, DeviceState.OFF, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_3, 2, [DeviceState.ON, DeviceState.ON, DeviceState.OFF]), ], ) -@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2]], indirect=True) +@pytest.mark.parametrize( + "mock_bridge", [[DEVICE, DEVICE2, DEVICE3, DEVICE4, DEVICE5]], indirect=True +) async def test_light( hass: HomeAssistant, mock_bridge, @@ -98,11 +115,19 @@ async def test_light( ("device", "entity_id", "light_id", "device_state"), [ (DEVICE, ENTITY_ID, 0, [DeviceState.OFF, DeviceState.ON]), - (DEVICE, ENTITY_ID2, 1, [DeviceState.ON, DeviceState.OFF]), - (DEVICE2, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE, ENTITY_ID_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE2, ENTITY_ID2, 0, [DeviceState.OFF]), + (DEVICE3, ENTITY_ID3, 0, [DeviceState.OFF]), + (DEVICE4, ENTITY_ID4, 0, [DeviceState.OFF, DeviceState.ON]), + (DEVICE4, ENTITY_ID4_2, 1, [DeviceState.ON, DeviceState.OFF]), + (DEVICE5, ENTITY_ID5, 0, [DeviceState.OFF, DeviceState.ON, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_2, 1, [DeviceState.ON, DeviceState.OFF, DeviceState.ON]), + (DEVICE5, ENTITY_ID5_3, 2, [DeviceState.ON, DeviceState.ON, DeviceState.OFF]), ], ) -@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +@pytest.mark.parametrize( + "mock_bridge", [[DEVICE, DEVICE2, DEVICE3, DEVICE4, DEVICE5]], indirect=True +) async def test_light_control_fail( hass: HomeAssistant, mock_bridge, From 0d19e85a0d8ff03d7d725956fc86c7ea3a0199b1 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Fri, 8 Nov 2024 02:59:30 +0200 Subject: [PATCH 303/511] Align Switcher cover platform with changes from light platform (#130094) Switcher small fix for cover --- .../components/switcher_kis/cover.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index c56fa7442fb..dc3b6d96aed 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -41,16 +41,20 @@ async def async_setup_entry( def async_add_cover(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add cover from Switcher device.""" entities: list[CoverEntity] = [] + if coordinator.data.device_type.category in ( DeviceCategory.SHUTTER, DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, ): - entities.append(SwitcherSingleCoverEntity(coordinator, 0)) - if ( - coordinator.data.device_type.category - == DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT - ): - entities.extend(SwitcherDualCoverEntity(coordinator, i) for i in range(2)) + number_of_covers = len(cast(SwitcherShutter, coordinator.data).position) + if number_of_covers == 1: + entities.append(SwitcherSingleCoverEntity(coordinator, 0)) + else: + entities.extend( + SwitcherMultiCoverEntity(coordinator, i) + for i in range(number_of_covers) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -152,8 +156,8 @@ class SwitcherSingleCoverEntity(SwitcherBaseCoverEntity): self._update_data() -class SwitcherDualCoverEntity(SwitcherBaseCoverEntity): - """Representation of a Switcher dual cover entity.""" +class SwitcherMultiCoverEntity(SwitcherBaseCoverEntity): + """Representation of a Switcher multiple cover entity.""" _attr_translation_key = "cover" From e407b4730d8d6fc612d3fc25526b6c2811ac1130 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 7 Nov 2024 20:03:07 -0800 Subject: [PATCH 304/511] Fix `KeyError` in nest integration when the old key format does not exist (#130057) * Fix bug in nest setup when the old key format does not exist * Further simplify the entry.data check * Update homeassistant/components/nest/api.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nest/api.py | 5 ++--- tests/components/nest/common.py | 12 ++++++++++++ tests/components/nest/test_init.py | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index aa359dcd167..5c65a70c75d 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -114,9 +114,8 @@ async def new_subscriber( implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): raise TypeError(f"Unexpected auth implementation {implementation}") - subscription_name = entry.data.get( - CONF_SUBSCRIPTION_NAME, entry.data[CONF_SUBSCRIBER_ID] - ) + if (subscription_name := entry.data.get(CONF_SUBSCRIPTION_NAME)) is None: + subscription_name = entry.data[CONF_SUBSCRIBER_ID] auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 9c8de0224f0..5d4719918a6 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -30,6 +30,7 @@ CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" CLOUD_PROJECT_ID = "cloud-id-9876" SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" +SUBSCRIPTION_NAME = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" @dataclass @@ -86,6 +87,17 @@ TEST_CONFIG_ENTRY_LEGACY = NestTestConfig( }, ) +TEST_CONFIG_NEW_SUBSCRIPTION = NestTestConfig( + config_entry_data={ + "sdm": {}, + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscription_name": SUBSCRIPTION_NAME, + "auth_implementation": "imported-cred", + }, + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) + class FakeSubscriber(GoogleNestSubscriber): """Fake subscriber that supplies a FakeDeviceManager.""" diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index 4c238683130..a17803a6cde 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -31,6 +31,7 @@ from .common import ( SUBSCRIBER_ID, TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY, + TEST_CONFIG_NEW_SUBSCRIPTION, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, PlatformSetup, @@ -97,6 +98,19 @@ async def test_setup_success( assert entries[0].state is ConfigEntryState.LOADED +@pytest.mark.parametrize("nest_test_config", [(TEST_CONFIG_NEW_SUBSCRIPTION)]) +async def test_setup_success_new_subscription_format( + hass: HomeAssistant, error_caplog: pytest.LogCaptureFixture, setup_platform +) -> None: + """Test successful setup.""" + await setup_platform() + assert not error_caplog.records + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + @pytest.mark.parametrize("subscriber_id", [("invalid-subscriber-format")]) async def test_setup_configuration_failure( hass: HomeAssistant, From 2b7d593ebea7a6c6d7de008f8c8c9218fedd51c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Nov 2024 07:45:16 +0100 Subject: [PATCH 305/511] Avoid collision when replacing existing config entry with same unique id (#130062) --- homeassistant/config_entries.py | 36 ++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a41f4f24701..0d4cc5fd102 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1507,10 +1507,14 @@ class ConfigEntriesFlowManager( version=result["version"], ) + if existing_entry is not None: + # Unload and remove the existing entry + await self.config_entries._async_remove(existing_entry.entry_id) # noqa: SLF001 await self.config_entries.async_add(entry) if existing_entry is not None: - await self.config_entries.async_remove(existing_entry.entry_id) + # Clean up devices and entities belonging to the existing entry + self.config_entries._async_clean_up(existing_entry) # noqa: SLF001 result["result"] = entry return result @@ -1900,7 +1904,21 @@ class ConfigEntries: self._async_schedule_save() async def async_remove(self, entry_id: str) -> dict[str, Any]: - """Remove an entry.""" + """Remove, unload and clean up after an entry.""" + unload_success, entry = await self._async_remove(entry_id) + self._async_clean_up(entry) + + for discovery_domain in entry.discovery_keys: + async_dispatcher_send_internal( + self.hass, + signal_discovered_config_entry_removed(discovery_domain), + entry, + ) + + return {"require_restart": not unload_success} + + async def _async_remove(self, entry_id: str) -> tuple[bool, ConfigEntry]: + """Remove and unload an entry.""" if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry @@ -1916,6 +1934,13 @@ class ConfigEntries: self.async_update_issues() self._async_schedule_save() + return (unload_success, entry) + + @callback + def _async_clean_up(self, entry: ConfigEntry) -> None: + """Clean up after an entry.""" + entry_id = entry.entry_id + dev_reg = device_registry.async_get(self.hass) ent_reg = entity_registry.async_get(self.hass) @@ -1934,13 +1959,6 @@ class ConfigEntries: ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) self._async_dispatch(ConfigEntryChange.REMOVED, entry) - for discovery_domain in entry.discovery_keys: - async_dispatcher_send_internal( - self.hass, - signal_discovered_config_entry_removed(discovery_domain), - entry, - ) - return {"require_restart": not unload_success} @callback def _async_shutdown(self, event: Event) -> None: From d1dab83f10b4781c970b8d7478bf9dfa76cf46cb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 08:22:47 +0100 Subject: [PATCH 306/511] Merge both stun server into one as it's the same server only on a different port (#130019) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/camera/__init__.py | 8 ++++++-- tests/components/camera/test_webrtc.py | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 6d65ea255c7..d31d21d424c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -421,8 +421,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if hass.config.webrtc.ice_servers: return hass.config.webrtc.ice_servers return [ - RTCIceServer(urls="stun:stun.home-assistant.io:80"), - RTCIceServer(urls="stun:stun.home-assistant.io:3478"), + RTCIceServer( + urls=[ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + ), ] async_register_ice_servers(hass, get_ice_servers) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index 7a1df556c20..ba5cf35c52f 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -296,8 +296,12 @@ async def test_ws_get_client_config( assert msg["result"] == { "configuration": { "iceServers": [ - {"urls": "stun:stun.home-assistant.io:80"}, - {"urls": "stun:stun.home-assistant.io:3478"}, + { + "urls": [ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + }, ], }, "getCandidatesUpfront": False, @@ -326,8 +330,12 @@ async def test_ws_get_client_config( assert msg["result"] == { "configuration": { "iceServers": [ - {"urls": "stun:stun.home-assistant.io:80"}, - {"urls": "stun:stun.home-assistant.io:3478"}, + { + "urls": [ + "stun:stun.home-assistant.io:80", + "stun:stun.home-assistant.io:3478", + ] + }, { "urls": ["stun:example2.com", "turn:example2.com"], "username": "user", From fa61e02207d4e92a87aeaab71b04d9d9e4a10700 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Nov 2024 01:36:30 -0600 Subject: [PATCH 307/511] Bump aiohttp to 3.11.0b4 (#130097) --- 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 e2b04c48b30..9b91c338bf6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b3 +aiohttp==3.11.0b4 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a96cb3b405b..4ca6d211788 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b3", + "aiohttp==3.11.0b4", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index ef0a423467a..0902ca9813d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b3 +aiohttp==3.11.0b4 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From ce94073321259d8e0c27ce6ddbc572626170bf36 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Fri, 8 Nov 2024 02:39:41 -0500 Subject: [PATCH 308/511] Bump python-roborock to 2.7.2 (#130100) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- .../roborock/snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 79a9bf77578..c305e4710fc 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.6.1", + "python-roborock==2.7.2", "vacuum-map-parser-roborock==0.1.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9b91c338bf6..f83322e045f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -168,7 +168,7 @@ get-mac==1000000000.0.0 charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x diff --git a/requirements_all.txt b/requirements_all.txt index 3641d949e0d..bc74ea16ce5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2396,7 +2396,7 @@ python-rabbitair==0.0.8 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cc01f44c65..a568f163375 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1917,7 +1917,7 @@ python-picnic-api==1.1.0 python-rabbitair==0.0.8 # homeassistant.components.roborock -python-roborock==2.6.1 +python-roborock==2.7.2 # homeassistant.components.smarttub python-smarttub==0.0.36 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 352b209c5fc..4a340863240 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -183,7 +183,7 @@ get-mac==1000000000.0.0 charset-normalizer==3.4.0 # dacite: Ensure we have a version that is able to handle type unions for -# Roborock, NAM, Brother, and GIOS. +# NAM, Brother, and GIOS. dacite>=1.7.0 # chacha20poly1305-reuseable==0.12.x is incompatible with cryptography==43.0.x diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index 805a498041a..26ecb729312 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -102,6 +102,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -109,6 +110,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -116,6 +118,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -123,6 +126,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -130,6 +134,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -137,6 +142,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -144,6 +150,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -151,6 +158,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -381,6 +389,7 @@ 'id': '120', 'mode': 'ro', 'name': '错误代码', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -388,6 +397,7 @@ 'id': '121', 'mode': 'ro', 'name': '设备状态', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -395,6 +405,7 @@ 'id': '122', 'mode': 'ro', 'name': '设备电量', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -402,6 +413,7 @@ 'id': '123', 'mode': 'rw', 'name': '清扫模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -409,6 +421,7 @@ 'id': '124', 'mode': 'rw', 'name': '拖地模式', + 'property': '{"range": []}', 'type': 'ENUM', }), dict({ @@ -416,6 +429,7 @@ 'id': '125', 'mode': 'rw', 'name': '主刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -423,6 +437,7 @@ 'id': '126', 'mode': 'rw', 'name': '边刷寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ @@ -430,6 +445,7 @@ 'id': '127', 'mode': 'rw', 'name': '滤网寿命', + 'property': '{"max": 100, "min": 0, "step": 1, "unit": null, "scale": 1}', 'type': 'VALUE', }), dict({ From 28832cbd3e9413d9bc4b41bec4a0c93d8cab0072 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Nov 2024 08:46:48 +0100 Subject: [PATCH 309/511] Update frontend to 20241106.1 (#130086) --- 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 2df14df4523..1ac7e661abe 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==20241106.0"] + "requirements": ["home-assistant-frontend==20241106.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f83322e045f..9df83f3bb23 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bc74ea16ce5..99c4191d046 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 # homeassistant.components.conversation home-assistant-intents==2024.11.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a568f163375..5c54380143a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.0 +home-assistant-frontend==20241106.1 # homeassistant.components.conversation home-assistant-intents==2024.11.6 From 3062bad19e5de59e43baccd2644696ffd928752b Mon Sep 17 00:00:00 2001 From: Kelvin Dekker <143089625+KelvinDekker@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:47:02 +0100 Subject: [PATCH 310/511] Fix typo in insteon strings (#130085) --- homeassistant/components/insteon/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index 1464a2dbc8f..4df997ac939 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -112,7 +112,7 @@ "services": { "add_all_link": { "name": "Add all link", - "description": "Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", + "description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", "fields": { "group": { "name": "Group", From 5d5908a03ff6ee5c0c2a20c1133ad1c30c875c98 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 8 Nov 2024 08:47:28 +0100 Subject: [PATCH 311/511] Add missing string to tedee plus test (#130081) --- homeassistant/components/tedee/strings.json | 3 +- tests/components/tedee/test_config_flow.py | 37 +++++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tedee/strings.json b/homeassistant/components/tedee/strings.json index 2dc0e23968c..b6966fa2933 100644 --- a/homeassistant/components/tedee/strings.json +++ b/homeassistant/components/tedee/strings.json @@ -38,7 +38,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "You selected a different bridge than the one this config entry was configured with, this is not allowed." }, "error": { "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index d3654783bd6..2e86286c8da 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -7,10 +7,11 @@ from pytedee_async import ( TedeeDataUpdateException, TedeeLocalAuthException, ) +from pytedee_async.bridge import TedeeBridge import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -134,11 +135,10 @@ async def test_reauth_flow( assert result["reason"] == "reauth_successful" -async def test_reconfigure_flow( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock -) -> None: - """Test that the reconfigure flow works.""" - +async def __do_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" mock_config_entry.add_to_hass(hass) reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) @@ -146,11 +146,19 @@ async def test_reconfigure_flow( assert reconfigure_result["type"] is FlowResultType.FORM assert reconfigure_result["step_id"] == "reconfigure" - result = await hass.config_entries.flow.async_configure( + return await hass.config_entries.flow.async_configure( reconfigure_result["flow_id"], {CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_HOST: "192.168.1.43"}, ) + +async def test_reconfigure_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Test that the reconfigure flow works.""" + + result = await __do_reconfigure_flow(hass, mock_config_entry) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" @@ -162,3 +170,18 @@ async def test_reconfigure_flow( CONF_LOCAL_ACCESS_TOKEN: LOCAL_ACCESS_TOKEN, CONF_WEBHOOK_ID: WEBHOOK_ID, } + + +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tedee: MagicMock +) -> None: + """Ensure reconfigure flow aborts when the bride changes.""" + + mock_tedee.get_local_bridge.return_value = TedeeBridge( + 0, "1111-1111", "Bridge-R2D2" + ) + + result = await __do_reconfigure_flow(hass, mock_config_entry) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" From ed1366f463521723fe4589f62403acdcaff6ea37 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:03:32 +0100 Subject: [PATCH 312/511] Add NASweb integration (#98118) * Add NASweb integration * Fix DeviceInfo import * Remove commented out code * Change class name for uniquness * Drop CoordinatorEntity inheritance * Rename class Output to more descriptive: RelaySwitch * Update required webio-api version * Implement on-the-fly addition/removal of entities * Set coordinator name matching device name * Set entities with too old status as unavailable * Drop Optional in favor of modern typing * Fix spelling of a variable * Rename commons to more fitting name: helper * Remove redundant code * Let unload fail when there is no coordinator * Fix bad docstring * Rename cord to coordinator for clarity * Remove default value for pop and let it raise exception * Drop workaround and use get_url from helper.network * Use webhook to send data from device * Deinitialize coordinator when no longer needed * Use Python formattable string * Use dataclass to store integration data in hass.data * Raise ConfigEntryNotReady when appropriate * Refactor NASwebData class * Move RelaySwitch to switch.py * Fix ConfigFlow tests * Create issues when entry fails to load * Respond when correctly received status update * Depend on webhook instead of http * Create issue when status is not received during entry set up * Make issue_id unique across integration entries * Remove unnecessary initializations * Inherit CoordinatorEntity to avoid code duplication * Optimize property access via assignment in __init__ * Use preexisting mechanism to fill schema with user input * Fix translation strings * Handle unavailable or unreachable internal url * Implement custom coordinator for push driven data updates * Move module-specific constants to respective modules * Fix requirements_all.txt * Fix CODEOWNERS file * Raise ConfigEntryError instead of issue creation * Fix entity registry import * Use HassKey as key in hass.data * Use typed ConfigEntry * Store runtime data in config entry * Rewrite to be more Pythonic * Move add/remove of switch entities to switch.py * Skip unnecessary check * Remove unnecessary type hints * Remove unnecessary nonlocal * Use a more descriptive docstring * Add docstrings to NASwebCoordinator * Fix formatting * Use correct return type * Fix tests to align with changed code * Remove commented code * Use serial number as config entry id * Catch AbortFlow exception * Update tests to check ConfigEntry Unique ID * Remove unnecessary form abort --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/nasweb/__init__.py | 125 +++++++++++ .../components/nasweb/config_flow.py | 137 ++++++++++++ homeassistant/components/nasweb/const.py | 7 + .../components/nasweb/coordinator.py | 191 ++++++++++++++++ homeassistant/components/nasweb/manifest.json | 14 ++ .../components/nasweb/nasweb_data.py | 64 ++++++ homeassistant/components/nasweb/strings.json | 50 +++++ homeassistant/components/nasweb/switch.py | 133 +++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nasweb/__init__.py | 1 + tests/components/nasweb/conftest.py | 61 +++++ tests/components/nasweb/test_config_flow.py | 208 ++++++++++++++++++ 18 files changed, 1017 insertions(+) create mode 100644 homeassistant/components/nasweb/__init__.py create mode 100644 homeassistant/components/nasweb/config_flow.py create mode 100644 homeassistant/components/nasweb/const.py create mode 100644 homeassistant/components/nasweb/coordinator.py create mode 100644 homeassistant/components/nasweb/manifest.json create mode 100644 homeassistant/components/nasweb/nasweb_data.py create mode 100644 homeassistant/components/nasweb/strings.json create mode 100644 homeassistant/components/nasweb/switch.py create mode 100644 tests/components/nasweb/__init__.py create mode 100644 tests/components/nasweb/conftest.py create mode 100644 tests/components/nasweb/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index 6a6918543ad..a980c0901d0 100644 --- a/.strict-typing +++ b/.strict-typing @@ -330,6 +330,7 @@ homeassistant.components.mysensors.* homeassistant.components.myuplink.* homeassistant.components.nam.* homeassistant.components.nanoleaf.* +homeassistant.components.nasweb.* homeassistant.components.neato.* homeassistant.components.nest.* homeassistant.components.netatmo.* diff --git a/CODEOWNERS b/CODEOWNERS index d039097fc82..e41267860d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -970,6 +970,8 @@ build.json @home-assistant/supervisor /tests/components/nam/ @bieniu /homeassistant/components/nanoleaf/ @milanmeu @joostlek /tests/components/nanoleaf/ @milanmeu @joostlek +/homeassistant/components/nasweb/ @nasWebio +/tests/components/nasweb/ @nasWebio /homeassistant/components/neato/ @Santobert /tests/components/neato/ @Santobert /homeassistant/components/nederlandse_spoorwegen/ @YarmoM diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py new file mode 100644 index 00000000000..1992cc41c75 --- /dev/null +++ b/homeassistant/components/nasweb/__init__.py @@ -0,0 +1,125 @@ +"""The NASweb integration.""" + +from __future__ import annotations + +import logging + +from webio_api import WebioAPI +from webio_api.api_client import AuthError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.network import NoURLAvailableError +from homeassistant.util.hass_dict import HassKey + +from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL +from .coordinator import NASwebCoordinator +from .nasweb_data import NASwebData + +PLATFORMS: list[Platform] = [Platform.SWITCH] + +NASWEB_CONFIG_URL = "https://{host}/page" + +_LOGGER = logging.getLogger(__name__) +type NASwebConfigEntry = ConfigEntry[NASwebCoordinator] +DATA_NASWEB: HassKey[NASwebData] = HassKey(DOMAIN) + + +async def async_setup_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bool: + """Set up NASweb from a config entry.""" + + if DATA_NASWEB not in hass.data: + data = NASwebData() + data.initialize(hass) + hass.data[DATA_NASWEB] = data + nasweb_data = hass.data[DATA_NASWEB] + + webio_api = WebioAPI( + entry.data[CONF_HOST], entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] + ) + try: + if not await webio_api.check_connection(): + raise ConfigEntryNotReady( + f"[{entry.data[CONF_HOST]}] Check connection failed" + ) + if not await webio_api.refresh_device_info(): + _LOGGER.error("[%s] Refresh device info failed", entry.data[CONF_HOST]) + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + webio_serial = webio_api.get_serial_number() + if webio_serial is None: + _LOGGER.error("[%s] Serial number not available", entry.data[CONF_HOST]) + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + if entry.unique_id != webio_serial: + _LOGGER.error( + "[%s] Serial number doesn't match config entry", entry.data[CONF_HOST] + ) + raise ConfigEntryError(translation_key="config_entry_error_serial_mismatch") + + coordinator = NASwebCoordinator( + hass, webio_api, name=f"NASweb[{webio_api.get_name()}]" + ) + entry.runtime_data = coordinator + nasweb_data.notify_coordinator.add_coordinator(webio_serial, entry.runtime_data) + + webhook_url = nasweb_data.get_webhook_url(hass) + if not await webio_api.status_subscription(webhook_url, True): + _LOGGER.error("Failed to subscribe for status updates from webio") + raise ConfigEntryError( + translation_key="config_entry_error_internal_error", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + if not await nasweb_data.notify_coordinator.check_connection(webio_serial): + _LOGGER.error("Did not receive status from device") + raise ConfigEntryError( + translation_key="config_entry_error_no_status_update", + translation_placeholders={"support_email": SUPPORT_EMAIL}, + ) + except TimeoutError as error: + raise ConfigEntryNotReady( + f"[{entry.data[CONF_HOST]}] Check connection reached timeout" + ) from error + except AuthError as error: + raise ConfigEntryError( + translation_key="config_entry_error_invalid_authentication" + ) from error + except NoURLAvailableError as error: + raise ConfigEntryError( + translation_key="config_entry_error_missing_internal_url" + ) from error + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, webio_serial)}, + manufacturer=MANUFACTURER, + name=webio_api.get_name(), + configuration_url=NASWEB_CONFIG_URL.format(host=entry.data[CONF_HOST]), + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NASwebConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + nasweb_data = hass.data[DATA_NASWEB] + coordinator = entry.runtime_data + serial = entry.unique_id + if serial is not None: + nasweb_data.notify_coordinator.remove_coordinator(serial) + if nasweb_data.can_be_deinitialized(): + nasweb_data.deinitialize(hass) + hass.data.pop(DATA_NASWEB) + webhook_url = nasweb_data.get_webhook_url(hass) + await coordinator.webio_api.status_subscription(webhook_url, False) + + return unload_ok diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py new file mode 100644 index 00000000000..3a9ad3f7d49 --- /dev/null +++ b/homeassistant/components/nasweb/config_flow.py @@ -0,0 +1,137 @@ +"""Config flow for NASweb integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol +from webio_api import WebioAPI +from webio_api.api_client import AuthError + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.network import NoURLAvailableError + +from .const import DOMAIN +from .coordinator import NASwebCoordinator +from .nasweb_data import NASwebData + +NASWEB_SCHEMA_IMG_URL = ( + "https://home-assistant.io/images/integrations/nasweb/nasweb_scheme.png" +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate user-provided data.""" + webio_api = WebioAPI(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + if not await webio_api.check_connection(): + raise CannotConnect + try: + await webio_api.refresh_device_info() + except AuthError as e: + raise InvalidAuth from e + + nasweb_data = NASwebData() + nasweb_data.initialize(hass) + try: + webio_serial = webio_api.get_serial_number() + if webio_serial is None: + raise MissingNASwebData("Device serial number is not available") + + coordinator = NASwebCoordinator(hass, webio_api) + webhook_url = nasweb_data.get_webhook_url(hass) + nasweb_data.notify_coordinator.add_coordinator(webio_serial, coordinator) + subscription = await webio_api.status_subscription(webhook_url, True) + if not subscription: + nasweb_data.notify_coordinator.remove_coordinator(webio_serial) + raise MissingNASwebData( + "Failed to subscribe for status updates from device" + ) + + result = await nasweb_data.notify_coordinator.check_connection(webio_serial) + nasweb_data.notify_coordinator.remove_coordinator(webio_serial) + if not result: + if subscription: + await webio_api.status_subscription(webhook_url, False) + raise MissingNASwebStatus("Did not receive status from device") + + name = webio_api.get_name() + finally: + nasweb_data.deinitialize(hass) + return {"title": name, CONF_UNIQUE_ID: webio_serial} + + +class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NASweb.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + await self.async_set_unique_id(info[CONF_UNIQUE_ID]) + self._abort_if_unique_id_configured() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except NoURLAvailableError: + errors["base"] = "missing_internal_url" + except MissingNASwebData: + errors["base"] = "missing_nasweb_data" + except MissingNASwebStatus: + errors["base"] = "missing_status" + except AbortFlow: + raise + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + description_placeholders={ + "nasweb_schema_img": '
', + }, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class MissingNASwebData(HomeAssistantError): + """Error to indicate missing information from NASweb.""" + + +class MissingNASwebStatus(HomeAssistantError): + """Error to indicate there was no status received from NASweb.""" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py new file mode 100644 index 00000000000..ec750c90c8c --- /dev/null +++ b/homeassistant/components/nasweb/const.py @@ -0,0 +1,7 @@ +"""Constants for the NASweb integration.""" + +DOMAIN = "nasweb" +MANUFACTURER = "chomtech.pl" +STATUS_UPDATE_MAX_TIME_INTERVAL = 60 +SUPPORT_EMAIL = "support@chomtech.eu" +WEBHOOK_URL = "{internal_url}/api/webhook/{webhook_id}" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py new file mode 100644 index 00000000000..90dca0f3022 --- /dev/null +++ b/homeassistant/components/nasweb/coordinator.py @@ -0,0 +1,191 @@ +"""Message routing coordinators for handling NASweb push notifications.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from datetime import datetime, timedelta +import logging +import time +from typing import Any + +from aiohttp.web import Request, Response +from webio_api import WebioAPI +from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE + +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import event +from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol + +from .const import STATUS_UPDATE_MAX_TIME_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class NotificationCoordinator: + """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" + + def __init__(self) -> None: + """Initialize coordinator.""" + self._coordinators: dict[str, NASwebCoordinator] = {} + + def add_coordinator(self, serial: str, coordinator: NASwebCoordinator) -> None: + """Add NASwebCoordinator to possible notification targets.""" + self._coordinators[serial] = coordinator + _LOGGER.debug("Added NASwebCoordinator for NASweb[%s]", serial) + + def remove_coordinator(self, serial: str) -> None: + """Remove NASwebCoordinator from possible notification targets.""" + self._coordinators.pop(serial) + _LOGGER.debug("Removed NASwebCoordinator for NASweb[%s]", serial) + + def has_coordinators(self) -> bool: + """Check if there is any registered coordinator for push notifications.""" + return len(self._coordinators) > 0 + + async def check_connection(self, serial: str) -> bool: + """Wait for first status update to confirm connection with NASweb.""" + nasweb_coordinator = self._coordinators.get(serial) + if nasweb_coordinator is None: + _LOGGER.error("Cannot check connection. No device match serial number") + return False + for counter in range(10): + _LOGGER.debug("Checking connection with: %s (%s)", serial, counter) + if nasweb_coordinator.is_connection_confirmed(): + return True + await asyncio.sleep(1) + return False + + async def handle_webhook_request( + self, hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + """Handle webhook request from Push API.""" + if not self.has_coordinators(): + return None + notification = await request.json() + serial = notification.get(KEY_DEVICE_SERIAL, None) + _LOGGER.debug("Received push: %s", notification) + if serial is None: + _LOGGER.warning("Received notification without nasweb identifier") + return None + nasweb_coordinator = self._coordinators.get(serial) + if nasweb_coordinator is None: + _LOGGER.warning("Received notification for not registered nasweb") + return None + await nasweb_coordinator.handle_push_notification(notification) + return Response(body='{"response": "ok"}', content_type="application/json") + + +class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): + """Coordinator managing status of single NASweb device. + + Since status updates are managed through push notifications, this class schedules + periodic checks to ensure that devices are marked unavailable if updates + haven't been received for a prolonged period. + """ + + def __init__( + self, hass: HomeAssistant, webio_api: WebioAPI, name: str = "NASweb[default]" + ) -> None: + """Initialize NASweb coordinator.""" + self._hass = hass + self.name = name + self.webio_api = webio_api + self._last_update: float | None = None + job_name = f"NASwebCoordinator[{name}]" + self._job = HassJob(self._handle_max_update_interval, job_name) + self._unsub_last_update_check: CALLBACK_TYPE | None = None + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + data: dict[str, Any] = {} + data[KEY_OUTPUTS] = self.webio_api.outputs + self.async_set_updated_data(data) + + def is_connection_confirmed(self) -> bool: + """Check whether coordinator received status update from NASweb.""" + return self._last_update is not None + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + schedule_update_check = not self._listeners + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self._async_unsub_last_update_check() + + self._listeners[remove_listener] = (update_callback, context) + # This is the first listener, set up interval. + if schedule_update_check: + self._schedule_last_update_check() + return remove_listener + + @callback + def async_set_updated_data(self, data: dict[str, Any]) -> None: + """Update data and notify listeners.""" + self.data = data + self.last_update = self._hass.loop.time() + _LOGGER.debug("Updated %s data", self.name) + if self._listeners: + self._schedule_last_update_check() + self.async_update_listeners() + + @callback + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() + + async def _handle_max_update_interval(self, now: datetime) -> None: + """Handle max update interval occurrence. + + This method is called when `STATUS_UPDATE_MAX_TIME_INTERVAL` has passed without + receiving a status update. It only needs to trigger state update of entities + which then change their state accordingly. + """ + self._unsub_last_update_check = None + if self._listeners: + self.async_update_listeners() + + def _schedule_last_update_check(self) -> None: + """Schedule a task to trigger entities state update after `STATUS_UPDATE_MAX_TIME_INTERVAL`. + + This method schedules a task (`_handle_max_update_interval`) to be executed after + `STATUS_UPDATE_MAX_TIME_INTERVAL` seconds without status update, which enables entities + to change their state to unavailable. After each status update this task is rescheduled. + """ + self._async_unsub_last_update_check() + now = self._hass.loop.time() + next_check = ( + now + timedelta(seconds=STATUS_UPDATE_MAX_TIME_INTERVAL).total_seconds() + ) + self._unsub_last_update_check = event.async_call_at( + self._hass, + self._job, + next_check, + ) + + def _async_unsub_last_update_check(self) -> None: + """Cancel any scheduled update check call.""" + if self._unsub_last_update_check: + self._unsub_last_update_check() + self._unsub_last_update_check = None + + async def handle_push_notification(self, notification: dict) -> None: + """Handle incoming push notification from NASweb.""" + msg_type = notification.get(KEY_TYPE) + _LOGGER.debug("Received push notification: %s", msg_type) + + if msg_type == TYPE_STATUS_UPDATE: + await self.process_status_update(notification) + self._last_update = time.time() + + async def process_status_update(self, new_status: dict) -> None: + """Process status update from NASweb.""" + self.webio_api.update_device_status(new_status) + new_data = {KEY_OUTPUTS: self.webio_api.outputs} + self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/manifest.json b/homeassistant/components/nasweb/manifest.json new file mode 100644 index 00000000000..e7e06419dad --- /dev/null +++ b/homeassistant/components/nasweb/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "nasweb", + "name": "NASweb", + "codeowners": ["@nasWebio"], + "config_flow": true, + "dependencies": ["webhook"], + "documentation": "https://www.home-assistant.io/integrations/nasweb", + "homekit": {}, + "integration_type": "hub", + "iot_class": "local_push", + "requirements": ["webio-api==0.1.8"], + "ssdp": [], + "zeroconf": [] +} diff --git a/homeassistant/components/nasweb/nasweb_data.py b/homeassistant/components/nasweb/nasweb_data.py new file mode 100644 index 00000000000..4f6a37e6cc7 --- /dev/null +++ b/homeassistant/components/nasweb/nasweb_data.py @@ -0,0 +1,64 @@ +"""Dataclass storing integration data in hass.data[DOMAIN].""" + +from dataclasses import dataclass, field +import logging + +from aiohttp.hdrs import METH_POST + +from homeassistant.components.webhook import ( + async_generate_id, + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import get_url + +from .const import DOMAIN, WEBHOOK_URL +from .coordinator import NotificationCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class NASwebData: + """Class storing integration data.""" + + notify_coordinator: NotificationCoordinator = field( + default_factory=NotificationCoordinator + ) + webhook_id = "" + + def is_initialized(self) -> bool: + """Return True if instance was initialized and is ready for use.""" + return bool(self.webhook_id) + + def can_be_deinitialized(self) -> bool: + """Return whether this instance can be deinitialized.""" + return not self.notify_coordinator.has_coordinators() + + def initialize(self, hass: HomeAssistant) -> None: + """Initialize NASwebData instance.""" + if self.is_initialized(): + return + new_webhook_id = async_generate_id() + webhook_register( + hass, + DOMAIN, + "NASweb", + new_webhook_id, + self.notify_coordinator.handle_webhook_request, + allowed_methods=[METH_POST], + ) + self.webhook_id = new_webhook_id + _LOGGER.debug("Registered webhook: %s", self.webhook_id) + + def deinitialize(self, hass: HomeAssistant) -> None: + """Deinitialize NASwebData instance.""" + if not self.is_initialized(): + return + webhook_unregister(hass, self.webhook_id) + + def get_webhook_url(self, hass: HomeAssistant) -> str: + """Return webhook url for Push API.""" + hass_url = get_url(hass, allow_external=False) + return WEBHOOK_URL.format(internal_url=hass_url, webhook_id=self.webhook_id) diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json new file mode 100644 index 00000000000..b8af8cd54db --- /dev/null +++ b/homeassistant/components/nasweb/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "step": { + "user": { + "title": "Add NASweb device", + "description": "{nasweb_schema_img}NASweb combines the functions of a control panel and the ability to manage building automation. The device monitors the flow of information from sensors and programmable switches and stores settings, definitions and configured actions.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_internal_url": "Make sure Home Assistant has valid internal url", + "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant.", + "missing_status": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "exceptions": { + "config_entry_error_invalid_authentication": { + "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create new one with correct username/password." + }, + "config_entry_error_internal_error": { + "message": "Something isn't right with device internal configuration. Try restarting the device and HomeAssistant. If the issue persists contact support at {support_email}" + }, + "config_entry_error_no_status_update": { + "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant Internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + }, + "config_entry_error_missing_internal_url": { + "message": "[%key:component::nasweb::config::error::missing_internal_url%]" + }, + "serial_mismatch": { + "message": "Connected to different NASweb device (serial number mismatch)." + } + }, + "entity": { + "switch": { + "switch_output": { + "name": "Relay Switch {index}" + } + } + } +} diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py new file mode 100644 index 00000000000..00e5a21da18 --- /dev/null +++ b/homeassistant/components/nasweb/switch.py @@ -0,0 +1,133 @@ +"""Platform for NASweb output.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from webio_api import Output as NASwebOutput + +from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL +from .coordinator import NASwebCoordinator + +OUTPUT_TRANSLATION_KEY = "switch_output" + +_LOGGER = logging.getLogger(__name__) + + +def _get_output(coordinator: NASwebCoordinator, index: int) -> NASwebOutput | None: + for out in coordinator.webio_api.outputs: + if out.index == index: + return out + return None + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up switch platform.""" + coordinator = config.runtime_data + current_outputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_outputs = {out.index for out in coordinator.webio_api.outputs} + added = {i for i in received_outputs if i not in current_outputs} + removed = {i for i in current_outputs if i not in received_outputs} + entities_to_add: list[RelaySwitch] = [] + for index in added: + webio_output = _get_output(coordinator, index) + if not isinstance(webio_output, NASwebOutput): + _LOGGER.error("Cannot create RelaySwitch entity without NASwebOutput") + continue + new_output = RelaySwitch(coordinator, webio_output) + entities_to_add.append(new_output) + current_outputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + for index in removed: + unique_id = f"{DOMAIN}.{config.unique_id}.relay_switch.{index}" + if entity_id := entity_registry.async_get_entity_id( + DOMAIN_SWITCH, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + current_outputs.remove(index) + else: + _LOGGER.warning("Failed to remove old output: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + +class RelaySwitch(SwitchEntity, BaseCoordinatorEntity): + """Entity representing NASweb Output.""" + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_output: NASwebOutput, + ) -> None: + """Initialize RelaySwitch.""" + super().__init__(coordinator) + self._output = nasweb_output + self._attr_icon = "mdi:export" + self._attr_has_entity_name = True + self._attr_translation_key = OUTPUT_TRANSLATION_KEY + self._attr_translation_placeholders = {"index": f"{nasweb_output.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._output.webio_serial}.relay_switch.{self._output.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._output.webio_serial)}, + ) + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self._output.state + if ( + self.coordinator.last_update is None + or time.time() - self._output.last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = ( + self._output.available if self._output.available is not None else False + ) + self.async_write_ha_state() + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn On RelaySwitch.""" + await self._output.turn_on() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn Off RelaySwitch.""" + await self._output.turn_off() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 923b2ec1606..887fb99a092 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -391,6 +391,7 @@ FLOWS = { "myuplink", "nam", "nanoleaf", + "nasweb", "neato", "nest", "netatmo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 449d36da474..14b8550d296 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4016,6 +4016,12 @@ "config_flow": true, "iot_class": "local_push" }, + "nasweb": { + "name": "NASweb", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "neato": { "name": "Neato Botvac", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index c851e586246..15d1777f381 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3056,6 +3056,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nasweb.*] +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.neato.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 99c4191d046..627d9937995 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2977,6 +2977,9 @@ weatherflow4py==1.0.6 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 +# homeassistant.components.nasweb +webio-api==0.1.8 + # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c54380143a..b726627f1d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2372,6 +2372,9 @@ watchdog==2.3.1 # homeassistant.components.weatherflow_cloud weatherflow4py==1.0.6 +# homeassistant.components.nasweb +webio-api==0.1.8 + # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/tests/components/nasweb/__init__.py b/tests/components/nasweb/__init__.py new file mode 100644 index 00000000000..d4906d710d5 --- /dev/null +++ b/tests/components/nasweb/__init__.py @@ -0,0 +1 @@ +"""Tests for the NASweb integration.""" diff --git a/tests/components/nasweb/conftest.py b/tests/components/nasweb/conftest.py new file mode 100644 index 00000000000..7757f40ee44 --- /dev/null +++ b/tests/components/nasweb/conftest.py @@ -0,0 +1,61 @@ +"""Common fixtures for the NASweb tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nasweb.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +BASE_CONFIG_FLOW = "homeassistant.components.nasweb.config_flow." +BASE_NASWEB_DATA = "homeassistant.components.nasweb.nasweb_data." +BASE_COORDINATOR = "homeassistant.components.nasweb.coordinator." +TEST_SERIAL_NUMBER = "0011223344556677" + + +@pytest.fixture +def validate_input_all_ok() -> Generator[dict[str, AsyncMock | MagicMock]]: + """Yield dictionary of mocked functions required for successful test_form execution.""" + with ( + patch( + BASE_CONFIG_FLOW + "WebioAPI.check_connection", + return_value=True, + ) as check_connection, + patch( + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info", + return_value=True, + ) as refresh_device_info, + patch( + BASE_NASWEB_DATA + "NASwebData.get_webhook_url", + return_value="http://127.0.0.1:8123/api/webhook/de705e77291402afa0dd961426e9f19bb53631a9f2a106c52cfd2d2266913c04", + ) as get_webhook_url, + patch( + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number", + return_value=TEST_SERIAL_NUMBER, + ) as get_serial, + patch( + BASE_CONFIG_FLOW + "WebioAPI.status_subscription", + return_value=True, + ) as status_subscription, + patch( + BASE_NASWEB_DATA + "NotificationCoordinator.check_connection", + return_value=True, + ) as check_status_confirmation, + ): + yield { + BASE_CONFIG_FLOW + "WebioAPI.check_connection": check_connection, + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info": refresh_device_info, + BASE_NASWEB_DATA + "NASwebData.get_webhook_url": get_webhook_url, + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number": get_serial, + BASE_CONFIG_FLOW + "WebioAPI.status_subscription": status_subscription, + BASE_NASWEB_DATA + + "NotificationCoordinator.check_connection": check_status_confirmation, + } diff --git a/tests/components/nasweb/test_config_flow.py b/tests/components/nasweb/test_config_flow.py new file mode 100644 index 00000000000..a5f2dca680d --- /dev/null +++ b/tests/components/nasweb/test_config_flow.py @@ -0,0 +1,208 @@ +"""Test the NASweb config flow.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from webio_api.api_client import AuthError + +from homeassistant import config_entries +from homeassistant.components.nasweb.const import DOMAIN +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.network import NoURLAvailableError + +from .conftest import ( + BASE_CONFIG_FLOW, + BASE_COORDINATOR, + BASE_NASWEB_DATA, + TEST_SERIAL_NUMBER, +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +TEST_USER_INPUT = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + + +async def _add_test_config_entry(hass: HomeAssistant) -> ConfigFlowResult: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + return result2 + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test the form.""" + result = await _add_test_config_entry(hass) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "1.1.1.1" + assert result.get("data") == TEST_USER_INPUT + + config_entry = result.get("result") + assert config_entry is not None + assert config_entry.unique_id == TEST_SERIAL_NUMBER + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch(BASE_CONFIG_FLOW + "WebioAPI.check_connection", return_value=False): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +async def test_form_invalid_auth( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_CONFIG_FLOW + "WebioAPI.refresh_device_info", + side_effect=AuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_auth"} + + +async def test_form_missing_internal_url( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test missing internal url.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_NASWEB_DATA + "NASwebData.get_webhook_url", side_effect=NoURLAvailableError + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_internal_url"} + + +async def test_form_missing_nasweb_data( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_CONFIG_FLOW + "WebioAPI.get_serial_number", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_nasweb_data"} + with patch(BASE_CONFIG_FLOW + "WebioAPI.status_subscription", return_value=False): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_nasweb_data"} + + +async def test_missing_status( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test missing status update.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + BASE_COORDINATOR + "NotificationCoordinator.check_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "missing_status"} + + +async def test_form_exception( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test other exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nasweb.config_flow.validate_input", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_form_already_configured( + hass: HomeAssistant, + validate_input_all_ok: dict[str, AsyncMock | MagicMock], +) -> None: + """Test already configured device.""" + result = await _add_test_config_entry(hass) + config_entry = result.get("result") + assert config_entry is not None + assert config_entry.unique_id == TEST_SERIAL_NUMBER + + result2_1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2_2 = await hass.config_entries.flow.async_configure( + result2_1["flow_id"], TEST_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2_2.get("type") == FlowResultType.ABORT + assert result2_2.get("reason") == "already_configured" From e3dfa84d6503ba7534d9a3294c55898dfd318696 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Fri, 8 Nov 2024 12:06:29 +0100 Subject: [PATCH 313/511] Bang & Olufsen add beolink grouping (#113438) * Add Beolink custom services Add support for media player grouping via beolink Give media player entity name * Fix progress not being set to None as Beolink listener Revert naming changes * Update API simplify Beolink attributes * Improve beolink custom services * Fix Beolink expandable source check Add unexpand return value Set entity name on initialization * Handle entity naming as intended * Fix "null" Beolink self friendly name * Add regex service input validation Add all_discovered to beolink_expand service Improve beolink_expand response * Add service icons * Fix merge Remove unnecessary assignment * Remove invalid typing Update response typing for updated API * Revert to old typed response dict method Remove mypy ignore line Fix jid possibly used before assignment * Re add debugging logging * Fix coroutine Fix formatting * Remove unnecessary update control * Make tests pass Fix remote leader media position bug Improve remote leader BangOlufsenSource comparison * Fix naming and add callback decorators * Move regex service check to variable Suppress KeyError Update tests * Re-add hass running check * Improve comments, naming and type hinting * Remove old temporary fix * Convert logged warning to raised exception for invalid media_player Simplify code using walrus operator * Fix test for invalid media_player grouping * Improve method naming * Improve _beolink_sources explanation * Improve _beolink_sources explanation * Fix tests * Remove service responses Fix and add tests * Change service to action where applicable * Show playback progress for listeners * Fix testing * Remove useless initialization * Fix allstandby name * Fix various casts with assertions Fix comment placement Fix group leader group_members rebase error Replace entity_id method call with attribute * Add syrupy snapshots for Beolink tests, checking entity states Use test JIDs 3 and 4 instead of 2 and 3 to avoid invalid attributes in testing * Add sections for fields using Beolink JIDs directly * Fix typo * FIx rebase mistake * Sort actions alphabetically --- .../components/bang_olufsen/icons.json | 9 + .../components/bang_olufsen/media_player.py | 189 +++- .../components/bang_olufsen/services.yaml | 79 ++ .../components/bang_olufsen/strings.json | 66 ++ .../components/bang_olufsen/websocket.py | 5 + tests/components/bang_olufsen/conftest.py | 26 +- .../snapshots/test_media_player.ambr | 874 ++++++++++++++++++ tests/components/bang_olufsen/test_init.py | 5 +- .../bang_olufsen/test_media_player.py | 271 +++++- 9 files changed, 1487 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/bang_olufsen/icons.json create mode 100644 homeassistant/components/bang_olufsen/services.yaml create mode 100644 tests/components/bang_olufsen/snapshots/test_media_player.ambr diff --git a/homeassistant/components/bang_olufsen/icons.json b/homeassistant/components/bang_olufsen/icons.json new file mode 100644 index 00000000000..fec0bf20937 --- /dev/null +++ b/homeassistant/components/bang_olufsen/icons.json @@ -0,0 +1,9 @@ +{ + "services": { + "beolink_join": { "service": "mdi:location-enter" }, + "beolink_expand": { "service": "mdi:location-enter" }, + "beolink_unexpand": { "service": "mdi:location-exit" }, + "beolink_leave": { "service": "mdi:close-circle-outline" }, + "beolink_allstandby": { "service": "mdi:close-circle-multiple-outline" } + } +} diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index e8108ee2cf7..5dd45573672 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any, cast from aiohttp import ClientConnectorError from mozart_api import __version__ as MOZART_API_VERSION -from mozart_api.exceptions import ApiException +from mozart_api.exceptions import ApiException, NotFoundException from mozart_api.models import ( Action, Art, @@ -38,6 +38,7 @@ from mozart_api.models import ( VolumeState, ) from mozart_api.mozart_client import MozartClient, get_highest_resolution_artwork +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -55,10 +56,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_current_platform, +) from homeassistant.util.dt import utcnow from . import BangOlufsenConfigEntry @@ -116,6 +124,58 @@ async def async_setup_entry( ] ) + # Register actions. + platform = async_get_current_platform() + + jid_regex = vol.Match( + r"(^\d{4})[.](\d{7})[.](\d{8})(@products\.bang-olufsen\.com)$" + ) + + platform.async_register_entity_service( + name="beolink_join", + schema={vol.Optional("beolink_jid"): jid_regex}, + func="async_beolink_join", + ) + + platform.async_register_entity_service( + name="beolink_expand", + schema={ + vol.Exclusive("all_discovered", "devices", ""): cv.boolean, + vol.Exclusive( + "beolink_jids", + "devices", + "Define either specific Beolink JIDs or all discovered", + ): vol.All( + cv.ensure_list, + [jid_regex], + ), + }, + func="async_beolink_expand", + ) + + platform.async_register_entity_service( + name="beolink_unexpand", + schema={ + vol.Required("beolink_jids"): vol.All( + cv.ensure_list, + [jid_regex], + ), + }, + func="async_beolink_unexpand", + ) + + platform.async_register_entity_service( + name="beolink_leave", + schema=None, + func="async_beolink_leave", + ) + + platform.async_register_entity_service( + name="beolink_allstandby", + schema=None, + func="async_beolink_allstandby", + ) + class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): """Representation of a media player.""" @@ -156,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} self._remote_leader: BeolinkLeader | None = None + # Extra state attributes for showing Beolink: peer(s), listener(s), leader and self + self._beolink_attributes: dict[str, dict[str, dict[str, str]]] = {} async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" @@ -165,6 +227,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): CONNECTION_STATUS: self._async_update_connection_state, WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.BEOLINK: self._async_update_beolink, + WebsocketNotification.CONFIGURATION: self._async_update_name_and_beolink, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, @@ -230,6 +293,9 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): await self._async_update_sound_modes() + # Update beolink attributes and device name. + await self._async_update_name_and_beolink() + async def async_update(self) -> None: """Update queue settings.""" # The WebSocket event listener is the main handler for connection state. @@ -372,9 +438,44 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self.async_write_ha_state() + async def _async_update_name_and_beolink(self) -> None: + """Update the device friendly name.""" + beolink_self = await self._client.get_beolink_self() + + # Update device name + device_registry = dr.async_get(self.hass) + assert self.device_entry is not None + + device_registry.async_update_device( + device_id=self.device_entry.id, + name=beolink_self.friendly_name, + ) + + await self._async_update_beolink() + async def _async_update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" + self._beolink_attributes = {} + + assert self.device_entry is not None + assert self.device_entry.name is not None + + # Add Beolink self + self._beolink_attributes = { + "beolink": {"self": {self.device_entry.name: self._beolink_jid}} + } + + # Add Beolink peers + peers = await self._client.get_beolink_peers() + + if len(peers) > 0: + self._beolink_attributes["beolink"]["peers"] = {} + for peer in peers: + self._beolink_attributes["beolink"]["peers"][peer.friendly_name] = ( + peer.jid + ) + # Add Beolink listeners / leader self._remote_leader = self._playback_metadata.remote_leader @@ -394,9 +495,14 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Add self group_members.append(self.entity_id) + self._beolink_attributes["beolink"]["leader"] = { + self._remote_leader.friendly_name: self._remote_leader.jid, + } + # If not listener, check if leader. else: beolink_listeners = await self._client.get_beolink_listeners() + beolink_listeners_attribute = {} # Check if the device is a leader. if len(beolink_listeners) > 0: @@ -417,6 +523,18 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): for beolink_listener in beolink_listeners ] ) + # Update Beolink attributes + for beolink_listener in beolink_listeners: + for peer in peers: + if peer.jid == beolink_listener.jid: + # Get the friendly names for the listeners from the peers + beolink_listeners_attribute[peer.friendly_name] = ( + beolink_listener.jid + ) + break + self._beolink_attributes["beolink"]["listeners"] = ( + beolink_listeners_attribute + ) self._attr_group_members = group_members @@ -602,6 +720,17 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): return self._source_change.name + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return information that is not returned anywhere else.""" + attributes: dict[str, Any] = {} + + # Add Beolink attributes + if self._beolink_attributes: + attributes.update(self._beolink_attributes) + + return attributes + async def async_turn_off(self) -> None: """Set the device to "networkStandby".""" await self._client.post_standby() @@ -873,23 +1002,30 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Beolink compatible B&O device. # Repeated presses / calls will cycle between compatible playing devices. if len(group_members) == 0: - await self._async_beolink_join() + await self.async_beolink_join() return # Get JID for each group member jids = [self._get_beolink_jid(group_member) for group_member in group_members] - await self._async_beolink_expand(jids) + await self.async_beolink_expand(jids) async def async_unjoin_player(self) -> None: """Unjoin Beolink session. End session if leader.""" - await self._async_beolink_leave() + await self.async_beolink_leave() - async def _async_beolink_join(self) -> None: + # Custom actions: + async def async_beolink_join(self, beolink_jid: str | None = None) -> None: """Join a Beolink multi-room experience.""" - await self._client.join_latest_beolink_experience() + if beolink_jid is None: + await self._client.join_latest_beolink_experience() + else: + await self._client.join_beolink_peer(jid=beolink_jid) - async def _async_beolink_expand(self, beolink_jids: list[str]) -> None: + async def async_beolink_expand( + self, beolink_jids: list[str] | None = None, all_discovered: bool = False + ) -> None: """Expand a Beolink multi-room experience with a device or devices.""" + # Ensure that the current source is expandable if not self._beolink_sources[cast(str, self._source_change.id)]: raise ServiceValidationError( @@ -901,10 +1037,37 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): }, ) - # Try to expand to all defined devices - for beolink_jid in beolink_jids: - await self._client.post_beolink_expand(jid=beolink_jid) + # Expand to all discovered devices + if all_discovered: + peers = await self._client.get_beolink_peers() - async def _async_beolink_leave(self) -> None: + for peer in peers: + try: + await self._client.post_beolink_expand(jid=peer.jid) + except NotFoundException: + _LOGGER.warning("Unable to expand to %s", peer.jid) + + # Try to expand to all defined devices + elif beolink_jids: + for beolink_jid in beolink_jids: + try: + await self._client.post_beolink_expand(jid=beolink_jid) + except NotFoundException: + _LOGGER.warning( + "Unable to expand to %s. Is the device available on the network?", + beolink_jid, + ) + + async def async_beolink_unexpand(self, beolink_jids: list[str]) -> None: + """Unexpand a Beolink multi-room experience with a device or devices.""" + # Unexpand all defined devices + for beolink_jid in beolink_jids: + await self._client.post_beolink_unexpand(jid=beolink_jid) + + async def async_beolink_leave(self) -> None: """Leave the current Beolink experience.""" await self._client.post_beolink_leave() + + async def async_beolink_allstandby(self) -> None: + """Set all connected Beolink devices to standby.""" + await self._client.post_beolink_allstandby() diff --git a/homeassistant/components/bang_olufsen/services.yaml b/homeassistant/components/bang_olufsen/services.yaml new file mode 100644 index 00000000000..e5d61420dff --- /dev/null +++ b/homeassistant/components/bang_olufsen/services.yaml @@ -0,0 +1,79 @@ +beolink_allstandby: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + +beolink_expand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + all_discovered: + required: false + example: false + selector: + boolean: + jid_options: + collapsed: false + fields: + beolink_jids: + required: false + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: + +beolink_join: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + jid_options: + collapsed: false + fields: + beolink_jid: + required: false + example: 1111.2222222.33333333@products.bang-olufsen.com + selector: + text: + +beolink_leave: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + +beolink_unexpand: + target: + entity: + integration: bang_olufsen + domain: media_player + device: + integration: bang_olufsen + fields: + jid_options: + collapsed: false + fields: + beolink_jids: + required: true + example: >- + [ + 1111.2222222.33333333@products.bang-olufsen.com, + 4444.5555555.66666666@products.bang-olufsen.com + ] + selector: + object: diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 3e336f7d2d8..aef6f953524 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -1,4 +1,8 @@ { + "common": { + "jid_options_name": "JID options", + "jid_options_description": "Advanced grouping options, where devices' unique Beolink IDs (Called JIDs) are used directly. JIDs can be found in the state attributes of the media player entity." + }, "config": { "error": { "api_exception": "[%key:common::config_flow::error::cannot_connect%]", @@ -25,6 +29,68 @@ } } }, + "services": { + "beolink_allstandby": { + "name": "Beolink all standby", + "description": "Set all Connected Beolink devices to standby." + }, + "beolink_expand": { + "name": "Beolink expand", + "description": "Expand current Beolink experience.", + "fields": { + "all_discovered": { + "name": "All discovered", + "description": "Expand Beolink experience to all discovered devices." + }, + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will join current Beolink experience." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + }, + "beolink_join": { + "name": "Beolink join", + "description": "Join a Beolink experience.", + "fields": { + "beolink_jid": { + "name": "Beolink JID", + "description": "Manually specify Beolink JID to join." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + }, + "beolink_leave": { + "name": "Beolink leave", + "description": "Leave a Beolink experience." + }, + "beolink_unexpand": { + "name": "Beolink unexpand", + "description": "Unexpand from current Beolink experience.", + "fields": { + "beolink_jids": { + "name": "Beolink JIDs", + "description": "Specify which Beolink JIDs will leave from current Beolink experience." + } + }, + "sections": { + "jid_options": { + "name": "[%key:component::bang_olufsen::common::jid_options_name%]", + "description": "[%key:component::bang_olufsen::common::jid_options_description%]" + } + } + } + }, "exceptions": { "m3u_invalid_format": { "message": "Media sources with the .m3u extension are not supported." diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 94b84189ccc..913f7cb3241 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -120,6 +120,11 @@ class BangOlufsenWebsocket(BangOlufsenBase): self.hass, f"{self._unique_id}_{WebsocketNotification.BEOLINK}", ) + elif notification_type is WebsocketNotification.CONFIGURATION: + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.CONFIGURATION}", + ) elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: async_dispatcher_send( self.hass, diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 6c19a29c1da..cbde856ff89 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -35,13 +35,13 @@ from .const import ( TEST_DATA_CREATE_ENTRY, TEST_DATA_CREATE_ENTRY_2, TEST_FRIENDLY_NAME, - TEST_FRIENDLY_NAME_2, TEST_FRIENDLY_NAME_3, - TEST_HOST_2, + TEST_FRIENDLY_NAME_4, TEST_HOST_3, + TEST_HOST_4, TEST_JID_1, - TEST_JID_2, TEST_JID_3, + TEST_JID_4, TEST_NAME, TEST_NAME_2, TEST_SERIAL_NUMBER, @@ -267,29 +267,29 @@ def mock_mozart_client() -> Generator[AsyncMock]: } client.get_beolink_peers = AsyncMock() client.get_beolink_peers.return_value = [ - BeolinkPeer( - friendly_name=TEST_FRIENDLY_NAME_2, - jid=TEST_JID_2, - ip_address=TEST_HOST_2, - ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, ), + BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME_4, + jid=TEST_JID_4, + ip_address=TEST_HOST_4, + ), ] client.get_beolink_listeners = AsyncMock() client.get_beolink_listeners.return_value = [ - BeolinkPeer( - friendly_name=TEST_FRIENDLY_NAME_2, - jid=TEST_JID_2, - ip_address=TEST_HOST_2, - ), BeolinkPeer( friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3, ip_address=TEST_HOST_3, ), + BeolinkPeer( + friendly_name=TEST_FRIENDLY_NAME_4, + jid=TEST_JID_4, + ip_address=TEST_HOST_4, + ), ] client.get_listening_mode_set = AsyncMock() diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..e48dc39198b --- /dev/null +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -0,0 +1,874 @@ +# serializer version: 1 +# name: test_async_beolink_allstandby + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[all_discovered-True-None-log_messages0-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[all_discovered-True-expand_side_effect1-log_messages1-2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[beolink_jids-parameter_value2-None-log_messages2-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_expand[beolink_jids-parameter_value3-expand_side_effect3-log_messages3-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_join + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_beolink_unexpand + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members0-1-0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members0-1-0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members1-0-1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players[group_members1-0-1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'media_position': 0, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Chromecast built-in', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source0-group_members0-expected_result0-invalid_source].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source': 'Tidal', + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_join_players_invalid[source1-group_members1-expected_result1-invalid_grouping_entity].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_unjoin_player + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_11111111', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'repeat': , + 'shuffle': False, + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_update_beolink_listener + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'leader': dict({ + 'Laundry room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.11111111@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'media_player.beosound_balance_11111111', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_11111111', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_async_update_beolink_listener.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'beolink': dict({ + 'listeners': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'peers': dict({ + 'Lego room Balance': '1111.1111111.33333333@products.bang-olufsen.com', + 'Lounge room Balance': '1111.1111111.44444444@products.bang-olufsen.com', + }), + 'self': dict({ + 'Living room Balance': '1111.1111111.22222222@products.bang-olufsen.com', + }), + }), + 'device_class': 'speaker', + 'entity_picture_local': None, + 'friendly_name': 'Living room Balance', + 'group_members': list([ + 'media_player.beosound_balance_22222222', + 'listener_not_in_hass-1111.1111111.33333333@products.bang-olufsen.com', + 'listener_not_in_hass-1111.1111111.44444444@products.bang-olufsen.com', + ]), + 'icon': 'mdi:speaker-wireless', + 'media_content_type': , + 'sound_mode': 'Test Listening Mode (123)', + 'sound_mode_list': list([ + 'Test Listening Mode (123)', + 'Test Listening Mode (234)', + 'Test Listening Mode 2 (345)', + ]), + 'source_list': list([ + 'Tidal', + 'Line-In', + 'HDMI A', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.beosound_balance_22222222', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/bang_olufsen/test_init.py b/tests/components/bang_olufsen/test_init.py index 5b809488ed8..c8e4c05f9ab 100644 --- a/tests/components/bang_olufsen/test_init.py +++ b/tests/components/bang_olufsen/test_init.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry -from .const import TEST_MODEL_BALANCE, TEST_NAME, TEST_SERIAL_NUMBER +from .const import TEST_FRIENDLY_NAME, TEST_MODEL_BALANCE, TEST_SERIAL_NUMBER from tests.common import MockConfigEntry @@ -35,7 +35,8 @@ async def test_setup_entry( identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} ) assert device is not None - assert device.name == TEST_NAME + # Is usually TEST_NAME, but is updated to the device's friendly name by _update_name_and_beolink + assert device.name == TEST_FRIENDLY_NAME assert device.model == TEST_MODEL_BALANCE # Ensure that the connection has been checked WebSocket connection has been initialized diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 8f23af9e04a..e991ab3d1bc 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -4,8 +4,10 @@ from contextlib import AbstractContextManager, nullcontext as does_not_raise import logging from unittest.mock import AsyncMock, patch +from mozart_api.exceptions import NotFoundException from mozart_api.models import ( BeolinkLeader, + BeolinkSelf, PlaybackContentMetadata, PlayQueueSettings, RenderingState, @@ -14,6 +16,8 @@ from mozart_api.models import ( WebsocketNotificationTag, ) import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.bang_olufsen.const import ( BANG_OLUFSEN_REPEAT_FROM_HA, @@ -46,24 +50,29 @@ from homeassistant.components.media_player import ( ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_REPEAT_SET, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, + SERVICE_UNJOIN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, MediaPlayerState, MediaType, RepeatMode, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.setup import async_setup_component from .const import ( @@ -76,7 +85,10 @@ from .const import ( TEST_DEEZER_TRACK, TEST_FALLBACK_SOURCES, TEST_FRIENDLY_NAME_2, + TEST_JID_1, TEST_JID_2, + TEST_JID_3, + TEST_JID_4, TEST_LISTENING_MODE_REF, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, @@ -136,6 +148,9 @@ async def test_initialization( mock_mozart_client.get_remote_menu.assert_called_once() mock_mozart_client.get_listening_mode_set.assert_called_once() mock_mozart_client.get_active_listening_mode.assert_called_once() + mock_mozart_client.get_beolink_self.assert_called_once() + mock_mozart_client.get_beolink_peers.assert_called_once() + mock_mozart_client.get_beolink_listeners.assert_called_once() async def test_async_update_sources_audio_only( @@ -530,11 +545,14 @@ async def test_async_update_beolink_line_in( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes["group_members"] == [] - assert mock_mozart_client.get_beolink_listeners.call_count == 1 + # Called once during _initialize and once during _async_update_beolink + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 async def test_async_update_beolink_listener( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -567,7 +585,56 @@ async def test_async_update_beolink_listener( TEST_MEDIA_PLAYER_ENTITY_ID, ] - assert mock_mozart_client.get_beolink_listeners.call_count == 0 + # Called once for each entity during _initialize + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + # Called once for each entity during _initialize and + # once more during _async_update_beolink for the entity that has the callback associated with it. + assert mock_mozart_client.get_beolink_peers.call_count == 3 + + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_update_name_and_beolink( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _async_update_name_and_beolink.""" + # Change response to ensure device name is changed + mock_mozart_client.get_beolink_self.return_value = BeolinkSelf( + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_1 + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + configuration_callback = ( + mock_mozart_client.get_notification_notifications.call_args[0][0] + ) + # Trigger callback + configuration_callback(WebsocketNotificationTag(value="configuration")) + + await hass.async_block_till_done() + + assert mock_mozart_client.get_beolink_self.call_count == 2 + assert mock_mozart_client.get_beolink_peers.call_count == 2 + assert mock_mozart_client.get_beolink_listeners.call_count == 2 + + # Check that device name has been changed + assert mock_config_entry.unique_id + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + ) + assert device.name == TEST_FRIENDLY_NAME_2 async def test_async_mute_volume( @@ -1343,6 +1410,7 @@ async def test_async_browse_media( ) async def test_async_join_players( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -1367,8 +1435,8 @@ async def test_async_join_players( source_change_callback(BangOlufsenSource.TIDAL) await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1379,6 +1447,14 @@ async def test_async_join_players( assert mock_mozart_client.post_beolink_expand.call_count == expand_count assert mock_mozart_client.join_latest_beolink_experience.call_count == join_count + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + @pytest.mark.parametrize( ("source", "group_members", "expected_result", "error_type"), @@ -1401,6 +1477,7 @@ async def test_async_join_players( ) async def test_async_join_players_invalid( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_config_entry_2: MockConfigEntry, @@ -1425,8 +1502,8 @@ async def test_async_join_players_invalid( with expected_result as exc_info: await hass.services.async_call( - "media_player", - "join", + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_GROUP_MEMBERS: group_members, @@ -1441,9 +1518,18 @@ async def test_async_join_players_invalid( assert mock_mozart_client.post_beolink_expand.call_count == 0 assert mock_mozart_client.join_latest_beolink_experience.call_count == 0 + # Main entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + # Secondary entity + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID_2)) + assert states == snapshot(exclude=props("media_position_updated_at")) + async def test_async_unjoin_player( hass: HomeAssistant, + snapshot: SnapshotAssertion, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -1453,14 +1539,181 @@ async def test_async_unjoin_player( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "unjoin", + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) mock_mozart_client.post_beolink_leave.assert_called_once() + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_join( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_join with defined JID.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jid": TEST_JID_2, + }, + blocking=True, + ) + + mock_mozart_client.join_beolink_peer.assert_called_once_with(jid=TEST_JID_2) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +@pytest.mark.parametrize( + ( + "parameter", + "parameter_value", + "expand_side_effect", + "log_messages", + "peers_call_count", + ), + [ + # All discovered + # Valid peers + ("all_discovered", True, None, [], 2), + # Invalid peers + ( + "all_discovered", + True, + NotFoundException(), + [f"Unable to expand to {TEST_JID_3}", f"Unable to expand to {TEST_JID_4}"], + 2, + ), + # Beolink JIDs + # Valid peer + ("beolink_jids", [TEST_JID_3, TEST_JID_4], None, [], 1), + # Invalid peer + ( + "beolink_jids", + [TEST_JID_3, TEST_JID_4], + NotFoundException(), + [ + f"Unable to expand to {TEST_JID_3}. Is the device available on the network?", + f"Unable to expand to {TEST_JID_4}. Is the device available on the network?", + ], + 1, + ), + ], +) +async def test_async_beolink_expand( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + parameter: str, + parameter_value: bool | list[str], + expand_side_effect: NotFoundException | None, + log_messages: list[str], + peers_call_count: int, +) -> None: + """Test async_beolink_expand.""" + mock_mozart_client.post_beolink_expand.side_effect = expand_side_effect + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + # Set the source to a beolink expandable source + source_change_callback(BangOlufsenSource.TIDAL) + + await hass.services.async_call( + DOMAIN, + "beolink_expand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + parameter: parameter_value, + }, + blocking=True, + ) + + # Check log messages + for log_message in log_messages: + assert log_message in caplog.text + + # Called once during _initialize and once during async_beolink_expand for all_discovered + assert mock_mozart_client.get_beolink_peers.call_count == peers_call_count + + assert mock_mozart_client.post_beolink_expand.call_count == len( + await mock_mozart_client.get_beolink_peers() + ) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_unexpand( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test test_async_beolink_unexpand.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_unexpand", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + "beolink_jids": [TEST_JID_3, TEST_JID_4], + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_unexpand.call_count == 2 + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + + +async def test_async_beolink_allstandby( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_beolink_allstandby.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + DOMAIN, + "beolink_allstandby", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_allstandby.assert_called_once() + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states == snapshot(exclude=props("media_position_updated_at")) + @pytest.mark.parametrize( ("repeat"), From 24b47b50ead07fdd1d2dd4e2aab17fee3cf1179a Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Fri, 8 Nov 2024 13:29:10 +0100 Subject: [PATCH 314/511] Migrate from entry unique id to emoncms unique id (#129133) * Migrate from entry unique id to emoncms unique id * Use a placeholder for the documentation URL * Use async_set_unique_id in config_flow * use _abort_if_unique_id_configured in config_flow * Avoid single-use variable Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Add async_migrate_entry * Remove commented code * Downgrade version if user add server without uuid * Improve code quality * Move code migrating HA to emoncms uuid to init * Fit doc url in less than 88 chars Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Improve code quality * Only update unique_id with async_update_entry Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Make emoncms_client compulsory to get_feed_list * Improve readability with unique id functions * Rmv test to give more sense to _migrate_unique_id --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/emoncms/__init__.py | 47 +++++++++++++++++ .../components/emoncms/config_flow.py | 33 +++++++----- homeassistant/components/emoncms/const.py | 4 ++ homeassistant/components/emoncms/sensor.py | 10 ++-- homeassistant/components/emoncms/strings.json | 7 +++ tests/components/emoncms/conftest.py | 16 ++++++ .../emoncms/snapshots/test_sensor.ambr | 2 +- tests/components/emoncms/test_config_flow.py | 18 +++++++ tests/components/emoncms/test_init.py | 51 ++++++++++++++++++- 9 files changed, 167 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 98ed6328578..0cd686b5b56 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -5,8 +5,11 @@ from pyemoncms import EmoncmsClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from .const import DOMAIN, EMONCMS_UUID_DOC_URL, LOGGER from .coordinator import EmoncmsCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -14,6 +17,49 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] +def _migrate_unique_id( + hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_unique_id: str +) -> None: + """Migrate to emoncms unique id if needed.""" + ent_reg = er.async_get(hass) + entry_entities = ent_reg.entities.get_entries_for_config_entry_id(entry.entry_id) + for entity in entry_entities: + if entity.unique_id.split("-")[0] == entry.entry_id: + feed_id = entity.unique_id.split("-")[-1] + LOGGER.debug(f"moving feed {feed_id} to hardware uuid") + ent_reg.async_update_entity( + entity.entity_id, new_unique_id=f"{emoncms_unique_id}-{feed_id}" + ) + hass.config_entries.async_update_entry( + entry, + unique_id=emoncms_unique_id, + ) + + +async def _check_unique_id_migration( + hass: HomeAssistant, entry: EmonCMSConfigEntry, emoncms_client: EmoncmsClient +) -> None: + """Check if we can migrate to the emoncms uuid.""" + emoncms_unique_id = await emoncms_client.async_get_uuid() + if emoncms_unique_id: + if entry.unique_id != emoncms_unique_id: + _migrate_unique_id(hass, entry, emoncms_unique_id) + else: + async_create_issue( + hass, + DOMAIN, + "migrate database", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="migrate_database", + translation_placeholders={ + "url": entry.data[CONF_URL], + "doc_url": EMONCMS_UUID_DOC_URL, + }, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Load a config entry.""" emoncms_client = EmoncmsClient( @@ -21,6 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b entry.data[CONF_API_KEY], session=async_get_clientsession(hass), ) + await _check_unique_id_migration(hass, entry, emoncms_client) coordinator = EmoncmsCoordinator(hass, emoncms_client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index b294a5cd3d4..e0d4d0d03e9 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( OptionsFlow, ) from homeassistant.const import CONF_API_KEY, CONF_URL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import selector from homeassistant.helpers.typing import ConfigType @@ -48,13 +48,10 @@ def sensor_name(url: str) -> str: return f"emoncms@{sensorip}" -async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]: +async def get_feed_list( + emoncms_client: EmoncmsClient, +) -> dict[str, Any]: """Check connection to emoncms and return feed list if successful.""" - emoncms_client = EmoncmsClient( - url, - api_key, - session=async_get_clientsession(hass), - ) return await emoncms_client.async_request("/feed/list.json") @@ -82,22 +79,25 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders = {} if user_input is not None: + self.url = user_input[CONF_URL] + self.api_key = user_input[CONF_API_KEY] self._async_abort_entries_match( { - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_URL: user_input[CONF_URL], + CONF_API_KEY: self.api_key, + CONF_URL: self.url, } ) - result = await get_feed_list( - self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] + emoncms_client = EmoncmsClient( + self.url, self.api_key, session=async_get_clientsession(self.hass) ) + result = await get_feed_list(emoncms_client) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} else: self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) - self.url = user_input[CONF_URL] - self.api_key = user_input[CONF_API_KEY] + await self.async_set_unique_id(await emoncms_client.async_get_uuid()) + self._abort_if_unique_id_configured() options = get_options(result[CONF_MESSAGE]) self.dropdown = { "options": options, @@ -191,7 +191,12 @@ class EmoncmsOptionsFlow(OptionsFlow): self.config_entry.data.get(CONF_ONLY_INCLUDE_FEEDID, []), ) options: list = include_only_feeds - result = await get_feed_list(self.hass, self._url, self._api_key) + emoncms_client = EmoncmsClient( + self._url, + self._api_key, + session=async_get_clientsession(self.hass), + ) + result = await get_feed_list(emoncms_client) if not result[CONF_SUCCESS]: errors["base"] = "api_error" description_placeholders = {"details": result[CONF_MESSAGE]} diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index 256db5726bb..c53f7cc8a9f 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -7,6 +7,10 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id" CONF_MESSAGE = "message" CONF_SUCCESS = "success" DOMAIN = "emoncms" +EMONCMS_UUID_DOC_URL = ( + "https://docs.openenergymonitor.org/emoncms/update.html" + "#upgrading-to-a-version-producing-a-unique-identifier" +) FEED_ID = "id" FEED_NAME = "name" FEED_TAG = "tag" diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index d8dec12800a..c696a569135 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -148,20 +148,20 @@ async def async_setup_entry( return coordinator = entry.runtime_data + # uuid was added in emoncms database 11.5.7 + unique_id = entry.unique_id if entry.unique_id else entry.entry_id elems = coordinator.data if not elems: return - sensors: list[EmonCmsSensor] = [] for idx, elem in enumerate(elems): if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds: continue - sensors.append( EmonCmsSensor( coordinator, - entry.entry_id, + unique_id, elem["unit"], name, idx, @@ -176,7 +176,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): def __init__( self, coordinator: EmoncmsCoordinator, - entry_id: str, + unique_id: str, unit_of_measurement: str | None, name: str, idx: int, @@ -189,7 +189,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): elem = self.coordinator.data[self.idx] self._attr_name = f"{name} {elem[FEED_NAME]}" self._attr_native_unit_of_measurement = unit_of_measurement - self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}" + self._attr_unique_id = f"{unique_id}-{elem[FEED_ID]}" if unit_of_measurement in ("kWh", "Wh"): self._attr_device_class = SensorDeviceClass.ENERGY self._attr_state_class = SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index e2b7602f6f2..0d841f2efb4 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -19,6 +19,9 @@ "include_only_feed_id": "Choose feeds to include" } } + }, + "abort": { + "already_configured": "This server is already configured" } }, "options": { @@ -41,6 +44,10 @@ "missing_include_only_feed_id": { "title": "No feed synchronized with the {domain} sensor", "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." + }, + "migrate_database": { + "title": "Upgrade your emoncms version", + "description": "Your [emoncms]({url}) does not ship a unique identifier.\n\n Please upgrade to at least version 11.5.7 and migrate your emoncms database.\n\n More info on [emoncms documentation]({doc_url})" } } } diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 29e86f3c59d..4bd1d68217a 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -91,6 +91,21 @@ def config_entry() -> MockConfigEntry: ) +FLOW_RESULT_SECOND_URL = copy.deepcopy(FLOW_RESULT) +FLOW_RESULT_SECOND_URL[CONF_URL] = "http://1.1.1.2" + + +@pytest.fixture +def config_entry_unique_id() -> MockConfigEntry: + """Mock emoncms config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=FLOW_RESULT_SECOND_URL, + unique_id="123-53535292", + ) + + FLOW_RESULT_NO_FEED = copy.deepcopy(FLOW_RESULT) FLOW_RESULT_NO_FEED[CONF_ONLY_INCLUDE_FEEDID] = None @@ -143,4 +158,5 @@ async def emoncms_client() -> AsyncGenerator[AsyncMock]: ): client = mock_client.return_value client.async_request.return_value = {"success": True, "message": FEEDS} + client.async_get_uuid.return_value = "123-53535292" yield client diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 5e718c1d8e8..f6a2745fb1a 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'XXXXXXXX-1', + 'unique_id': '123-53535292-1', 'unit_of_measurement': , }) # --- diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index b3afc714c59..5baf3d25b0e 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -142,3 +142,21 @@ async def test_options_flow_failure( assert result["description_placeholders"]["details"] == "failure" assert result["type"] is FlowResultType.FORM assert result["step_id"] == "init" + + +async def test_unique_id_exists( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, + config_entry_unique_id: MockConfigEntry, +) -> None: + """Test when entry with same unique id already exists.""" + config_entry_unique_id.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/emoncms/test_init.py b/tests/components/emoncms/test_init.py index b89b6e65a66..abe1a020034 100644 --- a/tests/components/emoncms/test_init.py +++ b/tests/components/emoncms/test_init.py @@ -4,11 +4,14 @@ from __future__ import annotations from unittest.mock import AsyncMock +from homeassistant.components.emoncms.const import DOMAIN, FEED_ID, FEED_NAME from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir from . import setup_integration -from .conftest import EMONCMS_FAILURE +from .conftest import EMONCMS_FAILURE, FEEDS from tests.common import MockConfigEntry @@ -38,3 +41,49 @@ async def test_failure( emoncms_client.async_request.return_value = EMONCMS_FAILURE config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(config_entry.entry_id) + + +async def test_migrate_uuid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test migration from home assistant uuid to emoncms uuid.""" + config_entry.add_to_hass(hass) + assert config_entry.unique_id is None + for _, feed in enumerate(FEEDS): + entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{config_entry.entry_id}-{feed[FEED_ID]}", + config_entry=config_entry, + suggested_object_id=f"{DOMAIN}_{feed[FEED_NAME]}", + ) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + emoncms_uuid = emoncms_client.async_get_uuid.return_value + assert config_entry.unique_id == emoncms_uuid + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + for nb, feed in enumerate(FEEDS): + assert entity_entries[nb].unique_id == f"{emoncms_uuid}-{feed[FEED_ID]}" + assert ( + entity_entries[nb].previous_unique_id + == f"{config_entry.entry_id}-{feed[FEED_ID]}" + ) + + +async def test_no_uuid( + hass: HomeAssistant, + config_entry: MockConfigEntry, + issue_registry: ir.IssueRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test an issue is created when the emoncms server does not ship an uuid.""" + emoncms_client.async_get_uuid.return_value = None + await setup_integration(hass, config_entry) + + assert issue_registry.async_get_issue(domain=DOMAIN, issue_id="migrate database") From 94d597fd41e4401d08badb9fdffdf6919c47f509 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:33:19 +0100 Subject: [PATCH 315/511] Add checks for flow title/description placeholders (#129140) * Add checks for title placeholders * Check both title and description * Improve comment --- tests/components/conftest.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 00738cd252f..5535ec3b976 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -569,6 +569,8 @@ async def _ensure_translation_exists( component: str, key: str, description_placeholders: dict[str, str] | None, + *, + translation_required: bool = True, ) -> None: """Raise if translation doesn't exist.""" full_key = f"component.{component}.{category}.{key}" @@ -579,6 +581,9 @@ async def _ensure_translation_exists( ) return + if not translation_required: + return + if full_key in ignore_translations: ignore_translations[full_key] = "used" return @@ -626,6 +631,20 @@ def check_config_translations(ignore_translations: str | list[str]) -> Generator setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) if result["type"] is FlowResultType.FORM: + if step_id := result.get("step_id"): + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _ensure_translation_exists( + flow.hass, + _ignore_translations, + category, + component, + f"step.{step_id}.{header}", + result["description_placeholders"], + translation_required=False, + ) if errors := result.get("errors"): for error in errors.values(): await _ensure_translation_exists( From 18cf96b92b55ca8ab66c359327b68fc296b0da08 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:42:19 +0100 Subject: [PATCH 316/511] Bring emoncms coverage to 100% (#130092) Remove mock_setup_entry from emoncms OptionsFlow test --- tests/components/emoncms/test_config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 5baf3d25b0e..1914f23fb0b 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -106,7 +106,6 @@ CONFIG_ENTRY = { async def test_options_flow( hass: HomeAssistant, - mock_setup_entry: AsyncMock, emoncms_client: AsyncMock, config_entry: MockConfigEntry, ) -> None: From 7672215095dbc032d51a0966f027049f58172ae7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:46:40 +0100 Subject: [PATCH 317/511] Trigger full CI run on homeassistant_hardware integration changes (#130129) Add components/homeassistant_hardware to core files --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index e211b8ca5ec..6fd3a74df92 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -79,6 +79,7 @@ components: &components - homeassistant/components/group/** - homeassistant/components/hassio/** - homeassistant/components/homeassistant/** + - homeassistant/components/homeassistant_hardware/** - homeassistant/components/http/** - homeassistant/components/image/** - homeassistant/components/input_boolean/** From 7678be8e2b8c3cf80c3c660ffd383dcc589949d6 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:01:36 +0100 Subject: [PATCH 318/511] Suez water: simplify config flow (#130083) Simplify config flow for suez water. Counter_id can now be automatically be fetched by the integration. The value is provided only in the source code of suez website and therefore not easily accessible to user not familiar with devlopment. Still possible to explicitly set the value for user with multiple value or value defined elsewhere. --- .../components/suez_water/config_flow.py | 17 +++++++- .../components/suez_water/strings.json | 3 +- .../components/suez_water/test_config_flow.py | 39 ++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py index a7ade642888..ac09cf4a1d3 100644 --- a/homeassistant/components/suez_water/config_flow.py +++ b/homeassistant/components/suez_water/config_flow.py @@ -20,7 +20,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTER_ID): str, + vol.Optional(CONF_COUNTER_ID): str, } ) @@ -31,16 +31,23 @@ async def validate_input(data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ try: + counter_id = data.get(CONF_COUNTER_ID) client = SuezClient( data[CONF_USERNAME], data[CONF_PASSWORD], - data[CONF_COUNTER_ID], + counter_id, ) if not await client.check_credentials(): raise InvalidAuth except PySuezError as ex: raise CannotConnect from ex + if counter_id is None: + try: + data[CONF_COUNTER_ID] = await client.find_counter() + except PySuezError as ex: + raise CounterNotFound from ex + class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Suez Water.""" @@ -61,6 +68,8 @@ class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except CounterNotFound: + errors["base"] = "counter_not_found" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" @@ -80,3 +89,7 @@ class CannotConnect(HomeAssistantError): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class CounterNotFound(HomeAssistantError): + """Error to indicate we cannot automatically found the counter id.""" diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index f9abd70fc19..a1af12abd55 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -12,7 +12,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "counter_not_found": "Could not find counter id automatically" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py index 766fd8c5fa5..6779b4c7d02 100644 --- a/tests/components/suez_water/test_config_flow.py +++ b/tests/components/suez_water/test_config_flow.py @@ -6,7 +6,7 @@ from pysuez.exception import PySuezError import pytest from homeassistant import config_entries -from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.components.suez_water.const import CONF_COUNTER_ID, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -127,3 +127,40 @@ async def test_form_error( assert result["title"] == "test-username" assert result["data"] == MOCK_DATA assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_auto_counter( + hass: HomeAssistant, mock_setup_entry: AsyncMock, suez_client: AsyncMock +) -> None: + """Test form set counter if not set by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + partial_form = {**MOCK_DATA} + partial_form.pop(CONF_COUNTER_ID) + suez_client.find_counter.side_effect = PySuezError("test counter not found") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + partial_form, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "counter_not_found"} + + suez_client.find_counter.side_effect = None + suez_client.find_counter.return_value = MOCK_DATA[CONF_COUNTER_ID] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + partial_form, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 From f49547d598fd7f1866c2186908969fa352980d91 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 14:19:46 +0100 Subject: [PATCH 319/511] Bump uv to 0.5.0 (#130127) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index b6d571f308e..903a121c032 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.4.28 +RUN pip3 install uv==0.5.0 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9df83f3bb23..05fabb340ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.28 +uv==0.5.0 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index 4ca6d211788..df3e2703d5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.4.28", + "uv==0.5.0", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index 0902ca9813d..f9ac034136d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.28 +uv==0.5.0 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 61b623dc32b..97fc6c49d12 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.4.28,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 03c3d09583e2b68a9018402a229d996fce4f440a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:41:00 +0000 Subject: [PATCH 320/511] Enable overriding connection port for tplink devices (#129619) Enable setting a port override during manual config entry setup. The feature will be undocumented as it's quite a specialized use case generally used for testing purposes. --- homeassistant/components/tplink/__init__.py | 3 + .../components/tplink/config_flow.py | 70 ++++++++++-- tests/components/tplink/conftest.py | 2 +- tests/components/tplink/test_config_flow.py | 104 ++++++++++++++++-- 4 files changed, 163 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index ceeb1120ed8..ee1d90e70b4 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -31,6 +31,7 @@ from homeassistant.const import ( CONF_MAC, CONF_MODEL, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import HomeAssistant, callback @@ -141,6 +142,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH) entry_use_http = entry.data.get(CONF_USES_HTTP, False) entry_aes_keys = entry.data.get(CONF_AES_KEYS) + port_override = entry.data.get(CONF_PORT) conn_params: Device.ConnectionParameters | None = None if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS): @@ -157,6 +159,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo timeout=CONNECT_TIMEOUT, http_client=client, aes_keys=entry_aes_keys, + port_override=port_override, ) if conn_params: config.connection_type = conn_params diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index a9f665e12fd..63f1b4e125b 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -32,6 +32,7 @@ from homeassistant.const import ( CONF_MAC, CONF_MODEL, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import callback @@ -69,6 +70,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): MINOR_VERSION = CONF_CONFIG_ENTRY_MINOR_VERSION host: str | None = None + port: int | None = None def __init__(self) -> None: """Initialize the config flow.""" @@ -260,6 +262,26 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): step_id="discovery_confirm", description_placeholders=placeholders ) + @staticmethod + def _async_get_host_port(host_str: str) -> tuple[str, int | None]: + """Parse the host string for host and port.""" + if "[" in host_str: + _, _, bracketed = host_str.partition("[") + host, _, port_str = bracketed.partition("]") + _, _, port_str = port_str.partition(":") + else: + host, _, port_str = host_str.partition(":") + + if not port_str: + return host, None + + try: + port = int(port_str) + except ValueError: + return host, None + + return host, port + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -270,14 +292,29 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: if not (host := user_input[CONF_HOST]): return await self.async_step_pick_device() - self._async_abort_entries_match({CONF_HOST: host}) + + host, port = self._async_get_host_port(host) + + match_dict = {CONF_HOST: host} + if port: + self.port = port + match_dict[CONF_PORT] = port + self._async_abort_entries_match(match_dict) + self.host = host credentials = await get_credentials(self.hass) try: device = await self._async_try_discover_and_update( - host, credentials, raise_on_progress=False, raise_on_timeout=False + host, + credentials, + raise_on_progress=False, + raise_on_timeout=False, + port=port, ) or await self._async_try_connect_all( - host, credentials=credentials, raise_on_progress=False + host, + credentials=credentials, + raise_on_progress=False, + port=port, ) except AuthenticationError: return await self.async_step_user_auth_confirm() @@ -318,7 +355,10 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): ) else: device = await self._async_try_connect_all( - self.host, credentials=credentials, raise_on_progress=False + self.host, + credentials=credentials, + raise_on_progress=False, + port=self.port, ) except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" @@ -420,6 +460,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): data[CONF_AES_KEYS] = device.config.aes_keys if device.credentials_hash: data[CONF_CREDENTIALS_HASH] = device.credentials_hash + if port := device.config.port_override: + data[CONF_PORT] = port return self.async_create_entry( title=f"{device.alias} {device.model}", data=data, @@ -430,6 +472,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host: str, credentials: Credentials | None, raise_on_progress: bool, + *, + port: int | None = None, ) -> Device | None: """Try to connect to the device speculatively. @@ -441,12 +485,15 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): host, credentials=credentials, http_client=create_async_tplink_clientsession(self.hass), + port=port, ) else: # This will just try the legacy protocol that doesn't require auth # and doesn't use http try: - device = await Device.connect(config=DeviceConfig(host)) + device = await Device.connect( + config=DeviceConfig(host, port_override=port) + ) except Exception: # noqa: BLE001 return None if device: @@ -462,6 +509,8 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): credentials: Credentials | None, raise_on_progress: bool, raise_on_timeout: bool, + *, + port: int | None = None, ) -> Device | None: """Try to discover the device and call update. @@ -470,7 +519,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_device = None try: self._discovered_device = await Discover.discover_single( - host, credentials=credentials + host, + credentials=credentials, + port=port, ) except TimeoutError as ex: if raise_on_timeout: @@ -526,6 +577,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() entry_data = reauth_entry.data host = entry_data[CONF_HOST] + port = entry_data.get(CONF_PORT) if user_input: username = user_input[CONF_USERNAME] @@ -537,8 +589,12 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): credentials=credentials, raise_on_progress=False, raise_on_timeout=False, + port=port, ) or await self._async_try_connect_all( - host, credentials=credentials, raise_on_progress=False + host, + credentials=credentials, + raise_on_progress=False, + port=port, ) except AuthenticationError as ex: errors[CONF_PASSWORD] = "invalid_auth" diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index 78cc9304bf7..25a4bd20270 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -37,7 +37,7 @@ def mock_discovery(): device = _mocked_device( device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), credentials_hash=CREDENTIALS_HASH_KLAP, - alias=None, + alias="My Bulb", ) devices = { "127.0.0.1": _mocked_device( diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index 12a5741058c..2697696c667 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -2,7 +2,7 @@ from contextlib import contextmanager import logging -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch from kasa import TimeoutError import pytest @@ -30,6 +30,7 @@ from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_PASSWORD, + CONF_PORT, CONF_USERNAME, ) from homeassistant.core import HomeAssistant @@ -665,6 +666,93 @@ async def test_manual_auth_errors( await hass.async_block_till_done() +@pytest.mark.parametrize( + ("host_str", "host", "port"), + [ + (f"{IP_ADDRESS}:1234", IP_ADDRESS, 1234), + ("[2001:db8:0::1]:4321", "2001:db8:0::1", 4321), + ], +) +async def test_manual_port_override( + hass: HomeAssistant, + mock_connect: AsyncMock, + mock_discovery: AsyncMock, + host_str, + host, + port, +) -> None: + """Test manually setup.""" + mock_discovery["mock_device"].config.port_override = port + mock_discovery["mock_device"].host = host + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + # side_effects to cause auth confirm as the port override usually only + # works with direct connections. + mock_discovery["discover_single"].side_effect = TimeoutError + mock_connect["connect"].side_effect = AuthenticationError + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: host_str} + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "user_auth_confirm" + assert not result2["errors"] + + creds = Credentials("fake_username", "fake_password") + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() + mock_discovery["try_connect_all"].assert_called_once_with( + host, credentials=creds, port=port, http_client=ANY + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == DEFAULT_ENTRY_TITLE + assert result3["data"] == { + **CREATE_ENTRY_DATA_KLAP, + CONF_PORT: port, + CONF_HOST: host, + } + assert result3["context"]["unique_id"] == MAC_ADDRESS + + +async def test_manual_port_override_invalid( + hass: HomeAssistant, mock_connect: AsyncMock, mock_discovery: AsyncMock +) -> None: + """Test manually setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: f"{IP_ADDRESS}:foo"} + ) + await hass.async_block_till_done() + + mock_discovery["discover_single"].assert_called_once_with( + "127.0.0.1", credentials=None, port=None + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_ENTRY_TITLE + assert result2["data"] == CREATE_ENTRY_DATA_KLAP + assert result2["context"]["unique_id"] == MAC_ADDRESS + + async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: """Test we get the form with discovery and abort for dhcp source when we get both.""" @@ -1072,7 +1160,7 @@ async def test_reauth( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT @@ -1107,7 +1195,7 @@ async def test_reauth_try_connect_all( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() assert result2["type"] is FlowResultType.ABORT @@ -1145,7 +1233,7 @@ async def test_reauth_try_connect_all_fail( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["try_connect_all"].assert_called_once() assert result2["errors"] == {"base": "cannot_connect"} @@ -1214,7 +1302,7 @@ async def test_reauth_update_with_encryption_change( assert "Connection type changed for 127.0.0.2" in caplog.text credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.2", credentials=credentials + "127.0.0.2", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT @@ -1416,7 +1504,7 @@ async def test_reauth_errors( credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.FORM @@ -1434,7 +1522,7 @@ async def test_reauth_errors( ) mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() @@ -1643,7 +1731,7 @@ async def test_reauth_update_other_flows( ) credentials = Credentials("fake_username", "fake_password") mock_discovery["discover_single"].assert_called_once_with( - "127.0.0.1", credentials=credentials + "127.0.0.1", credentials=credentials, port=None ) mock_discovery["mock_device"].update.assert_called_once_with() assert result2["type"] is FlowResultType.ABORT From b711b171930e275ec303d96df4a3c2f572c96057 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 8 Nov 2024 14:50:41 +0100 Subject: [PATCH 321/511] Remove Z-Wave incorrect lock service descriptions (#130034) --- homeassistant/components/zwave_js/services.yaml | 10 ---------- homeassistant/components/zwave_js/strings.json | 8 -------- 2 files changed, 18 deletions(-) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index f5063fdfd93..acf6e9a0665 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -51,16 +51,6 @@ set_lock_configuration: min: 0 max: 65535 unit_of_measurement: sec - outside_handles_can_open_door_configuration: - required: false - example: [true, true, true, false] - selector: - object: - inside_handles_can_open_door_configuration: - required: false - example: [true, true, true, false] - selector: - object: auto_relock_time: required: false example: 1 diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index ca7d5153e6e..28789bbf9f4 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -523,10 +523,6 @@ "description": "Duration in seconds the latch stays retracted.", "name": "Hold and release time" }, - "inside_handles_can_open_door_configuration": { - "description": "A list of four booleans which indicate which inside handles can open the door.", - "name": "Inside handles can open door configuration" - }, "lock_timeout": { "description": "Seconds until lock mode times out. Should only be used if operation type is `timed`.", "name": "Lock timeout" @@ -535,10 +531,6 @@ "description": "The operation type of the lock.", "name": "Operation Type" }, - "outside_handles_can_open_door_configuration": { - "description": "A list of four booleans which indicate which outside handles can open the door.", - "name": "Outside handles can open door configuration" - }, "twist_assist": { "description": "Enable Twist Assist.", "name": "Twist assist" From 074418f8f7ab051281513db98a11aa185e131d66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 14:53:46 +0100 Subject: [PATCH 322/511] Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware (#130078) * Drop OptionsFlowWithConfigEntry usage in homeassistant_hardware * Add homeassistant_hardware as other components rely on it * Maybe core_files not needed after all --- .../homeassistant_hardware/firmware_config_flow.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 37d12d2bd61..a91fb00c142 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -24,7 +24,6 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, OptionsFlow, - OptionsFlowWithConfigEntry, ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow @@ -496,13 +495,15 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow): return await self.async_step_pick_firmware() -class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry): +class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow): """Zigbee and Thread options flow handlers.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None: """Instantiate options flow.""" super().__init__(*args, **kwargs) + self._config_entry = config_entry + self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"]) # Make `context` a regular dictionary From 1f32e02ba2ca0af4b29201f6cac9e5d2c32ec75c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 15:10:51 +0100 Subject: [PATCH 323/511] Add Nord Pool integration (#129983) --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/nordpool/__init__.py | 29 + .../components/nordpool/config_flow.py | 92 + homeassistant/components/nordpool/const.py | 14 + .../components/nordpool/coordinator.py | 95 + homeassistant/components/nordpool/entity.py | 32 + homeassistant/components/nordpool/icons.json | 42 + .../components/nordpool/manifest.json | 12 + homeassistant/components/nordpool/sensor.py | 328 +++ .../components/nordpool/strings.json | 56 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nordpool/__init__.py | 9 + tests/components/nordpool/conftest.py | 76 + .../nordpool/fixtures/delivery_period.json | 272 ++ .../nordpool/snapshots/test_sensor.ambr | 2215 +++++++++++++++++ tests/components/nordpool/test_config_flow.py | 151 ++ tests/components/nordpool/test_coordinator.py | 114 + tests/components/nordpool/test_init.py | 39 + tests/components/nordpool/test_sensor.py | 25 + 24 files changed, 3628 insertions(+) create mode 100644 homeassistant/components/nordpool/__init__.py create mode 100644 homeassistant/components/nordpool/config_flow.py create mode 100644 homeassistant/components/nordpool/const.py create mode 100644 homeassistant/components/nordpool/coordinator.py create mode 100644 homeassistant/components/nordpool/entity.py create mode 100644 homeassistant/components/nordpool/icons.json create mode 100644 homeassistant/components/nordpool/manifest.json create mode 100644 homeassistant/components/nordpool/sensor.py create mode 100644 homeassistant/components/nordpool/strings.json create mode 100644 tests/components/nordpool/__init__.py create mode 100644 tests/components/nordpool/conftest.py create mode 100644 tests/components/nordpool/fixtures/delivery_period.json create mode 100644 tests/components/nordpool/snapshots/test_sensor.ambr create mode 100644 tests/components/nordpool/test_config_flow.py create mode 100644 tests/components/nordpool/test_coordinator.py create mode 100644 tests/components/nordpool/test_init.py create mode 100644 tests/components/nordpool/test_sensor.py diff --git a/.strict-typing b/.strict-typing index a980c0901d0..b0fd74bce54 100644 --- a/.strict-typing +++ b/.strict-typing @@ -340,6 +340,7 @@ homeassistant.components.nfandroidtv.* homeassistant.components.nightscout.* homeassistant.components.nissan_leaf.* homeassistant.components.no_ip.* +homeassistant.components.nordpool.* homeassistant.components.notify.* homeassistant.components.notion.* homeassistant.components.number.* diff --git a/CODEOWNERS b/CODEOWNERS index e41267860d8..022eda00123 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1012,6 +1012,8 @@ build.json @home-assistant/supervisor /homeassistant/components/noaa_tides/ @jdelaney72 /homeassistant/components/nobo_hub/ @echoromeo @oyvindwe /tests/components/nobo_hub/ @echoromeo @oyvindwe +/homeassistant/components/nordpool/ @gjohansson-ST +/tests/components/nordpool/ @gjohansson-ST /homeassistant/components/notify/ @home-assistant/core /tests/components/notify/ @home-assistant/core /homeassistant/components/notify_events/ @matrozov @papajojo diff --git a/homeassistant/components/nordpool/__init__.py b/homeassistant/components/nordpool/__init__.py new file mode 100644 index 00000000000..b688bf74a37 --- /dev/null +++ b/homeassistant/components/nordpool/__init__.py @@ -0,0 +1,29 @@ +"""The Nord Pool component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import PLATFORMS +from .coordinator import NordPoolDataUpdateCoordinator + +type NordPoolConfigEntry = ConfigEntry[NordPoolDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: + """Set up Nord Pool from a config entry.""" + + coordinator = NordPoolDataUpdateCoordinator(hass, entry) + await coordinator.fetch_data(dt_util.utcnow()) + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NordPoolConfigEntry) -> bool: + """Unload Nord Pool config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py new file mode 100644 index 00000000000..d184c04f3ce --- /dev/null +++ b/homeassistant/components/nordpool/config_flow.py @@ -0,0 +1,92 @@ +"""Adds config flow for Nord Pool integration.""" + +from __future__ import annotations + +from typing import Any + +from pynordpool import Currency, NordPoolClient, NordPoolError +from pynordpool.const import AREAS +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CURRENCY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util import dt as dt_util + +from .const import CONF_AREAS, DEFAULT_NAME, DOMAIN + +SELECT_AREAS = [ + SelectOptionDict(value=area, label=name) for area, name in AREAS.items() +] +SELECT_CURRENCY = [currency.value for currency in Currency] + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_AREAS, default=[]): SelectSelector( + SelectSelectorConfig( + options=SELECT_AREAS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ), + vol.Required(CONF_CURRENCY, default="SEK"): SelectSelector( + SelectSelectorConfig( + options=SELECT_CURRENCY, + multiple=False, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ), + } +) + + +async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]: + """Test fetch data from Nord Pool.""" + client = NordPoolClient(async_get_clientsession(hass)) + try: + data = await client.async_get_delivery_period( + dt_util.now(), + Currency(user_input[CONF_CURRENCY]), + user_input[CONF_AREAS], + ) + except NordPoolError: + return {"base": "cannot_connect"} + + if not data.raw: + return {"base": "no_data"} + + return {} + + +class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nord Pool integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input: + errors = await test_api(self.hass, user_input) + if not errors: + return self.async_create_entry( + title=DEFAULT_NAME, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py new file mode 100644 index 00000000000..19a978d946c --- /dev/null +++ b/homeassistant/components/nordpool/const.py @@ -0,0 +1,14 @@ +"""Constants for Nord Pool.""" + +import logging + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) + +DEFAULT_SCAN_INTERVAL = 60 +DOMAIN = "nordpool" +PLATFORMS = [Platform.SENSOR] +DEFAULT_NAME = "Nord Pool" + +CONF_AREAS = "areas" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py new file mode 100644 index 00000000000..27016ae2b4b --- /dev/null +++ b/homeassistant/components/nordpool/coordinator.py @@ -0,0 +1,95 @@ +"""DataUpdateCoordinator for the Nord Pool integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +from typing import TYPE_CHECKING + +from pynordpool import ( + Currency, + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolClient, + NordPoolError, + NordPoolResponseError, +) + +from homeassistant.const import CONF_CURRENCY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_AREAS, DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import NordPoolConfigEntry + + +class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): + """A Nord Pool Data Update Coordinator.""" + + config_entry: NordPoolConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: NordPoolConfigEntry) -> None: + """Initialize the Nord Pool coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + ) + self.client = NordPoolClient(session=async_get_clientsession(hass)) + self.unsub: Callable[[], None] | None = None + + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + next_hour = dt_util.utcnow() + timedelta(hours=1) + next_run = datetime( + next_hour.year, + next_hour.month, + next_hour.day, + next_hour.hour, + tzinfo=dt_util.UTC, + ) + LOGGER.debug("Next update at %s", next_run) + return next_run + + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + await super().async_shutdown() + if self.unsub: + self.unsub() + self.unsub = None + + async def fetch_data(self, now: datetime) -> None: + """Fetch data from Nord Pool.""" + self.unsub = async_track_point_in_utc_time( + self.hass, self.fetch_data, self.get_next_interval(dt_util.utcnow()) + ) + try: + data = await self.client.async_get_delivery_period( + dt_util.now(), + Currency(self.config_entry.data[CONF_CURRENCY]), + self.config_entry.data[CONF_AREAS], + ) + except NordPoolAuthenticationError as error: + LOGGER.error("Authentication error: %s", error) + self.async_set_update_error(error) + return + except NordPoolResponseError as error: + LOGGER.debug("Response error: %s", error) + self.async_set_update_error(error) + return + except NordPoolError as error: + LOGGER.debug("Connection error: %s", error) + self.async_set_update_error(error) + return + + if not data.raw: + self.async_set_update_error(UpdateFailed("No data")) + return + + self.async_set_updated_data(data) diff --git a/homeassistant/components/nordpool/entity.py b/homeassistant/components/nordpool/entity.py new file mode 100644 index 00000000000..32240aad12c --- /dev/null +++ b/homeassistant/components/nordpool/entity.py @@ -0,0 +1,32 @@ +"""Base entity for Nord Pool.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NordPoolDataUpdateCoordinator + + +class NordpoolBaseEntity(CoordinatorEntity[NordPoolDataUpdateCoordinator]): + """Representation of a Nord Pool base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: EntityDescription, + area: str, + ) -> None: + """Initiate Nord Pool base entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{area}-{entity_description.key}" + self.area = area + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, area)}, + name=f"Nord Pool {area}", + ) diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json new file mode 100644 index 00000000000..85434a2d09b --- /dev/null +++ b/homeassistant/components/nordpool/icons.json @@ -0,0 +1,42 @@ +{ + "entity": { + "sensor": { + "updated_at": { + "default": "mdi:clock-outline" + }, + "currency": { + "default": "mdi:currency-usd" + }, + "exchange_rate": { + "default": "mdi:currency-usd" + }, + "current_price": { + "default": "mdi:cash" + }, + "last_price": { + "default": "mdi:cash" + }, + "next_price": { + "default": "mdi:cash" + }, + "block_average": { + "default": "mdi:cash-multiple" + }, + "block_min": { + "default": "mdi:cash-multiple" + }, + "block_max": { + "default": "mdi:cash-multiple" + }, + "block_start_time": { + "default": "mdi:clock-time-twelve-outline" + }, + "block_end_time": { + "default": "mdi:clock-time-two-outline" + }, + "daily_average": { + "default": "mdi:cash-multiple" + } + } + } +} diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json new file mode 100644 index 00000000000..ba435c38b5e --- /dev/null +++ b/homeassistant/components/nordpool/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "nordpool", + "name": "Nord Pool", + "codeowners": ["@gjohansson-ST"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nordpool", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["pynordpool"], + "requirements": ["pynordpool==0.2.1"], + "single_config_entry": true +} diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py new file mode 100644 index 00000000000..e7e655a6657 --- /dev/null +++ b/homeassistant/components/nordpool/sensor.py @@ -0,0 +1,328 @@ +"""Sensor platform for Nord Pool integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta + +from pynordpool import DeliveryPeriodData + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util, slugify + +from . import NordPoolConfigEntry +from .const import LOGGER +from .coordinator import NordPoolDataUpdateCoordinator +from .entity import NordpoolBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_prices(data: DeliveryPeriodData) -> dict[str, tuple[float, float, float]]: + """Return previous, current and next prices. + + Output: {"SE3": (10.0, 10.5, 12.1)} + """ + last_price_entries: dict[str, float] = {} + current_price_entries: dict[str, float] = {} + next_price_entries: dict[str, float] = {} + current_time = dt_util.utcnow() + previous_time = current_time - timedelta(hours=1) + next_time = current_time + timedelta(hours=1) + price_data = data.entries + for entry in price_data: + if entry.start <= current_time <= entry.end: + current_price_entries = entry.entry + if entry.start <= previous_time <= entry.end: + last_price_entries = entry.entry + if entry.start <= next_time <= entry.end: + next_price_entries = entry.entry + + result = {} + for area, price in current_price_entries.items(): + result[area] = (last_price_entries[area], price, next_price_entries[area]) + LOGGER.debug("Prices: %s", result) + return result + + +def get_blockprices( + data: DeliveryPeriodData, +) -> dict[str, dict[str, tuple[datetime, datetime, float, float, float]]]: + """Return average, min and max for block prices. + + Output: {"SE3": {"Off-peak 1": (_datetime_, _datetime_, 9.3, 10.5, 12.1)}} + """ + result: dict[str, dict[str, tuple[datetime, datetime, float, float, float]]] = {} + block_prices = data.block_prices + for entry in block_prices: + for _area in entry.average: + if _area not in result: + result[_area] = {} + result[_area][entry.name] = ( + entry.start, + entry.end, + entry.average[_area]["average"], + entry.average[_area]["min"], + entry.average[_area]["max"], + ) + + LOGGER.debug("Block prices: %s", result) + return result + + +@dataclass(frozen=True, kw_only=True) +class NordpoolDefaultSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool default sensor entity.""" + + value_fn: Callable[[DeliveryPeriodData], str | float | datetime | None] + + +@dataclass(frozen=True, kw_only=True) +class NordpoolPricesSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool prices sensor entity.""" + + value_fn: Callable[[tuple[float, float, float]], float | None] + + +@dataclass(frozen=True, kw_only=True) +class NordpoolBlockPricesSensorEntityDescription(SensorEntityDescription): + """Describes Nord Pool block prices sensor entity.""" + + value_fn: Callable[ + [tuple[datetime, datetime, float, float, float]], float | datetime | None + ] + + +DEFAULT_SENSOR_TYPES: tuple[NordpoolDefaultSensorEntityDescription, ...] = ( + NordpoolDefaultSensorEntityDescription( + key="updated_at", + translation_key="updated_at", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.updated_at, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NordpoolDefaultSensorEntityDescription( + key="currency", + translation_key="currency", + value_fn=lambda data: data.currency, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NordpoolDefaultSensorEntityDescription( + key="exchange_rate", + translation_key="exchange_rate", + value_fn=lambda data: data.exchange_rate, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) +PRICES_SENSOR_TYPES: tuple[NordpoolPricesSensorEntityDescription, ...] = ( + NordpoolPricesSensorEntityDescription( + key="current_price", + translation_key="current_price", + value_fn=lambda data: data[1] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="last_price", + translation_key="last_price", + value_fn=lambda data: data[0] / 1000, + suggested_display_precision=2, + ), + NordpoolPricesSensorEntityDescription( + key="next_price", + translation_key="next_price", + value_fn=lambda data: data[2] / 1000, + suggested_display_precision=2, + ), +) +BLOCK_PRICES_SENSOR_TYPES: tuple[NordpoolBlockPricesSensorEntityDescription, ...] = ( + NordpoolBlockPricesSensorEntityDescription( + key="block_average", + translation_key="block_average", + value_fn=lambda data: data[2] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_min", + translation_key="block_min", + value_fn=lambda data: data[3] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_max", + translation_key="block_max", + value_fn=lambda data: data[4] / 1000, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_start_time", + translation_key="block_start_time", + value_fn=lambda data: data[0], + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), + NordpoolBlockPricesSensorEntityDescription( + key="block_end_time", + translation_key="block_end_time", + value_fn=lambda data: data[1], + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + ), +) +DAILY_AVERAGE_PRICES_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="daily_average", + translation_key="daily_average", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NordPoolConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Nord Pool sensor platform.""" + + coordinator = entry.runtime_data + + entities: list[NordpoolBaseEntity] = [] + currency = entry.runtime_data.data.currency + + for area in get_prices(entry.runtime_data.data): + LOGGER.debug("Setting up base sensors for area %s", area) + entities.extend( + NordpoolSensor(coordinator, description, area) + for description in DEFAULT_SENSOR_TYPES + ) + LOGGER.debug( + "Setting up price sensors for area %s with currency %s", area, currency + ) + entities.extend( + NordpoolPriceSensor(coordinator, description, area, currency) + for description in PRICES_SENSOR_TYPES + ) + entities.extend( + NordpoolDailyAveragePriceSensor(coordinator, description, area, currency) + for description in DAILY_AVERAGE_PRICES_SENSOR_TYPES + ) + for block_name in get_blockprices(coordinator.data)[area]: + LOGGER.debug( + "Setting up block price sensors for area %s with currency %s in block %s", + area, + currency, + block_name, + ) + entities.extend( + NordpoolBlockPriceSensor( + coordinator, description, area, currency, block_name + ) + for description in BLOCK_PRICES_SENSOR_TYPES + ) + async_add_entities(entities) + + +class NordpoolSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool sensor.""" + + entity_description: NordpoolDefaultSensorEntityDescription + + @property + def native_value(self) -> str | float | datetime | None: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) + + +class NordpoolPriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool price sensor.""" + + entity_description: NordpoolPricesSensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolPricesSensorEntityDescription, + area: str, + currency: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + self._attr_native_unit_of_measurement = f"{currency}/kWh" + + @property + def native_value(self) -> float | None: + """Return value of sensor.""" + return self.entity_description.value_fn( + get_prices(self.coordinator.data)[self.area] + ) + + +class NordpoolBlockPriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool block price sensor.""" + + entity_description: NordpoolBlockPricesSensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolBlockPricesSensorEntityDescription, + area: str, + currency: str, + block_name: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + if entity_description.device_class is not SensorDeviceClass.TIMESTAMP: + self._attr_native_unit_of_measurement = f"{currency}/kWh" + self._attr_unique_id = f"{slugify(block_name)}-{area}-{entity_description.key}" + self.block_name = block_name + self._attr_translation_placeholders = {"block": block_name} + + @property + def native_value(self) -> float | datetime | None: + """Return value of sensor.""" + return self.entity_description.value_fn( + get_blockprices(self.coordinator.data)[self.area][self.block_name] + ) + + +class NordpoolDailyAveragePriceSensor(NordpoolBaseEntity, SensorEntity): + """Representation of a Nord Pool daily average price sensor.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: SensorEntityDescription, + area: str, + currency: str, + ) -> None: + """Initiate Nord Pool sensor.""" + super().__init__(coordinator, entity_description, area) + self._attr_native_unit_of_measurement = f"{currency}/kWh" + + @property + def native_value(self) -> float | None: + """Return value of sensor.""" + return self.coordinator.data.area_average[self.area] / 1000 diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json new file mode 100644 index 00000000000..e55950c7d67 --- /dev/null +++ b/homeassistant/components/nordpool/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_data": "API connected but the response was empty" + }, + "step": { + "user": { + "data": { + "currency": "Currency", + "areas": "Areas" + } + } + } + }, + "entity": { + "sensor": { + "updated_at": { + "name": "Last updated" + }, + "currency": { + "name": "Currency" + }, + "exchange_rate": { + "name": "Exchange rate" + }, + "current_price": { + "name": "Current price" + }, + "last_price": { + "name": "Previous price" + }, + "next_price": { + "name": "Next price" + }, + "block_average": { + "name": "{block} average" + }, + "block_min": { + "name": "{block} lowest price" + }, + "block_max": { + "name": "{block} highest price" + }, + "block_start_time": { + "name": "{block} time from" + }, + "block_end_time": { + "name": "{block} time until" + }, + "daily_average": { + "name": "Daily average" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 887fb99a092..cbd30b560ce 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -408,6 +408,7 @@ FLOWS = { "nina", "nmap_tracker", "nobo_hub", + "nordpool", "notion", "nuheat", "nuki", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 14b8550d296..a1fdb9478f3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4187,6 +4187,13 @@ "config_flow": true, "iot_class": "local_push" }, + "nordpool": { + "name": "Nord Pool", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "single_config_entry": true + }, "norway_air": { "name": "Om Luftkvalitet i Norge (Norway Air)", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 15d1777f381..4d33f16d968 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3156,6 +3156,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nordpool.*] +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.notify.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 627d9937995..95d759b3211 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2095,6 +2095,9 @@ pynetio==0.1.9.1 # homeassistant.components.nobo_hub pynobo==1.8.1 +# homeassistant.components.nordpool +pynordpool==0.2.1 + # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b726627f1d6..0ac8e41900e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1688,6 +1688,9 @@ pynetgear==0.10.10 # homeassistant.components.nobo_hub pynobo==1.8.1 +# homeassistant.components.nordpool +pynordpool==0.2.1 + # homeassistant.components.nuki pynuki==1.6.3 diff --git a/tests/components/nordpool/__init__.py b/tests/components/nordpool/__init__.py new file mode 100644 index 00000000000..20d74d38486 --- /dev/null +++ b/tests/components/nordpool/__init__.py @@ -0,0 +1,9 @@ +"""Tests for the Nord Pool integration.""" + +from homeassistant.components.nordpool.const import CONF_AREAS +from homeassistant.const import CONF_CURRENCY + +ENTRY_CONFIG = { + CONF_AREAS: ["SE3", "SE4"], + CONF_CURRENCY: "SEK", +} diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py new file mode 100644 index 00000000000..305179c531a --- /dev/null +++ b/tests/components/nordpool/conftest.py @@ -0,0 +1,76 @@ +"""Fixtures for the Nord Pool integration.""" + +from __future__ import annotations + +from datetime import datetime +import json +from typing import Any +from unittest.mock import patch + +from pynordpool import NordPoolClient +from pynordpool.const import Currency +from pynordpool.model import DeliveryPeriodData +import pytest + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.fixture +async def load_int( + hass: HomeAssistant, get_data: DeliveryPeriodData +) -> MockConfigEntry: + """Set up the Nord Pool integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_data") +async def get_data_from_library( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, load_json: dict[str, Any] +) -> DeliveryPeriodData: + """Retrieve data from Nord Pool library.""" + + client = NordPoolClient(aioclient_mock.create_session(hass.loop)) + with patch("pynordpool.NordPoolClient._get", return_value=load_json): + output = await client.async_get_delivery_period( + datetime(2024, 11, 5, 13, tzinfo=dt_util.UTC), Currency.SEK, ["SE3", "SE4"] + ) + await client._session.close() + return output + + +@pytest.fixture(name="load_json") +def load_json_from_fixture(load_data: str) -> dict[str, Any]: + """Load fixture with json data and return.""" + return json.loads(load_data) + + +@pytest.fixture(name="load_data", scope="package") +def load_data_from_fixture() -> str: + """Load fixture with fixture data and return.""" + return load_fixture("delivery_period.json", DOMAIN) diff --git a/tests/components/nordpool/fixtures/delivery_period.json b/tests/components/nordpool/fixtures/delivery_period.json new file mode 100644 index 00000000000..77d51dc9433 --- /dev/null +++ b/tests/components/nordpool/fixtures/delivery_period.json @@ -0,0 +1,272 @@ +{ + "deliveryDateCET": "2024-11-05", + "version": 3, + "updatedAt": "2024-11-04T12:15:03.9456464Z", + "deliveryAreas": ["SE3", "SE4"], + "market": "DayAhead", + "multiAreaEntries": [ + { + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T00:00:00Z", + "entryPerArea": { + "SE3": 250.73, + "SE4": 283.79 + } + }, + { + "deliveryStart": "2024-11-05T00:00:00Z", + "deliveryEnd": "2024-11-05T01:00:00Z", + "entryPerArea": { + "SE3": 76.36, + "SE4": 81.36 + } + }, + { + "deliveryStart": "2024-11-05T01:00:00Z", + "deliveryEnd": "2024-11-05T02:00:00Z", + "entryPerArea": { + "SE3": 73.92, + "SE4": 79.15 + } + }, + { + "deliveryStart": "2024-11-05T02:00:00Z", + "deliveryEnd": "2024-11-05T03:00:00Z", + "entryPerArea": { + "SE3": 61.69, + "SE4": 65.19 + } + }, + { + "deliveryStart": "2024-11-05T03:00:00Z", + "deliveryEnd": "2024-11-05T04:00:00Z", + "entryPerArea": { + "SE3": 64.6, + "SE4": 68.44 + } + }, + { + "deliveryStart": "2024-11-05T04:00:00Z", + "deliveryEnd": "2024-11-05T05:00:00Z", + "entryPerArea": { + "SE3": 453.27, + "SE4": 516.71 + } + }, + { + "deliveryStart": "2024-11-05T05:00:00Z", + "deliveryEnd": "2024-11-05T06:00:00Z", + "entryPerArea": { + "SE3": 996.28, + "SE4": 1240.85 + } + }, + { + "deliveryStart": "2024-11-05T06:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "entryPerArea": { + "SE3": 1406.14, + "SE4": 1648.25 + } + }, + { + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T08:00:00Z", + "entryPerArea": { + "SE3": 1346.54, + "SE4": 1570.5 + } + }, + { + "deliveryStart": "2024-11-05T08:00:00Z", + "deliveryEnd": "2024-11-05T09:00:00Z", + "entryPerArea": { + "SE3": 1150.28, + "SE4": 1345.37 + } + }, + { + "deliveryStart": "2024-11-05T09:00:00Z", + "deliveryEnd": "2024-11-05T10:00:00Z", + "entryPerArea": { + "SE3": 1031.32, + "SE4": 1206.51 + } + }, + { + "deliveryStart": "2024-11-05T10:00:00Z", + "deliveryEnd": "2024-11-05T11:00:00Z", + "entryPerArea": { + "SE3": 927.37, + "SE4": 1085.8 + } + }, + { + "deliveryStart": "2024-11-05T11:00:00Z", + "deliveryEnd": "2024-11-05T12:00:00Z", + "entryPerArea": { + "SE3": 925.05, + "SE4": 1081.72 + } + }, + { + "deliveryStart": "2024-11-05T12:00:00Z", + "deliveryEnd": "2024-11-05T13:00:00Z", + "entryPerArea": { + "SE3": 949.49, + "SE4": 1130.38 + } + }, + { + "deliveryStart": "2024-11-05T13:00:00Z", + "deliveryEnd": "2024-11-05T14:00:00Z", + "entryPerArea": { + "SE3": 1042.03, + "SE4": 1256.91 + } + }, + { + "deliveryStart": "2024-11-05T14:00:00Z", + "deliveryEnd": "2024-11-05T15:00:00Z", + "entryPerArea": { + "SE3": 1258.89, + "SE4": 1765.82 + } + }, + { + "deliveryStart": "2024-11-05T15:00:00Z", + "deliveryEnd": "2024-11-05T16:00:00Z", + "entryPerArea": { + "SE3": 1816.45, + "SE4": 2522.55 + } + }, + { + "deliveryStart": "2024-11-05T16:00:00Z", + "deliveryEnd": "2024-11-05T17:00:00Z", + "entryPerArea": { + "SE3": 2512.65, + "SE4": 3533.03 + } + }, + { + "deliveryStart": "2024-11-05T17:00:00Z", + "deliveryEnd": "2024-11-05T18:00:00Z", + "entryPerArea": { + "SE3": 1819.83, + "SE4": 2524.06 + } + }, + { + "deliveryStart": "2024-11-05T18:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "entryPerArea": { + "SE3": 1011.77, + "SE4": 1804.46 + } + }, + { + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T20:00:00Z", + "entryPerArea": { + "SE3": 835.53, + "SE4": 1112.57 + } + }, + { + "deliveryStart": "2024-11-05T20:00:00Z", + "deliveryEnd": "2024-11-05T21:00:00Z", + "entryPerArea": { + "SE3": 796.19, + "SE4": 1051.69 + } + }, + { + "deliveryStart": "2024-11-05T21:00:00Z", + "deliveryEnd": "2024-11-05T22:00:00Z", + "entryPerArea": { + "SE3": 522.3, + "SE4": 662.44 + } + }, + { + "deliveryStart": "2024-11-05T22:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "entryPerArea": { + "SE3": 289.14, + "SE4": 349.21 + } + } + ], + "blockPriceAggregates": [ + { + "blockName": "Off-peak 1", + "deliveryStart": "2024-11-04T23:00:00Z", + "deliveryEnd": "2024-11-05T07:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 422.87, + "min": 61.69, + "max": 1406.14 + }, + "SE4": { + "average": 497.97, + "min": 65.19, + "max": 1648.25 + } + } + }, + { + "blockName": "Peak", + "deliveryStart": "2024-11-05T07:00:00Z", + "deliveryEnd": "2024-11-05T19:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 1315.97, + "min": 925.05, + "max": 2512.65 + }, + "SE4": { + "average": 1735.59, + "min": 1081.72, + "max": 3533.03 + } + } + }, + { + "blockName": "Off-peak 2", + "deliveryStart": "2024-11-05T19:00:00Z", + "deliveryEnd": "2024-11-05T23:00:00Z", + "averagePricePerArea": { + "SE3": { + "average": 610.79, + "min": 289.14, + "max": 835.53 + }, + "SE4": { + "average": 793.98, + "min": 349.21, + "max": 1112.57 + } + } + } + ], + "currency": "SEK", + "exchangeRate": 11.6402, + "areaStates": [ + { + "state": "Final", + "areas": ["SE3", "SE4"] + } + ], + "areaAverages": [ + { + "areaCode": "SE3", + "price": 900.74 + }, + { + "areaCode": "SE4", + "price": 1166.12 + } + ] +} diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..01600352861 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -0,0 +1,2215 @@ +# serializer version: 1 +# name: test_sensor[sensor.nord_pool_se3_currency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_currency', + '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': 'Currency', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'currency', + 'unique_id': 'SE3-currency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_currency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Currency', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_currency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SEK', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_current_price-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.nord_pool_se3_current_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_price', + 'unique_id': 'SE3-current_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_current_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Current price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_current_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.01177', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_daily_average-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.nord_pool_se3_daily_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daily average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_average', + 'unique_id': 'SE3-daily_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_daily_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Daily average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_daily_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.90074', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_exchange_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_exchange_rate', + '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': 'Exchange rate', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exchange_rate', + 'unique_id': 'SE3-exchange_rate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_exchange_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Exchange rate', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_exchange_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.6402', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_last_updated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se3_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'updated_at', + 'unique_id': 'SE3-updated_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_last_updated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Last updated', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_last_updated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T12:15:03+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_next_price-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.nord_pool_se3_next_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_price', + 'unique_id': 'SE3-next_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_next_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Next price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_next_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.83553', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-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.nord_pool_se3_off_peak_1_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_1-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42287', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-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.nord_pool_se3_off_peak_1_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_1-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.40614', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-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.nord_pool_se3_off_peak_1_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_1-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 1 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06169', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-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.nord_pool_se3_off_peak_1_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_1-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 1 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-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.nord_pool_se3_off_peak_1_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_1-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 1 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_1_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-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.nord_pool_se3_off_peak_2_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_2-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.61079', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-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.nord_pool_se3_off_peak_2_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_2-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.83553', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-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.nord_pool_se3_off_peak_2_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_2-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Off-peak 2 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.28914', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-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.nord_pool_se3_off_peak_2_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_2-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 2 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-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.nord_pool_se3_off_peak_2_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_2-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Off-peak 2 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_off_peak_2_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_average-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.nord_pool_se3_peak_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'peak-SE3-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.31597', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-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.nord_pool_se3_peak_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'peak-SE3-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.51265', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-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.nord_pool_se3_peak_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'peak-SE3-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Peak lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.92505', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_from-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.nord_pool_se3_peak_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'peak-SE3-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Peak time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_until-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.nord_pool_se3_peak_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'peak-SE3-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_peak_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE3 Peak time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_peak_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_previous_price-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.nord_pool_se3_previous_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Previous price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_price', + 'unique_id': 'SE3-last_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se3_previous_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Previous price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se3_previous_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.81983', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_currency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_currency', + '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': 'Currency', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'currency', + 'unique_id': 'SE4-currency', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_currency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Currency', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_currency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'SEK', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_current_price-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.nord_pool_se4_current_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_price', + 'unique_id': 'SE4-current_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_current_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Current price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_current_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.80446', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_daily_average-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.nord_pool_se4_daily_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Daily average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_average', + 'unique_id': 'SE4-daily_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_daily_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Daily average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_daily_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.16612', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_exchange_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_exchange_rate', + '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': 'Exchange rate', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'exchange_rate', + 'unique_id': 'SE4-exchange_rate', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_exchange_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Exchange rate', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_exchange_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.6402', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_last_updated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.nord_pool_se4_last_updated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last updated', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'updated_at', + 'unique_id': 'SE4-updated_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_last_updated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Last updated', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_last_updated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T12:15:03+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_next_price-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.nord_pool_se4_next_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Next price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_price', + 'unique_id': 'SE4-next_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_next_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Next price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_next_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11257', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-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.nord_pool_se4_off_peak_1_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_1-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.49797', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-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.nord_pool_se4_off_peak_1_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_1-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.64825', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-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.nord_pool_se4_off_peak_1_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 1 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_1-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 1 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.06519', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-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.nord_pool_se4_off_peak_1_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_1-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 1 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-04T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-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.nord_pool_se4_off_peak_1_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 1 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_1-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 1 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_1_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-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.nord_pool_se4_off_peak_2_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'off_peak_2-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.79398', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-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.nord_pool_se4_off_peak_2_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'off_peak_2-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.11257', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-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.nord_pool_se4_off_peak_2_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Off-peak 2 lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'off_peak_2-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Off-peak 2 lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.34921', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-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.nord_pool_se4_off_peak_2_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'off_peak_2-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 2 time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-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.nord_pool_se4_off_peak_2_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Off-peak 2 time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'off_peak_2-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Off-peak 2 time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_off_peak_2_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T23:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_average-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.nord_pool_se4_peak_average', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak average', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_average', + 'unique_id': 'peak-SE4-block_average', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_average-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak average', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_average', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.73559', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-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.nord_pool_se4_peak_highest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak highest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_max', + 'unique_id': 'peak-SE4-block_max', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak highest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_highest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.53303', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-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.nord_pool_se4_peak_lowest_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Peak lowest price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_min', + 'unique_id': 'peak-SE4-block_min', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Peak lowest price', + 'state_class': , + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_lowest_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.08172', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_from-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.nord_pool_se4_peak_time_from', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time from', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_start_time', + 'unique_id': 'peak-SE4-block_start_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_from-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Peak time from', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_time_from', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T07:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_until-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.nord_pool_se4_peak_time_until', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Peak time until', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'block_end_time', + 'unique_id': 'peak-SE4-block_end_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_peak_time_until-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Nord Pool SE4 Peak time until', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_peak_time_until', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-11-05T19:00:00+00:00', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_previous_price-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.nord_pool_se4_previous_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Previous price', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_price', + 'unique_id': 'SE4-last_price', + 'unit_of_measurement': 'SEK/kWh', + }) +# --- +# name: test_sensor[sensor.nord_pool_se4_previous_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Previous price', + 'unit_of_measurement': 'SEK/kWh', + }), + 'context': , + 'entity_id': 'sensor.nord_pool_se4_previous_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.52406', + }) +# --- diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py new file mode 100644 index 00000000000..dbd85a07a17 --- /dev/null +++ b/tests/components/nordpool/test_config_flow.py @@ -0,0 +1,151 @@ +"""Test the Nord Pool config flow.""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import patch + +from pynordpool import ( + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolConnectionError, + NordPoolError, + NordPoolResponseError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ENTRY_CONFIG + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["version"] == 1 + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_single_config_entry( + hass: HomeAssistant, load_int: None, get_data: DeliveryPeriodData +) -> None: + """Test abort for single config entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.parametrize( + ("error_message", "p_error"), + [ + (NordPoolConnectionError, "cannot_connect"), + (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolError, "cannot_connect"), + (NordPoolResponseError, "cannot_connect"), + ], +) +async def test_cannot_connect( + hass: HomeAssistant, + get_data: DeliveryPeriodData, + error_message: Exception, + p_error: str, +) -> None: + """Test cannot connect error.""" + + 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.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error_message, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test empty data error.""" + + 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 + + invalid_data = replace(get_data, raw={}) + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=invalid_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"] == {"base": "no_data"} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py new file mode 100644 index 00000000000..9cff34adb1f --- /dev/null +++ b/tests/components/nordpool/test_coordinator.py @@ -0,0 +1,114 @@ +"""The test for the Nord Pool coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pynordpool import ( + DeliveryPeriodData, + NordPoolAuthenticationError, + NordPoolError, + NordPoolResponseError, +) +import pytest + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.freeze_time("2024-11-05T12:00:00+00:00") +async def test_coordinator( + hass: HomeAssistant, + get_data: DeliveryPeriodData, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the Nord Pool coordinator with errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + + config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + ) as mock_data, + ): + mock_data.return_value = get_data + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "0.94949" + mock_data.reset_mock() + + mock_data.side_effect = NordPoolError("error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + assert "Authentication error" not in caplog.text + mock_data.side_effect = NordPoolAuthenticationError("Authentication error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Authentication error" in caplog.text + mock_data.reset_mock() + + assert "Response error" not in caplog.text + mock_data.side_effect = NordPoolResponseError("Response error") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Response error" in caplog.text + mock_data.reset_mock() + + mock_data.return_value = DeliveryPeriodData( + raw={}, + requested_date="2024-11-05", + updated_at=dt_util.utcnow(), + entries=[], + block_prices=[], + currency="SEK", + exchange_rate=1, + area_average={}, + ) + mock_data.side_effect = None + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + mock_data.return_value = get_data + mock_data.side_effect = None + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == "1.81983" diff --git a/tests/components/nordpool/test_init.py b/tests/components/nordpool/test_init.py new file mode 100644 index 00000000000..5ec1c4b3a0b --- /dev/null +++ b/tests/components/nordpool/test_init.py @@ -0,0 +1,39 @@ +"""Test for Nord Pool component Init.""" + +from __future__ import annotations + +from unittest.mock import patch + +from pynordpool import DeliveryPeriodData + +from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: + """Test load and unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + ) + entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py new file mode 100644 index 00000000000..c7a305c8a40 --- /dev/null +++ b/tests/components/nordpool/test_sensor.py @@ -0,0 +1,25 @@ +"""The test for the Nord Pool sensor platform.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import snapshot_platform + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Nord Pool sensor.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) From 3eab0b704e551f4740251b65cdbf3c8814b84e74 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 8 Nov 2024 16:12:18 +0200 Subject: [PATCH 324/511] Get/Set custom config parameter for zwave_js node (#129332) * Get/Set custom config parameter for zwave_js node * add tests * handle errors on set * test FailedCommand --- homeassistant/components/zwave_js/api.py | 71 +++++++++ tests/components/zwave_js/test_api.py | 176 +++++++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 7d3bd8273ec..bd49e85b601 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -56,6 +56,7 @@ from zwave_js_server.model.utils import ( async_parse_qr_code_string, async_try_parse_dsk_from_qr_code_string, ) +from zwave_js_server.model.value import ConfigurationValueFormat from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api @@ -106,6 +107,8 @@ PROPERTY = "property" PROPERTY_KEY = "property_key" ENDPOINT = "endpoint" VALUE = "value" +VALUE_SIZE = "value_size" +VALUE_FORMAT = "value_format" # constants for log config commands CONFIG = "config" @@ -416,6 +419,8 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_rebuild_node_routes) websocket_api.async_register_command(hass, websocket_set_config_parameter) websocket_api.async_register_command(hass, websocket_get_config_parameters) + websocket_api.async_register_command(hass, websocket_get_raw_config_parameter) + websocket_api.async_register_command(hass, websocket_set_raw_config_parameter) websocket_api.async_register_command(hass, websocket_subscribe_log_updates) websocket_api.async_register_command(hass, websocket_update_log_config) websocket_api.async_register_command(hass, websocket_get_log_config) @@ -1760,6 +1765,72 @@ async def websocket_get_config_parameters( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/set_raw_config_parameter", + vol.Required(DEVICE_ID): str, + vol.Required(PROPERTY): int, + vol.Required(VALUE): int, + vol.Required(VALUE_SIZE): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), + vol.Required(VALUE_FORMAT): vol.Coerce(ConfigurationValueFormat), + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_set_raw_config_parameter( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Set a custom config parameter value for a Z-Wave node.""" + result = await node.async_set_raw_config_parameter_value( + msg[VALUE], + msg[PROPERTY], + value_size=msg[VALUE_SIZE], + value_format=msg[VALUE_FORMAT], + ) + + connection.send_result( + msg[ID], + { + STATUS: result.status, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_raw_config_parameter", + vol.Required(DEVICE_ID): str, + vol.Required(PROPERTY): int, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_raw_config_parameter( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + node: Node, +) -> None: + """Get a custom config parameter value for a Z-Wave node.""" + value = await node.async_get_raw_config_parameter_value( + msg[PROPERTY], + ) + + connection.send_result( + msg[ID], + { + VALUE: value, + }, + ) + + def filename_is_present_if_logging_to_file(obj: dict) -> dict: """Validate that filename is provided if log_to_file is True.""" if obj.get(LOG_TO_FILE, False) and FILENAME not in obj: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 8251d7d280f..df1adbc98e5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -78,6 +78,8 @@ from homeassistant.components.zwave_js.api import ( TYPE, UUID, VALUE, + VALUE_FORMAT, + VALUE_SIZE, VERSION, ) from homeassistant.components.zwave_js.const import ( @@ -3137,6 +3139,180 @@ async def test_get_config_parameters( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_set_raw_config_parameter( + hass: HomeAssistant, + client, + multisensor_6, + integration, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the set_raw_config_parameter WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + # Change from async_send_command to async_send_command_no_wait + client.async_send_command_no_wait.return_value = None + + # Test setting a raw config parameter value + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["status"] == "queued" + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.set_raw_config_parameter_value" + assert args["nodeId"] == multisensor_6.node_id + assert args["options"]["parameter"] == 102 + assert args["options"]["value"] == 1 + assert args["options"]["valueSize"] == 2 + assert args["options"]["valueFormat"] == 1 + + # Reset the mock for async_send_command_no_wait instead + client.async_send_command_no_wait.reset_mock() + + # Test getting non-existent node fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: "fake_device", + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/set_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + VALUE: 1, + VALUE_SIZE: 2, + VALUE_FORMAT: 1, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_get_raw_config_parameter( + hass: HomeAssistant, + multisensor_6, + integration, + client, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the get_raw_config_parameter websocket command.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = {"value": 1} + + # Test getting a raw config parameter value + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"]["value"] == 1 + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "endpoint.get_raw_config_parameter_value" + assert args["nodeId"] == multisensor_6.node_id + assert args["options"]["parameter"] == 102 + + client.async_send_command.reset_mock() + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_raw_config_parameter_value", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + + # Test getting non-existent node fails + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: "fake_device", + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test FailedCommand exception + client.async_send_command.side_effect = FailedCommand("test", "test") + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "test" + assert msg["error"]["message"] == "Command failed: test" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json_auto_id( + { + TYPE: "zwave_js/get_raw_config_parameter", + DEVICE_ID: device.id, + PROPERTY: 102, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + @pytest.mark.parametrize( ("firmware_data", "expected_data"), [({"target": "1"}, {"firmware_target": 1}), ({}, {})], From 52ed1bf44abb95928e67a6d65bedeef583d006ba Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Nov 2024 15:13:05 +0100 Subject: [PATCH 325/511] Update frontend to 20241106.2 (#130128) --- 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 1ac7e661abe..4dc5a2b0ae4 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==20241106.1"] + "requirements": ["home-assistant-frontend==20241106.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 05fabb340ff..c73cb5edaa3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 home-assistant-bluetooth==1.13.0 -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 home-assistant-intents==2024.11.6 httpx==0.27.2 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 95d759b3211..0309ab20c35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation home-assistant-intents==2024.11.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ac8e41900e..644be49d95a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ hole==0.8.0 holidays==0.60 # homeassistant.components.frontend -home-assistant-frontend==20241106.1 +home-assistant-frontend==20241106.2 # homeassistant.components.conversation home-assistant-intents==2024.11.6 From 6c7ac7a6ef5bbe48b10576d3f0398be1af29b441 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Nov 2024 15:53:26 +0100 Subject: [PATCH 326/511] Bump spotifyaio to 0.8.7 (#130140) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 8cf8d735553..afe352904ce 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.5"], + "requirements": ["spotifyaio==0.8.7"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0309ab20c35..b1882cd620f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2713,7 +2713,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 644be49d95a..7a923dc8422 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.5 +spotifyaio==0.8.7 # homeassistant.components.sql sqlparse==0.5.0 From 51e691f8321e30cb25c0de24b92e52cfd699f5b3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 15:54:14 +0100 Subject: [PATCH 327/511] Add go2rtc workaround for HA managed one until upstream fixes it (#130139) --- homeassistant/components/go2rtc/__init__.py | 75 +++++-- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/server.py | 15 +- tests/components/go2rtc/test_init.py | 211 ++++++++++++++++++-- tests/components/go2rtc/test_server.py | 5 +- 5 files changed, 270 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index a07a62305f2..ca4aeeed938 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,5 +1,8 @@ """The go2rtc component.""" +from __future__ import annotations + +from dataclasses import dataclass import logging import shutil @@ -38,7 +41,13 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL +from .const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, + HA_MANAGED_RTSP_PORT, + HA_MANAGED_URL, +) from .server import Server _LOGGER = logging.getLogger(__name__) @@ -85,13 +94,22 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) +_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) +@dataclass(frozen=True) +class Go2RtcData: + """Data for go2rtc.""" + + url: str + managed: bool + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None + managed = False if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True @@ -126,8 +144,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL + managed = True - hass.data[_DATA_GO2RTC] = url + hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) @@ -142,28 +161,32 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - url = hass.data[_DATA_GO2RTC] + data = hass.data[_DATA_GO2RTC] # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), url) + client = Go2RtcRestClient(async_get_clientsession(hass), data.url) await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( - f"Could not connect to go2rtc instance on {url}" + f"Could not connect to go2rtc instance on {data.url}" ) from err - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 - _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) + _LOGGER.warning( + "Could not connect to go2rtc instance on %s (%s)", data.url, err + ) return False - provider = WebRTCProvider(hass, url) + provider = WebRTCProvider(hass, data) async_register_webrtc_provider(hass, provider) return True @@ -181,12 +204,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, url: str) -> None: + def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: """Initialize the WebRTC provider.""" self._hass = hass - self._url = url + self._data = data self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, url) + self._rest_client = Go2RtcRestClient(self._session, data.url) self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -208,7 +231,7 @@ class WebRTCProvider(CameraWebRTCProvider): ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._url, source=camera.entity_id + self._session, self._data.url, source=camera.entity_id ) if not (stream_source := await camera.stream_source()): @@ -219,8 +242,30 @@ class WebRTCProvider(CameraWebRTCProvider): streams = await self._rest_client.streams.list() - if (stream := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream.producers + if self._data.managed: + # HA manages the go2rtc instance + stream_org_name = camera.entity_id + "_orginal" + stream_redirect_sources = [ + f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}", + f"ffmpeg:{stream_org_name}#audio=opus", + ] + + if ( + (stream_org := streams.get(stream_org_name)) is None + or not any( + stream_source == producer.url for producer in stream_org.producers + ) + or (stream_redirect := streams.get(camera.entity_id)) is None + or stream_redirect_sources != [p.url for p in stream_redirect.producers] + ): + await self._rest_client.streams.add(stream_org_name, stream_source) + await self._rest_client.streams.add( + camera.entity_id, stream_redirect_sources + ) + + # go2rtc instance is managed outside HA + elif (stream_org := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream_org.producers ): await self._rest_client.streams.add( camera.entity_id, diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index d33ae3e3897..3c4dc9a9500 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" +HA_MANAGED_RTSP_PORT = 18554 diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index ed3b44aadf9..91f4433546c 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -24,15 +24,16 @@ _RESPAWN_COOLDOWN = 1 # Default configuration for HA # - Api is listening only on localhost -# - Disable rtsp listener +# - Enable rtsp for localhost only as ffmpeg needs it # - Clear default ice servers -_GO2RTC_CONFIG_FORMAT = r""" +_GO2RTC_CONFIG_FORMAT = r"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:{api_port}" rtsp: - # ffmpeg needs rtsp for opus audio transcoding - listen: "127.0.0.1:18554" + listen: "127.0.0.1:{rtsp_port}" webrtc: listen: ":18555/tcp" @@ -67,7 +68,9 @@ def _create_temp_file(api_ip: str) -> str: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, api_port=HA_MANAGED_API_PORT + api_ip=api_ip, + api_port=HA_MANAGED_API_PORT, + rtsp_port=HA_MANAGED_RTSP_PORT, ).encode() ) return file.name diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 18a46fdd4d1..ea1971a31d9 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator import logging from typing import NamedTuple -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, call, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream @@ -296,7 +296,7 @@ async def _test_setup_and_signaling( ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go_binary( +async def test_setup_managed( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -308,15 +308,131 @@ async def test_setup_go_binary( config: ConfigType, ui_enabled: bool, ) -> None: - """Test the go2rtc config entry with binary.""" + """Test the go2rtc setup with managed go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry + camera = init_test_integration - def after_setup() -> None: - server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) - server_start.assert_called_once() + entity_id = camera.entity_id + stream_name_orginal = camera.entity_id + "_orginal" + assert camera.frontend_stream_type == StreamType.HLS - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() + + receive_message_callback = Mock(spec_set=WebRTCSendMessage) + + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + stream_added_calls = [ + call(stream_name_orginal, "rtsp://stream"), + call( + entity_id, + [ + f"rtsp://127.0.0.1:18554/{stream_name_orginal}", + f"ffmpeg:{stream_name_orginal}#audio=opus", + ], + ), + ] + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original missing + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream original source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://different")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # Stream source different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream([Producer("rtsp://different")]), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + assert rest_client.streams.add.call_args_list == stream_added_calls + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + stream_name_orginal: Stream([Producer("rtsp://stream")]), + entity_id: Stream( + [ + Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), + Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + ] + ), + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) await hass.async_stop() @@ -332,7 +448,7 @@ async def test_setup_go_binary( ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go( +async def test_setup_self_hosted( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -342,16 +458,83 @@ async def test_setup_go( mock_is_docker_env: Mock, has_go2rtc_entry: bool, ) -> None: - """Test the go2rtc config entry without binary.""" + """Test the go2rtc with selfhosted go2rtc instance.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} + camera = init_test_integration - def after_setup() -> None: - server.assert_not_called() + entity_id = camera.entity_id + assert camera.frontend_stream_type == StreamType.HLS - await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert config_entries[0].state == ConfigEntryState.LOADED + server.assert_not_called() + + receive_message_callback = Mock(spec_set=WebRTCSendMessage) + + async def test() -> None: + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + ws_client.send.assert_called_once_with( + WebRTCOffer( + OFFER_SDP, + camera.async_get_webrtc_client_configuration().configuration.ice_servers, + ) + ) + ws_client.subscribe.assert_called_once() + + # Simulate the answer from the go2rtc server + callback = ws_client.subscribe.call_args[0][0] + callback(WebRTCAnswer(ANSWER_SDP)) + receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) + + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # Stream exists but the source is different + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://different")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_called_once_with( + entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + ) + + # If the stream is already added, the stream should not be added again. + rest_client.streams.add.reset_mock() + rest_client.streams.list.return_value = { + entity_id: Stream([Producer("rtsp://stream")]) + } + + receive_message_callback.reset_mock() + ws_client.reset_mock() + await test() + + rest_client.streams.add.assert_not_called() + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") ) mock_get_binary.assert_not_called() diff --git a/tests/components/go2rtc/test_server.py b/tests/components/go2rtc/test_server.py index d810dbd88eb..e4fe3993f3c 100644 --- a/tests/components/go2rtc/test_server.py +++ b/tests/components/go2rtc/test_server.py @@ -105,12 +105,13 @@ async def test_server_run_success( # Verify that the config file was written mock_tempfile.write.assert_called_once_with( - f""" + f"""# This file is managed by Home Assistant +# Do not edit it manually + api: listen: "{api_ip}:11984" rtsp: - # ffmpeg needs rtsp for opus audio transcoding listen: "127.0.0.1:18554" webrtc: From 6b90d8ff1ab78c00e04f08c683bfb1cbe5aabfce Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Fri, 8 Nov 2024 16:54:46 +0200 Subject: [PATCH 328/511] Add binary sensor platform to the Lektrico integration (#129872) --- homeassistant/components/lektrico/__init__.py | 1 + .../components/lektrico/binary_sensor.py | 139 ++++++ .../components/lektrico/strings.json | 32 ++ .../lektrico/fixtures/get_info.json | 12 +- .../snapshots/test_binary_sensor.ambr | 471 ++++++++++++++++++ .../components/lektrico/test_binary_sensor.py | 32 ++ 6 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lektrico/binary_sensor.py create mode 100644 tests/components/lektrico/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/lektrico/test_binary_sensor.py diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index c309bb42ece..475b6132541 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -12,6 +12,7 @@ from .coordinator import LektricoDeviceDataUpdateCoordinator # List the platforms that charger supports. CHARGERS_PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/lektrico/binary_sensor.py b/homeassistant/components/lektrico/binary_sensor.py new file mode 100644 index 00000000000..d0a3e39690c --- /dev/null +++ b/homeassistant/components/lektrico/binary_sensor.py @@ -0,0 +1,139 @@ +"""Support for Lektrico binary sensors entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Lektrico binary sensor entity.""" + + value_fn: Callable[[dict[str, Any]], bool] + + +BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = ( + LektricoBinarySensorEntityDescription( + key="state_e_activated", + translation_key="state_e_activated", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["state_e_activated"]), + ), + LektricoBinarySensorEntityDescription( + key="overtemp", + translation_key="overtemp", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overtemp"]), + ), + LektricoBinarySensorEntityDescription( + key="critical_temp", + translation_key="critical_temp", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["critical_temp"]), + ), + LektricoBinarySensorEntityDescription( + key="overcurrent", + translation_key="overcurrent", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overcurrent"]), + ), + LektricoBinarySensorEntityDescription( + key="meter_fault", + translation_key="meter_fault", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["meter_fault"]), + ), + LektricoBinarySensorEntityDescription( + key="undervoltage", + translation_key="undervoltage", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["undervoltage_error"]), + ), + LektricoBinarySensorEntityDescription( + key="overvoltage", + translation_key="overvoltage", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["overvoltage_error"]), + ), + LektricoBinarySensorEntityDescription( + key="rcd_error", + translation_key="rcd_error", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["rcd_error"]), + ), + LektricoBinarySensorEntityDescription( + key="cp_diode_failure", + translation_key="cp_diode_failure", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["cp_diode_failure"]), + ), + LektricoBinarySensorEntityDescription( + key="contactor_failure", + translation_key="contactor_failure", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + value_fn=lambda data: bool(data["contactor_failure"]), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico binary sensor entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LektricoBinarySensor( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in BINARY_SENSORS + ) + + +class LektricoBinarySensor(LektricoEntity, BinarySensorEntity): + """Defines a Lektrico binary sensor entity.""" + + entity_description: LektricoBinarySensorEntityDescription + + def __init__( + self, + description: LektricoBinarySensorEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico binary sensor.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._coordinator = coordinator + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index e6dc7b9eb46..e24700c9b09 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -22,6 +22,38 @@ } }, "entity": { + "binary_sensor": { + "state_e_activated": { + "name": "Ev error" + }, + "overtemp": { + "name": "Thermal throttling" + }, + "critical_temp": { + "name": "Overheating" + }, + "overcurrent": { + "name": "Overcurrent" + }, + "meter_fault": { + "name": "Metering error" + }, + "undervoltage": { + "name": "Undervoltage" + }, + "overvoltage": { + "name": "Overvoltage" + }, + "rcd_error": { + "name": "Rcd error" + }, + "cp_diode_failure": { + "name": "Ev diode short" + }, + "contactor_failure": { + "name": "Relay contacts welded" + } + }, "button": { "charge_start": { "name": "Charge start" diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json index bcd84a9a9df..2b099a666e5 100644 --- a/tests/components/lektrico/fixtures/get_info.json +++ b/tests/components/lektrico/fixtures/get_info.json @@ -14,5 +14,15 @@ "dynamic_current": 32, "user_current": 32, "lb_mode": 0, - "require_auth": true + "require_auth": true, + "state_e_activated": false, + "undervoltage_error": true, + "rcd_error": false, + "meter_fault": false, + "overcurrent": false, + "overtemp": false, + "overvoltage_error": false, + "contactor_failure": false, + "cp_diode_failure": false, + "critical_temp": false } diff --git a/tests/components/lektrico/snapshots/test_binary_sensor.ambr b/tests/components/lektrico/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..6a28e7c60de --- /dev/null +++ b/tests/components/lektrico/snapshots/test_binary_sensor.ambr @@ -0,0 +1,471 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.1p7k_500006_ev_diode_short-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ev diode short', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cp_diode_failure', + 'unique_id': '500006_cp_diode_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_diode_short-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Ev diode short', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_diode_short', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_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': 'Ev error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_e_activated', + 'unique_id': '500006_state_e_activated', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_ev_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Ev error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_ev_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_metering_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_metering_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': 'Metering error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'meter_fault', + 'unique_id': '500006_meter_fault', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_metering_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Metering error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_metering_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overcurrent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overcurrent', + 'unique_id': '500006_overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overheating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'critical_temp', + 'unique_id': '500006_critical_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overvoltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overvoltage', + 'unique_id': '500006_overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_rcd_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_rcd_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': 'Rcd error', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'rcd_error', + 'unique_id': '500006_rcd_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_rcd_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Rcd error', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_rcd_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_relay_contacts_welded-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_relay_contacts_welded', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Relay contacts welded', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'contactor_failure', + 'unique_id': '500006_contactor_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_relay_contacts_welded-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Relay contacts welded', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_relay_contacts_welded', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_thermal_throttling-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_thermal_throttling', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Thermal throttling', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'overtemp', + 'unique_id': '500006_overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_thermal_throttling-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Thermal throttling', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_thermal_throttling', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_undervoltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.1p7k_500006_undervoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Undervoltage', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'undervoltage', + 'unique_id': '500006_undervoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.1p7k_500006_undervoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': '1p7k_500006 Undervoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.1p7k_500006_undervoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lektrico/test_binary_sensor.py b/tests/components/lektrico/test_binary_sensor.py new file mode 100644 index 00000000000..d49eac6cc23 --- /dev/null +++ b/tests/components/lektrico/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the Lektrico binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.BINARY_SENSOR], + LB_DEVICES_PLATFORMS=[Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 353ccf3ea7d67af121db1b77dac3278140ec585b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 8 Nov 2024 15:55:19 +0100 Subject: [PATCH 329/511] Only apply OptionsFlowWithConfigEntry deprecation to core (#130054) * Only apply OptionsFlowWithConfigEntry deprecation to core * Fix match string in pytest.raises * Improve coverage --- homeassistant/config_entries.py | 18 ++++++++++------- tests/test_config_entries.py | 34 ++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0d4cc5fd102..64eadeb0d7e 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -63,7 +63,7 @@ from .helpers.event import ( RANDOM_MICROSECOND_MIN, async_call_later, ) -from .helpers.frame import report +from .helpers.frame import ReportBehavior, report, report_usage from .helpers.json import json_bytes, json_bytes_sorted, json_fragment from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .loader import async_suggest_report_issue @@ -3168,17 +3168,21 @@ class OptionsFlow(ConfigEntryBaseFlow): class OptionsFlowWithConfigEntry(OptionsFlow): - """Base class for options flows with config entry and options.""" + """Base class for options flows with config entry and options. + + This class is being phased out, and should not be referenced in new code. + It is kept only for backward compatibility, and only for custom integrations. + """ def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self._config_entry = config_entry self._options = deepcopy(dict(config_entry.options)) - report( - "inherits from OptionsFlowWithConfigEntry, which is deprecated " - "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, + report_usage( + "inherits from OptionsFlowWithConfigEntry", + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.IGNORE, ) @property diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index df464f6af1b..eb2a719eab8 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5040,6 +5040,24 @@ async def test_async_wait_component_startup(hass: HomeAssistant) -> None: assert "test" in hass.config.components +@pytest.mark.parametrize( + "integration_frame_path", + ["homeassistant/components/my_integration", "homeassistant.core"], +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_options_flow_with_config_entry_core() -> None: + """Test that OptionsFlowWithConfigEntry cannot be used in core.""" + entry = MockConfigEntry( + domain="hue", + data={"first": True}, + options={"sub_dict": {"1": "one"}, "sub_list": ["one"]}, + ) + + with pytest.raises(RuntimeError, match="inherits from OptionsFlowWithConfigEntry"): + _ = config_entries.OptionsFlowWithConfigEntry(entry) + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) -> None: @@ -5051,15 +5069,17 @@ async def test_options_flow_with_config_entry(caplog: pytest.LogCaptureFixture) ) options_flow = config_entries.OptionsFlowWithConfigEntry(entry) - assert ( - "Detected that integration 'hue' inherits from OptionsFlowWithConfigEntry," - " which is deprecated and will stop working in 2025.12" in caplog.text - ) + assert caplog.text == "" # No deprecation warning for custom components - options_flow._options["sub_dict"]["2"] = "two" - options_flow._options["sub_list"].append("two") + # Ensure available at startup + assert options_flow.config_entry is entry + assert options_flow.options == entry.options - assert options_flow._options == { + options_flow.options["sub_dict"]["2"] = "two" + options_flow.options["sub_list"].append("two") + + # Ensure it does not mutate the entry options + assert options_flow.options == { "sub_dict": {"1": "one", "2": "two"}, "sub_list": ["one", "two"], } From 14285973b875da6ac8ea121359a98f190397b17f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 8 Nov 2024 16:00:24 +0100 Subject: [PATCH 330/511] Bump ha-ffmpeg to 3.2.2 (#130142) --- homeassistant/components/ffmpeg/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json index e5f4f8b93a8..085db6791b3 100644 --- a/homeassistant/components/ffmpeg/manifest.json +++ b/homeassistant/components/ffmpeg/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/ffmpeg", "integration_type": "system", - "requirements": ["ha-ffmpeg==3.2.1"] + "requirements": ["ha-ffmpeg==3.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c73cb5edaa3..3f7bb758e81 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 go2rtc-client==0.1.0 -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 hassil==1.7.4 diff --git a/requirements_all.txt b/requirements_all.txt index b1882cd620f..45e2077abf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1069,7 +1069,7 @@ guppy3==3.1.4.post1 h2==4.1.0 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a923dc8422..9e34403c87b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -907,7 +907,7 @@ guppy3==3.1.4.post1 h2==4.1.0 # homeassistant.components.ffmpeg -ha-ffmpeg==3.2.1 +ha-ffmpeg==3.2.2 # homeassistant.components.iotawatt ha-iotawattpy==0.1.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 97fc6c49d12..745159d61d3 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.1 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From c4762f3ff4ea611b012e497f4858440b7c69335c Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 8 Nov 2024 17:15:28 +0200 Subject: [PATCH 331/511] Fix issue when timestamp is None (#130133) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/seventeentrack/services.py | 33 +++++++++------- .../snapshots/test_services.ambr | 29 ++++++++++++++ .../seventeentrack/test_services.py | 38 +++++++++++++++++++ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 0833bc0a97b..54c23e6d619 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -1,8 +1,8 @@ """Services for the seventeentrack integration.""" -from typing import Final +from typing import Any, Final -from pyseventeentrack.package import PACKAGE_STATUS_MAP +from pyseventeentrack.package import PACKAGE_STATUS_MAP, Package import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -81,18 +81,7 @@ def setup_services(hass: HomeAssistant) -> None: return { "packages": [ - { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp.isoformat(), - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } + package_to_dict(package) for package in live_packages if slugify(package.status) in package_states or package_states == [] ] @@ -110,6 +99,22 @@ def setup_services(hass: HomeAssistant) -> None: await seventeen_coordinator.client.profile.archive_package(tracking_number) + def package_to_dict(package: Package) -> dict[str, Any]: + result = { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + if timestamp := package.timestamp: + result[ATTR_TIMESTAMP] = timestamp.isoformat() + return result + async def _validate_service(config_entry_id): entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) if not entry: diff --git a/tests/components/seventeentrack/snapshots/test_services.ambr b/tests/components/seventeentrack/snapshots/test_services.ambr index 568acea33a5..e172a2de594 100644 --- a/tests/components/seventeentrack/snapshots/test_services.ambr +++ b/tests/components/seventeentrack/snapshots/test_services.ambr @@ -71,3 +71,32 @@ ]), }) # --- +# name: test_packages_with_none_timestamp + dict({ + 'packages': list([ + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 1', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'In Transit', + 'tracking_info_language': 'Unknown', + 'tracking_number': '456', + }), + dict({ + 'destination_country': 'Belgium', + 'friendly_name': 'friendly name 2', + 'info_text': 'info text 1', + 'location': 'location 1', + 'origin_country': 'Belgium', + 'package_type': 'Registered Parcel', + 'status': 'Delivered', + 'timestamp': '2020-08-10T10:32:00+00:00', + 'tracking_info_language': 'Unknown', + 'tracking_number': '789', + }), + ]), + }) +# --- diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index 54c9349c121..bbd5644ad63 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -150,6 +150,28 @@ async def test_archive_package( ) +async def test_packages_with_none_timestamp( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure service returns all packages when non provided.""" + await _mock_invalid_packages(mock_seventeentrack) + await init_integration(hass, mock_config_entry) + service_response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PACKAGES, + { + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, + }, + blocking=True, + return_response=True, + ) + + assert service_response == snapshot + + async def _mock_packages(mock_seventeentrack): package1 = get_package(status=10) package2 = get_package( @@ -167,3 +189,19 @@ async def _mock_packages(mock_seventeentrack): package2, package3, ] + + +async def _mock_invalid_packages(mock_seventeentrack): + package1 = get_package( + status=10, + timestamp=None, + ) + package2 = get_package( + tracking_number="789", + friendly_name="friendly name 2", + status=40, + ) + mock_seventeentrack.return_value.profile.packages.return_value = [ + package1, + package2, + ] From 2dc81ed866d2437dc2454cb73031a7eb2f00d762 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 8 Nov 2024 16:15:57 +0100 Subject: [PATCH 332/511] Force int value on port in P1Monitor (#130084) --- homeassistant/components/p1_monitor/config_flow.py | 11 +++++++---- tests/components/p1_monitor/test_config_flow.py | 1 + 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/p1_monitor/config_flow.py b/homeassistant/components/p1_monitor/config_flow.py index 055973e8e37..a7ede186d72 100644 --- a/homeassistant/components/p1_monitor/config_flow.py +++ b/homeassistant/components/p1_monitor/config_flow.py @@ -57,10 +57,13 @@ class P1MonitorFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema( { vol.Required(CONF_HOST): TextSelector(), - vol.Required(CONF_PORT, default=80): NumberSelector( - NumberSelectorConfig( - mode=NumberSelectorMode.BOX, - ) + vol.Required(CONF_PORT, default=80): vol.All( + NumberSelector( + NumberSelectorConfig( + min=1, max=65535, mode=NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), ), } ), diff --git a/tests/components/p1_monitor/test_config_flow.py b/tests/components/p1_monitor/test_config_flow.py index ea1d12055a0..cbd89320074 100644 --- a/tests/components/p1_monitor/test_config_flow.py +++ b/tests/components/p1_monitor/test_config_flow.py @@ -36,6 +36,7 @@ async def test_full_user_flow(hass: HomeAssistant) -> None: assert result2.get("type") is FlowResultType.CREATE_ENTRY assert result2.get("title") == "P1 Monitor" assert result2.get("data") == {CONF_HOST: "example.com", CONF_PORT: 80} + assert isinstance(result2["data"][CONF_PORT], int) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_p1monitor.mock_calls) == 1 From a8db25fbd8882463798caed449f9639b68c930f7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 8 Nov 2024 18:05:05 +0100 Subject: [PATCH 333/511] Split test doesn't need to be executed per Python version (#130147) --- .github/workflows/ci.yaml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b4c1ad8a74d..778ab8b0647 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -819,10 +819,6 @@ jobs: needs: - info - base - strategy: - fail-fast: false - matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} name: Split tests for full run steps: - name: Install additional OS dependencies @@ -836,11 +832,11 @@ jobs: libgammu-dev - name: Check out code from GitHub uses: actions/checkout@v4.2.2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v5.3.0 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true - name: Restore base Python virtual environment id: cache-venv @@ -858,7 +854,7 @@ jobs: - name: Upload pytest_buckets uses: actions/upload-artifact@v4.4.3 with: - name: pytest_buckets-${{ matrix.python-version }} + name: pytest_buckets path: pytest_buckets.txt overwrite: true @@ -923,7 +919,7 @@ jobs: - name: Download pytest_buckets uses: actions/download-artifact@v4.1.8 with: - name: pytest_buckets-${{ matrix.python-version }} + name: pytest_buckets - name: Compile English translations run: | . venv/bin/activate From 4a8a674bd36cf0d5a1a325f9bfd6afe513564105 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 18:36:19 +0100 Subject: [PATCH 334/511] Refrase imap fetch service description string (#130152) --- homeassistant/components/imap/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 115d46f3d0e..7c4a0d9a973 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -104,7 +104,7 @@ "services": { "fetch": { "name": "Fetch message", - "description": "Fetch the email message from the server.", + "description": "Fetch an email message from the server.", "fields": { "entry": { "name": "Entry", From f7cc91903ce890c05592c60ee02539e4d9907852 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 8 Nov 2024 09:37:00 -0800 Subject: [PATCH 335/511] Fix bugs in nest stream expiration handling (#130150) --- homeassistant/components/nest/camera.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 30f96f819c1..2bee54df3dd 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -235,7 +235,9 @@ class NestWebRTCEntity(NestCameraBaseEntity): async def _async_refresh_stream(self) -> None: """Refresh stream to extend expiration time.""" now = utcnow() - for webrtc_stream in list(self._webrtc_sessions.values()): + for session_id, webrtc_stream in list(self._webrtc_sessions.items()): + if session_id not in self._webrtc_sessions: + continue if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): _LOGGER.debug( "Stream does not yet expire: %s", webrtc_stream.expires_at @@ -247,7 +249,8 @@ class NestWebRTCEntity(NestCameraBaseEntity): except ApiException as err: _LOGGER.debug("Failed to extend stream: %s", err) else: - self._webrtc_sessions[webrtc_stream.media_session_id] = webrtc_stream + if session_id in self._webrtc_sessions: + self._webrtc_sessions[session_id] = webrtc_stream async def async_camera_image( self, width: int | None = None, height: int | None = None From a7be76ba0a8b4e92818055090cfbb94a1a85eb87 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 8 Nov 2024 18:40:43 +0100 Subject: [PATCH 336/511] Fix volume_up not working in some cases in bluesound integration (#130146) --- .../components/bluesound/media_player.py | 2 +- .../components/bluesound/test_media_player.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 1d46af2cc4b..97985a74300 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -770,7 +770,7 @@ class BluesoundPlayer(MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" - volume = int(volume * 100) + volume = int(round(volume * 100)) volume = min(100, volume) volume = max(0, volume) diff --git a/tests/components/bluesound/test_media_player.py b/tests/components/bluesound/test_media_player.py index 894528265e1..0bf615de3da 100644 --- a/tests/components/bluesound/test_media_player.py +++ b/tests/components/bluesound/test_media_player.py @@ -345,3 +345,31 @@ async def test_attr_bluesound_group( ).attributes.get("bluesound_group") assert attr_bluesound_group == ["player-name1111", "player-name2222"] + + +async def test_volume_up_from_6_to_7( + hass: HomeAssistant, + setup_config_entry: None, + player_mocks: PlayerMocks, +) -> None: + """Test the media player volume up from 6 to 7. + + This fails if if rounding is not done correctly. See https://github.com/home-assistant/core/issues/129956 for more details. + """ + player_mocks.player_data.status_long_polling_mock.set( + dataclasses.replace( + player_mocks.player_data.status_long_polling_mock.get(), volume=6 + ) + ) + + # give the long polling loop a chance to update the state; this could be any async call + await hass.async_block_till_done() + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.player_name1111"}, + blocking=True, + ) + + player_mocks.player_data.player.volume.assert_called_once_with(level=7) From e4aaaf10c32e271aeddf5f4f2c68538a3b8ed10b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 8 Nov 2024 17:44:15 +0000 Subject: [PATCH 337/511] Fix utility_meter on DST changes (#129862) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/utility_meter/manifest.json | 2 +- .../components/utility_meter/sensor.py | 21 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/utility_meter/test_sensor.py | 20 ++++++++++++++++++ 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json index 25e803e6a2d..31a2d4e9584 100644 --- a/homeassistant/components/utility_meter/manifest.json +++ b/homeassistant/components/utility_meter/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["croniter"], "quality_scale": "internal", - "requirements": ["croniter==2.0.2"] + "requirements": ["cronsim==2.6"] } diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 6b8c07c7ef7..9cd4523afa6 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -9,7 +9,7 @@ from decimal import Decimal, DecimalException, InvalidOperation import logging from typing import Any, Self -from croniter import croniter +from cronsim import CronSim import voluptuous as vol from homeassistant.components.sensor import ( @@ -405,6 +405,16 @@ class UtilityMeterSensor(RestoreSensor): self._tariff = tariff self._tariff_entity = tariff_entity self._next_reset = None + self.scheduler = ( + CronSim( + self._cron_pattern, + dt_util.now( + dt_util.get_default_time_zone() + ), # we need timezone for DST purposes (see issue #102984) + ) + if self._cron_pattern + else None + ) def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" @@ -543,11 +553,10 @@ class UtilityMeterSensor(RestoreSensor): async def _program_reset(self): """Program the reset of the utility meter.""" - if self._cron_pattern is not None: - tz = dt_util.get_default_time_zone() - self._next_reset = croniter(self._cron_pattern, dt_util.now(tz)).get_next( - datetime - ) # we need timezone for DST purposes (see issue #102984) + if self.scheduler: + self._next_reset = next(self.scheduler) + + _LOGGER.debug("Next reset of %s is %s", self.entity_id, self._next_reset) self.async_on_remove( async_track_point_in_time( self.hass, diff --git a/requirements_all.txt b/requirements_all.txt index 45e2077abf8..c61a39f30b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -702,7 +702,7 @@ connect-box==0.3.1 construct==2.10.68 # homeassistant.components.utility_meter -croniter==2.0.2 +cronsim==2.6 # homeassistant.components.crownstone crownstone-cloud==1.4.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e34403c87b..e15d9f437c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -598,7 +598,7 @@ colorthief==0.2.1 construct==2.10.68 # homeassistant.components.utility_meter -croniter==2.0.2 +cronsim==2.6 # homeassistant.components.crownstone crownstone-cloud==1.4.11 diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 745bf0ce012..a4540a4714d 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1804,6 +1804,26 @@ async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst2(hass: HomeAssistant) -> None: + """Test weekly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Berlin" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("daily"), "2024-10-26T23:59:00.000000+02:00" + ) + + state = hass.states.get("sensor.energy_bill") + last_reset = dt_util.parse_datetime("2024-10-27T00:00:00.000000+02:00") + assert ( + dt_util.as_local(dt_util.parse_datetime(state.attributes.get("last_reset"))) + == last_reset + ) + + next_reset = dt_util.parse_datetime("2024-10-28T00:00:00.000000+01:00").isoformat() + assert state.attributes.get("next_reset") == next_reset + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From da9c73a76769ab103ac0f89c1bc550024d8f7429 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 19:53:52 +0100 Subject: [PATCH 338/511] Add reconfigure flow to Nord Pool (#130151) --- .../components/nordpool/config_flow.py | 19 ++++ .../components/nordpool/strings.json | 9 ++ tests/components/nordpool/test_config_flow.py | 96 ++++++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index d184c04f3ce..a9a834d8225 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -90,3 +90,22 @@ class NordpoolConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=DATA_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reconfiguration step.""" + errors: dict[str, str] = {} + if user_input: + errors = await test_api(self.hass, user_input) + reconfigure_entry = self._get_reconfigure_entry() + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index e55950c7d67..59ba009eb90 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_data": "API connected but the response was empty" @@ -10,6 +13,12 @@ "currency": "Currency", "areas": "Areas" } + }, + "reconfigure": { + "data": { + "currency": "[%key:component::nordpool::config::step::user::data::currency%]", + "areas": "[%key:component::nordpool::config::step::user::data::areas%]" + } } } }, diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index dbd85a07a17..d17db619b02 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -15,12 +15,15 @@ from pynordpool import ( import pytest from homeassistant import config_entries -from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.const import CONF_AREAS, DOMAIN +from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ENTRY_CONFIG +from tests.common import MockConfigEntry + @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_form(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: @@ -149,3 +152,94 @@ async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Nord Pool" assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_reconfigure( + hass: HomeAssistant, + load_int: MockConfigEntry, + get_data: DeliveryPeriodData, +) -> None: + """Test reconfiguration.""" + + result = await load_int.start_reconfigure_flow(hass) + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert load_int.data == { + "areas": [ + "SE3", + ], + "currency": "EUR", + } + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +@pytest.mark.parametrize( + ("error_message", "p_error"), + [ + (NordPoolConnectionError, "cannot_connect"), + (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolError, "cannot_connect"), + (NordPoolResponseError, "cannot_connect"), + ], +) +async def test_reconfigure_cannot_connect( + hass: HomeAssistant, + load_int: MockConfigEntry, + get_data: DeliveryPeriodData, + error_message: Exception, + p_error: str, +) -> None: + """Test cannot connect error in a reeconfigure flow.""" + + result = await load_int.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=error_message, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["errors"] == {"base": p_error} + + with patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + return_value=get_data, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AREAS: ["SE3"], + CONF_CURRENCY: "EUR", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert load_int.data == { + "areas": [ + "SE3", + ], + "currency": "EUR", + } From e4036a2f14834f059dab0dab59462883a20671fe Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 8 Nov 2024 20:14:33 +0100 Subject: [PATCH 339/511] Bump python-linkplay to v0.0.18 (#130159) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index f2b2e2da00c..9ddb6abf093 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.17"], + "requirements": ["python-linkplay==0.0.18"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c61a39f30b8..0d900f672f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2362,7 +2362,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.17 +python-linkplay==0.0.18 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e15d9f437c6..41f683dacc4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.17 +python-linkplay==0.0.18 # homeassistant.components.matter python-matter-server==6.6.0 From 1ac9217630059ece15f4a744a3423cac132bf5d5 Mon Sep 17 00:00:00 2001 From: Sheldon Ip <4224778+sheldonip@users.noreply.github.com> Date: Fri, 8 Nov 2024 11:15:17 -0800 Subject: [PATCH 340/511] Fix translations in ollama (#130164) --- homeassistant/components/ollama/strings.json | 4 +++- tests/components/ollama/test_config_flow.py | 4 ---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index c307f160228..248cac34f11 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -11,9 +11,11 @@ "title": "Downloading model" } }, + "abort": { + "download_failed": "Model downloading failed" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "download_failed": "Model downloading failed", "unknown": "[%key:common::config_flow::error::unknown%]" }, "progress": { diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 82c954a1737..7755f2208b4 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -204,10 +204,6 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.ollama.config.abort.download_failed"], -) async def test_download_error(hass: HomeAssistant) -> None: """Test we handle errors while downloading a model.""" result = await hass.config_entries.flow.async_init( From c97cc3487932cb3df128e9a11c32cdecd7c13d4d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 20:16:46 +0100 Subject: [PATCH 341/511] Use f-strings in go2rtc code and test and do not use abbreviation (#130158) --- homeassistant/components/go2rtc/__init__.py | 10 +++++----- tests/components/go2rtc/test_init.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index ca4aeeed938..e44361f69a4 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -244,21 +244,21 @@ class WebRTCProvider(CameraWebRTCProvider): if self._data.managed: # HA manages the go2rtc instance - stream_org_name = camera.entity_id + "_orginal" + stream_original_name = f"{camera.entity_id}_orginal" stream_redirect_sources = [ - f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_org_name}", - f"ffmpeg:{stream_org_name}#audio=opus", + f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", + f"ffmpeg:{stream_original_name}#audio=opus", ] if ( - (stream_org := streams.get(stream_org_name)) is None + (stream_org := streams.get(stream_original_name)) is None or not any( stream_source == producer.url for producer in stream_org.producers ) or (stream_redirect := streams.get(camera.entity_id)) is None or stream_redirect_sources != [p.url for p in stream_redirect.producers] ): - await self._rest_client.streams.add(stream_org_name, stream_source) + await self._rest_client.streams.add(stream_original_name, stream_source) await self._rest_client.streams.add( camera.entity_id, stream_redirect_sources ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index ea1971a31d9..e085bab31b3 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -313,7 +313,7 @@ async def test_setup_managed( camera = init_test_integration entity_id = camera.entity_id - stream_name_orginal = camera.entity_id + "_orginal" + stream_name_orginal = f"{camera.entity_id}_orginal" assert camera.frontend_stream_type == StreamType.HLS assert await async_setup_component(hass, DOMAIN, config) From 9037cb8a7d00b40bd269b6a964a2a7d755c424ab Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Nov 2024 20:38:38 +0100 Subject: [PATCH 342/511] Fix typo in go2rtc (#130165) Fix typo in original --- homeassistant/components/go2rtc/__init__.py | 2 +- tests/components/go2rtc/test_init.py | 26 ++++++++++----------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index e44361f69a4..04b5b9f9317 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -244,7 +244,7 @@ class WebRTCProvider(CameraWebRTCProvider): if self._data.managed: # HA manages the go2rtc instance - stream_original_name = f"{camera.entity_id}_orginal" + stream_original_name = f"{camera.entity_id}_original" stream_redirect_sources = [ f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", f"ffmpeg:{stream_original_name}#audio=opus", diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index e085bab31b3..ec586776142 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -313,7 +313,7 @@ async def test_setup_managed( camera = init_test_integration entity_id = camera.entity_id - stream_name_orginal = f"{camera.entity_id}_orginal" + stream_name_original = f"{camera.entity_id}_original" assert camera.frontend_stream_type == StreamType.HLS assert await async_setup_component(hass, DOMAIN, config) @@ -346,12 +346,12 @@ async def test_setup_managed( await test() stream_added_calls = [ - call(stream_name_orginal, "rtsp://stream"), + call(stream_name_original, "rtsp://stream"), call( entity_id, [ - f"rtsp://127.0.0.1:18554/{stream_name_orginal}", - f"ffmpeg:{stream_name_orginal}#audio=opus", + f"rtsp://127.0.0.1:18554/{stream_name_original}", + f"ffmpeg:{stream_name_original}#audio=opus", ], ), ] @@ -362,8 +362,8 @@ async def test_setup_managed( rest_client.streams.list.return_value = { entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ) } @@ -377,11 +377,11 @@ async def test_setup_managed( # Stream original source different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://different")]), + stream_name_original: Stream([Producer("rtsp://different")]), entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ), } @@ -395,7 +395,7 @@ async def test_setup_managed( # Stream source different rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://stream")]), + stream_name_original: Stream([Producer("rtsp://stream")]), entity_id: Stream([Producer("rtsp://different")]), } @@ -408,11 +408,11 @@ async def test_setup_managed( # If the stream is already added, the stream should not be added again. rest_client.streams.add.reset_mock() rest_client.streams.list.return_value = { - stream_name_orginal: Stream([Producer("rtsp://stream")]), + stream_name_original: Stream([Producer("rtsp://stream")]), entity_id: Stream( [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_orginal}"), - Producer(f"ffmpeg:{stream_name_orginal}#audio=opus"), + Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), + Producer(f"ffmpeg:{stream_name_original}#audio=opus"), ] ), } From 0a4c0fe7ccd72a9ff78ee2ee5d166ca9c4f194d0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:09:53 +0100 Subject: [PATCH 343/511] Add option to specify additional markers for wheel build requirements (#129949) --- script/gen_requirements_all.py | 35 +++++++++++++++++++---- tests/script/test_gen_requirements_all.py | 26 +++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4a340863240..02dad3aef3f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -58,8 +58,16 @@ INCLUDED_REQUIREMENTS_WHEELS = { # will be included in requirements_all_{action}.txt OVERRIDDEN_REQUIREMENTS_ACTIONS = { - "pytest": {"exclude": set(), "include": {"python-gammu"}}, - "wheels_aarch64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, + "pytest": { + "exclude": set(), + "include": {"python-gammu"}, + "markers": {}, + }, + "wheels_aarch64": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, # Pandas has issues building on armhf, it is expected they # will drop the platform in the near future (they consider it # "flimsy" on 386). The following packages depend on pandas, @@ -67,10 +75,23 @@ OVERRIDDEN_REQUIREMENTS_ACTIONS = { "wheels_armhf": { "exclude": {"env-canada", "noaa-coops", "pyezviz", "pykrakenapi"}, "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_armv7": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_amd64": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, + }, + "wheels_i386": { + "exclude": set(), + "include": INCLUDED_REQUIREMENTS_WHEELS, + "markers": {}, }, - "wheels_armv7": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, - "wheels_amd64": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, - "wheels_i386": {"exclude": set(), "include": INCLUDED_REQUIREMENTS_WHEELS}, } IGNORE_PIN = ("colorlog>2.1,<3", "urllib3") @@ -311,6 +332,10 @@ def process_action_requirement(req: str, action: str) -> str: return req if normalized_package_name in EXCLUDED_REQUIREMENTS_ALL: return f"# {req}" + if markers := OVERRIDDEN_REQUIREMENTS_ACTIONS[action]["markers"].get( + normalized_package_name, None + ): + return f"{req};{markers}" return req diff --git a/tests/script/test_gen_requirements_all.py b/tests/script/test_gen_requirements_all.py index 793b3de63c5..519a5c21855 100644 --- a/tests/script/test_gen_requirements_all.py +++ b/tests/script/test_gen_requirements_all.py @@ -1,5 +1,7 @@ """Tests for the gen_requirements_all script.""" +from unittest.mock import patch + from script import gen_requirements_all @@ -23,3 +25,27 @@ def test_include_overrides_subsets() -> None: for overrides in gen_requirements_all.OVERRIDDEN_REQUIREMENTS_ACTIONS.values(): for req in overrides["include"]: assert req in gen_requirements_all.EXCLUDED_REQUIREMENTS_ALL + + +def test_requirement_override_markers() -> None: + """Test override markers are applied to the correct requirements.""" + data = { + "pytest": { + "exclude": set(), + "include": set(), + "markers": {"env-canada": "python_version<'3.13'"}, + } + } + with patch.dict( + gen_requirements_all.OVERRIDDEN_REQUIREMENTS_ACTIONS, data, clear=True + ): + assert ( + gen_requirements_all.process_action_requirement( + "env-canada==0.7.2", "pytest" + ) + == "env-canada==0.7.2;python_version<'3.13'" + ) + assert ( + gen_requirements_all.process_action_requirement("other==1.0", "pytest") + == "other==1.0" + ) From 48e7fed901717580ac69bd3b7c7929208d8a460f Mon Sep 17 00:00:00 2001 From: murfy76 Date: Fri, 8 Nov 2024 22:03:01 +0100 Subject: [PATCH 344/511] Add voc and formaldehyde to Tuya CO2 Detector (#130119) --- homeassistant/components/tuya/sensor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index fd8efcac95d..b9677037b7e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -203,6 +203,17 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), *BATTERY_SENSORS, ), # Two-way temperature and humidity switch From 742eca5927cac735d63ecf66498d830e2190eda8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 8 Nov 2024 22:09:43 +0100 Subject: [PATCH 345/511] Use TemplateStateFromEntityId in Template trigger entity (#130136) --- homeassistant/components/template/trigger_entity.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index df84ce057c3..5130f332d5b 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.template import TemplateStateFromEntityId from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -41,11 +42,11 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module def _process_data(self) -> None: """Process new data.""" - this = None - if state := self.hass.states.get(self.entity_id): - this = state.as_dict() run_variables = self.coordinator.data["run_variables"] - variables = {"this": this, **(run_variables or {})} + variables = { + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **(run_variables or {}), + } self._render_templates(variables) From cd11f01ace64a6f6c661367a09ab6f06d5d09ac2 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Fri, 8 Nov 2024 22:12:16 +0100 Subject: [PATCH 346/511] Add support for MW/GW/TW and GWh/TWh (#130089) --- homeassistant/components/number/const.py | 6 +++--- homeassistant/components/sensor/const.py | 6 +++--- homeassistant/const.py | 5 +++++ homeassistant/util/unit_conversion.py | 8 ++++++++ tests/components/sensor/test_recorder.py | 8 ++++---- tests/components/template/test_config_flow.py | 2 +- tests/test_const.py | 9 ++++++++- tests/util/test_unit_conversion.py | 9 +++++++++ 8 files changed, 41 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index ad95c9b5358..5eea525fb6a 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -162,7 +162,7 @@ class NumberDeviceClass(StrEnum): ENERGY = "energy" """Energy. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ ENERGY_STORAGE = "energy_storage" @@ -171,7 +171,7 @@ class NumberDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ FREQUENCY = "frequency" @@ -279,7 +279,7 @@ class NumberDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW` + Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` """ PRECIPITATION = "precipitation" diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index da0b48a23a0..aa3d1906b21 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -182,7 +182,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `cal`, `kcal`, `Mcal`, `Gcal` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" @@ -191,7 +191,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring stored energy, for example the amount of electric energy currently stored in a battery or the capacity of a battery. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `Wh`, `kWh`, `MWh`, `GWh`, `TWh`, `MJ`, `GJ` """ FREQUENCY = "frequency" @@ -299,7 +299,7 @@ class SensorDeviceClass(StrEnum): POWER = "power" """Power. - Unit of measurement: `W`, `kW` + Unit of measurement: `W`, `kW`, `MW`, `GW`, `TW` """ PRECIPITATION = "precipitation" diff --git a/homeassistant/const.py b/homeassistant/const.py index 1da3b819f9f..0bdd625e417 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -725,6 +725,9 @@ class UnitOfPower(StrEnum): WATT = "W" KILO_WATT = "kW" + MEGA_WATT = "MW" + GIGA_WATT = "GW" + TERA_WATT = "TW" BTU_PER_HOUR = "BTU/h" @@ -770,6 +773,8 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" KILO_WATT_HOUR = "kWh" MEGA_WATT_HOUR = "MWh" + GIGA_WATT_HOUR = "GWh" + TERA_WATT_HOUR = "TWh" CALORIE = "cal" KILO_CALORIE = "kcal" MEGA_CALORIE = "Mcal" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 6bc595bd487..289df28738a 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -222,6 +222,8 @@ class EnergyConverter(BaseUnitConverter): UnitOfEnergy.WATT_HOUR: 1e3, UnitOfEnergy.KILO_WATT_HOUR: 1, UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3, + UnitOfEnergy.GIGA_WATT_HOUR: 1 / 1e6, + UnitOfEnergy.TERA_WATT_HOUR: 1 / 1e9, UnitOfEnergy.CALORIE: _WH_TO_CAL * 1e3, UnitOfEnergy.KILO_CALORIE: _WH_TO_CAL, UnitOfEnergy.MEGA_CALORIE: _WH_TO_CAL / 1e3, @@ -292,10 +294,16 @@ class PowerConverter(BaseUnitConverter): _UNIT_CONVERSION: dict[str | None, float] = { UnitOfPower.WATT: 1, UnitOfPower.KILO_WATT: 1 / 1000, + UnitOfPower.MEGA_WATT: 1 / 1e6, + UnitOfPower.GIGA_WATT: 1 / 1e9, + UnitOfPower.TERA_WATT: 1 / 1e12, } VALID_UNITS = { UnitOfPower.WATT, UnitOfPower.KILO_WATT, + UnitOfPower.MEGA_WATT, + UnitOfPower.GIGA_WATT, + UnitOfPower.TERA_WATT, } diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 37f080d2de2..0e8c2a5e188 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -4233,8 +4233,8 @@ async def async_record_states( @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, @@ -4445,8 +4445,8 @@ async def test_validate_statistics_unit_ignore_device_class( @pytest.mark.parametrize( ("units", "attributes", "unit", "unit2", "supported_unit"), [ - (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), - (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "W, kW"), + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W", "kW", "GW, MW, TW, W, kW"), ( US_CUSTOMARY_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 72c453d48dc..a3e53aab9e1 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -794,7 +794,7 @@ EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of tem ), "unit_of_measurement": ( "'None' is not a valid unit for device class 'energy'; " - "expected one of 'cal', 'Gcal', 'GJ', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'Wh'" + "expected one of 'cal', 'Gcal', 'GJ', 'GWh', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'TWh', 'Wh'" ), }, ), diff --git a/tests/test_const.py b/tests/test_const.py index c572c4a08d7..87a14ecfe9c 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -66,7 +66,14 @@ def test_all() -> None: "DEVICE_CLASS_", ) + _create_tuples(const.UnitOfApparentPower, "POWER_") - + _create_tuples(const.UnitOfPower, "POWER_") + + _create_tuples( + [ + const.UnitOfPower.WATT, + const.UnitOfPower.KILO_WATT, + const.UnitOfPower.BTU_PER_HOUR, + ], + "POWER_", + ) + _create_tuples( [ const.UnitOfEnergy.KILO_WATT_HOUR, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 3b8fd3bc466..b07b96e0de7 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -357,10 +357,16 @@ _CONVERTED_VALUE: dict[ EnergyConverter: [ (10, UnitOfEnergy.WATT_HOUR, 0.01, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.WATT_HOUR, 0.00001, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00000001, UnitOfEnergy.GIGA_WATT_HOUR), + (10, UnitOfEnergy.WATT_HOUR, 0.00000000001, UnitOfEnergy.TERA_WATT_HOUR), (10, UnitOfEnergy.KILO_WATT_HOUR, 10000, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.KILO_WATT_HOUR, 0.01, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000000, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_WATT_HOUR, 10e6, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_WATT_HOUR, 10e9, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.TERA_WATT_HOUR, 10e9, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.TERA_WATT_HOUR, 10e12, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.GIGA_JOULE, 2777.78, UnitOfEnergy.KILO_WATT_HOUR), (10, UnitOfEnergy.GIGA_JOULE, 2.77778, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.MEGA_JOULE, 2.77778, UnitOfEnergy.KILO_WATT_HOUR), @@ -439,6 +445,9 @@ _CONVERTED_VALUE: dict[ ], PowerConverter: [ (10, UnitOfPower.KILO_WATT, 10000, UnitOfPower.WATT), + (10, UnitOfPower.MEGA_WATT, 10e6, UnitOfPower.WATT), + (10, UnitOfPower.GIGA_WATT, 10e9, UnitOfPower.WATT), + (10, UnitOfPower.TERA_WATT, 10e12, UnitOfPower.WATT), (10, UnitOfPower.WATT, 0.01, UnitOfPower.KILO_WATT), ], PressureConverter: [ From 182be6e0ea461bd65654223386d4e1373b9ac640 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 8 Nov 2024 23:10:29 +0100 Subject: [PATCH 347/511] Fix failing UniFi Protect tests on some systems (#129516) --- .../unifiprotect/test_media_source.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index 60cd3150884..18944460ca5 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -669,7 +669,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.RING, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=[], @@ -683,7 +683,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.MOTION, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=[], @@ -697,7 +697,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["person"], @@ -706,7 +706,7 @@ async def test_browse_media_recent_truncated( metadata={ "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "person", "cropped_id": "event_id", } @@ -720,7 +720,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "person"], @@ -734,7 +734,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -748,7 +748,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -758,7 +758,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", } @@ -772,7 +772,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -782,7 +782,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -802,7 +802,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle", "licensePlate"], @@ -812,7 +812,7 @@ async def test_browse_media_recent_truncated( "license_plate": {"name": "ABC1234", "confidence_level": 95}, "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -823,7 +823,7 @@ async def test_browse_media_recent_truncated( }, }, { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "person", "cropped_id": "event_id", }, @@ -837,7 +837,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["vehicle"], @@ -846,7 +846,7 @@ async def test_browse_media_recent_truncated( metadata={ "detected_thumbnails": [ { - "clock_best_wall": datetime(1000, 1, 1, 0, 0, 0), + "clock_best_wall": datetime(2000, 1, 1, 0, 0, 0), "type": "vehicle", "cropped_id": "event_id", "attributes": { @@ -870,7 +870,7 @@ async def test_browse_media_recent_truncated( model=ModelType.EVENT, id="test_event_id", type=EventType.SMART_AUDIO_DETECT, - start=datetime(1000, 1, 1, 0, 0, 0), + start=datetime(2000, 1, 1, 0, 0, 0), end=None, score=100, smart_detect_types=["alrmSpeak"], From 964ad43a27556be2b56a685c5b0aa9f0ab11f541 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Nov 2024 23:07:05 +0000 Subject: [PATCH 348/511] Bump orjson to 3.10.11 (#130182) --- 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 3f7bb758e81..99811a11bab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.10 +orjson==3.10.11 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.4.0 diff --git a/pyproject.toml b/pyproject.toml index df3e2703d5c..7855a6671cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,7 +57,7 @@ dependencies = [ "Pillow==10.4.0", "propcache==0.2.0", "pyOpenSSL==24.2.1", - "orjson==3.10.10", + "orjson==3.10.11", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index f9ac034136d..c7436cab5b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,7 @@ cryptography==43.0.1 Pillow==10.4.0 propcache==0.2.0 pyOpenSSL==24.2.1 -orjson==3.10.10 +orjson==3.10.11 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From 2802b77f21d50d8c002a4dba370c7f8a38296a92 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 8 Nov 2024 18:12:14 -0500 Subject: [PATCH 349/511] Bump nice-go to 0.3.10 (#130173) Bump Nice G.O. to 0.3.10 --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index d3f54e5e668..817d7ef9bc9 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==0.3.9"] + "requirements": ["nice-go==0.3.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d900f672f7..f883405070c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.9 +nice-go==0.3.10 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41f683dacc4..a4d7dd7f85b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1217,7 +1217,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.9 +nice-go==0.3.10 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 9f7e6048f832c9ae0f5258a37aaf93d2023f619b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 8 Nov 2024 23:17:43 +0000 Subject: [PATCH 350/511] Code quality improvements on utility_meter (#129918) * clean * update snapshot * move name, native_value and native_unit_of_measurement to _attr's * Apply suggestions from code review Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/utility_meter/sensor.py | 93 ++++--------- .../snapshots/test_diagnostics.ambr | 24 +++- .../utility_meter/test_diagnostics.py | 24 +++- tests/components/utility_meter/test_sensor.py | 126 +++++------------- 4 files changed, 103 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 9cd4523afa6..19ef3c1f3a8 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -379,14 +379,13 @@ class UtilityMeterSensor(RestoreSensor): self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity - self._state = None self._last_period = Decimal(0) self._last_reset = dt_util.utcnow() self._last_valid_state = None self._collecting = None - self._name = name + self._attr_name = name self._input_device_class = None - self._unit_of_measurement = None + self._attr_native_unit_of_measurement = None self._period = meter_type if meter_type is not None: # For backwards compatibility reasons we convert the period and offset into a cron pattern @@ -419,8 +418,8 @@ class UtilityMeterSensor(RestoreSensor): def start(self, attributes: Mapping[str, Any]) -> None: """Initialize unit and state upon source initial update.""" self._input_device_class = attributes.get(ATTR_DEVICE_CLASS) - self._unit_of_measurement = attributes.get(ATTR_UNIT_OF_MEASUREMENT) - self._state = 0 + self._attr_native_unit_of_measurement = attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._attr_native_value = 0 self.async_write_ha_state() @staticmethod @@ -495,13 +494,13 @@ class UtilityMeterSensor(RestoreSensor): ) return - if self._state is None: + if self.native_value is None: # First state update initializes the utility_meter sensors for sensor in self.hass.data[DATA_UTILITY][self._parent_meter][ DATA_TARIFF_SENSORS ]: sensor.start(new_state_attributes) - if self._unit_of_measurement is None: + if self.native_unit_of_measurement is None: _LOGGER.warning( "Source sensor %s has no unit of measurement. Please %s", self._sensor_source_id, @@ -512,10 +511,12 @@ class UtilityMeterSensor(RestoreSensor): adjustment := self.calculate_adjustment(old_state, new_state) ) is not None and (self._sensor_net_consumption or adjustment >= 0): # If net_consumption is off, the adjustment must be non-negative - self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line + self._attr_native_value += adjustment # type: ignore[operator] # self._attr_native_value will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line self._input_device_class = new_state_attributes.get(ATTR_DEVICE_CLASS) - self._unit_of_measurement = new_state_attributes.get(ATTR_UNIT_OF_MEASUREMENT) + self._attr_native_unit_of_measurement = new_state_attributes.get( + ATTR_UNIT_OF_MEASUREMENT + ) self._last_valid_state = new_state_val self.async_write_ha_state() @@ -544,7 +545,7 @@ class UtilityMeterSensor(RestoreSensor): _LOGGER.debug( "%s - %s - source <%s>", - self._name, + self.name, COLLECTING if self._collecting is not None else PAUSED, self._sensor_source_id, ) @@ -584,14 +585,16 @@ class UtilityMeterSensor(RestoreSensor): return _LOGGER.debug("Reset utility meter <%s>", self.entity_id) self._last_reset = dt_util.utcnow() - self._last_period = Decimal(self._state) if self._state else Decimal(0) - self._state = 0 + self._last_period = ( + Decimal(self.native_value) if self.native_value else Decimal(0) + ) + self._attr_native_value = 0 self.async_write_ha_state() async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" - _LOGGER.debug("Calibrate %s = %s type(%s)", self._name, value, type(value)) - self._state = Decimal(str(value)) + _LOGGER.debug("Calibrate %s = %s type(%s)", self.name, value, type(value)) + self._attr_native_value = Decimal(str(value)) self.async_write_ha_state() async def async_added_to_hass(self): @@ -607,10 +610,11 @@ class UtilityMeterSensor(RestoreSensor): ) if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: - # new introduced in 2022.04 - self._state = last_sensor_data.native_value + self._attr_native_value = last_sensor_data.native_value self._input_device_class = last_sensor_data.input_device_class - self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._attr_native_unit_of_measurement = ( + last_sensor_data.native_unit_of_measurement + ) self._last_period = last_sensor_data.last_period self._last_reset = last_sensor_data.last_reset self._last_valid_state = last_sensor_data.last_valid_state @@ -618,39 +622,6 @@ class UtilityMeterSensor(RestoreSensor): # Null lambda to allow cancelling the collection on tariff change self._collecting = lambda: None - elif state := await self.async_get_last_state(): - # legacy to be removed on 2022.10 (we are keeping this to avoid utility_meter counter losses) - try: - self._state = Decimal(state.state) - except InvalidOperation: - _LOGGER.error( - "Could not restore state <%s>. Resetting utility_meter.%s", - state.state, - self.name, - ) - else: - self._unit_of_measurement = state.attributes.get( - ATTR_UNIT_OF_MEASUREMENT - ) - self._last_period = ( - Decimal(state.attributes[ATTR_LAST_PERIOD]) - if state.attributes.get(ATTR_LAST_PERIOD) - and is_number(state.attributes[ATTR_LAST_PERIOD]) - else Decimal(0) - ) - self._last_valid_state = ( - Decimal(state.attributes[ATTR_LAST_VALID_STATE]) - if state.attributes.get(ATTR_LAST_VALID_STATE) - and is_number(state.attributes[ATTR_LAST_VALID_STATE]) - else None - ) - self._last_reset = dt_util.as_utc( - dt_util.parse_datetime(state.attributes.get(ATTR_LAST_RESET)) - ) - if state.attributes.get(ATTR_STATUS) == COLLECTING: - # Null lambda to allow cancelling the collection on tariff change - self._collecting = lambda: None - @callback def async_source_tracking(event): """Wait for source to be ready, then start meter.""" @@ -675,7 +646,7 @@ class UtilityMeterSensor(RestoreSensor): _LOGGER.debug( "<%s> collecting %s from %s", self.name, - self._unit_of_measurement, + self.native_unit_of_measurement, self._sensor_source_id, ) self._collecting = async_track_state_change_event( @@ -690,22 +661,15 @@ class UtilityMeterSensor(RestoreSensor): self._collecting() self._collecting = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def device_class(self): """Return the device class of the sensor.""" if self._input_device_class is not None: return self._input_device_class - if self._unit_of_measurement in DEVICE_CLASS_UNITS[SensorDeviceClass.ENERGY]: + if ( + self.native_unit_of_measurement + in DEVICE_CLASS_UNITS[SensorDeviceClass.ENERGY] + ): return SensorDeviceClass.ENERGY return None @@ -718,11 +682,6 @@ class UtilityMeterSensor(RestoreSensor): else SensorStateClass.TOTAL_INCREASING ) - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index c69164264da..6cdf121d7e3 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -41,7 +41,17 @@ 'status': 'collecting', 'tariff': 'tariff0', }), - 'last_sensor_data': None, + 'last_sensor_data': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 3, + 'native_unit_of_measurement': 'kWh', + 'native_value': dict({ + '__type': "", + 'decimal_str': '3', + }), + 'status': 'collecting', + }), 'name': 'Energy Bill tariff0', 'period': 'monthly', 'source': 'sensor.input1', @@ -57,7 +67,17 @@ 'status': 'paused', 'tariff': 'tariff1', }), - 'last_sensor_data': None, + 'last_sensor_data': dict({ + 'last_period': '0', + 'last_reset': '2024-04-05T00:00:00+00:00', + 'last_valid_state': 7, + 'native_unit_of_measurement': 'kWh', + 'native_value': dict({ + '__type': "", + 'decimal_str': '7', + }), + 'status': 'paused', + }), 'name': 'Energy Bill tariff1', 'period': 'monthly', 'source': 'sensor.input1', diff --git a/tests/components/utility_meter/test_diagnostics.py b/tests/components/utility_meter/test_diagnostics.py index 9ecabe813b1..8be5f949940 100644 --- a/tests/components/utility_meter/test_diagnostics.py +++ b/tests/components/utility_meter/test_diagnostics.py @@ -91,7 +91,17 @@ async def test_diagnostics( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": 3, + "status": "collecting", + }, ), ( State( @@ -101,7 +111,17 @@ async def test_diagnostics( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "7", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": 7, + "status": "paused", + }, ), ], ) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index a4540a4714d..0ab78739f7f 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -26,7 +26,6 @@ from homeassistant.components.utility_meter.const import ( ) from homeassistant.components.utility_meter.sensor import ( ATTR_LAST_RESET, - ATTR_LAST_VALID_STATE, ATTR_STATUS, COLLECTING, PAUSED, @@ -760,64 +759,6 @@ async def test_restore_state( "status": "paused", }, ), - # sensor.energy_bill_tariff2 has missing keys and falls back to - # saved state - ( - State( - "sensor.energy_bill_tariff2", - "2.1", - attributes={ - ATTR_STATUS: PAUSED, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - { - "native_value": { - "__type": "", - "decimal_str": "2.2", - }, - "native_unit_of_measurement": "kWh", - "last_valid_state": "None", - }, - ), - # sensor.energy_bill_tariff3 has invalid data and falls back to - # saved state - ( - State( - "sensor.energy_bill_tariff3", - "3.1", - attributes={ - ATTR_STATUS: COLLECTING, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - { - "native_value": { - "__type": "", - "decimal_str": "3f", # Invalid - }, - "native_unit_of_measurement": "kWh", - "last_valid_state": "None", - }, - ), - # No extra saved data, fall back to saved state - ( - State( - "sensor.energy_bill_tariff4", - "error", - attributes={ - ATTR_STATUS: COLLECTING, - ATTR_LAST_RESET: last_reset_1, - ATTR_LAST_VALID_STATE: None, - ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.MEGA_WATT_HOUR, - }, - ), - {}, - ), ], ) @@ -852,25 +793,6 @@ async def test_restore_state( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - state = hass.states.get("sensor.energy_bill_tariff2") - assert state.state == "2.1" - assert state.attributes.get("status") == PAUSED - assert state.attributes.get("last_reset") == last_reset_1 - assert state.attributes.get("last_valid_state") == "None" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.energy_bill_tariff3") - assert state.state == "3.1" - assert state.attributes.get("status") == COLLECTING - assert state.attributes.get("last_reset") == last_reset_1 - assert state.attributes.get("last_valid_state") == "None" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.MEGA_WATT_HOUR - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY - - state = hass.states.get("sensor.energy_bill_tariff4") - assert state.state == STATE_UNKNOWN - # utility_meter is loaded, now set sensors according to utility_meter: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) @@ -882,12 +804,7 @@ async def test_restore_state( state = hass.states.get("sensor.energy_bill_tariff0") assert state.attributes.get("status") == COLLECTING - for entity_id in ( - "sensor.energy_bill_tariff1", - "sensor.energy_bill_tariff2", - "sensor.energy_bill_tariff3", - "sensor.energy_bill_tariff4", - ): + for entity_id in ("sensor.energy_bill_tariff1",): state = hass.states.get(entity_id) assert state.attributes.get("status") == PAUSED @@ -939,7 +856,18 @@ async def test_service_reset_no_tariffs( ATTR_LAST_RESET: last_reset, }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "last_valid_state": None, + "status": "collecting", + "input_device_class": "energy", + }, ), ], ) @@ -1045,21 +973,33 @@ async def test_service_reset_no_tariffs_correct_with_multi( State( "sensor.energy_bill", "3", - attributes={ - ATTR_LAST_RESET: last_reset, - }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "3", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "status": "collecting", + }, ), ( State( "sensor.water_bill", "6", - attributes={ - ATTR_LAST_RESET: last_reset, - }, ), - {}, + { + "native_value": { + "__type": "", + "decimal_str": "6", + }, + "native_unit_of_measurement": "kWh", + "last_reset": last_reset, + "last_period": "0", + "status": "collecting", + }, ), ], ) From b413e481cbc1e288713c4cff01d09c6789a7f7d1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:12:52 +0100 Subject: [PATCH 351/511] Update numpy to 2.1.3 (#130191) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 90fa6289b8d..775bde3c859 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==2.1.2"] + "requirements": ["numpy==2.1.3"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index d589c117edd..11c99a7428f 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==2.1.2", "pyiqvia==2022.04.0"] + "requirements": ["numpy==2.1.3", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 304ef5bbf62..fdf81d99e65 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.2"] + "requirements": ["PyTurboJPEG==1.7.5", "av==13.1.0", "numpy==2.1.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 906ce02f5b1..91ce27badd3 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==2.1.2", + "numpy==2.1.3", "Pillow==10.4.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index b2f47738d4a..d7981105fd2 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==2.1.2"] + "requirements": ["numpy==2.1.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 99811a11bab..a8a7e009c4a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.2 +numpy==2.1.3 pandas~=2.2.3 # Constrain multidict to avoid typing issues diff --git a/requirements_all.txt b/requirements_all.txt index f883405070c..cf6795cf93e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1491,7 +1491,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.2 +numpy==2.1.3 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4d7dd7f85b..b4c9dc86c1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==2.1.2 +numpy==2.1.3 # homeassistant.components.nyt_games nyt_games==0.4.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 02dad3aef3f..edcbc69c15d 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -148,7 +148,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==2.1.2 +numpy==2.1.3 pandas~=2.2.3 # Constrain multidict to avoid typing issues From cd0349ee4ddd88daf62624f81560439cf947d4cf Mon Sep 17 00:00:00 2001 From: Tristan Bastian Date: Sat, 9 Nov 2024 10:41:08 +0100 Subject: [PATCH 352/511] Bump tplink-omada-client to 1.4.3 (#130184) --- homeassistant/components/tplink_omada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 6bde656dc30..af20b54675b 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink-omada-client==1.4.2"] + "requirements": ["tplink-omada-client==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cf6795cf93e..e7b39f5d6c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2858,7 +2858,7 @@ total-connect-client==2024.5 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.2 +tplink-omada-client==1.4.3 # homeassistant.components.transmission transmission-rpc==7.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4c9dc86c1e..44ca05a1c47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2271,7 +2271,7 @@ toonapi==0.3.0 total-connect-client==2024.5 # homeassistant.components.tplink_omada -tplink-omada-client==1.4.2 +tplink-omada-client==1.4.3 # homeassistant.components.transmission transmission-rpc==7.0.3 From 8384100e1b66ca871d61b57b932764d35612b4d4 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:46:38 +0100 Subject: [PATCH 353/511] Rename tedee library (#130203) --- homeassistant/components/tedee/__init__.py | 2 +- homeassistant/components/tedee/binary_sensor.py | 4 ++-- homeassistant/components/tedee/config_flow.py | 2 +- homeassistant/components/tedee/coordinator.py | 4 ++-- homeassistant/components/tedee/entity.py | 2 +- homeassistant/components/tedee/lock.py | 2 +- homeassistant/components/tedee/manifest.json | 4 ++-- homeassistant/components/tedee/sensor.py | 2 +- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- tests/components/tedee/conftest.py | 4 ++-- tests/components/tedee/test_binary_sensor.py | 2 +- tests/components/tedee/test_config_flow.py | 4 ++-- tests/components/tedee/test_init.py | 2 +- tests/components/tedee/test_lock.py | 6 +++--- tests/components/tedee/test_sensor.py | 2 +- 16 files changed, 27 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tedee/__init__.py b/homeassistant/components/tedee/__init__.py index cd593f68e3a..528a5052678 100644 --- a/homeassistant/components/tedee/__init__.py +++ b/homeassistant/components/tedee/__init__.py @@ -7,7 +7,7 @@ from typing import Any from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response -from pytedee_async.exception import TedeeDataUpdateException, TedeeWebhookException +from aiotedee.exception import TedeeDataUpdateException, TedeeWebhookException from homeassistant.components.http import HomeAssistantView from homeassistant.components.webhook import ( diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 5eab7bfa254..b586db7c2a7 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from pytedee_async import TedeeLock -from pytedee_async.lock import TedeeLockState +from aiotedee import TedeeLock +from aiotedee.lock import TedeeLockState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/tedee/config_flow.py b/homeassistant/components/tedee/config_flow.py index 65d4ec12e80..422d818d1b5 100644 --- a/homeassistant/components/tedee/config_flow.py +++ b/homeassistant/components/tedee/config_flow.py @@ -4,7 +4,7 @@ from collections.abc import Mapping import logging from typing import Any -from pytedee_async import ( +from aiotedee import ( TedeeAuthException, TedeeClient, TedeeClientException, diff --git a/homeassistant/components/tedee/coordinator.py b/homeassistant/components/tedee/coordinator.py index de3090a3f78..445585a1a2c 100644 --- a/homeassistant/components/tedee/coordinator.py +++ b/homeassistant/components/tedee/coordinator.py @@ -8,7 +8,7 @@ import logging import time from typing import Any -from pytedee_async import ( +from aiotedee import ( TedeeClient, TedeeClientException, TedeeDataUpdateException, @@ -16,7 +16,7 @@ from pytedee_async import ( TedeeLock, TedeeWebhookException, ) -from pytedee_async.bridge import TedeeBridge +from aiotedee.bridge import TedeeBridge from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST diff --git a/homeassistant/components/tedee/entity.py b/homeassistant/components/tedee/entity.py index c72e293a292..96cc6f2b3f5 100644 --- a/homeassistant/components/tedee/entity.py +++ b/homeassistant/components/tedee/entity.py @@ -1,6 +1,6 @@ """Bases for Tedee entities.""" -from pytedee_async.lock import TedeeLock +from aiotedee.lock import TedeeLock from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py index 34d313f3e48..6e89a48f2a0 100644 --- a/homeassistant/components/tedee/lock.py +++ b/homeassistant/components/tedee/lock.py @@ -2,7 +2,7 @@ from typing import Any -from pytedee_async import TedeeClientException, TedeeLock, TedeeLockState +from aiotedee import TedeeClientException, TedeeLock, TedeeLockState from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 4f071267a25..bca51f08f93 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -6,7 +6,7 @@ "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/tedee", "iot_class": "local_push", - "loggers": ["pytedee_async"], + "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["pytedee-async==0.2.20"] + "requirements": ["aiotedee==0.2.20"] } diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py index 33894a5eb52..90f76317fff 100644 --- a/homeassistant/components/tedee/sensor.py +++ b/homeassistant/components/tedee/sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from pytedee_async import TedeeLock +from aiotedee import TedeeLock from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index e7b39f5d6c2..972c94f3c73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -392,6 +392,9 @@ aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig aiotankerkoenig==0.4.2 +# homeassistant.components.tedee +aiotedee==0.2.20 + # homeassistant.components.tractive aiotractive==0.6.0 @@ -2295,9 +2298,6 @@ pyswitchbee==1.8.3 # homeassistant.components.tautulli pytautulli==23.1.1 -# homeassistant.components.tedee -pytedee-async==0.2.20 - # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44ca05a1c47..c38ac10c53a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -374,6 +374,9 @@ aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig aiotankerkoenig==0.4.2 +# homeassistant.components.tedee +aiotedee==0.2.20 + # homeassistant.components.tractive aiotractive==0.6.0 @@ -1852,9 +1855,6 @@ pyswitchbee==1.8.3 # homeassistant.components.tautulli pytautulli==23.1.1 -# homeassistant.components.tedee -pytedee-async==0.2.20 - # homeassistant.components.motionmount python-MotionMount==2.2.0 diff --git a/tests/components/tedee/conftest.py b/tests/components/tedee/conftest.py index 68444de640c..8e028cb5300 100644 --- a/tests/components/tedee/conftest.py +++ b/tests/components/tedee/conftest.py @@ -6,8 +6,8 @@ from collections.abc import Generator import json from unittest.mock import AsyncMock, MagicMock, patch -from pytedee_async.bridge import TedeeBridge -from pytedee_async.lock import TedeeLock +from aiotedee.bridge import TedeeBridge +from aiotedee.lock import TedeeLock import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/tests/components/tedee/test_binary_sensor.py b/tests/components/tedee/test_binary_sensor.py index 788d31c84d2..dfe70e7a2ea 100644 --- a/tests/components/tedee/test_binary_sensor.py +++ b/tests/components/tedee/test_binary_sensor.py @@ -3,8 +3,8 @@ from datetime import timedelta from unittest.mock import MagicMock +from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/tedee/test_config_flow.py b/tests/components/tedee/test_config_flow.py index 2e86286c8da..825e01aca70 100644 --- a/tests/components/tedee/test_config_flow.py +++ b/tests/components/tedee/test_config_flow.py @@ -2,12 +2,12 @@ from unittest.mock import MagicMock, patch -from pytedee_async import ( +from aiotedee import ( TedeeClientException, TedeeDataUpdateException, TedeeLocalAuthException, ) -from pytedee_async.bridge import TedeeBridge +from aiotedee.bridge import TedeeBridge import pytest from homeassistant.components.tedee.const import CONF_LOCAL_ACCESS_TOKEN, DOMAIN diff --git a/tests/components/tedee/test_init.py b/tests/components/tedee/test_init.py index d4ac1c9d290..63701bb1788 100644 --- a/tests/components/tedee/test_init.py +++ b/tests/components/tedee/test_init.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import MagicMock, patch from urllib.parse import urlparse -from pytedee_async.exception import ( +from aiotedee.exception import ( TedeeAuthException, TedeeClientException, TedeeWebhookException, diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 3f6b97e2c70..45eae6e22d9 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -4,13 +4,13 @@ from datetime import timedelta from unittest.mock import MagicMock from urllib.parse import urlparse -from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock, TedeeLockState -from pytedee_async.exception import ( +from aiotedee import TedeeLock, TedeeLockState +from aiotedee.exception import ( TedeeClientException, TedeeDataUpdateException, TedeeLocalAuthException, ) +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/tedee/test_sensor.py b/tests/components/tedee/test_sensor.py index 72fbd9cbe8d..ddbcd5086af 100644 --- a/tests/components/tedee/test_sensor.py +++ b/tests/components/tedee/test_sensor.py @@ -3,8 +3,8 @@ from datetime import timedelta from unittest.mock import MagicMock +from aiotedee import TedeeLock from freezegun.api import FrozenDateTimeFactory -from pytedee_async import TedeeLock import pytest from syrupy import SnapshotAssertion From d11012b2b7395a259004672f9ada28ae96feb944 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Nov 2024 10:50:11 +0100 Subject: [PATCH 354/511] Move check thresholds valid to platform schema in threshold (#129540) --- .../components/threshold/binary_sensor.py | 35 ++++++++++++------- .../threshold/test_binary_sensor.py | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index da7d92f7051..3d52d2225be 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -61,15 +61,29 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME: Final = "Threshold" -PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float), - vol.Optional(CONF_LOWER): vol.Coerce(float), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UPPER): vol.Coerce(float), - } + +def no_missing_threshold(value: dict) -> dict: + """Validate data point list is greater than polynomial degrees.""" + if value.get(CONF_LOWER) is None and value.get(CONF_UPPER) is None: + raise vol.Invalid("Lower or Upper thresholds are not provided") + + return value + + +PLATFORM_SCHEMA = vol.All( + BINARY_SENSOR_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce( + float + ), + vol.Optional(CONF_LOWER): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UPPER): vol.Coerce(float), + } + ), + no_missing_threshold, ) @@ -126,9 +140,6 @@ async def async_setup_platform( hysteresis: float = config[CONF_HYSTERESIS] device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) - if lower is None and upper is None: - raise ValueError("Lower or Upper thresholds not provided") - async_add_entities( [ ThresholdSensor( diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index e0973c7a580..259009c6319 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -538,7 +538,7 @@ async def test_sensor_no_lower_upper( await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - assert "Lower or Upper thresholds not provided" in caplog.text + assert "Lower or Upper thresholds are not provided" in caplog.text async def test_device_id( From 701f35488c2bf2032da2b9e71968955b364d3325 Mon Sep 17 00:00:00 2001 From: jb101010-2 <168106462+jb101010-2@users.noreply.github.com> Date: Sat, 9 Nov 2024 10:57:22 +0100 Subject: [PATCH 355/511] Add water price sensor to suez water (#130141) * Suez water: add water price sensor * sensor description * clean up --- .../components/suez_water/coordinator.py | 46 ++++++++- homeassistant/components/suez_water/sensor.py | 94 ++++++++++++------- .../components/suez_water/strings.json | 3 + tests/components/suez_water/conftest.py | 8 +- .../suez_water/snapshots/test_sensor.ambr | 51 +++++++++- tests/components/suez_water/test_sensor.py | 21 +++-- 6 files changed, 175 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py index 55f3ba348d4..224929c606e 100644 --- a/homeassistant/components/suez_water/coordinator.py +++ b/homeassistant/components/suez_water/coordinator.py @@ -1,6 +1,11 @@ """Suez water update coordinator.""" -from pysuez import AggregatedData, PySuezError, SuezClient +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date +from typing import Any + +from pysuez import PySuezError, SuezClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -11,7 +16,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_COUNTER_ID, DATA_REFRESH_INTERVAL, DOMAIN -class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): +@dataclass +class SuezWaterAggregatedAttributes: + """Class containing aggregated sensor extra attributes.""" + + this_month_consumption: dict[date, float] + previous_month_consumption: dict[date, float] + last_year_overall: dict[str, float] + this_year_overall: dict[str, float] + history: dict[date, float] + highest_monthly_consumption: float + + +@dataclass +class SuezWaterData: + """Class used to hold all fetch data from suez api.""" + + aggregated_value: float + aggregated_attr: Mapping[str, Any] + price: float + + +class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]): """Suez water coordinator.""" _suez_client: SuezClient @@ -37,10 +63,22 @@ class SuezWaterCoordinator(DataUpdateCoordinator[AggregatedData]): if not await self._suez_client.check_credentials(): raise ConfigEntryError("Invalid credentials for suez water") - async def _async_update_data(self) -> AggregatedData: + async def _async_update_data(self) -> SuezWaterData: """Fetch data from API endpoint.""" try: - data = await self._suez_client.fetch_aggregated_data() + aggregated = await self._suez_client.fetch_aggregated_data() + data = SuezWaterData( + aggregated_value=aggregated.value, + aggregated_attr={ + "this_month_consumption": aggregated.current_month, + "previous_month_consumption": aggregated.previous_month, + "highest_monthly_consumption": aggregated.highest_monthly_consumption, + "last_year_overall": aggregated.previous_year, + "this_year_overall": aggregated.current_year, + "history": aggregated.history, + }, + price=(await self._suez_client.get_price()).price, + ) except PySuezError as err: _LOGGER.exception(err) raise UpdateFailed( diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index 22a61c835e1..2ba699a9af1 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -2,19 +2,53 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from pysuez.const import ATTRIBUTION + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfVolume +from homeassistant.const import CURRENCY_EURO, UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_COUNTER_ID, DOMAIN -from .coordinator import SuezWaterCoordinator +from .coordinator import SuezWaterCoordinator, SuezWaterData + + +@dataclass(frozen=True, kw_only=True) +class SuezWaterSensorEntityDescription(SensorEntityDescription): + """Describes Suez water sensor entity.""" + + value_fn: Callable[[SuezWaterData], float | str | None] + attr_fn: Callable[[SuezWaterData], Mapping[str, Any] | None] = lambda _: None + + +SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = ( + SuezWaterSensorEntityDescription( + key="water_usage_yesterday", + translation_key="water_usage_yesterday", + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.WATER, + value_fn=lambda suez_data: suez_data.aggregated_value, + attr_fn=lambda suez_data: suez_data.aggregated_attr, + ), + SuezWaterSensorEntityDescription( + key="water_price", + translation_key="water_price", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda suez_data: suez_data.price, + ), +) async def async_setup_entry( @@ -24,46 +58,42 @@ async def async_setup_entry( ) -> None: """Set up Suez Water sensor from a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([SuezAggregatedSensor(coordinator, entry.data[CONF_COUNTER_ID])]) + counter_id = entry.data[CONF_COUNTER_ID] + + async_add_entities( + SuezWaterSensor(coordinator, counter_id, description) for description in SENSORS + ) -class SuezAggregatedSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): - """Representation of a Sensor.""" +class SuezWaterSensor(CoordinatorEntity[SuezWaterCoordinator], SensorEntity): + """Representation of a Suez water sensor.""" _attr_has_entity_name = True - _attr_translation_key = "water_usage_yesterday" - _attr_native_unit_of_measurement = UnitOfVolume.LITERS - _attr_device_class = SensorDeviceClass.WATER + _attr_attribution = ATTRIBUTION + entity_description: SuezWaterSensorEntityDescription - def __init__(self, coordinator: SuezWaterCoordinator, counter_id: int) -> None: - """Initialize the data object.""" + def __init__( + self, + coordinator: SuezWaterCoordinator, + counter_id: int, + entity_description: SuezWaterSensorEntityDescription, + ) -> None: + """Initialize the suez water sensor entity.""" super().__init__(coordinator) - self._attr_extra_state_attributes = {} - self._attr_unique_id = f"{counter_id}_water_usage_yesterday" + self._attr_unique_id = f"{counter_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(counter_id))}, entry_type=DeviceEntryType.SERVICE, manufacturer="Suez", ) + self.entity_description = entity_description @property - def native_value(self) -> float: - """Return the current daily usage.""" - return self.coordinator.data.value + def native_value(self) -> float | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) @property - def attribution(self) -> str: - """Return data attribution message.""" - return self.coordinator.data.attribution - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return aggregated data.""" - return { - "this_month_consumption": self.coordinator.data.current_month, - "previous_month_consumption": self.coordinator.data.previous_month, - "highest_monthly_consumption": self.coordinator.data.highest_monthly_consumption, - "last_year_overall": self.coordinator.data.previous_year, - "this_year_overall": self.coordinator.data.current_year, - "history": self.coordinator.data.history, - } + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra state of the sensor.""" + return self.entity_description.attr_fn(self.coordinator.data) diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json index a1af12abd55..6be2affab97 100644 --- a/homeassistant/components/suez_water/strings.json +++ b/homeassistant/components/suez_water/strings.json @@ -23,6 +23,9 @@ "sensor": { "water_usage_yesterday": { "name": "Water usage yesterday" + }, + "water_price": { + "name": "Water price" } } } diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py index 0cbf16095bf..f634a053c65 100644 --- a/tests/components/suez_water/conftest.py +++ b/tests/components/suez_water/conftest.py @@ -3,10 +3,11 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from pysuez import AggregatedData, PriceResult +from pysuez.const import ATTRIBUTION import pytest from homeassistant.components.suez_water.const import DOMAIN -from homeassistant.components.suez_water.coordinator import AggregatedData from tests.common import MockConfigEntry @@ -38,7 +39,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="suez_client") -def mock_suez_data() -> Generator[AsyncMock]: +def mock_suez_client() -> Generator[AsyncMock]: """Create mock for suez_water external api.""" with ( patch( @@ -64,7 +65,7 @@ def mock_suez_data() -> Generator[AsyncMock]: }, current_year=1500, previous_year=1000, - attribution="suez water mock test", + attribution=ATTRIBUTION, highest_monthly_consumption=2558, history={ "2024-01-01": 130, @@ -75,4 +76,5 @@ def mock_suez_data() -> Generator[AsyncMock]: ) suez_client.fetch_aggregated_data.return_value = result + suez_client.get_price.return_value = PriceResult("4.74") yield suez_client diff --git a/tests/components/suez_water/snapshots/test_sensor.ambr b/tests/components/suez_water/snapshots/test_sensor.ambr index acc3042f93b..da0ed3df7dd 100644 --- a/tests/components/suez_water/snapshots/test_sensor.ambr +++ b/tests/components/suez_water/snapshots/test_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-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.suez_mock_device_water_price', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water price', + 'platform': 'suez_water', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_price', + 'unique_id': 'test-counter_water_price', + 'unit_of_measurement': '€', + }) +# --- +# name: test_sensors_valid_state[sensor.suez_mock_device_water_price-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by toutsurmoneau.fr', + 'device_class': 'monetary', + 'friendly_name': 'Suez mock device Water price', + 'unit_of_measurement': '€', + }), + 'context': , + 'entity_id': 'sensor.suez_mock_device_water_price', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.74', + }) +# --- # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -35,7 +84,7 @@ # name: test_sensors_valid_state[sensor.suez_mock_device_water_usage_yesterday-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'suez water mock test', + 'attribution': 'Data provided by toutsurmoneau.fr', 'device_class': 'water', 'friendly_name': 'Suez mock device Water usage yesterday', 'highest_monthly_consumption': 2558, diff --git a/tests/components/suez_water/test_sensor.py b/tests/components/suez_water/test_sensor.py index 1cd40dff75b..cb578432f62 100644 --- a/tests/components/suez_water/test_sensor.py +++ b/tests/components/suez_water/test_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy import SnapshotAssertion from homeassistant.components.suez_water.const import DATA_REFRESH_INTERVAL @@ -32,11 +33,13 @@ async def test_sensors_valid_state( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize("method", [("fetch_aggregated_data"), ("get_price")]) async def test_sensors_failed_update( hass: HomeAssistant, suez_client: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, + method: str, ) -> None: """Test that suez_water sensor reflect failure when api fails.""" @@ -45,18 +48,20 @@ async def test_sensors_failed_update( assert mock_config_entry.state is ConfigEntryState.LOADED entity_ids = await hass.async_add_executor_job(hass.states.entity_ids) - assert len(entity_ids) == 1 + assert len(entity_ids) == 2 - state = hass.states.get(entity_ids[0]) - assert entity_ids[0] - assert state.state != STATE_UNAVAILABLE + for entity in entity_ids: + state = hass.states.get(entity) + assert entity + assert state.state != STATE_UNAVAILABLE - suez_client.fetch_aggregated_data.side_effect = PySuezError("Should fail to update") + getattr(suez_client, method).side_effect = PySuezError("Should fail to update") freezer.tick(DATA_REFRESH_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done(True) - state = hass.states.get(entity_ids[0]) - assert state - assert state.state == STATE_UNAVAILABLE + for entity in entity_ids: + state = hass.states.get(entity) + assert entity + assert state.state == STATE_UNAVAILABLE From 08f5081197c9f7d86bade818858d3599d4ec287e Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:03:48 +0100 Subject: [PATCH 356/511] Rename lamarzocco library (#130204) --- homeassistant/components/lamarzocco/__init__.py | 10 +++++----- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/button.py | 4 ++-- homeassistant/components/lamarzocco/calendar.py | 2 +- homeassistant/components/lamarzocco/config_flow.py | 8 ++++---- homeassistant/components/lamarzocco/coordinator.py | 10 +++++----- homeassistant/components/lamarzocco/diagnostics.py | 2 +- homeassistant/components/lamarzocco/entity.py | 4 ++-- homeassistant/components/lamarzocco/manifest.json | 4 ++-- homeassistant/components/lamarzocco/number.py | 8 ++++---- homeassistant/components/lamarzocco/select.py | 8 ++++---- homeassistant/components/lamarzocco/sensor.py | 4 ++-- homeassistant/components/lamarzocco/switch.py | 8 ++++---- homeassistant/components/lamarzocco/update.py | 4 ++-- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- tests/components/lamarzocco/__init__.py | 2 +- tests/components/lamarzocco/conftest.py | 6 +++--- tests/components/lamarzocco/test_binary_sensor.py | 2 +- tests/components/lamarzocco/test_button.py | 2 +- tests/components/lamarzocco/test_config_flow.py | 6 +++--- tests/components/lamarzocco/test_init.py | 4 ++-- tests/components/lamarzocco/test_number.py | 4 ++-- tests/components/lamarzocco/test_select.py | 4 ++-- tests/components/lamarzocco/test_sensor.py | 2 +- tests/components/lamarzocco/test_switch.py | 2 +- tests/components/lamarzocco/test_update.py | 4 ++-- 27 files changed, 64 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 82a91c0003f..da513bc8cff 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -2,12 +2,12 @@ import logging -from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.const import BT_MODEL_PREFIXES, FirmwareType -from lmcloud.exceptions import AuthFail, RequestNotSuccessful from packaging import version +from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful from homeassistant.components.bluetooth import async_discovered_service_info from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index c48453214bd..444e4d0723b 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index 60374a85e1e..b9bc7fc8844 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -4,8 +4,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py index 3d8b2474c94..0ec9b55a9a1 100644 --- a/homeassistant/components/lamarzocco/calendar.py +++ b/homeassistant/components/lamarzocco/calendar.py @@ -3,7 +3,7 @@ from collections.abc import Iterator from datetime import datetime, timedelta -from lmcloud.models import LaMarzoccoWakeUpSleepEntry +from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 4fadd3a9a32..04e705edbdc 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -6,10 +6,10 @@ from collections.abc import Mapping import logging from typing import Any -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoDeviceInfo import voluptuous as vol from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index e2ff8791a05..05fee98c599 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -8,11 +8,11 @@ import logging from time import time from typing import Any -from lmcloud.client_bluetooth import LaMarzoccoBluetoothClient -from lmcloud.client_cloud import LaMarzoccoCloudClient -from lmcloud.client_local import LaMarzoccoLocalClient -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.client_bluetooth import LaMarzoccoBluetoothClient +from pylamarzocco.client_cloud import LaMarzoccoCloudClient +from pylamarzocco.client_local import LaMarzoccoLocalClient +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MODEL, CONF_NAME, EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py index edce6a349aa..43ae51ee192 100644 --- a/homeassistant/components/lamarzocco/diagnostics.py +++ b/homeassistant/components/lamarzocco/diagnostics.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict from typing import Any, TypedDict -from lmcloud.const import FirmwareType +from pylamarzocco.const import FirmwareType from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index f7e6ff9e2b8..1ea84302a17 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.const import FirmwareType -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.const import FirmwareType +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index bfe0d34a9e4..6b226051118 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -32,6 +32,6 @@ "documentation": "https://www.home-assistant.io/integrations/lamarzocco", "integration_type": "device", "iot_class": "cloud_polling", - "loggers": ["lmcloud"], - "requirements": ["lmcloud==1.2.3"] + "loggers": ["pylamarzocco"], + "requirements": ["pylamarzocco==1.2.3"] } diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index df75147e7e1..825c5d6deb0 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -4,16 +4,16 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud.const import ( +from pylamarzocco.const import ( KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey, PrebrewMode, ) -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.number import ( NumberDeviceClass, diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py index 1958fa6f210..1889ba38d6b 100644 --- a/homeassistant/components/lamarzocco/select.py +++ b/homeassistant/components/lamarzocco/select.py @@ -4,10 +4,10 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index ca8a118c1ee..04b095e798c 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -3,8 +3,8 @@ from collections.abc import Callable from dataclasses import dataclass -from lmcloud.const import BoilerType, MachineModel, PhysicalKey -from lmcloud.lm_machine import LaMarzoccoMachine +from pylamarzocco.const import BoilerType, MachineModel, PhysicalKey +from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py index a611424418f..f7690885f05 100644 --- a/homeassistant/components/lamarzocco/switch.py +++ b/homeassistant/components/lamarzocco/switch.py @@ -4,10 +4,10 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from lmcloud.const import BoilerType -from lmcloud.exceptions import RequestNotSuccessful -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoMachineConfig +from pylamarzocco.const import BoilerType +from pylamarzocco.exceptions import RequestNotSuccessful +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoMachineConfig from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py index 61f436a7d7f..371ff679bae 100644 --- a/homeassistant/components/lamarzocco/update.py +++ b/homeassistant/components/lamarzocco/update.py @@ -3,8 +3,8 @@ from dataclasses import dataclass from typing import Any -from lmcloud.const import FirmwareType -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import RequestNotSuccessful from homeassistant.components.update import ( UpdateDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index 972c94f3c73..acc44aecb43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1309,9 +1309,6 @@ linear-garage-door==0.2.9 # homeassistant.components.linode linode-api==4.1.9b1 -# homeassistant.components.lamarzocco -lmcloud==1.2.3 - # homeassistant.components.google_maps locationsharinglib==5.0.1 @@ -2026,6 +2023,9 @@ pykwb==0.0.8 # homeassistant.components.lacrosse pylacrosse==0.4 +# homeassistant.components.lamarzocco +pylamarzocco==1.2.3 + # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c38ac10c53a..6299b26c2cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,9 +1090,6 @@ libsoundtouch==0.8 # homeassistant.components.linear_garage_door linear-garage-door==0.2.9 -# homeassistant.components.lamarzocco -lmcloud==1.2.3 - # homeassistant.components.london_underground london-tube-status==0.5 @@ -1631,6 +1628,9 @@ pykrakenapi==0.1.8 # homeassistant.components.kulersky pykulersky==0.5.2 +# homeassistant.components.lamarzocco +pylamarzocco==1.2.3 + # homeassistant.components.lastfm pylast==5.1.0 diff --git a/tests/components/lamarzocco/__init__.py b/tests/components/lamarzocco/__init__.py index f88fa474f8b..f6ca0fe40df 100644 --- a/tests/components/lamarzocco/__init__.py +++ b/tests/components/lamarzocco/__init__.py @@ -1,6 +1,6 @@ """Mock inputs for tests.""" -from lmcloud.const import MachineModel +from pylamarzocco.const import MachineModel from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index d8047dfbabf..210dd9406cc 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -5,9 +5,9 @@ import json from unittest.mock import MagicMock, patch from bleak.backends.device import BLEDevice -from lmcloud.const import FirmwareType, MachineModel, SteamLevel -from lmcloud.lm_machine import LaMarzoccoMachine -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import FirmwareType, MachineModel, SteamLevel +from pylamarzocco.lm_machine import LaMarzoccoMachine +from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.lamarzocco.const import DOMAIN diff --git a/tests/components/lamarzocco/test_binary_sensor.py b/tests/components/lamarzocco/test_binary_sensor.py index 120d825c804..956bfe90dd4 100644 --- a/tests/components/lamarzocco/test_binary_sensor.py +++ b/tests/components/lamarzocco/test_binary_sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py index b754688f369..fdea26c9f6f 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 13cf6a72b81..be93779848f 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -2,9 +2,9 @@ from unittest.mock import MagicMock, patch -from lmcloud.const import MachineModel -from lmcloud.exceptions import AuthFail, RequestNotSuccessful -from lmcloud.models import LaMarzoccoDeviceInfo +from pylamarzocco.const import MachineModel +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.models import LaMarzoccoDeviceInfo import pytest from homeassistant.components.dhcp import DhcpServiceInfo diff --git a/tests/components/lamarzocco/test_init.py b/tests/components/lamarzocco/test_init.py index 2c812f79438..b99077a9059 100644 --- a/tests/components/lamarzocco/test_init.py +++ b/tests/components/lamarzocco/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch -from lmcloud.const import FirmwareType -from lmcloud.exceptions import AuthFail, RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful import pytest from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE diff --git a/tests/components/lamarzocco/test_number.py b/tests/components/lamarzocco/test_number.py index 352271f26cf..710a0220e06 100644 --- a/tests/components/lamarzocco/test_number.py +++ b/tests/components/lamarzocco/test_number.py @@ -3,14 +3,14 @@ from typing import Any from unittest.mock import MagicMock -from lmcloud.const import ( +from pylamarzocco.const import ( KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey, PrebrewMode, ) -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_select.py b/tests/components/lamarzocco/test_select.py index 415954d30be..24b96f84f37 100644 --- a/tests/components/lamarzocco/test_select.py +++ b/tests/components/lamarzocco/test_select.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock -from lmcloud.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import MachineModel, PrebrewMode, SmartStandbyMode, SteamLevel +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 760dcffd28f..6f14d52d1fc 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from lmcloud.const import MachineModel +from pylamarzocco.const import MachineModel import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 802ab59148e..5c6d1cb1e42 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion diff --git a/tests/components/lamarzocco/test_update.py b/tests/components/lamarzocco/test_update.py index 3dc2a86b574..aef37d7c921 100644 --- a/tests/components/lamarzocco/test_update.py +++ b/tests/components/lamarzocco/test_update.py @@ -2,8 +2,8 @@ from unittest.mock import MagicMock -from lmcloud.const import FirmwareType -from lmcloud.exceptions import RequestNotSuccessful +from pylamarzocco.const import FirmwareType +from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy import SnapshotAssertion From 0304588bb8ad3751a8a478a75d101b0dd075f7a8 Mon Sep 17 00:00:00 2001 From: Tom Gamull Date: Sat, 9 Nov 2024 05:19:36 -0500 Subject: [PATCH 357/511] Fix missing unit of measurement for blink wifi strength (#128409) --- homeassistant/components/blink/sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index f20f8188b42..e0b5989cc80 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -10,7 +10,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import EntityCategory, UnitOfTemperature +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,6 +36,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=TYPE_WIFI_STRENGTH, translation_key="wifi_strength", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), From 25fb70f281408f087e642ed1e9e71a1b003fb178 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:29:24 +0100 Subject: [PATCH 358/511] Add blood glucose concentration device class (#129340) --- homeassistant/components/nightscout/sensor.py | 9 +++++--- homeassistant/components/number/const.py | 8 +++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ .../components/recorder/statistics.py | 6 +++++ .../components/recorder/websocket_api.py | 4 ++++ homeassistant/components/sensor/const.py | 11 ++++++++++ .../components/sensor/device_condition.py | 5 +++++ .../components/sensor/device_trigger.py | 5 +++++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ homeassistant/const.py | 7 ++++++ homeassistant/util/unit_conversion.py | 12 ++++++++++ tests/util/test_unit_conversion.py | 22 +++++++++++++++++++ 14 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 92291bdc4f9..620349ec3c3 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -9,9 +9,9 @@ from typing import Any from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,7 +37,10 @@ async def async_setup_entry( class NightscoutSensor(SensorEntity): """Implementation of a Nightscout sensor.""" - _attr_native_unit_of_measurement = "mg/dL" + _attr_device_class = SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION + _attr_native_unit_of_measurement = ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER + ) _attr_icon = "mdi:cloud-question" def __init__(self, api: NightscoutAPI, name: str, unique_id: str | None) -> None: diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 5eea525fb6a..23e3ce0910b 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -109,6 +110,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `%` """ + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + CO = "carbon_monoxide" """Carbon Monoxide gas concentration. @@ -429,6 +436,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.AQI: {None}, NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, + NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), NumberDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, NumberDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index a122aaecb09..5e0fc6e44d2 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -15,6 +15,9 @@ "battery": { "default": "mdi:battery" }, + "blood_glucose_concentration": { + "default": "mdi:spoon-sugar" + }, "carbon_dioxide": { "default": "mdi:molecule-co2" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 580385172e3..b9aec880ecc 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -43,6 +43,9 @@ "battery": { "name": "[%key:component::sensor::entity_component::battery::name%]" }, + "blood_glucose_concentration": { + "name": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]" + }, "carbon_dioxide": { "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" }, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4ffe7c72971..9a66c4542b5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,6 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -128,6 +129,11 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{ + unit: BloodGlugoseConcentrationConverter + for unit in BloodGlugoseConcentrationConverter.VALID_UNITS + }, + **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index ac917e903df..8b8d1cfb0c6 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -54,6 +55,9 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( { + vol.Optional("blood_glucose_concentration"): vol.In( + BloodGlugoseConcentrationConverter.VALID_UNITS + ), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index aa3d1906b21..ee6167a5643 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -47,6 +48,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -127,6 +129,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `%` """ + BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" + """Blood glucose concentration. + + Unit of measurement: `mg/dL`, `mmol/L` + """ + CO = "carbon_monoxide" """Carbon Monoxide gas concentration. @@ -493,6 +501,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlugoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, @@ -524,6 +533,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.AQI: {None}, SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), SensorDeviceClass.CO: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CO2: {CONCENTRATION_PARTS_PER_MILLION}, SensorDeviceClass.CONDUCTIVITY: set(UnitOfConductivity), @@ -599,6 +609,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CO2: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.CONDUCTIVITY: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index f2b51899312..56ecb36adb3 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -37,6 +37,7 @@ CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" +CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration" CONF_IS_CO = "is_carbon_monoxide" CONF_IS_CO2 = "is_carbon_dioxide" CONF_IS_CONDUCTIVITY = "is_conductivity" @@ -87,6 +88,9 @@ ENTITY_CONDITIONS = { SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ + {CONF_TYPE: CONF_IS_BLOOD_GLUCOSE_CONCENTRATION} + ], SensorDeviceClass.CO: [{CONF_TYPE: CONF_IS_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_IS_CO2}], SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_IS_CONDUCTIVITY}], @@ -151,6 +155,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_AQI, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, + CONF_IS_BLOOD_GLUCOSE_CONCENTRATION, CONF_IS_CO, CONF_IS_CO2, CONF_IS_CONDUCTIVITY, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index b07b3fac11e..ffee10d9f40 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -36,6 +36,7 @@ CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" +CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" CONF_CO = "carbon_monoxide" CONF_CO2 = "carbon_dioxide" CONF_CONDUCTIVITY = "conductivity" @@ -86,6 +87,9 @@ ENTITY_TRIGGERS = { SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ + {CONF_TYPE: CONF_BLOOD_GLUCOSE_CONCENTRATION} + ], SensorDeviceClass.CO: [{CONF_TYPE: CONF_CO}], SensorDeviceClass.CO2: [{CONF_TYPE: CONF_CO2}], SensorDeviceClass.CONDUCTIVITY: [{CONF_TYPE: CONF_CONDUCTIVITY}], @@ -151,6 +155,7 @@ TRIGGER_SCHEMA = vol.All( CONF_AQI, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, + CONF_BLOOD_GLUCOSE_CONCENTRATION, CONF_CO, CONF_CO2, CONF_CONDUCTIVITY, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 6132fcbc1e9..ea4c902e665 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -12,6 +12,9 @@ "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, + "blood_glucose_concentration": { + "default": "mdi:spoon-sugar" + }, "carbon_dioxide": { "default": "mdi:molecule-co2" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 71bead342c4..6d529e72c3b 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -6,6 +6,7 @@ "is_aqi": "Current {entity_name} air quality index", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", + "is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration", "is_carbon_monoxide": "Current {entity_name} carbon monoxide concentration level", "is_carbon_dioxide": "Current {entity_name} carbon dioxide concentration level", "is_conductivity": "Current {entity_name} conductivity", @@ -56,6 +57,7 @@ "aqi": "{entity_name} air quality index changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", + "blood_glucose_concentration": "{entity_name} blood glucose concentration changes", "carbon_monoxide": "{entity_name} carbon monoxide concentration changes", "carbon_dioxide": "{entity_name} carbon dioxide concentration changes", "conductivity": "{entity_name} conductivity changes", @@ -149,6 +151,9 @@ "battery": { "name": "Battery" }, + "blood_glucose_concentration": { + "name": "Blood glucose concentration" + }, "carbon_monoxide": { "name": "Carbon monoxide" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 0bdd625e417..558e7ec2b0b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1358,6 +1358,13 @@ CONCENTRATION_PARTS_PER_MILLION: Final = "ppm" CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" +class UnitOfBloodGlucoseConcentration(StrEnum): + """Blood glucose concentration units.""" + + MILLIGRAMS_PER_DECILITER = "mg/dL" + MILLIMOLE_PER_LITER = "mmol/L" + + # Speed units class UnitOfSpeed(StrEnum): """Speed units.""" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 289df28738a..95d8fbc9df1 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -10,6 +10,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -173,6 +174,17 @@ class DistanceConverter(BaseUnitConverter): } +class BloodGlugoseConcentrationConverter(BaseUnitConverter): + """Utility to convert blood glucose concentration values.""" + + UNIT_CLASS = "blood_glucose_concentration" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER: 18, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER: 1, + } + VALID_UNITS = set(UnitOfBloodGlucoseConcentration) + + class ConductivityConverter(BaseUnitConverter): """Utility to convert electric current values.""" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index b07b96e0de7..a57cdde821f 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, UnitOfElectricCurrent, @@ -32,6 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -59,6 +61,7 @@ INVALID_SYMBOL = "bob" _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( + BloodGlugoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -80,6 +83,11 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + BloodGlugoseConcentrationConverter: ( + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + 18, + ), ConductivityConverter: ( UnitOfConductivity.MICROSIEMENS_PER_CM, UnitOfConductivity.MILLISIEMENS_PER_CM, @@ -130,6 +138,20 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + BloodGlugoseConcentrationConverter: [ + ( + 90, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + 5, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + ), + ( + 1, + UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, + 18, + UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, + ), + ], ConductivityConverter: [ # Deprecated to deprecated (5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS), From 69ba0d3a50aa09810d1fbeee0797af63ef9b8709 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 9 Nov 2024 11:35:18 +0100 Subject: [PATCH 359/511] Report update_percentage in ezviz update entity (#129377) --- homeassistant/components/ezviz/update.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py index 05735d152cf..25a506a0052 100644 --- a/homeassistant/components/ezviz/update.py +++ b/homeassistant/components/ezviz/update.py @@ -73,11 +73,9 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity): return self.data["version"] @property - def in_progress(self) -> bool | int | None: + def in_progress(self) -> bool: """Update installation progress.""" - if self.data["upgrade_in_progress"]: - return self.data["upgrade_percent"] - return False + return bool(self.data["upgrade_in_progress"]) @property def latest_version(self) -> str | None: @@ -93,6 +91,13 @@ class EzvizUpdateEntity(EzvizEntity, UpdateEntity): return self.data["latest_firmware_info"].get("desc") return None + @property + def update_percentage(self) -> int | None: + """Update installation progress.""" + if self.data["upgrade_in_progress"]: + return self.data["upgrade_percent"] + return None + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: From 8b8e949bdfa2592c7b3a833c0dda502c3741bd8f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:07:20 +0100 Subject: [PATCH 360/511] Update wheel builder to 2024.11.0 (#130209) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0c8df57d5a2..835969f368f 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -135,7 +135,7 @@ jobs: sed -i "/uv/d" requirements_diff.txt - name: Build wheels - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -208,7 +208,7 @@ jobs: cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -223,7 +223,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -237,7 +237,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -251,7 +251,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2024.07.1 + uses: home-assistant/wheels@2024.11.0 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 03bc711c51e904bebba441c593a93f0724986e4d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 9 Nov 2024 12:25:06 +0100 Subject: [PATCH 361/511] Add Reolink chime vehicle tone (#129835) --- homeassistant/components/reolink/icons.json | 6 ++++++ homeassistant/components/reolink/select.py | 10 ++++++++++ homeassistant/components/reolink/strings.json | 16 ++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index 7f4a15ffe21..d333a8a0201 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -246,6 +246,12 @@ "off": "mdi:music-note-off" } }, + "vehicle_tone": { + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } + }, "visitor_tone": { "default": "mdi:music-note", "state": { diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 1306c881059..a444997a907 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -197,6 +197,16 @@ CHIME_SELECT_ENTITIES = ( value=lambda chime: ChimeToneEnum(chime.tone("people")).name, method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), ), + ReolinkChimeSelectEntityDescription( + key="vehicle_tone", + cmd_key="GetDingDongCfg", + translation_key="vehicle_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + supported=lambda chime: "vehicle" in chime.chime_event_types, + value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name, + method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value), + ), ReolinkChimeSelectEntityDescription( key="visitor_tone", cmd_key="GetDingDongCfg", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index fbc88ed1b50..1d699b7b658 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -606,6 +606,22 @@ "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" } }, + "vehicle_tone": { + "name": "Vehicle ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } + }, "visitor_tone": { "name": "Visitor ringtone", "state": { From 4e2f5bdb7d140f5001cd564b3dbe5ac996ba8575 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:45:50 +0100 Subject: [PATCH 362/511] Add tests for cast skill action in Habitica (#129596) --- tests/components/habitica/fixtures/tasks.json | 3 +- tests/components/habitica/test_services.py | 273 ++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/components/habitica/test_services.py diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 0d6ffba0732..768768b4478 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -454,7 +454,8 @@ "createdAt": "2024-09-21T22:17:19.513Z", "updatedAt": "2024-09-21T22:19:35.576Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", - "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490" + "id": "2f6fcabc-f670-4ec3-ba65-817e8deea490", + "alias": "pay_bills" }, { "_id": "1aa3137e-ef72-4d1f-91ee-41933602f438", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py new file mode 100644 index 00000000000..072fc2b7721 --- /dev/null +++ b/tests/components/habitica/test_services.py @@ -0,0 +1,273 @@ +"""Test Habitica actions.""" + +from collections.abc import Generator +from http import HTTPStatus +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.habitica.const import ( + ATTR_CONFIG_ENTRY, + ATTR_SKILL, + ATTR_TASK, + DEFAULT_URL, + DOMAIN, + SERVICE_CAST_SKILL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError + +from .conftest import mock_called_with + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def services_only() -> Generator[None]: + """Enable only services.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [], + ): + yield + + +@pytest.fixture(autouse=True) +async def load_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + services_only: Generator, +) -> None: + """Load config entry.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + ("service_data", "item", "target_id"), + [ + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "pickpocket", + }, + "pickPocket", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "backstab", + }, + "backStab", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "fireball", + }, + "fireball", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ( + { + ATTR_TASK: "pay_bills", + ATTR_SKILL: "smash", + }, + "smash", + "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ), + ], + ids=[ + "cast pickpocket", + "cast backstab", + "cast fireball", + "cast smash", + "select task by name", + "select task_by_alias", + ], +) +async def test_cast_skill( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + item: str, + target_id: str, +) -> None: + """Test Habitica cast skill action.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/user/class/cast/{item}?targetId={target_id}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TASK: "task-not-found", + ATTR_SKILL: "smash", + }, + HTTPStatus.OK, + ServiceValidationError, + "Unable to cast skill, could not find the task 'task-not-found", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + "Currently rate limited, try again later", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.NOT_FOUND, + ServiceValidationError, + "Unable to cast skill, your character does not have the skill or spell smash", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Unable to cast skill, not enough mana. Your character has 50 MP, but the skill costs 10 MP", + ), + ( + { + ATTR_TASK: "Rechnungen bezahlen", + ATTR_SKILL: "smash", + }, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + "Unable to connect to Habitica, try again later", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_cast_skill_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica cast skill action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/user/class/cast/smash?targetId=2f6fcabc-f670-4ec3-ba65-817e8deea490", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_habitica") +async def test_get_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, +) -> None: + """Test Habitica config entry exceptions.""" + + with pytest.raises( + ServiceValidationError, + match="The selected character is not configured in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: "0000000000000000", + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + return_response=True, + blocking=True, + ) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + with pytest.raises( + ServiceValidationError, + match="The selected character is currently not loaded or disabled in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CAST_SKILL, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + ATTR_TASK: "2f6fcabc-f670-4ec3-ba65-817e8deea490", + ATTR_SKILL: "smash", + }, + return_response=True, + blocking=True, + ) From 4adffdd1a607c386ab02ce64f610a7aa7a5212c7 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sat, 9 Nov 2024 07:01:59 -0500 Subject: [PATCH 363/511] Fix wording in Google Calendar create_event strings for consistency (#130183) --- homeassistant/components/google/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index c029b46051e..2ea45239a53 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -87,8 +87,8 @@ } }, "create_event": { - "name": "Creates event", - "description": "Add a new calendar event.", + "name": "Create event", + "description": "Adds a new calendar event.", "fields": { "summary": { "name": "Summary", From 4d7405de2c723d562e843c6753a93314428657d4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:03:26 +0100 Subject: [PATCH 364/511] Install zlib-dev for pillow wheel build (#130211) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 835969f368f..ef01bb122d3 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -142,7 +142,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libffi-dev;openssl-dev;yaml-dev;nasm" + apk: "libffi-dev;openssl-dev;yaml-dev;nasm;zlib-dev" skip-binary: aiohttp;multidict;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -230,7 +230,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -244,7 +244,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" @@ -258,7 +258,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 1f43dc667600bf48eff9972833612a1c963ac598 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:12:04 +0100 Subject: [PATCH 365/511] Fix cast skill test in Habitica (#130213) --- tests/components/habitica/test_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 072fc2b7721..1dd7b748936 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -168,7 +168,7 @@ async def test_cast_skill( }, HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError, - "Currently rate limited, try again later", + "Rate limit exceeded, try again later", ), ( { From 5f0f29704b5cffef35ea396606885d8b9e3ed1a0 Mon Sep 17 00:00:00 2001 From: Marco <46717884+marcodutto@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:32:00 +0100 Subject: [PATCH 366/511] Add smarty reset filters timer button (#129637) --- homeassistant/components/smarty/__init__.py | 8 +- homeassistant/components/smarty/button.py | 74 +++++++++++++++++++ homeassistant/components/smarty/strings.json | 5 ++ tests/components/smarty/conftest.py | 1 + .../smarty/snapshots/test_button.ambr | 47 ++++++++++++ tests/components/smarty/test_button.py | 45 +++++++++++ 6 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smarty/button.py create mode 100644 tests/components/smarty/snapshots/test_button.ambr create mode 100644 tests/components/smarty/test_button.py diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index 0e5ca216621..0d043804c3d 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -30,7 +30,13 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.FAN, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.FAN, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py new file mode 100644 index 00000000000..b8e31cf6fc8 --- /dev/null +++ b/homeassistant/components/smarty/button.py @@ -0,0 +1,74 @@ +"""Platform to control a Salda Smarty XP/XV ventilation unit.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from pysmarty2 import Smarty + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SmartyConfigEntry, SmartyCoordinator +from .entity import SmartyEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SmartyButtonDescription(ButtonEntityDescription): + """Class describing Smarty button.""" + + press_fn: Callable[[Smarty], bool | None] + + +ENTITIES: tuple[SmartyButtonDescription, ...] = ( + SmartyButtonDescription( + key="reset_filters_timer", + translation_key="reset_filters_timer", + press_fn=lambda smarty: smarty.reset_filters_timer(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smarty Button Platform.""" + + coordinator = entry.runtime_data + + async_add_entities( + SmartyButton(coordinator, description) for description in ENTITIES + ) + + +class SmartyButton(SmartyEntity, ButtonEntity): + """Representation of a Smarty Button.""" + + entity_description: SmartyButtonDescription + + def __init__( + self, + coordinator: SmartyCoordinator, + entity_description: SmartyButtonDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{entity_description.key}" + ) + + async def async_press(self, **kwargs: Any) -> None: + """Press the button.""" + await self.hass.async_add_executor_job( + self.entity_description.press_fn, self.coordinator.client + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 5553a1c0135..188459b4f16 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -42,6 +42,11 @@ "name": "Boost state" } }, + "button": { + "reset_filters_timer": { + "name": "Reset filters timer" + } + }, "sensor": { "supply_air_temperature": { "name": "Supply air temperature" diff --git a/tests/components/smarty/conftest.py b/tests/components/smarty/conftest.py index c61ec4b1022..a9b518d88f4 100644 --- a/tests/components/smarty/conftest.py +++ b/tests/components/smarty/conftest.py @@ -50,6 +50,7 @@ def mock_smarty() -> Generator[AsyncMock]: client.filter_timer = 31 client.get_configuration_version.return_value = 111 client.get_software_version.return_value = 127 + client.reset_filters_timer.return_value = True yield client diff --git a/tests/components/smarty/snapshots/test_button.ambr b/tests/components/smarty/snapshots/test_button.ambr new file mode 100644 index 00000000000..38849bd2b2e --- /dev/null +++ b/tests/components/smarty/snapshots/test_button.ambr @@ -0,0 +1,47 @@ +# serializer version: 1 +# name: test_all_entities[button.mock_title_reset_filters_timer-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.mock_title_reset_filters_timer', + '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': 'Reset filters timer', + 'platform': 'smarty', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_filters_timer', + 'unique_id': '01JAZ5DPW8C62D620DGYNG2R8H_reset_filters_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.mock_title_reset_filters_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Reset filters timer', + }), + 'context': , + 'entity_id': 'button.mock_title_reset_filters_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smarty/test_button.py b/tests/components/smarty/test_button.py new file mode 100644 index 00000000000..0a7b67f2be6 --- /dev/null +++ b/tests/components/smarty/test_button.py @@ -0,0 +1,45 @@ +"""Tests for the Smarty button platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.smarty.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_smarty: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + target={ATTR_ENTITY_ID: "button.mock_title_reset_filters_timer"}, + blocking=True, + ) + mock_smarty.reset_filters_timer.assert_called_once_with() From 6837ea947cb9e642c359bf8ccf546fbacb1e112a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 9 Nov 2024 15:54:18 +0100 Subject: [PATCH 367/511] Cleanup yaml import and legacy file notify service (#130219) --- homeassistant/components/file/__init__.py | 91 +-------- homeassistant/components/file/config_flow.py | 23 --- homeassistant/components/file/notify.py | 83 +------- homeassistant/components/file/sensor.py | 31 +-- tests/components/file/test_notify.py | 201 ++----------------- tests/components/file/test_sensor.py | 23 --- 6 files changed, 18 insertions(+), 434 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 0c9cfee5f4d..4139b021422 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -3,88 +3,19 @@ from copy import deepcopy from typing import Any -from homeassistant.components.notify import migrate_notify_issue -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_FILE_PATH, - CONF_NAME, - CONF_PLATFORM, - CONF_SCAN_INTERVAL, - Platform, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import ( - config_validation as cv, - discovery, - issue_registry as ir, -) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import config_validation as cv from .const import DOMAIN -from .notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA -from .sensor import PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA - -IMPORT_SCHEMA = { - Platform.SENSOR: SENSOR_PLATFORM_SCHEMA, - Platform.NOTIFY: NOTIFY_PLATFORM_SCHEMA, -} CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the file integration.""" - - hass.data[DOMAIN] = config - if hass.config_entries.async_entries(DOMAIN): - # We skip import in case we already have config entries - return True - # 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(hass, DOMAIN, "File", "2024.12.0") - # The YAML config was imported with HA Core 2024.6.0 and will be removed with - # HA Core 2024.12 - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - learn_more_url="https://www.home-assistant.io/integrations/file/", - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "File", - }, - ) - - # Import the YAML config into separate config entries - platforms_config: dict[Platform, list[ConfigType]] = { - domain: config[domain] for domain in PLATFORMS if domain in config - } - for domain, items in platforms_config.items(): - for item in items: - if item[CONF_PLATFORM] == DOMAIN: - file_config_item = IMPORT_SCHEMA[domain](item) - file_config_item[CONF_PLATFORM] = domain - if CONF_SCAN_INTERVAL in file_config_item: - del file_config_item[CONF_SCAN_INTERVAL] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=file_config_item, - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a file component entry.""" config = {**entry.data, **entry.options} @@ -102,20 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, [Platform(entry.data[CONF_PLATFORM])] ) entry.async_on_unload(entry.add_update_listener(update_listener)) - if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data: - # New notify entities are being setup through the config entry, - # but during the deprecation period we want to keep the legacy notify platform, - # so we forward the setup config through discovery. - # Only the entities from yaml will still be available as legacy service. - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - config, - hass.data[DOMAIN], - ) - ) return True diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 2b8a9bde749..992635d05fd 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from copy import deepcopy -import os from typing import Any import voluptuous as vol @@ -16,7 +15,6 @@ from homeassistant.config_entries import ( ) from homeassistant.const import ( CONF_FILE_PATH, - CONF_FILENAME, CONF_NAME, CONF_PLATFORM, CONF_UNIT_OF_MEASUREMENT, @@ -132,27 +130,6 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): """Handle file sensor config flow.""" return await self._async_handle_step(Platform.SENSOR.value, user_input) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import `file`` config from configuration.yaml.""" - self._async_abort_entries_match(import_data) - platform = import_data[CONF_PLATFORM] - name: str = import_data.get(CONF_NAME, DEFAULT_NAME) - file_name: str - if platform == Platform.NOTIFY: - file_name = import_data.pop(CONF_FILENAME) - file_path: str = os.path.join(self.hass.config.config_dir, file_name) - import_data[CONF_FILE_PATH] = file_path - else: - file_path = import_data[CONF_FILE_PATH] - title = f"{name} [{file_path}]" - data = deepcopy(import_data) - options = {} - for key, value in import_data.items(): - if key not in (CONF_FILE_PATH, CONF_PLATFORM, CONF_NAME): - data.pop(key) - options[key] = value - return self.async_create_entry(title=title, data=data, options=options) - class FileOptionsFlowHandler(OptionsFlow): """Handle File options.""" diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 9411b7cf1a8..10e3d4a4ac6 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,104 +2,23 @@ from __future__ import annotations -from functools import partial -import logging import os from typing import Any, TextIO -import voluptuous as vol - from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, NotifyEntity, NotifyEntityFeature, - migrate_notify_issue, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME +from homeassistant.const import CONF_FILE_PATH, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON -_LOGGER = logging.getLogger(__name__) - -# The legacy platform schema uses a filename, after import -# The full file path is stored in the config entry -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILENAME): cv.string, - vol.Optional(CONF_TIMESTAMP, default=False): cv.boolean, - } -) - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> FileNotificationService | None: - """Get the file notification service.""" - if discovery_info is None: - # We only set up through discovery - return None - file_path: str = discovery_info[CONF_FILE_PATH] - timestamp: bool = discovery_info[CONF_TIMESTAMP] - - return FileNotificationService(file_path, timestamp) - - -class FileNotificationService(BaseNotificationService): - """Implement the notification service for the File service.""" - - def __init__(self, file_path: str, add_timestamp: bool) -> None: - """Initialize the service.""" - self._file_path = file_path - self.add_timestamp = add_timestamp - - async def async_send_message(self, message: str = "", **kwargs: Any) -> None: - """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", service_name=self._service_name - ) - await self.hass.async_add_executor_job( - partial(self.send_message, message, **kwargs) - ) - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to a file.""" - file: TextIO - filepath = self._file_path - try: - with open(filepath, "a", encoding="utf8") as file: - if os.stat(filepath).st_size == 0: - title = ( - f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log" - f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" - ) - file.write(title) - - if self.add_timestamp: - text = f"{dt_util.utcnow().isoformat()} {message}\n" - else: - text = f"{message}\n" - file.write(text) - except OSError as exc: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="write_access_failed", - translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, - ) from exc - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index e37a3df86a6..879c06e29f3 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -6,12 +6,8 @@ import logging import os from file_read_backwards import FileReadBackwards -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_FILE_PATH, @@ -20,38 +16,13 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DEFAULT_NAME, FILE_ICON _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_FILE_PATH): cv.isfile, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the file sensor from YAML. - - The YAML platform config is automatically - imported to a config entry, this method can be removed - when YAML support is removed. - """ - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 33e4739a488..e7cb85a9cfc 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -12,222 +12,46 @@ from homeassistant.components.file import DOMAIN from homeassistant.components.notify import ATTR_TITLE_DEFAULT from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, assert_setup_component - - -async def test_bad_config(hass: HomeAssistant) -> None: - """Test set up the platform with bad/missing config.""" - config = {notify.DOMAIN: {"name": "test", "platform": "file"}} - with assert_setup_component(0, domain="notify") as handle_config: - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert not handle_config[notify.DOMAIN] +from tests.common import MockConfigEntry @pytest.mark.parametrize( ("domain", "service", "params"), [ - (notify.DOMAIN, "test", {"message": "one, two, testing, testing"}), ( notify.DOMAIN, "send_message", {"entity_id": "notify.test", "message": "one, two, testing, testing"}, ), ], - ids=["legacy", "entity"], -) -@pytest.mark.parametrize( - ("timestamp", "config"), - [ - ( - False, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - "timestamp": False, - } - ] - }, - ), - ( - True, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - "timestamp": True, - } - ] - }, - ), - ], - ids=["no_timestamp", "timestamp"], ) +@pytest.mark.parametrize("timestamp", [False, True], ids=["no_timestamp", "timestamp"]) async def test_notify_file( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - timestamp: bool, mock_is_allowed_path: MagicMock, - config: ConfigType, + timestamp: bool, domain: str, service: str, params: dict[str, str], ) -> None: """Test the notify file output.""" filename = "mock_file" - message = params["message"] - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) + full_filename = os.path.join(hass.config.path(), filename) - freezer.move_to(dt_util.utcnow()) - - m_open = mock_open() - with ( - patch("homeassistant.components.file.notify.open", m_open, create=True), - patch("homeassistant.components.file.notify.os.stat") as mock_st, - ): - mock_st.return_value.st_size = 0 - title = ( - f"{ATTR_TITLE_DEFAULT} notifications " - f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" - ) - - await hass.services.async_call(domain, service, params, blocking=True) - - full_filename = os.path.join(hass.config.path(), filename) - assert m_open.call_count == 1 - assert m_open.call_args == call(full_filename, "a", encoding="utf8") - - assert m_open.return_value.write.call_count == 2 - if not timestamp: - assert m_open.return_value.write.call_args_list == [ - call(title), - call(f"{message}\n"), - ] - else: - assert m_open.return_value.write.call_args_list == [ - call(title), - call(f"{dt_util.utcnow().isoformat()} {message}\n"), - ] - - -@pytest.mark.parametrize( - ("domain", "service", "params"), - [(notify.DOMAIN, "test", {"message": "one, two, testing, testing"})], - ids=["legacy"], -) -@pytest.mark.parametrize( - ("is_allowed", "config"), - [ - ( - True, - { - "notify": [ - { - "name": "test", - "platform": "file", - "filename": "mock_file", - } - ] - }, - ), - ], - ids=["allowed_but_access_failed"], -) -async def test_legacy_notify_file_exception( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - mock_is_allowed_path: MagicMock, - config: ConfigType, - domain: str, - service: str, - params: dict[str, str], -) -> None: - """Test legacy notify file output has exception.""" - assert await async_setup_component(hass, notify.DOMAIN, config) - await hass.async_block_till_done() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - - freezer.move_to(dt_util.utcnow()) - - m_open = mock_open() - with ( - patch("homeassistant.components.file.notify.open", m_open, create=True), - patch("homeassistant.components.file.notify.os.stat") as mock_st, - ): - mock_st.side_effect = OSError("Access Failed") - with pytest.raises(ServiceValidationError) as exc: - await hass.services.async_call(domain, service, params, blocking=True) - assert f"{exc.value!r}" == "ServiceValidationError('write_access_failed')" - - -@pytest.mark.parametrize( - ("timestamp", "data", "options"), - [ - ( - False, - { - "name": "test", - "platform": "notify", - "file_path": "mock_file", - }, - { - "timestamp": False, - }, - ), - ( - True, - { - "name": "test", - "platform": "notify", - "file_path": "mock_file", - }, - { - "timestamp": True, - }, - ), - ], - ids=["no_timestamp", "timestamp"], -) -async def test_legacy_notify_file_entry_only_setup( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, - timestamp: bool, - mock_is_allowed_path: MagicMock, - data: dict[str, Any], - options: dict[str, Any], -) -> None: - """Test the legacy notify file output in entry only setup.""" - filename = "mock_file" - - domain = notify.DOMAIN - service = "test" - params = {"message": "one, two, testing, testing"} message = params["message"] entry = MockConfigEntry( domain=DOMAIN, - data=data, + data={"name": "test", "platform": "notify", "file_path": full_filename}, + options={"timestamp": timestamp}, version=2, - options=options, - title=f"test [{data['file_path']}]", + title=f"test [{filename}]", ) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done(wait_background_tasks=True) + assert await hass.config_entries.async_setup(entry.entry_id) freezer.move_to(dt_util.utcnow()) @@ -245,7 +69,7 @@ async def test_legacy_notify_file_entry_only_setup( await hass.services.async_call(domain, service, params, blocking=True) assert m_open.call_count == 1 - assert m_open.call_args == call(filename, "a", encoding="utf8") + assert m_open.call_args == call(full_filename, "a", encoding="utf8") assert m_open.return_value.write.call_count == 2 if not timestamp: @@ -277,14 +101,14 @@ async def test_legacy_notify_file_entry_only_setup( ], ids=["not_allowed"], ) -async def test_legacy_notify_file_not_allowed( +async def test_notify_file_not_allowed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_is_allowed_path: MagicMock, config: dict[str, Any], options: dict[str, Any], ) -> None: - """Test legacy notify file output not allowed.""" + """Test notify file output not allowed.""" entry = MockConfigEntry( domain=DOMAIN, data=config, @@ -301,11 +125,10 @@ async def test_legacy_notify_file_not_allowed( @pytest.mark.parametrize( ("service", "params"), [ - ("test", {"message": "one, two, testing, testing"}), ( "send_message", {"entity_id": "notify.test", "message": "one, two, testing, testing"}, - ), + ) ], ) @pytest.mark.parametrize( diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py index 634ae9d626c..9e6a16e3e27 100644 --- a/tests/components/file/test_sensor.py +++ b/tests/components/file/test_sensor.py @@ -7,33 +7,10 @@ import pytest from homeassistant.components.file import DOMAIN from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, get_fixture_path -@patch("os.path.isfile", Mock(return_value=True)) -@patch("os.access", Mock(return_value=True)) -async def test_file_value_yaml_setup( - hass: HomeAssistant, mock_is_allowed_path: MagicMock -) -> None: - """Test the File sensor from YAML setup.""" - config = { - "sensor": { - "platform": "file", - "scan_interval": 30, - "name": "file1", - "file_path": get_fixture_path("file_value.txt", "file"), - } - } - - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() - - state = hass.states.get("sensor.file1") - assert state.state == "21" - - @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) async def test_file_value_entry_setup( From c89ab7a14244768db7ffdcbb276862f617e2d3bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 9 Nov 2024 15:54:58 +0100 Subject: [PATCH 368/511] Bump pyTibber (#130216) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 205bc1352eb..d1bfefec484 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.4"] + "requirements": ["pyTibber==0.30.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index acc44aecb43..2d39d791817 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.4 +pyTibber==0.30.7 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6299b26c2cb..a551f731fad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.4 +pyTibber==0.30.7 # homeassistant.components.dlink pyW215==0.7.0 From e6d16f06fc24eacd77a50c8beb85515d2cf7e608 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 9 Nov 2024 15:55:39 +0100 Subject: [PATCH 369/511] Fix uptime sensor for Vodafone Station (#130215) --- homeassistant/components/vodafone_station/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 136aa94b43a..fb76253eb3d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -43,12 +43,10 @@ def _calculate_uptime( ) -> datetime: """Calculate device uptime.""" - assert isinstance(last_value, datetime) - delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key]) if ( - not last_value + not isinstance(last_value, datetime) or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION ): return delta_uptime From c10f078f2a2153feef85eb5ec299a893111d8a91 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:04:10 +0100 Subject: [PATCH 370/511] Add sensors for attribute points (str, int, per, con) to Habitica (#130186) --- .../components/habitica/coordinator.py | 5 + homeassistant/components/habitica/icons.json | 12 + homeassistant/components/habitica/sensor.py | 78 ++++- .../components/habitica/strings.json | 80 +++++ homeassistant/components/habitica/util.py | 50 +++ tests/components/habitica/conftest.py | 5 + .../fixtures/common_buttons_unavailable.json | 19 +- .../components/habitica/fixtures/content.json | 287 ++++++++++++++++++ .../habitica/fixtures/healer_fixture.json | 33 +- .../fixtures/healer_skills_unavailable.json | 33 +- .../fixtures/quest_invitation_off.json | 3 +- .../habitica/fixtures/rogue_fixture.json | 33 +- .../fixtures/rogue_skills_unavailable.json | 33 +- .../fixtures/rogue_stealth_unavailable.json | 33 +- tests/components/habitica/fixtures/user.json | 33 +- .../habitica/fixtures/warrior_fixture.json | 33 +- .../fixtures/warrior_skills_unavailable.json | 33 +- .../habitica/fixtures/wizard_fixture.json | 33 +- .../fixtures/wizard_frost_unavailable.json | 33 +- .../fixtures/wizard_skills_unavailable.json | 33 +- .../habitica/snapshots/test_sensor.ambr | 220 ++++++++++++++ .../components/habitica/test_binary_sensor.py | 6 +- tests/components/habitica/test_button.py | 10 + tests/components/habitica/test_todo.py | 5 + 24 files changed, 1047 insertions(+), 96 deletions(-) create mode 100644 tests/components/habitica/fixtures/content.json diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index cce2c684ba8..f9ffb1b53bd 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -51,12 +51,17 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): ), ) self.api = habitipy + self.content: dict[str, Any] = {} async def _async_update_data(self) -> HabiticaData: try: user_response = await self.api.user.get() tasks_response = await self.api.tasks.user.get() tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) + if not self.content: + self.content = await self.api.content.get( + language=user_response["preferences"]["language"] + ) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.debug("Rate limit exceeded, will try again later") diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 0698b85afe1..b2b7e548fd7 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -126,6 +126,18 @@ }, "rewards": { "default": "mdi:treasure-chest" + }, + "strength": { + "default": "mdi:arm-flex-outline" + }, + "intelligence": { + "default": "mdi:head-snowflake-outline" + }, + "perception": { + "default": "mdi:eye-outline" + }, + "constitution": { + "default": "mdi:run-fast" } }, "switch": { diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 77356f88265..3b2395ecc52 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import StateType from .const import DOMAIN, UNIT_TASKS from .entity import HabiticaBase from .types import HabiticaConfigEntry -from .util import entity_used_in +from .util import entity_used_in, get_attribute_points, get_attributes_total _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,10 @@ _LOGGER = logging.getLogger(__name__) class HabitipySensorEntityDescription(SensorEntityDescription): """Habitipy Sensor Description.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[dict[str, Any], dict[str, Any]], StateType] + attributes_fn: ( + Callable[[dict[str, Any], dict[str, Any]], dict[str, Any] | None] | None + ) = None @dataclass(kw_only=True, frozen=True) @@ -65,76 +68,80 @@ class HabitipySensorEntity(StrEnum): REWARDS = "rewards" GEMS = "gems" TRINKETS = "trinkets" + STRENGTH = "strength" + INTELLIGENCE = "intelligence" + CONSTITUTION = "constitution" + PERCEPTION = "perception" SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( HabitipySensorEntityDescription( key=HabitipySensorEntity.DISPLAY_NAME, translation_key=HabitipySensorEntity.DISPLAY_NAME, - value_fn=lambda user: user.get("profile", {}).get("name"), + value_fn=lambda user, _: user.get("profile", {}).get("name"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH, translation_key=HabitipySensorEntity.HEALTH, native_unit_of_measurement="HP", suggested_display_precision=0, - value_fn=lambda user: user.get("stats", {}).get("hp"), + value_fn=lambda user, _: user.get("stats", {}).get("hp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.HEALTH_MAX, translation_key=HabitipySensorEntity.HEALTH_MAX, native_unit_of_measurement="HP", entity_registry_enabled_default=False, - value_fn=lambda user: user.get("stats", {}).get("maxHealth"), + value_fn=lambda user, _: user.get("stats", {}).get("maxHealth"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA, translation_key=HabitipySensorEntity.MANA, native_unit_of_measurement="MP", suggested_display_precision=0, - value_fn=lambda user: user.get("stats", {}).get("mp"), + value_fn=lambda user, _: user.get("stats", {}).get("mp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.MANA_MAX, translation_key=HabitipySensorEntity.MANA_MAX, native_unit_of_measurement="MP", - value_fn=lambda user: user.get("stats", {}).get("maxMP"), + value_fn=lambda user, _: user.get("stats", {}).get("maxMP"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE, translation_key=HabitipySensorEntity.EXPERIENCE, native_unit_of_measurement="XP", - value_fn=lambda user: user.get("stats", {}).get("exp"), + value_fn=lambda user, _: user.get("stats", {}).get("exp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.EXPERIENCE_MAX, translation_key=HabitipySensorEntity.EXPERIENCE_MAX, native_unit_of_measurement="XP", - value_fn=lambda user: user.get("stats", {}).get("toNextLevel"), + value_fn=lambda user, _: user.get("stats", {}).get("toNextLevel"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.LEVEL, translation_key=HabitipySensorEntity.LEVEL, - value_fn=lambda user: user.get("stats", {}).get("lvl"), + value_fn=lambda user, _: user.get("stats", {}).get("lvl"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GOLD, translation_key=HabitipySensorEntity.GOLD, native_unit_of_measurement="GP", suggested_display_precision=2, - value_fn=lambda user: user.get("stats", {}).get("gp"), + value_fn=lambda user, _: user.get("stats", {}).get("gp"), ), HabitipySensorEntityDescription( key=HabitipySensorEntity.CLASS, translation_key=HabitipySensorEntity.CLASS, - value_fn=lambda user: user.get("stats", {}).get("class"), + value_fn=lambda user, _: user.get("stats", {}).get("class"), device_class=SensorDeviceClass.ENUM, options=["warrior", "healer", "wizard", "rogue"], ), HabitipySensorEntityDescription( key=HabitipySensorEntity.GEMS, translation_key=HabitipySensorEntity.GEMS, - value_fn=lambda user: user.get("balance", 0) * 4, + value_fn=lambda user, _: user.get("balance", 0) * 4, suggested_display_precision=0, native_unit_of_measurement="gems", ), @@ -142,7 +149,7 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( key=HabitipySensorEntity.TRINKETS, translation_key=HabitipySensorEntity.TRINKETS, value_fn=( - lambda user: user.get("purchased", {}) + lambda user, _: user.get("purchased", {}) .get("plan", {}) .get("consecutive", {}) .get("trinkets", 0) @@ -150,6 +157,38 @@ SENSOR_DESCRIPTIONS: tuple[HabitipySensorEntityDescription, ...] = ( suggested_display_precision=0, native_unit_of_measurement="⧖", ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.STRENGTH, + translation_key=HabitipySensorEntity.STRENGTH, + value_fn=lambda user, content: get_attributes_total(user, content, "str"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "str"), + suggested_display_precision=0, + native_unit_of_measurement="STR", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.INTELLIGENCE, + translation_key=HabitipySensorEntity.INTELLIGENCE, + value_fn=lambda user, content: get_attributes_total(user, content, "int"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "int"), + suggested_display_precision=0, + native_unit_of_measurement="INT", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.PERCEPTION, + translation_key=HabitipySensorEntity.PERCEPTION, + value_fn=lambda user, content: get_attributes_total(user, content, "per"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "per"), + suggested_display_precision=0, + native_unit_of_measurement="PER", + ), + HabitipySensorEntityDescription( + key=HabitipySensorEntity.CONSTITUTION, + translation_key=HabitipySensorEntity.CONSTITUTION, + value_fn=lambda user, content: get_attributes_total(user, content, "con"), + attributes_fn=lambda user, content: get_attribute_points(user, content, "con"), + suggested_display_precision=0, + native_unit_of_measurement="CON", + ), ) @@ -243,7 +282,16 @@ class HabitipySensor(HabiticaBase, SensorEntity): def native_value(self) -> StateType: """Return the state of the device.""" - return self.entity_description.value_fn(self.coordinator.data.user) + return self.entity_description.value_fn( + self.coordinator.data.user, self.coordinator.content + ) + + @property + def extra_state_attributes(self) -> dict[str, float | None] | None: + """Return entity specific state attributes.""" + if func := self.entity_description.attributes_fn: + return func(self.coordinator.data.user, self.coordinator.content) + return None class HabitipyTaskSensor(HabiticaBase, SensorEntity): diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index f7d2f20b8f9..5e453c61037 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -164,6 +164,86 @@ }, "rewards": { "name": "Rewards" + }, + "strength": { + "name": "Strength", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "Battle gear" + }, + "class": { + "name": "Class equip bonus" + }, + "allocated": { + "name": "Allocated attribute points" + }, + "buffs": { + "name": "Buffs" + } + } + }, + "intelligence": { + "name": "Intelligence", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } + }, + "perception": { + "name": "Perception", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } + }, + "constitution": { + "name": "Constitution", + "state_attributes": { + "level": { + "name": "[%key:component::habitica::entity::sensor::level::name%]" + }, + "equipment": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::equipment::name%]" + }, + "class": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::class::name%]" + }, + "allocated": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::allocated::name%]" + }, + "buffs": { + "name": "[%key:component::habitica::entity::sensor::strength::state_attributes::buffs::name%]" + } + } } }, "switch": { diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 93a7c234a5d..03acb08baf9 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +from math import floor from typing import TYPE_CHECKING, Any from dateutil.rrule import ( @@ -139,3 +140,52 @@ def get_recurrence_rule(recurrence: rrule) -> str: """ return str(recurrence).split("RRULE:")[1] + + +def get_attribute_points( + user: dict[str, Any], content: dict[str, Any], attribute: str +) -> dict[str, float]: + """Get modifiers contributing to strength attribute.""" + + gear_set = { + "weapon", + "armor", + "head", + "shield", + "back", + "headAccessory", + "eyewear", + "body", + } + + equipment = sum( + stats[attribute] + for gear in gear_set + if (equipped := user["items"]["gear"]["equipped"].get(gear)) + and (stats := content["gear"]["flat"].get(equipped)) + ) + + class_bonus = sum( + stats[attribute] / 2 + for gear in gear_set + if (equipped := user["items"]["gear"]["equipped"].get(gear)) + and (stats := content["gear"]["flat"].get(equipped)) + and stats["klass"] == user["stats"]["class"] + ) + + return { + "level": min(round(user["stats"]["lvl"] / 2), 50), + "equipment": equipment, + "class": class_bonus, + "allocated": user["stats"][attribute], + "buffs": user["stats"]["buffs"][attribute], + } + + +def get_attributes_total( + user: dict[str, Any], content: dict[str, Any], attribute: str +) -> int: + """Get total attribute points.""" + return floor( + sum(value for value in get_attribute_points(user, content, attribute).values()) + ) diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index b5ceadd2762..03b76561abc 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -56,6 +56,11 @@ def mock_habitica(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) return aioclient_mock diff --git a/tests/components/habitica/fixtures/common_buttons_unavailable.json b/tests/components/habitica/fixtures/common_buttons_unavailable.json index 08039ae1762..efee5364e02 100644 --- a/tests/components/habitica/fixtures/common_buttons_unavailable.json +++ b/tests/components/habitica/fixtures/common_buttons_unavailable.json @@ -29,11 +29,26 @@ "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/content.json b/tests/components/habitica/fixtures/content.json new file mode 100644 index 00000000000..e8e14dead73 --- /dev/null +++ b/tests/components/habitica/fixtures/content.json @@ -0,0 +1,287 @@ +{ + "success": true, + "data": { + "gear": { + "flat": { + "weapon_warrior_5": { + "text": "Ruby Sword", + "notes": "Weapon whose forge-glow never fades. Increases Strength by 15. ", + "str": 15, + "value": 90, + "type": "weapon", + "key": "weapon_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "armor_warrior_5": { + "text": "Golden Armor", + "notes": "Looks ceremonial, but no known blade can pierce it. Increases Constitution by 11.", + "con": 11, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "head_warrior_5": { + "text": "Golden Helm", + "notes": "Regal crown bound to shining armor. Increases Strength by 12.", + "str": 12, + "value": 80, + "last": true, + "type": "head", + "key": "head_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "shield_warrior_5": { + "text": "Golden Shield", + "notes": "Shining badge of the vanguard. Increases Constitution by 9.", + "con": 9, + "value": 90, + "last": true, + "type": "shield", + "key": "shield_warrior_5", + "set": "warrior-5", + "klass": "warrior", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "weapon_wizard_5": { + "twoHanded": true, + "text": "Archmage Staff", + "notes": "Assists in weaving the most complex of spells. Increases Intelligence by 15 and Perception by 7. Two-handed item.", + "int": 15, + "per": 7, + "value": 160, + "type": "weapon", + "key": "weapon_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "con": 0 + }, + "armor_wizard_5": { + "text": "Royal Magus Robe", + "notes": "Symbol of the power behind the throne. Increases Intelligence by 12.", + "int": 12, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "head_wizard_5": { + "text": "Royal Magus Hat", + "notes": "Shows authority over fortune, weather, and lesser mages. Increases Perception by 10.", + "per": 10, + "value": 80, + "last": true, + "type": "head", + "key": "head_wizard_5", + "set": "wizard-5", + "klass": "wizard", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "weapon_healer_5": { + "text": "Royal Scepter", + "notes": "Fit to grace the hand of a monarch, or of one who stands at a monarch's right hand. Increases Intelligence by 9. ", + "int": 9, + "value": 90, + "type": "weapon", + "key": "weapon_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "armor_healer_5": { + "text": "Royal Mantle", + "notes": "Attire of those who have saved the lives of kings. Increases Constitution by 18.", + "con": 18, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "head_healer_5": { + "text": "Royal Diadem", + "notes": "For king, queen, or miracle-worker. Increases Intelligence by 9.", + "int": 9, + "value": 80, + "last": true, + "type": "head", + "key": "head_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "per": 0, + "con": 0 + }, + "shield_healer_5": { + "text": "Royal Shield", + "notes": "Bestowed upon those most dedicated to the kingdom's defense. Increases Constitution by 12.", + "con": 12, + "value": 90, + "last": true, + "type": "shield", + "key": "shield_healer_5", + "set": "healer-5", + "klass": "healer", + "index": "5", + "str": 0, + "int": 0, + "per": 0 + }, + "weapon_rogue_5": { + "text": "Ninja-to", + "notes": "Sleek and deadly as the ninja themselves. Increases Strength by 8. ", + "str": 8, + "value": 90, + "type": "weapon", + "key": "weapon_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "armor_rogue_5": { + "text": "Umbral Armor", + "notes": "Allows stealth in the open in broad daylight. Increases Perception by 18.", + "per": 18, + "value": 120, + "last": true, + "type": "armor", + "key": "armor_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "head_rogue_5": { + "text": "Umbral Hood", + "notes": "Conceals even thoughts from those who would probe them. Increases Perception by 12.", + "per": 12, + "value": 80, + "last": true, + "type": "head", + "key": "head_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "str": 0, + "int": 0, + "con": 0 + }, + "shield_rogue_5": { + "text": "Ninja-to", + "notes": "Sleek and deadly as the ninja themselves. Increases Strength by 8. ", + "str": 8, + "value": 90, + "type": "shield", + "key": "shield_rogue_5", + "set": "rogue-5", + "klass": "rogue", + "index": "5", + "int": 0, + "per": 0, + "con": 0 + }, + "back_special_heroicAureole": { + "text": "Heroic Aureole", + "notes": "The gems on this aureole glimmer when you tell your tales of glory. Increases all stats by 7.", + "con": 7, + "str": 7, + "per": 7, + "int": 7, + "value": 175, + "type": "back", + "key": "back_special_heroicAureole", + "set": "special-heroicAureole", + "klass": "special", + "index": "heroicAureole" + }, + "headAccessory_armoire_gogglesOfBookbinding": { + "per": 8, + "set": "bookbinder", + "notes": "These goggles will help you zero in on any task, large or small! Increases Perception by 8. Enchanted Armoire: Bookbinder Set (Item 1 of 4).", + "text": "Goggles of Bookbinding", + "value": 100, + "type": "headAccessory", + "key": "headAccessory_armoire_gogglesOfBookbinding", + "klass": "armoire", + "index": "gogglesOfBookbinding", + "str": 0, + "int": 0, + "con": 0 + }, + "eyewear_armoire_plagueDoctorMask": { + "con": 5, + "int": 5, + "set": "plagueDoctor", + "notes": "An authentic mask worn by the doctors who battle the Plague of Procrastination. Increases Constitution and Intelligence by 5 each. Enchanted Armoire: Plague Doctor Set (Item 2 of 3).", + "text": "Plague Doctor Mask", + "value": 100, + "type": "eyewear", + "key": "eyewear_armoire_plagueDoctorMask", + "klass": "armoire", + "index": "plagueDoctorMask", + "str": 0, + "per": 0 + }, + "body_special_aetherAmulet": { + "text": "Aether Amulet", + "notes": "This amulet has a mysterious history. Increases Constitution and Strength by 10 each.", + "value": 175, + "str": 10, + "con": 10, + "type": "body", + "key": "body_special_aetherAmulet", + "set": "special-aetherAmulet", + "klass": "special", + "index": "aetherAmulet", + "int": 0, + "per": 0 + } + } + } + }, + "appVersion": "5.29.2" +} diff --git a/tests/components/habitica/fixtures/healer_fixture.json b/tests/components/habitica/fixtures/healer_fixture.json index 04cbabcfa2d..85f719f4ca7 100644 --- a/tests/components/habitica/fixtures/healer_fixture.json +++ b/tests/components/habitica/fixtures/healer_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_healer_5", + "armor": "armor_healer_5", + "head": "head_healer_5", + "shield": "shield_healer_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/healer_skills_unavailable.json b/tests/components/habitica/fixtures/healer_skills_unavailable.json index 305a5f8cda1..a6bff246b2a 100644 --- a/tests/components/habitica/fixtures/healer_skills_unavailable.json +++ b/tests/components/habitica/fixtures/healer_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_healer_5", + "armor": "armor_healer_5", + "head": "head_healer_5", + "shield": "shield_healer_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/quest_invitation_off.json b/tests/components/habitica/fixtures/quest_invitation_off.json index f862a85c7c4..b5eccd99e10 100644 --- a/tests/components/habitica/fixtures/quest_invitation_off.json +++ b/tests/components/habitica/fixtures/quest_invitation_off.json @@ -29,7 +29,8 @@ "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true diff --git a/tests/components/habitica/fixtures/rogue_fixture.json b/tests/components/habitica/fixtures/rogue_fixture.json index f0ea42a7182..1e5e996c034 100644 --- a/tests/components/habitica/fixtures/rogue_fixture.json +++ b/tests/components/habitica/fixtures/rogue_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/rogue_skills_unavailable.json b/tests/components/habitica/fixtures/rogue_skills_unavailable.json index 2709731ba55..c7c5ff32245 100644 --- a/tests/components/habitica/fixtures/rogue_skills_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": true, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json index a4e86abbb91..9fd7adcca42 100644 --- a/tests/components/habitica/fixtures/rogue_stealth_unavailable.json +++ b/tests/components/habitica/fixtures/rogue_stealth_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 4, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_rogue_5", + "armor": "armor_rogue_5", + "head": "head_rogue_5", + "shield": "shield_rogue_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/user.json b/tests/components/habitica/fixtures/user.json index 818f4ed4eda..569c5b81a02 100644 --- a/tests/components/habitica/fixtures/user.json +++ b/tests/components/habitica/fixtures/user.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,12 +24,17 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true @@ -59,6 +64,20 @@ } }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/warrior_fixture.json b/tests/components/habitica/fixtures/warrior_fixture.json index 53d18206f9a..3517e8a908a 100644 --- a/tests/components/habitica/fixtures/warrior_fixture.json +++ b/tests/components/habitica/fixtures/warrior_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/warrior_skills_unavailable.json b/tests/components/habitica/fixtures/warrior_skills_unavailable.json index 53160646569..b3d33c85d5c 100644 --- a/tests/components/habitica/fixtures/warrior_skills_unavailable.json +++ b/tests/components/habitica/fixtures/warrior_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_warrior_5", + "armor": "armor_warrior_5", + "head": "head_warrior_5", + "shield": "shield_warrior_5", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_fixture.json b/tests/components/habitica/fixtures/wizard_fixture.json index 0f9f2a49639..de596e231de 100644 --- a/tests/components/habitica/fixtures/wizard_fixture.json +++ b/tests/components/habitica/fixtures/wizard_fixture.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,17 +24,36 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 5 + "points": 5, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": true, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, "needsCron": true, - "lastCron": "2024-09-21T22:01:55.586Z" + "lastCron": "2024-09-21T22:01:55.586Z", + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_frost_unavailable.json b/tests/components/habitica/fixtures/wizard_frost_unavailable.json index ba57568e99e..31d10fde4b9 100644 --- a/tests/components/habitica/fixtures/wizard_frost_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_frost_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": true, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/fixtures/wizard_skills_unavailable.json b/tests/components/habitica/fixtures/wizard_skills_unavailable.json index 11bf0a19193..f3bdee9dd74 100644 --- a/tests/components/habitica/fixtures/wizard_skills_unavailable.json +++ b/tests/components/habitica/fixtures/wizard_skills_unavailable.json @@ -4,10 +4,10 @@ "profile": { "name": "test-user" }, "stats": { "buffs": { - "str": 0, - "int": 0, - "per": 0, - "con": 0, + "str": 26, + "int": 26, + "per": 26, + "con": 26, "stealth": 0, "streaks": false, "seafoam": false, @@ -24,16 +24,35 @@ "maxHealth": 50, "maxMP": 166, "toNextLevel": 880, - "points": 0 + "points": 0, + "str": 15, + "con": 15, + "int": 15, + "per": 15 }, "preferences": { "sleep": false, "automaticAllocation": false, - "disableClasses": false + "disableClasses": false, + "language": "en" }, "flags": { "classSelected": true }, - "needsCron": false + "needsCron": false, + "items": { + "gear": { + "equipped": { + "weapon": "weapon_wizard_5", + "armor": "armor_wizard_5", + "head": "head_wizard_5", + "shield": "shield_base_0", + "back": "heroicAureole", + "headAccessory": "headAccessory_armoire_gogglesOfBookbinding", + "eyewear": "plagueDoctorMask", + "body": "aetherAmulet" + } + } + } } } diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index ee75b424a93..3a43069bfc4 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -59,6 +59,61 @@ 'state': 'wizard', }) # --- +# name: test_sensors[sensor.test_user_constitution-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_constitution', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Constitution', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_constitution', + 'unit_of_measurement': 'CON', + }) +# --- +# name: test_sensors[sensor.test_user_constitution-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 20, + 'friendly_name': 'test-user Constitution', + 'level': 19, + 'unit_of_measurement': 'CON', + }), + 'context': , + 'entity_id': 'sensor.test_user_constitution', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- # name: test_sensors[sensor.test_user_dailies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -567,6 +622,61 @@ 'state': '0', }) # --- +# name: test_sensors[sensor.test_user_intelligence-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_intelligence', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Intelligence', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_intelligence', + 'unit_of_measurement': 'INT', + }) +# --- +# name: test_sensors[sensor.test_user_intelligence-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 0, + 'friendly_name': 'test-user Intelligence', + 'level': 19, + 'unit_of_measurement': 'INT', + }), + 'context': , + 'entity_id': 'sensor.test_user_intelligence', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60', + }) +# --- # name: test_sensors[sensor.test_user_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -854,6 +964,61 @@ 'state': '880', }) # --- +# name: test_sensors[sensor.test_user_perception-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_perception', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Perception', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_perception', + 'unit_of_measurement': 'PER', + }) +# --- +# name: test_sensors[sensor.test_user_perception-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 8, + 'friendly_name': 'test-user Perception', + 'level': 19, + 'unit_of_measurement': 'PER', + }), + 'context': , + 'entity_id': 'sensor.test_user_perception', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '68', + }) +# --- # name: test_sensors[sensor.test_user_rewards-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -915,6 +1080,61 @@ 'state': '1', }) # --- +# name: test_sensors[sensor.test_user_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_user_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Strength', + 'platform': 'habitica', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-0000-0000-0000-000000000000_strength', + 'unit_of_measurement': 'STR', + }) +# --- +# name: test_sensors[sensor.test_user_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'allocated': 15, + 'buffs': 26, + 'class': 0, + 'equipment': 27, + 'friendly_name': 'test-user Strength', + 'level': 19, + 'unit_of_measurement': 'STR', + }), + 'context': , + 'entity_id': 'sensor.test_user_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87', + }) +# --- # name: test_sensors[sensor.test_user_to_do_s-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_binary_sensor.py b/tests/components/habitica/test_binary_sensor.py index 5b19cd008bf..1710f8f217e 100644 --- a/tests/components/habitica/test_binary_sensor.py +++ b/tests/components/habitica/test_binary_sensor.py @@ -66,7 +66,11 @@ async def test_pending_quest_states( json=load_json_object_fixture(f"{fixture}.json", DOMAIN), ) aioclient_mock.get(f"{DEFAULT_URL}/api/v3/tasks/user", json={"data": []}) - + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_button.py b/tests/components/habitica/test_button.py index 6bd62f3a58e..979cefef923 100644 --- a/tests/components/habitica/test_button.py +++ b/tests/components/habitica/test_button.py @@ -63,6 +63,11 @@ async def test_buttons( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -163,6 +168,11 @@ async def test_button_press( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture("tasks.json", DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/habitica/test_todo.py b/tests/components/habitica/test_todo.py index 88947caba2d..c9a4b3dd37a 100644 --- a/tests/components/habitica/test_todo.py +++ b/tests/components/habitica/test_todo.py @@ -672,6 +672,11 @@ async def test_next_due_date( f"{DEFAULT_URL}/api/v3/tasks/user", json=load_json_object_fixture(fixture, DOMAIN), ) + aioclient_mock.get( + f"{DEFAULT_URL}/api/v3/content", + params={"language": "en"}, + json=load_json_object_fixture("content.json", DOMAIN), + ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) From 97fa568876b1e1672e9a725f49563bc8c69c9d7a Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:11:34 +0100 Subject: [PATCH 371/511] No longer thrown an error when device is offline in linkplay (#130161) --- homeassistant/components/linkplay/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 36834610c04..983d8777a6a 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -9,7 +9,7 @@ from typing import Any, Concatenate from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus from linkplay.controller import LinkPlayController, LinkPlayMultiroom -from linkplay.exceptions import LinkPlayException, LinkPlayRequestException +from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol from homeassistant.components import media_source @@ -201,9 +201,8 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): try: await self._bridge.player.update_status() self._update_properties() - except LinkPlayException: + except LinkPlayRequestException: self._attr_available = False - raise @exception_wrap async def async_select_source(self, source: str) -> None: From 622682eb4397f60bdcc35c3facef5fe983cfc951 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sat, 9 Nov 2024 16:42:10 +0100 Subject: [PATCH 372/511] Change update after button press for lamarzocco (#129616) --- homeassistant/components/lamarzocco/button.py | 24 ++++++++++++++----- tests/components/lamarzocco/test_button.py | 22 ++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py index b9bc7fc8844..ae79e21897f 100644 --- a/homeassistant/components/lamarzocco/button.py +++ b/homeassistant/components/lamarzocco/button.py @@ -1,11 +1,11 @@ """Button platform for La Marzocco espresso machines.""" +import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any from pylamarzocco.exceptions import RequestNotSuccessful -from pylamarzocco.lm_machine import LaMarzoccoMachine from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant @@ -13,9 +13,11 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import LaMarzoccoConfigEntry +from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription +BACKFLUSH_ENABLED_DURATION = 15 + @dataclass(frozen=True, kw_only=True) class LaMarzoccoButtonEntityDescription( @@ -24,14 +26,25 @@ class LaMarzoccoButtonEntityDescription( ): """Description of a La Marzocco button.""" - press_fn: Callable[[LaMarzoccoMachine], Coroutine[Any, Any, None]] + press_fn: Callable[[LaMarzoccoUpdateCoordinator], Coroutine[Any, Any, None]] + + +async def async_backflush_and_update(coordinator: LaMarzoccoUpdateCoordinator) -> None: + """Press backflush button.""" + await coordinator.device.start_backflush() + # lib will set state optimistically + coordinator.async_set_updated_data(None) + # backflush is enabled for 15 seconds + # then turns off automatically + await asyncio.sleep(BACKFLUSH_ENABLED_DURATION + 1) + await coordinator.async_request_refresh() ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = ( LaMarzoccoButtonEntityDescription( key="start_backflush", translation_key="start_backflush", - press_fn=lambda machine: machine.start_backflush(), + press_fn=async_backflush_and_update, ), ) @@ -59,7 +72,7 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): async def async_press(self) -> None: """Press button.""" try: - await self.entity_description.press_fn(self.coordinator.device) + await self.entity_description.press_fn(self.coordinator) except RequestNotSuccessful as exc: raise HomeAssistantError( translation_domain=DOMAIN, @@ -68,4 +81,3 @@ class LaMarzoccoButtonEntity(LaMarzoccoEntity, ButtonEntity): "key": self.entity_description.key, }, ) from exc - await self.coordinator.async_request_refresh() diff --git a/tests/components/lamarzocco/test_button.py b/tests/components/lamarzocco/test_button.py index fdea26c9f6f..61b7ba77c22 100644 --- a/tests/components/lamarzocco/test_button.py +++ b/tests/components/lamarzocco/test_button.py @@ -1,6 +1,6 @@ """Tests for the La Marzocco Buttons.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from pylamarzocco.exceptions import RequestNotSuccessful import pytest @@ -33,14 +33,18 @@ async def test_start_backflush( assert entry assert entry == snapshot - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - { - ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", - }, - blocking=True, - ) + with patch( + "homeassistant.components.lamarzocco.button.asyncio.sleep", + new_callable=AsyncMock, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.{serial_number}_start_backflush", + }, + blocking=True, + ) assert len(mock_lamarzocco.start_backflush.mock_calls) == 1 mock_lamarzocco.start_backflush.assert_called_once() From 928e5348e41ada697464d8b7ad000f27832c34d5 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 9 Nov 2024 16:47:02 +0100 Subject: [PATCH 373/511] Add custom integration action sections support to hassfest (#130148) --- script/hassfest/services.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 92fca14d373..8c9ab5c0c0b 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -75,6 +75,14 @@ CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( } ) +CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( + { + vol.Optional("collapsed"): bool, + vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + } +) + + CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Schema( { @@ -105,7 +113,17 @@ CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( vol.Optional("target"): vol.Any( selector.TargetSelector.CONFIG_SCHEMA, None ), - vol.Optional("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + CUSTOM_INTEGRATION_FIELD_SCHEMA, + CUSTOM_INTEGRATION_SECTION_SCHEMA, + ) + } + ), + unique_field_validator, + ), } ), None, From b61580a937832f285707940522258b8fd4a61074 Mon Sep 17 00:00:00 2001 From: Daniel Oltmanns Date: Sat, 9 Nov 2024 16:48:00 +0100 Subject: [PATCH 374/511] Add fan preset mode icons and strings to vesync (#129584) --- homeassistant/components/vesync/fan.py | 1 + homeassistant/components/vesync/icons.json | 16 ++++++++++++++++ homeassistant/components/vesync/strings.json | 14 ++++++++++++++ tests/components/vesync/snapshots/test_fan.ambr | 8 ++++---- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 58a262e769f..098a17e90f0 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -94,6 +94,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): | FanEntityFeature.TURN_ON ) _attr_name = None + _attr_translation_key = "vesync" _enable_turn_on_off_backwards_compatibility = False def __init__(self, fan) -> None: diff --git a/homeassistant/components/vesync/icons.json b/homeassistant/components/vesync/icons.json index cfdefb2ed09..e4769acc9a5 100644 --- a/homeassistant/components/vesync/icons.json +++ b/homeassistant/components/vesync/icons.json @@ -1,4 +1,20 @@ { + "entity": { + "fan": { + "vesync": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "mdi:fan-auto", + "sleep": "mdi:sleep", + "pet": "mdi:paw", + "turbo": "mdi:weather-tornado" + } + } + } + } + } + }, "services": { "update_devices": { "service": "mdi:update" diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 5ff0aa58722..b6e4e2fd957 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -42,6 +42,20 @@ "current_voltage": { "name": "Current voltage" } + }, + "fan": { + "vesync": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto", + "sleep": "Sleep", + "pet": "Pet", + "turbo": "Turbo" + } + } + } + } } }, "services": { diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 21985afd7bf..60af4ae3d5b 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -67,7 +67,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': 'air-purifier', 'unit_of_measurement': None, }), @@ -158,7 +158,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', 'unit_of_measurement': None, }), @@ -256,7 +256,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': '400s-purifier', 'unit_of_measurement': None, }), @@ -355,7 +355,7 @@ 'platform': 'vesync', 'previous_unique_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'vesync', 'unique_id': '600s-purifier', 'unit_of_measurement': None, }), From 31b505828bd6aee1f386bb433a08418cb88acd70 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 9 Nov 2024 17:13:07 +0100 Subject: [PATCH 375/511] Simplify Bang & Olufsen source determination (#130072) --- .../components/bang_olufsen/const.py | 59 +------------------ .../components/bang_olufsen/media_player.py | 30 ---------- tests/components/bang_olufsen/const.py | 6 +- .../snapshots/test_media_player.ambr | 2 +- .../bang_olufsen/test_media_player.py | 58 +++++------------- 5 files changed, 24 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 1e06f153cdb..209311d3e8a 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -17,62 +17,9 @@ from homeassistant.components.media_player import ( class BangOlufsenSource: """Class used for associating device source ids with friendly names. May not include all sources.""" - URI_STREAMER: Final[Source] = Source( - name="Audio Streamer", - id="uriStreamer", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - BLUETOOTH: Final[Source] = Source( - name="Bluetooth", - id="bluetooth", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - CHROMECAST: Final[Source] = Source( - name="Chromecast built-in", - id="chromeCast", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - LINE_IN: Final[Source] = Source( - name="Line-In", - id="lineIn", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - SPDIF: Final[Source] = Source( - name="Optical", - id="spdif", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - NET_RADIO: Final[Source] = Source( - name="B&O Radio", - id="netRadio", - is_seekable=False, - is_enabled=True, - is_playable=True, - ) - DEEZER: Final[Source] = Source( - name="Deezer", - id="deezer", - is_seekable=True, - is_enabled=True, - is_playable=True, - ) - TIDAL: Final[Source] = Source( - name="Tidal", - id="tidal", - is_seekable=True, - is_enabled=True, - is_playable=True, - ) + LINE_IN: Final[Source] = Source(name="Line-In", id="lineIn") + SPDIF: Final[Source] = Source(name="Optical", id="spdif") + URI_STREAMER: Final[Source] = Source(name="Audio Streamer", id="uriStreamer") BANG_OLUFSEN_STATES: dict[str, MediaPlayerState] = { diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 5dd45573672..56aa66d32e8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -688,36 +688,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): @property def source(self) -> str | None: """Return the current audio source.""" - - # Try to fix some of the source_change chromecast weirdness. - if hasattr(self._playback_metadata, "title"): - # source_change is chromecast but line in is selected. - if self._playback_metadata.title == BangOlufsenSource.LINE_IN.name: - return BangOlufsenSource.LINE_IN.name - - # source_change is chromecast but bluetooth is selected. - if self._playback_metadata.title == BangOlufsenSource.BLUETOOTH.name: - return BangOlufsenSource.BLUETOOTH.name - - # source_change is line in, bluetooth or optical but stale metadata is sent through the WebSocket, - # And the source has not changed. - if self._source_change.id in ( - BangOlufsenSource.BLUETOOTH.id, - BangOlufsenSource.LINE_IN.id, - BangOlufsenSource.SPDIF.id, - ): - return BangOlufsenSource.CHROMECAST.name - - # source_change is chromecast and there is metadata but no artwork. Bluetooth does support metadata but not artwork - # So i assume that it is bluetooth and not chromecast - if ( - hasattr(self._playback_metadata, "art") - and self._playback_metadata.art is not None - and len(self._playback_metadata.art) == 0 - and self._source_change.id == BangOlufsenSource.CHROMECAST.id - ): - return BangOlufsenSource.BLUETOOTH.name - return self._source_change.name @property diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index 3769aef5cd3..6602a898eb6 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -16,6 +16,7 @@ from mozart_api.models import ( PlayQueueItemType, RenderingState, SceneProperties, + Source, UserFlow, VolumeLevel, VolumeMute, @@ -125,7 +126,10 @@ TEST_DATA_ZEROCONF_IPV6 = ZeroconfServiceInfo( }, ) -TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name, BangOlufsenSource.LINE_IN.name] +TEST_SOURCE = Source( + name="Tidal", id="tidal", is_seekable=True, is_enabled=True, is_playable=True +) +TEST_AUDIO_SOURCES = [TEST_SOURCE.name, BangOlufsenSource.LINE_IN.name] TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ diff --git a/tests/components/bang_olufsen/snapshots/test_media_player.ambr b/tests/components/bang_olufsen/snapshots/test_media_player.ambr index e48dc39198b..ea96e286821 100644 --- a/tests/components/bang_olufsen/snapshots/test_media_player.ambr +++ b/tests/components/bang_olufsen/snapshots/test_media_player.ambr @@ -573,7 +573,7 @@ 'Test Listening Mode (234)', 'Test Listening Mode 2 (345)', ]), - 'source': 'Chromecast built-in', + 'source': 'Line-In', 'source_list': list([ 'Tidal', 'Line-In', diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index e991ab3d1bc..aa35b0265dc 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -105,6 +105,7 @@ from .const import ( TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, TEST_SOUND_MODE_2, TEST_SOUND_MODES, + TEST_SOURCE, TEST_SOURCES, TEST_VIDEO_SOURCES, TEST_VOLUME, @@ -231,7 +232,7 @@ async def test_async_update_sources_availability( # Add a source that is available and playable mock_mozart_client.get_available_sources.return_value = SourceArray( - items=[BangOlufsenSource.TIDAL] + items=[TEST_SOURCE] ) # Send playback_source. The source is not actually used, so its attributes don't matter @@ -239,7 +240,7 @@ async def test_async_update_sources_availability( assert mock_mozart_client.get_available_sources.call_count == 2 assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [BangOlufsenSource.TIDAL.name] + assert states.attributes[ATTR_INPUT_SOURCE_LIST] == [TEST_SOURCE.name] async def test_async_update_playback_metadata( @@ -357,19 +358,17 @@ async def test_async_update_playback_state( @pytest.mark.parametrize( - ("reported_source", "real_source", "content_type", "progress", "metadata"), + ("source", "content_type", "progress", "metadata"), [ - # Normal source, music mediatype expected, no progress expected + # Normal source, music mediatype expected ( - BangOlufsenSource.TIDAL, - BangOlufsenSource.TIDAL, + TEST_SOURCE, MediaType.MUSIC, TEST_PLAYBACK_PROGRESS.progress, PlaybackContentMetadata(), ), - # URI source, url media type expected, no progress expected + # URI source, url media type expected ( - BangOlufsenSource.URI_STREAMER, BangOlufsenSource.URI_STREAMER, MediaType.URL, TEST_PLAYBACK_PROGRESS.progress, @@ -378,44 +377,17 @@ async def test_async_update_playback_state( # Line-In source,media type expected, progress 0 expected ( BangOlufsenSource.LINE_IN, - BangOlufsenSource.CHROMECAST, MediaType.MUSIC, 0, PlaybackContentMetadata(), ), - # Chromecast as source, but metadata says Line-In. - # Progress is not set to 0 as the source is Chromecast first - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.LINE_IN, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(title=BangOlufsenSource.LINE_IN.name), - ), - # Chromecast as source, but metadata says Bluetooth - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.BLUETOOTH, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(title=BangOlufsenSource.BLUETOOTH.name), - ), - # Chromecast as source, but metadata says Bluetooth in another way - ( - BangOlufsenSource.CHROMECAST, - BangOlufsenSource.BLUETOOTH, - MediaType.MUSIC, - TEST_PLAYBACK_PROGRESS.progress, - PlaybackContentMetadata(art=[]), - ), ], ) async def test_async_update_source_change( hass: HomeAssistant, mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, - reported_source: Source, - real_source: Source, + source: Source, content_type: MediaType, progress: int, metadata: PlaybackContentMetadata, @@ -444,10 +416,10 @@ async def test_async_update_source_change( # Simulate metadata playback_metadata_callback(metadata) - source_change_callback(reported_source) + source_change_callback(source) assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) - assert states.attributes[ATTR_INPUT_SOURCE] == real_source.name + assert states.attributes[ATTR_INPUT_SOURCE] == source.name assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == content_type assert states.attributes[ATTR_MEDIA_POSITION] == progress @@ -774,7 +746,7 @@ async def test_async_media_next_track( ("source", "expected_result", "seek_called_times"), [ # Seekable source, seek expected - (BangOlufsenSource.DEEZER, does_not_raise(), 1), + (TEST_SOURCE, does_not_raise(), 1), # Non seekable source, seek shouldn't work (BangOlufsenSource.LINE_IN, pytest.raises(HomeAssistantError), 0), # Malformed source, seek shouldn't work @@ -862,7 +834,7 @@ async def test_async_clear_playlist( # Invalid source ("Test source", pytest.raises(ServiceValidationError), 0, 0), # Valid audio source - (BangOlufsenSource.TIDAL.name, does_not_raise(), 1, 0), + (TEST_SOURCE.name, does_not_raise(), 1, 0), # Valid video source (TEST_VIDEO_SOURCES[0], does_not_raise(), 0, 1), ], @@ -1432,7 +1404,7 @@ async def test_async_join_players( await hass.config_entries.async_setup(mock_config_entry_2.entry_id) # Set the source to a beolink expandable source - source_change_callback(BangOlufsenSource.TIDAL) + source_change_callback(TEST_SOURCE) await hass.services.async_call( MEDIA_PLAYER_DOMAIN, @@ -1468,7 +1440,7 @@ async def test_async_join_players( ), # Invalid media_player entity ( - BangOlufsenSource.TIDAL, + TEST_SOURCE, [TEST_MEDIA_PLAYER_ENTITY_ID_3], pytest.raises(ServiceValidationError), "invalid_grouping_entity", @@ -1637,7 +1609,7 @@ async def test_async_beolink_expand( ) # Set the source to a beolink expandable source - source_change_callback(BangOlufsenSource.TIDAL) + source_change_callback(TEST_SOURCE) await hass.services.async_call( DOMAIN, From e3315383ab9af2b2de1aacba8554c26595039063 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 9 Nov 2024 11:13:57 -0500 Subject: [PATCH 376/511] Improve entity test coverage for Russound RIO (#129828) --- tests/components/russound_rio/__init__.py | 12 +++++ tests/components/russound_rio/conftest.py | 39 +++++++++++++--- .../russound_rio/fixtures/get_sources.json | 10 +++++ .../russound_rio/fixtures/get_zones.json | 22 ++++++++++ .../russound_rio/snapshots/test_init.ambr | 37 ++++++++++++++++ .../russound_rio/test_config_flow.py | 14 +++--- tests/components/russound_rio/test_init.py | 44 +++++++++++++++++++ 7 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 tests/components/russound_rio/fixtures/get_sources.json create mode 100644 tests/components/russound_rio/fixtures/get_zones.json create mode 100644 tests/components/russound_rio/snapshots/test_init.ambr create mode 100644 tests/components/russound_rio/test_init.py diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py index 96171071907..d0e6d77f1ee 100644 --- a/tests/components/russound_rio/__init__.py +++ b/tests/components/russound_rio/__init__.py @@ -1 +1,13 @@ """Tests for the Russound RIO integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 91d009f13f4..5c4d105e03a 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -1,16 +1,19 @@ """Test fixtures for Russound RIO integration.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +from aiorussound import Controller, RussoundTcpConnectionHandler, Source +from aiorussound.rio import ZoneControlSurface +from aiorussound.util import controller_device_str, zone_device_str import pytest from homeassistant.components.russound_rio.const import DOMAIN from homeassistant.core import HomeAssistant -from .const import HARDWARE_MAC, MOCK_CONFIG, MOCK_CONTROLLERS, MODEL +from .const import HARDWARE_MAC, HOST, MOCK_CONFIG, MODEL, PORT -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -33,7 +36,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture -def mock_russound() -> Generator[AsyncMock]: +def mock_russound_client() -> Generator[AsyncMock]: """Mock the Russound RIO client.""" with ( patch( @@ -41,8 +44,30 @@ def mock_russound() -> Generator[AsyncMock]: ) as mock_client, patch( "homeassistant.components.russound_rio.config_flow.RussoundClient", - return_value=mock_client, + new=mock_client, ), ): - mock_client.controllers = MOCK_CONTROLLERS - yield mock_client + client = mock_client.return_value + zones = { + int(k): ZoneControlSurface.from_dict(v) + for k, v in load_json_object_fixture("get_zones.json", DOMAIN).items() + } + client.sources = { + int(k): Source.from_dict(v) + for k, v in load_json_object_fixture("get_sources.json", DOMAIN).items() + } + for k, v in zones.items(): + v.device_str = zone_device_str(1, k) + v.fetch_current_source = Mock( + side_effect=lambda current_source=v.current_source: client.sources.get( + int(current_source) + ) + ) + + client.controllers = { + 1: Controller( + 1, "MCA-C5", client, controller_device_str(1), HARDWARE_MAC, None, zones + ) + } + client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) + yield client diff --git a/tests/components/russound_rio/fixtures/get_sources.json b/tests/components/russound_rio/fixtures/get_sources.json new file mode 100644 index 00000000000..e39d702b8a1 --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_sources.json @@ -0,0 +1,10 @@ +{ + "1": { + "name": "Aux", + "type": "Miscellaneous Audio" + }, + "2": { + "name": "Spotify", + "type": "Russound Media Streamer" + } +} diff --git a/tests/components/russound_rio/fixtures/get_zones.json b/tests/components/russound_rio/fixtures/get_zones.json new file mode 100644 index 00000000000..396310339b3 --- /dev/null +++ b/tests/components/russound_rio/fixtures/get_zones.json @@ -0,0 +1,22 @@ +{ + "1": { + "name": "Backyard", + "volume": "10", + "status": "ON", + "enabled": "True", + "current_source": "1" + }, + "2": { + "name": "Kitchen", + "volume": "50", + "status": "OFF", + "enabled": "True", + "current_source": "2" + }, + "3": { + "name": "Bedroom", + "volume": "10", + "status": "OFF", + "enabled": "False" + } +} diff --git a/tests/components/russound_rio/snapshots/test_init.ambr b/tests/components/russound_rio/snapshots/test_init.ambr new file mode 100644 index 00000000000..fcd59dd06f7 --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_init.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://127.0.0.1', + 'connections': set({ + tuple( + 'mac', + '00:11:22:33:44:55', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'russound_rio', + '00:11:22:33:44:55', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Russound', + 'model': 'MCA-C5', + 'model_id': None, + 'name': 'MCA-C5', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 9461fe1d5be..cf754852731 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -11,7 +11,7 @@ from .const import MOCK_CONFIG, MODEL async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -32,13 +32,13 @@ async def test_form( async def test_form_cannot_connect( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - mock_russound.connect.side_effect = TimeoutError + mock_russound_client.connect.side_effect = TimeoutError result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -48,7 +48,7 @@ async def test_form_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} # Recover with correct information - mock_russound.connect.side_effect = None + mock_russound_client.connect.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG, @@ -61,7 +61,7 @@ async def test_form_cannot_connect( async def test_import( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock ) -> None: """Test we import a config entry.""" result = await hass.config_entries.flow.async_init( @@ -77,10 +77,10 @@ async def test_import( async def test_import_cannot_connect( - hass: HomeAssistant, mock_russound: AsyncMock + hass: HomeAssistant, mock_russound_client: AsyncMock ) -> None: """Test we handle import cannot connect error.""" - mock_russound.connect.side_effect = TimeoutError + mock_russound_client.connect.side_effect = TimeoutError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG diff --git a/tests/components/russound_rio/test_init.py b/tests/components/russound_rio/test_init.py new file mode 100644 index 00000000000..6787ee37c79 --- /dev/null +++ b/tests/components/russound_rio/test_init.py @@ -0,0 +1,44 @@ +"""Tests for the Russound RIO integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.russound_rio.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test the Cambridge Audio configuration entry not ready.""" + mock_russound_client.connect.side_effect = TimeoutError + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_russound_client.connect = AsyncMock(return_value=True) + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry == snapshot From 2cc54867944d804f7033f0ff3f5e458ec579aabe Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 9 Nov 2024 10:14:40 -0600 Subject: [PATCH 377/511] Bump SoCo to 0.30.6 (#130223) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index d6c5eb298d8..76a7d0bfa91 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"], + "requirements": ["soco==0.30.6", "sonos-websocket==0.1.3"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/requirements_all.txt b/requirements_all.txt index 2d39d791817..78ccbc5a3af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2689,7 +2689,7 @@ smhi-pkg==1.0.18 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.4 +soco==0.30.6 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a551f731fad..d9c5131d5c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2144,7 +2144,7 @@ smhi-pkg==1.0.18 snapcast==2.3.6 # homeassistant.components.sonos -soco==0.30.4 +soco==0.30.6 # homeassistant.components.solarlog solarlog_cli==0.3.2 From 0de4bfcc2c4d4812363df1f75d7993acf66f23a7 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:33:28 +0100 Subject: [PATCH 378/511] Add missing translation string for NINA (#129826) --- homeassistant/components/nina/strings.json | 6 ++---- tests/components/nina/test_config_flow.py | 5 ----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 9747feaddb7..98ea88d8798 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -38,12 +38,10 @@ } } }, - "abort": { - "unknown": "[%key:common::config_flow::error::unknown%]" - }, "error": { "no_selection": "[%key:component::nina::config::error::no_selection%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index cd0904b181d..309c8860c20 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import patch from pynina import ApiError -import pytest from homeassistant.components.nina.const import ( CONF_AREA_FILTER, @@ -279,10 +278,6 @@ async def test_options_flow_connection_error(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.nina.options.error.unknown"], -) async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: """Test config flow options but with an unexpected exception.""" config_entry = MockConfigEntry( From 21d81d5a5ca93f60c18130135f0d8ad5c11a7b83 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 9 Nov 2024 10:02:15 -0800 Subject: [PATCH 379/511] Bump google-nest-sdm to 6.1.5 (#130229) --- 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 581113f0c96..44eaeeaf62d 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==6.1.4"] + "requirements": ["google-nest-sdm==6.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 78ccbc5a3af..35c0f061863 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1018,7 +1018,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.4 +google-nest-sdm==6.1.5 # homeassistant.components.google_photos google-photos-library-api==0.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d9c5131d5c1..05a32f0420e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -868,7 +868,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.8.2 # homeassistant.components.nest -google-nest-sdm==6.1.4 +google-nest-sdm==6.1.5 # homeassistant.components.google_photos google-photos-library-api==0.12.1 From 5d0277a0d1a07db1659268f5f96b912651eedfb1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 9 Nov 2024 19:34:25 +0100 Subject: [PATCH 380/511] Add actions for quest handling to Habitica (#129650) --- homeassistant/components/habitica/const.py | 7 +- homeassistant/components/habitica/icons.json | 18 +++ homeassistant/components/habitica/services.py | 63 ++++++++++ .../components/habitica/services.yaml | 20 +++- .../components/habitica/strings.json | 69 ++++++++++- tests/components/habitica/test_services.py | 110 +++++++++++++++++- 6 files changed, 282 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 55322a13e6a..2107386c709 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -26,7 +26,12 @@ ATTR_CONFIG_ENTRY = "config_entry" ATTR_SKILL = "skill" ATTR_TASK = "task" SERVICE_CAST_SKILL = "cast_skill" - +SERVICE_START_QUEST = "start_quest" +SERVICE_ACCEPT_QUEST = "accept_quest" +SERVICE_CANCEL_QUEST = "cancel_quest" +SERVICE_ABORT_QUEST = "abort_quest" +SERVICE_REJECT_QUEST = "reject_quest" +SERVICE_LEAVE_QUEST = "leave_quest" WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index b2b7e548fd7..bf59aa78d5c 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -163,6 +163,24 @@ }, "cast_skill": { "service": "mdi:creation-outline" + }, + "accept_quest": { + "service": "mdi:script-text" + }, + "reject_quest": { + "service": "mdi:script-text" + }, + "leave_quest": { + "service": "mdi:script-text" + }, + "abort_quest": { + "service": "mdi:script-text-key" + }, + "cancel_quest": { + "service": "mdi:script-text-key" + }, + "start_quest": { + "service": "mdi:script-text-key" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 440e2d4fb23..9bea15aae71 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -30,8 +30,14 @@ from .const import ( ATTR_TASK, DOMAIN, EVENT_API_CALL_SUCCESS, + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, SERVICE_API_CALL, + SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ) from .types import HabiticaConfigEntry @@ -54,6 +60,12 @@ SERVICE_CAST_SKILL_SCHEMA = vol.Schema( } ) +SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + } +) + def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: """Return config entry or raise if not found or not loaded.""" @@ -160,6 +172,57 @@ def async_setup_services(hass: HomeAssistant) -> None: await coordinator.async_request_refresh() return response + async def manage_quests(call: ServiceCall) -> ServiceResponse: + """Accept, reject, start, leave or cancel quests.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + COMMAND_MAP = { + SERVICE_ABORT_QUEST: "abort", + SERVICE_ACCEPT_QUEST: "accept", + SERVICE_CANCEL_QUEST: "cancel", + SERVICE_LEAVE_QUEST: "leave", + SERVICE_REJECT_QUEST: "reject", + SERVICE_START_QUEST: "force-start", + } + try: + return await coordinator.api.groups.party.quests[ + COMMAND_MAP[call.service] + ].post() + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + if e.status == HTTPStatus.NOT_FOUND: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_call_exception" + ) from e + + for service in ( + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, + ): + hass.services.async_register( + DOMAIN, + service, + manage_quests, + schema=SERVICE_MANAGE_QUEST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( DOMAIN, SERVICE_API_CALL, diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 546ac8c1c34..955a0779cd3 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -17,7 +17,7 @@ api_call: object: cast_skill: fields: - config_entry: + config_entry: &config_entry required: true selector: config_entry: @@ -37,3 +37,21 @@ cast_skill: required: true selector: text: +accept_quest: + fields: + config_entry: *config_entry +reject_quest: + fields: + config_entry: *config_entry +start_quest: + fields: + config_entry: *config_entry +cancel_quest: + fields: + config_entry: *config_entry +abort_quest: + fields: + config_entry: *config_entry +leave_quest: + fields: + config_entry: *config_entry diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 5e453c61037..42f1dbee459 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -1,7 +1,8 @@ { "common": { "todos": "To-Do's", - "dailies": "Dailies" + "dailies": "Dailies", + "config_entry_name": "Select character" }, "config": { "abort": { @@ -311,6 +312,12 @@ }, "task_not_found": { "message": "Unable to cast skill, could not find the task {task}" + }, + "quest_action_unallowed": { + "message": "Action not allowed, only quest leader or group leader can perform this action" + }, + "quest_not_found": { + "message": "Unable to complete action, quest or group not found" } }, "issues": { @@ -355,6 +362,66 @@ "description": "The name (or task ID) of the task you want to target with the skill or spell." } } + }, + "accept_quest": { + "name": "Accept a quest invitation", + "description": "Accept a pending invitation to a quest.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Choose the Habitica character for which to perform the action." + } + } + }, + "reject_quest": { + "name": "Reject a quest invitation", + "description": "Reject a pending invitation to a quest.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "leave_quest": { + "name": "Leave a quest", + "description": "Leave the current quest you are participating in.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "abort_quest": { + "name": "Abort an active quest", + "description": "Terminate your party's ongoing quest. All progress will be lost and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "cancel_quest": { + "name": "Cancel a pending quest", + "description": "Cancel a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } + }, + "start_quest": { + "name": "Force-start a pending quest", + "description": "Begin the quest immediately, bypassing any pending invitations that haven't been accepted or rejected. Only quest leader or group leader can perform this action.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" + } + } } }, "selector": { diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 1dd7b748936..390077e2205 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -13,7 +13,13 @@ from homeassistant.components.habitica.const import ( ATTR_TASK, DEFAULT_URL, DOMAIN, + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, SERVICE_CAST_SKILL, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -24,6 +30,9 @@ from .conftest import mock_called_with from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +REQUEST_EXCEPTION_MSG = "Unable to connect to Habitica, try again later" +RATE_LIMIT_EXCEPTION_MSG = "Rate limit exceeded, try again later" + @pytest.fixture(autouse=True) def services_only() -> Generator[None]: @@ -168,7 +177,7 @@ async def test_cast_skill( }, HTTPStatus.TOO_MANY_REQUESTS, ServiceValidationError, - "Rate limit exceeded, try again later", + RATE_LIMIT_EXCEPTION_MSG, ), ( { @@ -195,7 +204,7 @@ async def test_cast_skill( }, HTTPStatus.BAD_REQUEST, HomeAssistantError, - "Unable to connect to Habitica, try again later", + REQUEST_EXCEPTION_MSG, ), ], ) @@ -271,3 +280,100 @@ async def test_get_config_entry( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "command"), + [ + (SERVICE_ABORT_QUEST, "abort"), + (SERVICE_ACCEPT_QUEST, "accept"), + (SERVICE_CANCEL_QUEST, "cancel"), + (SERVICE_LEAVE_QUEST, "leave"), + (SERVICE_REJECT_QUEST, "reject"), + (SERVICE_START_QUEST, "force-start"), + ], + ids=[], +) +async def test_handle_quests( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service: str, + command: str, +) -> None: + """Test Habitica actions for quest handling.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + service, + service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/groups/party/quests/{command}", + ) + + +@pytest.mark.parametrize( + ( + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + HTTPStatus.NOT_FOUND, + ServiceValidationError, + "Unable to complete action, quest or group not found", + ), + ( + HTTPStatus.UNAUTHORIZED, + ServiceValidationError, + "Action not allowed, only quest leader or group leader can perform this action", + ), + ( + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_handle_quests_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica handle quests action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/groups/party/quests/accept", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_ACCEPT_QUEST, + service_data={ATTR_CONFIG_ENTRY: config_entry.entry_id}, + return_response=True, + blocking=True, + ) From adb1c59859c490712eb1c9b05660f3f425d45329 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 21:37:56 +0100 Subject: [PATCH 381/511] Update grpcio to 1.67.1 (#130240) --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a8a7e009c4a..9a5d046fbc3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -81,9 +81,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.66.2 -grpcio-status==1.66.2 -grpcio-reflection==1.66.2 +grpcio==1.67.1 +grpcio-status==1.67.1 +grpcio-reflection==1.67.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index edcbc69c15d..37d0ea1d105 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,9 +117,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.66.2 -grpcio-status==1.66.2 -grpcio-reflection==1.66.2 +grpcio==1.67.1 +grpcio-status==1.67.1 +grpcio-reflection==1.67.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From 0fc019305e034e0d5c8116a9fabbf5318783a231 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sat, 9 Nov 2024 15:38:29 -0500 Subject: [PATCH 382/511] Fix typo in reminder date language string in Todoist integration (#130241) --- homeassistant/components/todoist/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 5b083ac58bf..721b491bbf5 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -78,7 +78,7 @@ "description": "When should user be reminded of this task, in natural language." }, "reminder_date_lang": { - "name": "Reminder data language", + "name": "Reminder date language", "description": "The language of reminder_date_string." }, "reminder_date": { From 31a2bb1b986d26885f1ad849ef55c480521b4c35 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Nov 2024 22:58:16 +0100 Subject: [PATCH 383/511] Fix flaky modbus tests (#130252) --- tests/components/modbus/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 5c612f9f8ad..cdea046ceea 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -57,7 +57,7 @@ def check_config_loaded_fixture(): @pytest.fixture(name="register_words") def register_words_fixture(): """Set default for register_words.""" - return [0x00, 0x00] + return [0x00] @pytest.fixture(name="config_addon") From ecd8dde3473d0416ef57c62cf62c3a26d32989ca Mon Sep 17 00:00:00 2001 From: Lothar Bach Date: Sat, 9 Nov 2024 23:21:29 +0100 Subject: [PATCH 384/511] Fix path to tesla fleet key file in config folder (#130124) * Tesla Fleet load key file from config folder * Fix test --------- Co-authored-by: G Johansson --- homeassistant/components/tesla_fleet/__init__.py | 2 +- tests/components/tesla_fleet/test_button.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 70db4a183aa..e7030b568b3 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -134,7 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - signing = product["command_signing"] == "required" if signing: if not tesla.private_key: - await tesla.get_private_key("config/tesla_fleet.key") + await tesla.get_private_key(hass.config.path("tesla_fleet.key")) api = VehicleSigned(tesla.vehicle, vin) else: api = VehicleSpecific(tesla.vehicle, vin) diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py index 07fdc962be9..ef1cfd90357 100644 --- a/tests/components/tesla_fleet/test_button.py +++ b/tests/components/tesla_fleet/test_button.py @@ -77,9 +77,13 @@ async def test_press_signing_error( new_product["response"][0]["command_signing"] = "required" mock_products.return_value = new_product - await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + with ( + patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), + ): + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) with ( + patch("homeassistant.components.tesla_fleet.TeslaFleetApi.get_private_key"), patch( "homeassistant.components.tesla_fleet.VehicleSigned.flash_lights", side_effect=NotOnWhitelistFault, From 73a62a09b06415d6c27e677e7ab7c2942f25464d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 00:54:52 -0800 Subject: [PATCH 385/511] Update nest tests to unload config entries to perform clean teardown (#130266) --- tests/components/nest/common.py | 1 + tests/components/nest/conftest.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 5d4719918a6..f34c40e09f9 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -107,6 +107,7 @@ class FakeSubscriber(GoogleNestSubscriber): def __init__(self) -> None: # pylint: disable=super-init-not-called """Initialize Fake Subscriber.""" self._device_manager = DeviceManager() + self._subscriber_name = "fake-name" def set_update_callback(self, target: Callable[[EventMessage], Awaitable[None]]): """Capture the callback set by Home Assistant.""" diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 85c64aff379..b070d025612 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -22,6 +22,7 @@ from homeassistant.components.application_credentials import ( ) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID, SDM_SCOPES +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -287,6 +288,8 @@ async def setup_base_platform( await hass.async_block_till_done() yield _setup_func + if config_entry and config_entry.state == ConfigEntryState.LOADED: + await hass.config_entries.async_unload(config_entry.entry_id) @pytest.fixture From cafa598fd64b2b0e6bfab7915bfc097ba1520193 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Nov 2024 10:18:12 +0000 Subject: [PATCH 386/511] Bump aiohttp to 3.11.0b5 (#130264) --- 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 9a5d046fbc3..2c03e458920 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b4 +aiohttp==3.11.0b5 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 7855a6671cc..3cb7fa0e439 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b4", + "aiohttp==3.11.0b5", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index c7436cab5b8..f69fc2b02bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b4 +aiohttp==3.11.0b5 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From f3229c723c40f15a58ffb1f7251b9ff81a2a5b91 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 11:19:10 +0100 Subject: [PATCH 387/511] Bump pynordpool to 0.2.2 (#130257) --- homeassistant/components/nordpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index ba435c38b5e..bf093eb3ee9 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pynordpool"], - "requirements": ["pynordpool==0.2.1"], + "requirements": ["pynordpool==0.2.2"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 35c0f061863..cb0b156cfff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2099,7 +2099,7 @@ pynetio==0.1.9.1 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.1 +pynordpool==0.2.2 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05a32f0420e..a13f27c3b98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1692,7 +1692,7 @@ pynetgear==0.10.10 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.1 +pynordpool==0.2.2 # homeassistant.components.nuki pynuki==1.6.3 From d0dbca41f7b5b574b1d95e88f2f567a5853f3033 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sun, 10 Nov 2024 05:20:55 -0500 Subject: [PATCH 388/511] Support additional media player states for Russound RIO (#130261) --- .../components/russound_rio/entity.py | 4 +- .../components/russound_rio/media_player.py | 9 +++ tests/components/russound_rio/conftest.py | 6 +- tests/components/russound_rio/const.py | 6 ++ .../russound_rio/test_media_player.py | 58 +++++++++++++++++++ 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/components/russound_rio/test_media_player.py diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 0233305bb1f..9790ff43e68 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -96,6 +96,4 @@ class RussoundBaseEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Remove callbacks.""" - await self._client.unregister_state_update_callbacks( - self._state_update_callback - ) + self._client.unregister_state_update_callbacks(self._state_update_callback) diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 316e4d2be7c..561f3b008c7 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -132,7 +132,16 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState | None: """Return the state of the device.""" status = self._zone.status + mode = self._source.mode if status == "ON": + if mode == "playing": + return MediaPlayerState.PLAYING + if mode == "paused": + return MediaPlayerState.PAUSED + if mode == "transitioning": + return MediaPlayerState.BUFFERING + if mode == "stopped": + return MediaPlayerState.IDLE return MediaPlayerState.ON if status == "OFF": return MediaPlayerState.OFF diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 5c4d105e03a..09cccd7d83f 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -28,11 +28,9 @@ def mock_setup_entry(): @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: """Mock a Russound RIO config entry.""" - entry = MockConfigEntry( + return MockConfigEntry( domain=DOMAIN, data=MOCK_CONFIG, unique_id=HARDWARE_MAC, title=MODEL ) - entry.add_to_hass(hass) - return entry @pytest.fixture @@ -70,4 +68,6 @@ def mock_russound_client() -> Generator[AsyncMock]: ) } client.connection_handler = RussoundTcpConnectionHandler(HOST, PORT) + client.is_connected = Mock(return_value=True) + client.unregister_state_update_callbacks.return_value = True yield client diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 527f4fe3377..3d2924693d2 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -2,6 +2,8 @@ from collections import namedtuple +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN + HOST = "127.0.0.1" PORT = 9621 MODEL = "MCA-C5" @@ -14,3 +16,7 @@ MOCK_CONFIG = { _CONTROLLER = namedtuple("Controller", ["mac_address", "controller_type"]) # noqa: PYI024 MOCK_CONTROLLERS = {1: _CONTROLLER(mac_address=HARDWARE_MAC, controller_type=MODEL)} + +DEVICE_NAME = "mca_c5" +NAME_ZONE_1 = "backyard" +ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{DEVICE_NAME}_{NAME_ZONE_1}" diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py new file mode 100644 index 00000000000..38ef603c21d --- /dev/null +++ b/tests/components/russound_rio/test_media_player.py @@ -0,0 +1,58 @@ +"""Tests for the Russound RIO media player.""" + +from unittest.mock import AsyncMock + +from aiorussound.models import CallbackType +import pytest + +from homeassistant.const import ( + STATE_BUFFERING, + STATE_IDLE, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID_ZONE_1 + +from tests.common import MockConfigEntry + + +async def mock_state_update(client: AsyncMock) -> None: + """Trigger a callback in the media player.""" + for callback in client.register_state_update_callbacks.call_args_list: + await callback[0][0](client, CallbackType.STATE) + + +@pytest.mark.parametrize( + ("zone_status", "source_mode", "media_player_state"), + [ + ("ON", None, STATE_ON), + ("ON", "playing", STATE_PLAYING), + ("ON", "paused", STATE_PAUSED), + ("ON", "transitioning", STATE_BUFFERING), + ("ON", "stopped", STATE_IDLE), + ("OFF", None, STATE_OFF), + ("OFF", "stopped", STATE_OFF), + ], +) +async def test_entity_state( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + zone_status: str, + source_mode: str | None, + media_player_state: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_russound_client.controllers[1].zones[1].status = zone_status + mock_russound_client.sources[1].mode = source_mode + await mock_state_update(mock_russound_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID_ZONE_1) + assert state.state == media_player_state From 7fdcb985181662a4f08241c429ea78152b7fb7f6 Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sun, 10 Nov 2024 05:25:32 -0500 Subject: [PATCH 389/511] Update description for generic hygrostat description (#130244) --- homeassistant/components/generic_hygrostat/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic_hygrostat/strings.json b/homeassistant/components/generic_hygrostat/strings.json index a21ab68c628..2be3955eff1 100644 --- a/homeassistant/components/generic_hygrostat/strings.json +++ b/homeassistant/components/generic_hygrostat/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Add generic hygrostat", - "description": "Create a entity that control the humidity via a switch and sensor.", + "description": "Create a humidifier entity that control the humidity via a switch and sensor.", "data": { "device_class": "Device class", "dry_tolerance": "Dry tolerance", From e382f924e6af17f2cdad283ad19b644d363c649a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Nov 2024 11:38:56 +0100 Subject: [PATCH 390/511] Add support for Python 3.13 (#129442) --- .github/workflows/ci.yaml | 2 +- .github/workflows/wheels.yml | 12 +++++----- homeassistant/components/huum/__init__.py | 15 +++++++++---- homeassistant/components/huum/climate.py | 12 +++++----- homeassistant/components/huum/config_flow.py | 7 ++++-- homeassistant/components/huum/manifest.json | 2 +- homeassistant/components/profiler/__init__.py | 4 ++++ .../components/profiler/manifest.json | 2 +- homeassistant/package_constraints.txt | 3 +++ pyproject.toml | 14 ++++++++++++ requirements.txt | 3 +++ requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/huum/conftest.py | 6 +++++ tests/components/profiler/test_init.py | 22 +++++++++++++++++++ 15 files changed, 89 insertions(+), 23 deletions(-) create mode 100644 tests/components/huum/conftest.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 778ab8b0647..fa05f6082a2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ env: MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2024.12" DEFAULT_PYTHON: "3.12" - ALL_PYTHON_VERSIONS: "['3.12']" + ALL_PYTHON_VERSIONS: "['3.12', '3.13']" # 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/wheels.yml b/.github/workflows/wheels.yml index ef01bb122d3..b9f54bba081 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -112,7 +112,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312"] + abi: ["cp312", "cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -156,7 +156,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp312"] + abi: ["cp312", "cp313"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -198,6 +198,7 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt - name: Create requirements for cython<3 + if: matrix.abi == 'cp312' run: | # Some dependencies still require 'cython<3' # and don't yet use isolated build environments. @@ -209,6 +210,7 @@ jobs: - name: Build wheels (old cython) uses: home-assistant/wheels@2024.11.0 + if: matrix.abi == 'cp312' with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -231,7 +233,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -245,7 +247,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -259,7 +261,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df..c533ca34ef3 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -3,23 +3,30 @@ from __future__ import annotations import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum +import sys from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS +if sys.version_info < (3, 13): + from huum.exceptions import Forbidden, NotAuthenticated + from huum.huum import Huum + _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huum from a config entry.""" + if sys.version_info >= (3, 13): + raise HomeAssistantError( + "Huum is not supported on Python 3.13. Please use Python 3.12." + ) + username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index df740aea3d1..b659e33038a 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -3,13 +3,9 @@ from __future__ import annotations import logging +import sys from typing import Any -from huum.const import SaunaStatus -from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse - from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -24,6 +20,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +if sys.version_info < (3, 13): + from huum.const import SaunaStatus + from huum.exceptions import SafetyException + from huum.huum import Huum + from huum.schemas import HuumStatusResponse + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d..10c31378184 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -3,10 +3,9 @@ from __future__ import annotations import logging +import sys from typing import Any -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -15,6 +14,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +if sys.version_info < (3, 13): + from huum.exceptions import Forbidden, NotAuthenticated + from huum.huum import Huum + _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index cc393f3785f..025d1b97f21 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.11"] + "requirements": ["huum==0.7.11;python_version<'3.13'"] } diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 9b2b9736574..389e3384ad9 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -436,6 +436,10 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time + if sys.version_info >= (3, 13): + raise HomeAssistantError( + "Memory profiling is not supported on Python 3.13. Please use Python 3.12." + ) from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 9f27ee7f7d0..8d2814c8c7f 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -7,7 +7,7 @@ "quality_scale": "internal", "requirements": [ "pyprof2calltree==1.4.5", - "guppy3==3.1.4.post1", + "guppy3==3.1.4.post1;python_version<'3.13'", "objgraph==3.5.0" ], "single_config_entry": true diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2c03e458920..0606cdd3435 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,6 +13,7 @@ async-interrupt==1.2.0 async-upnp-client==0.41.0 atomicwrites-homeassistant==1.4.1 attrs==24.2.0 +audioop-lts==0.2.1;python_version>='3.13' av==13.1.0 awesomeversion==24.6.0 bcrypt==4.2.0 @@ -59,6 +60,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2024.2.1 SQLAlchemy==2.0.31 +standard-aifc==3.13.0;python_version>='3.13' +standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 diff --git a/pyproject.toml b/pyproject.toml index 3cb7fa0e439..c18f616abad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "async-interrupt==1.2.0", "attrs==24.2.0", "atomicwrites-homeassistant==1.4.1", + "audioop-lts==0.2.1;python_version>='3.13'", "awesomeversion==24.6.0", "bcrypt==4.2.0", "certifi>=2021.5.30", @@ -65,6 +66,8 @@ dependencies = [ "requests==2.32.3", "securetar==2024.2.1", "SQLAlchemy==2.0.31", + "standard-aifc==3.13.0;python_version>='3.13'", + "standard-telnetlib==3.13.0;python_version>='3.13'", "typing-extensions>=4.12.2,<5.0", "ulid-transform==1.0.2", # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 @@ -617,6 +620,17 @@ filterwarnings = [ # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i", + # -- New in Python 3.13 + # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 + # https://github.com/kurtmckee/feedparser/issues/481 + "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", + # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib + "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad", + "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i", + # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", diff --git a/requirements.txt b/requirements.txt index f69fc2b02bf..d3c60eb302e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ astral==2.2 async-interrupt==1.2.0 attrs==24.2.0 atomicwrites-homeassistant==1.4.1 +audioop-lts==0.2.1;python_version>='3.13' awesomeversion==24.6.0 bcrypt==4.2.0 certifi>=2021.5.30 @@ -37,6 +38,8 @@ PyYAML==6.0.2 requests==2.32.3 securetar==2024.2.1 SQLAlchemy==2.0.31 +standard-aifc==3.13.0;python_version>='3.13' +standard-telnetlib==3.13.0;python_version>='3.13' typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 diff --git a/requirements_all.txt b/requirements_all.txt index cb0b156cfff..7813e5fc733 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1066,7 +1066,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.4.post1 +guppy3==3.1.4.post1;python_version<'3.13' # homeassistant.components.iaqualink h2==4.1.0 @@ -1148,7 +1148,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11 +huum==0.7.11;python_version<'3.13' # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a13f27c3b98..2843974cc9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -904,7 +904,7 @@ growattServer==1.5.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.4.post1 +guppy3==3.1.4.post1;python_version<'3.13' # homeassistant.components.iaqualink h2==4.1.0 @@ -971,7 +971,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11 +huum==0.7.11;python_version<'3.13' # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 00000000000..da66cc54b72 --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,6 @@ +"""Skip test collection for Python 3.13.""" + +import sys + +if sys.version_info >= (3, 13): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 3f0e0b92056..37940df437b 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,6 +5,7 @@ from functools import lru_cache import logging import os from pathlib import Path +import sys from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -70,6 +71,9 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() +@pytest.mark.skipif( + sys.version_info >= (3, 13), reason="not yet available on Python 3.13" +) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -101,6 +105,24 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() +@pytest.mark.skipif(sys.version_info < (3, 13), reason="still works on python 3.12") +async def test_memory_usage_py313(hass: HomeAssistant, tmp_path: Path) -> None: + """Test raise an error on python3.13.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) + with pytest.raises( + HomeAssistantError, + match="Memory profiling is not supported on Python 3.13. Please use Python 3.12.", + ): + await hass.services.async_call( + DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True + ) + + async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 7515deddab3ebd18b43bc0cd35fa313ee52ce660 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 10 Nov 2024 11:48:52 +0100 Subject: [PATCH 391/511] Palazzetti DHCP Discovery (#129731) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- .../components/palazzetti/config_flow.py | 41 ++++++++++++++++ .../components/palazzetti/manifest.json | 9 ++++ .../components/palazzetti/strings.json | 3 ++ homeassistant/generated/dhcp.py | 9 ++++ .../components/palazzetti/test_config_flow.py | 48 ++++++++++++++++++- 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/palazzetti/config_flow.py b/homeassistant/components/palazzetti/config_flow.py index a58461b9ca7..fe892b6624d 100644 --- a/homeassistant/components/palazzetti/config_flow.py +++ b/homeassistant/components/palazzetti/config_flow.py @@ -6,6 +6,7 @@ from pypalazzetti.client import PalazzettiClient from pypalazzetti.exceptions import CommunicationError import voluptuous as vol +from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST from homeassistant.helpers import device_registry as dr @@ -16,6 +17,8 @@ from .const import DOMAIN, LOGGER class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): """Palazzetti config flow.""" + _discovered_device: PalazzettiClient + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -48,3 +51,41 @@ class PalazzettiConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + + LOGGER.debug( + "DHCP discovery detected Palazzetti: %s", discovery_info.macaddress + ) + + await self.async_set_unique_id(dr.format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured() + self._discovered_device = PalazzettiClient(hostname=discovery_info.ip) + try: + await self._discovered_device.connect() + except CommunicationError: + return self.async_abort(reason="cannot_connect") + + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self._discovered_device.name, + data={CONF_HOST: self._discovered_device.host}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": self._discovered_device.name, + "host": self._discovered_device.host, + }, + ) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index a1b25f563bf..552289ebeac 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -3,6 +3,15 @@ "name": "Palazzetti", "codeowners": ["@dotvav"], "config_flow": true, + "dhcp": [ + { + "hostname": "connbox*", + "macaddress": "40F3857*" + }, + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json index fdf50f29f0d..cc10c8ed5c6 100644 --- a/homeassistant/components/palazzetti/strings.json +++ b/homeassistant/components/palazzetti/strings.json @@ -8,6 +8,9 @@ "data_description": { "host": "The host name or the IP address of the Palazzetti CBox" } + }, + "discovery_confirm": { + "description": "Do you want to add {name} ({host}) to Home Assistant?" } }, "abort": { diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index cd20b88b285..7dacf9a0bca 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -379,6 +379,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "gateway*", "macaddress": "F8811A*", }, + { + "domain": "palazzetti", + "hostname": "connbox*", + "macaddress": "40F3857*", + }, + { + "domain": "palazzetti", + "registered_devices": True, + }, { "domain": "powerwall", "hostname": "1118431-*", diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 960ad7a1184..03c56c33d0c 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -4,8 +4,9 @@ from unittest.mock import AsyncMock from pypalazzetti.exceptions import CommunicationError +from homeassistant.components import dhcp from homeassistant.components.palazzetti.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -92,3 +93,48 @@ async def test_duplicate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_dhcp_flow( + hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the DHCP flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + ), + context={"source": SOURCE_DHCP}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stove" + assert result["result"].unique_id == "11:22:33:44:55:66" + + +async def test_dhcp_flow_error( + hass: HomeAssistant, mock_palazzetti_client: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test the DHCP flow.""" + mock_palazzetti_client.connect.side_effect = CommunicationError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=dhcp.DhcpServiceInfo( + hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + ), + context={"source": SOURCE_DHCP}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" From 7925007ab45050aa25c4a9c9f5819d83a8c6e03e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:00:45 +0100 Subject: [PATCH 392/511] Bump psutil to 6.1.0 (#130254) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 236f25bb1ed..4c6ae0653d3 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.0.0"] + "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7813e5fc733..e09673d4534 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1651,7 +1651,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.0.0 +psutil==6.1.0 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2843974cc9a..c3db5b00adf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1349,7 +1349,7 @@ prometheus-client==0.21.0 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==6.0.0 +psutil==6.1.0 # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 From e8dc62411a1f0d5bc57412ca4f31388f02720801 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 03:01:59 -0800 Subject: [PATCH 393/511] Improve nest camera stream expiration to be defensive against errors (#130265) --- homeassistant/components/nest/camera.py | 176 ++++++++++++++---------- tests/components/nest/test_camera.py | 44 ++++++ 2 files changed, 144 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 2bee54df3dd..4cb88e63641 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,9 +2,9 @@ from __future__ import annotations -from abc import ABC, abstractmethod +from abc import ABC import asyncio -from collections.abc import Callable +from collections.abc import Awaitable, Callable import datetime import functools import logging @@ -46,6 +46,11 @@ PLACEHOLDER = Path(__file__).parent / "placeholder.png" # Used to schedule an alarm to refresh the stream before expiration STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) +# Refresh streams with a bounded interval and backoff on failure +MIN_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=1) +MAX_REFRESH_BACKOFF_INTERVAL = datetime.timedelta(minutes=10) +BACKOFF_MULTIPLIER = 1.5 + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -67,6 +72,68 @@ async def async_setup_entry( async_add_entities(entities) +class StreamRefresh: + """Class that will refresh an expiring stream. + + This class will schedule an alarm for the next expiration time of a stream. + When the alarm fires, it runs the provided `refresh_cb` to extend the + lifetime of the stream and return a new expiration time. + + A simple backoff will be applied when the refresh callback fails. + """ + + def __init__( + self, + hass: HomeAssistant, + expires_at: datetime.datetime, + refresh_cb: Callable[[], Awaitable[datetime.datetime | None]], + ) -> None: + """Initialize StreamRefresh.""" + self._hass = hass + self._unsub: Callable[[], None] | None = None + self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL + self._refresh_cb = refresh_cb + self._schedule_stream_refresh(expires_at - STREAM_EXPIRATION_BUFFER) + + def unsub(self) -> None: + """Invalidates the stream.""" + if self._unsub: + self._unsub() + + async def _handle_refresh(self, _: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + self._unsub = None + try: + expires_at = await self._refresh_cb() + except ApiException as err: + _LOGGER.debug("Failed to refresh stream: %s", err) + # Increase backoff until the max backoff interval is reached + self._min_refresh_interval = min( + self._min_refresh_interval * BACKOFF_MULTIPLIER, + MAX_REFRESH_BACKOFF_INTERVAL, + ) + refresh_time = utcnow() + self._min_refresh_interval + else: + if expires_at is None: + return + self._min_refresh_interval = MIN_REFRESH_BACKOFF_INTERVAL # Reset backoff + # Defend against invalid stream expiration time in the past + refresh_time = max( + expires_at - STREAM_EXPIRATION_BUFFER, + utcnow() + self._min_refresh_interval, + ) + self._schedule_stream_refresh(refresh_time) + + def _schedule_stream_refresh(self, refresh_time: datetime.datetime) -> None: + """Schedules an alarm to refresh any streams before expiration.""" + _LOGGER.debug("Scheduling stream refresh for %s", refresh_time) + self._unsub = async_track_point_in_utc_time( + self._hass, + self._handle_refresh, + refresh_time, + ) + + class NestCameraBaseEntity(Camera, ABC): """Devices that support cameras.""" @@ -86,41 +153,6 @@ class NestCameraBaseEntity(Camera, ABC): self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 # The API "name" field is a unique device identifier. self._attr_unique_id = f"{self._device.name}-camera" - self._stream_refresh_unsub: Callable[[], None] | None = None - - @abstractmethod - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - - @abstractmethod - async def _async_refresh_stream(self) -> None: - """Refresh any stream to extend expiration time.""" - - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh any streams before expiration.""" - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - expiration_time = self._stream_expires_at() - if not expiration_time: - return - refresh_time = expiration_time - STREAM_EXPIRATION_BUFFER - _LOGGER.debug("Scheduled next stream refresh for %s", refresh_time) - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, _: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - _LOGGER.debug("Examining streams to refresh") - self._stream_refresh_unsub = None - try: - await self._async_refresh_stream() - finally: - self._schedule_stream_refresh() async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" @@ -128,12 +160,6 @@ class NestCameraBaseEntity(Camera, ABC): self._device.add_update_listener(self.async_write_ha_state) ) - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - await super().async_will_remove_from_hass() - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - class NestRTSPEntity(NestCameraBaseEntity): """Nest cameras that use RTSP.""" @@ -146,6 +172,7 @@ class NestRTSPEntity(NestCameraBaseEntity): super().__init__(device) self._create_stream_url_lock = asyncio.Lock() self._rtsp_live_stream_trait = device.traits[CameraLiveStreamTrait.NAME] + self._refresh_unsub: Callable[[], None] | None = None @property def use_stream_for_stills(self) -> bool: @@ -173,20 +200,21 @@ class NestRTSPEntity(NestCameraBaseEntity): ) except ApiException as err: raise HomeAssistantError(f"Nest API error: {err}") from err - self._schedule_stream_refresh() + refresh = StreamRefresh( + self.hass, + self._rtsp_stream.expires_at, + self._async_refresh_stream, + ) + self._refresh_unsub = refresh.unsub assert self._rtsp_stream if self._rtsp_stream.expires_at < utcnow(): _LOGGER.warning("Stream already expired") return self._rtsp_stream.rtsp_stream_url - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - return self._rtsp_stream.expires_at if self._rtsp_stream else None - - async def _async_refresh_stream(self) -> None: + async def _async_refresh_stream(self) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" if not self._rtsp_stream: - return + return None _LOGGER.debug("Extending RTSP stream") try: self._rtsp_stream = await self._rtsp_stream.extend_rtsp_stream() @@ -197,14 +225,17 @@ class NestRTSPEntity(NestCameraBaseEntity): if self.stream: await self.stream.stop() self.stream = None - return + return None # Update the stream worker with the latest valid url if self.stream: self.stream.update_source(self._rtsp_stream.rtsp_stream_url) + return self._rtsp_stream.expires_at async def async_will_remove_from_hass(self) -> None: """Invalidates the RTSP token when unloaded.""" await super().async_will_remove_from_hass() + if self._refresh_unsub is not None: + self._refresh_unsub() if self._rtsp_stream: try: await self._rtsp_stream.stop_stream() @@ -220,37 +251,23 @@ class NestWebRTCEntity(NestCameraBaseEntity): """Initialize the camera.""" super().__init__(device) self._webrtc_sessions: dict[str, WebRtcStream] = {} + self._refresh_unsub: dict[str, Callable[[], None]] = {} @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" return StreamType.WEB_RTC - def _stream_expires_at(self) -> datetime.datetime | None: - """Next time when a stream expires.""" - if not self._webrtc_sessions: - return None - return min(stream.expires_at for stream in self._webrtc_sessions.values()) - - async def _async_refresh_stream(self) -> None: + async def _async_refresh_stream(self, session_id: str) -> datetime.datetime | None: """Refresh stream to extend expiration time.""" - now = utcnow() - for session_id, webrtc_stream in list(self._webrtc_sessions.items()): - if session_id not in self._webrtc_sessions: - continue - if now < (webrtc_stream.expires_at - STREAM_EXPIRATION_BUFFER): - _LOGGER.debug( - "Stream does not yet expire: %s", webrtc_stream.expires_at - ) - continue - _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id) - try: - webrtc_stream = await webrtc_stream.extend_stream() - except ApiException as err: - _LOGGER.debug("Failed to extend stream: %s", err) - else: - if session_id in self._webrtc_sessions: - self._webrtc_sessions[session_id] = webrtc_stream + if not (webrtc_stream := self._webrtc_sessions.get(session_id)): + return None + _LOGGER.debug("Extending WebRTC stream %s", webrtc_stream.media_session_id) + webrtc_stream = await webrtc_stream.extend_stream() + if session_id in self._webrtc_sessions: + self._webrtc_sessions[session_id] = webrtc_stream + return webrtc_stream.expires_at + return None async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -278,7 +295,12 @@ class NestWebRTCEntity(NestCameraBaseEntity): ) self._webrtc_sessions[session_id] = stream send_message(WebRTCAnswer(stream.answer_sdp)) - self._schedule_stream_refresh() + refresh = StreamRefresh( + self.hass, + stream.expires_at, + functools.partial(self._async_refresh_stream, session_id), + ) + self._refresh_unsub[session_id] = refresh.unsub @callback def close_webrtc_session(self, session_id: str) -> None: @@ -287,6 +309,8 @@ class NestWebRTCEntity(NestCameraBaseEntity): _LOGGER.debug( "Closing WebRTC session %s, %s", session_id, stream.media_session_id ) + unsub = self._refresh_unsub.pop(session_id) + unsub() async def stop_stream() -> None: try: diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 500dbc0f46f..029879f1413 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -483,6 +483,50 @@ async def test_stream_response_already_expired( assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" +async def test_extending_stream_already_expired( + hass: HomeAssistant, + auth: FakeAuth, + setup_platform: PlatformSetup, + camera_device: None, +) -> None: + """Test a API response when extending the stream returns an expired stream url.""" + now = utcnow() + stream_1_expiration = now + datetime.timedelta(seconds=180) + stream_2_expiration = now + datetime.timedelta(seconds=30) # Will be in the past + stream_3_expiration = now + datetime.timedelta(seconds=600) + auth.responses = [ + make_stream_url_response(stream_1_expiration, token_num=1), + make_stream_url_response(stream_2_expiration, token_num=2), + make_stream_url_response(stream_3_expiration, token_num=3), + ] + await setup_platform() + + assert len(hass.states.async_all()) == 1 + cam = hass.states.get("camera.my_camera") + assert cam is not None + assert cam.state == CameraState.STREAMING + + # The stream is expired, but we return it anyway + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.1.streamingToken" + + # Jump to when the stream will be refreshed + await fire_alarm(hass, now + datetime.timedelta(seconds=160)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" + + # The stream will have expired in the past, but 1 minute min refresh interval is applied. + # The stream token is not updated. + await fire_alarm(hass, now + datetime.timedelta(seconds=170)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.2.streamingToken" + + # Now go past the min update interval and the stream is refreshed + await fire_alarm(hass, now + datetime.timedelta(seconds=225)) + stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") + assert stream_source == "rtsp://some/url?auth=g.3.streamingToken" + + async def test_camera_removed( hass: HomeAssistant, auth: FakeAuth, From 7d2d6a82b0fcaee12bdcb702c46cca2c96be6cea Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:02:55 +0100 Subject: [PATCH 394/511] Allow dynamic max preset in linkplay play preset (#130160) --- homeassistant/components/linkplay/media_player.py | 5 ++++- homeassistant/components/linkplay/services.yaml | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 983d8777a6a..a625412852e 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -291,7 +291,10 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): @exception_wrap async def async_play_preset(self, preset_number: int) -> None: """Play preset number.""" - await self._bridge.player.play_preset(preset_number) + try: + await self._bridge.player.play_preset(preset_number) + except ValueError as err: + raise HomeAssistantError(err) from err @exception_wrap async def async_join_players(self, group_members: list[str]) -> None: diff --git a/homeassistant/components/linkplay/services.yaml b/homeassistant/components/linkplay/services.yaml index 20bc47be7a7..0d7335a28c8 100644 --- a/homeassistant/components/linkplay/services.yaml +++ b/homeassistant/components/linkplay/services.yaml @@ -11,5 +11,4 @@ play_preset: selector: number: min: 1 - max: 10 mode: box From d0ad834d93643dab7f8e91aa358be05a20e2ed65 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:14:13 +0100 Subject: [PATCH 395/511] Move manual trigger entity tests (#130134) --- .../test_trigger_template_entity.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{components/template/test_manual_trigger_entity.py => helpers/test_trigger_template_entity.py} (100%) diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/helpers/test_trigger_template_entity.py similarity index 100% rename from tests/components/template/test_manual_trigger_entity.py rename to tests/helpers/test_trigger_template_entity.py From 0677bba5bd7fdfecf2baef4c962fc0c87176468e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 10 Nov 2024 12:26:07 +0100 Subject: [PATCH 396/511] Add actions for scoring habits and rewards in Habitica (#129605) --- homeassistant/components/habitica/const.py | 4 + homeassistant/components/habitica/icons.json | 6 + homeassistant/components/habitica/services.py | 74 +++++++- .../components/habitica/services.yaml | 19 +- .../components/habitica/strings.json | 39 +++- tests/components/habitica/conftest.py | 2 +- tests/components/habitica/fixtures/tasks.json | 3 +- tests/components/habitica/test_services.py | 171 +++++++++++++++++- 8 files changed, 311 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 2107386c709..ae98cb13dcb 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -25,6 +25,7 @@ UNIT_TASKS = "tasks" ATTR_CONFIG_ENTRY = "config_entry" ATTR_SKILL = "skill" ATTR_TASK = "task" +ATTR_DIRECTION = "direction" SERVICE_CAST_SKILL = "cast_skill" SERVICE_START_QUEST = "start_quest" SERVICE_ACCEPT_QUEST = "accept_quest" @@ -32,6 +33,9 @@ SERVICE_CANCEL_QUEST = "cancel_quest" SERVICE_ABORT_QUEST = "abort_quest" SERVICE_REJECT_QUEST = "reject_quest" SERVICE_LEAVE_QUEST = "leave_quest" +SERVICE_SCORE_HABIT = "score_habit" +SERVICE_SCORE_REWARD = "score_reward" + WARRIOR = "warrior" ROGUE = "rogue" HEALER = "healer" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index bf59aa78d5c..d33b9c60c96 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -181,6 +181,12 @@ }, "start_quest": { "service": "mdi:script-text-key" + }, + "score_habit": { + "service": "mdi:counter" + }, + "score_reward": { + "service": "mdi:sack" } } } diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 9bea15aae71..df620675699 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -25,6 +25,7 @@ from .const import ( ATTR_ARGS, ATTR_CONFIG_ENTRY, ATTR_DATA, + ATTR_DIRECTION, ATTR_PATH, ATTR_SKILL, ATTR_TASK, @@ -37,6 +38,8 @@ from .const import ( SERVICE_CAST_SKILL, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, + SERVICE_SCORE_HABIT, + SERVICE_SCORE_REWARD, SERVICE_START_QUEST, ) from .types import HabiticaConfigEntry @@ -65,6 +68,13 @@ SERVICE_MANAGE_QUEST_SCHEMA = vol.Schema( vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), } ) +SERVICE_SCORE_TASK_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(), + vol.Required(ATTR_TASK): cv.string, + vol.Optional(ATTR_DIRECTION): cv.string, + } +) def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: @@ -82,7 +92,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry -def async_setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" async def handle_api_call(call: ServiceCall) -> None: @@ -223,6 +233,53 @@ def async_setup_services(hass: HomeAssistant) -> None: supports_response=SupportsResponse.ONLY, ) + async def score_task(call: ServiceCall) -> ServiceResponse: + """Score a task action.""" + entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + try: + task_id, task_value = next( + (task["id"], task.get("value")) + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (task["id"], task.get("alias")) + or call.data[ATTR_TASK] == task["text"] + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + try: + response: dict[str, Any] = ( + await coordinator.api.tasks[task_id] + .score[call.data.get(ATTR_DIRECTION, "up")] + .post() + ) + except ClientResponseError as e: + if e.status == HTTPStatus.TOO_MANY_REQUESTS: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + ) from e + if e.status == HTTPStatus.UNAUTHORIZED and task_value is not None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_gold", + translation_placeholders={ + "gold": f"{coordinator.data.user["stats"]["gp"]:.2f} GP", + "cost": f"{task_value} GP", + }, + ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + ) from e + else: + await coordinator.async_request_refresh() + return response + hass.services.async_register( DOMAIN, SERVICE_API_CALL, @@ -237,3 +294,18 @@ def async_setup_services(hass: HomeAssistant) -> None: schema=SERVICE_CAST_SKILL_SCHEMA, supports_response=SupportsResponse.ONLY, ) + + hass.services.async_register( + DOMAIN, + SERVICE_SCORE_HABIT, + score_task, + schema=SERVICE_SCORE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SCORE_REWARD, + score_task, + schema=SERVICE_SCORE_TASK_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index 955a0779cd3..b539f6c65bf 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -33,7 +33,7 @@ cast_skill: - "fireball" mode: dropdown translation_key: "skill_select" - task: + task: &task required: true selector: text: @@ -55,3 +55,20 @@ abort_quest: leave_quest: fields: config_entry: *config_entry +score_habit: + fields: + config_entry: *config_entry + task: *task + direction: + required: true + selector: + select: + options: + - value: up + label: "➕" + - value: down + label: "➖" +score_reward: + fields: + config_entry: *config_entry + task: *task diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 42f1dbee459..fd793675a5c 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -301,6 +301,9 @@ "not_enough_mana": { "message": "Unable to cast skill, not enough mana. Your character has {mana}, but the skill costs {cost}." }, + "not_enough_gold": { + "message": "Unable to buy reward, not enough gold. Your character has {gold}, but the reward costs {cost}." + }, "skill_not_found": { "message": "Unable to cast skill, your character does not have the skill or spell {skill}." }, @@ -311,7 +314,7 @@ "message": "The selected character is currently not loaded or disabled in Home Assistant." }, "task_not_found": { - "message": "Unable to cast skill, could not find the task {task}" + "message": "Unable to complete action, could not find the task {task}" }, "quest_action_unallowed": { "message": "Action not allowed, only quest leader or group leader can perform this action" @@ -350,7 +353,7 @@ "description": "Use a skill or spell from your Habitica character on a specific task to affect its progress or status.", "fields": { "config_entry": { - "name": "Select character", + "name": "[%key:component::habitica::common::config_entry_name%]", "description": "Choose the Habitica character to cast the skill." }, "skill": { @@ -422,6 +425,38 @@ "description": "[%key:component::habitica::services::accept_quest::fields::config_entry::description%]" } } + }, + "score_habit": { + "name": "Track a habit", + "description": "Increase the positive or negative streak of a habit to track its progress.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica character tracking your habit." + }, + "task": { + "name": "Habit name", + "description": "The name (or task ID) of the Habitica habit." + }, + "direction": { + "name": "Reward or loss", + "description": "Is it positive or negative progress you want to track for your habit." + } + } + }, + "score_reward": { + "name": "Buy a reward", + "description": "Reward yourself and buy one of your custom rewards with gold earned by fulfilling tasks.", + "fields": { + "config_entry": { + "name": "[%key:component::habitica::common::config_entry_name%]", + "description": "Select the Habitica character buying the reward." + }, + "task": { + "name": "Reward name", + "description": "The name (or task ID) of the custom reward." + } + } } }, "selector": { diff --git a/tests/components/habitica/conftest.py b/tests/components/habitica/conftest.py index 03b76561abc..8d729f4358f 100644 --- a/tests/components/habitica/conftest.py +++ b/tests/components/habitica/conftest.py @@ -34,7 +34,7 @@ def mock_called_with( ( call for call in mock_client.mock_calls - if call[0] == method.upper() and call[1] == URL(url) + if call[0].upper() == method.upper() and call[1] == URL(url) ), None, ) diff --git a/tests/components/habitica/fixtures/tasks.json b/tests/components/habitica/fixtures/tasks.json index 768768b4478..2e8305283d0 100644 --- a/tests/components/habitica/fixtures/tasks.json +++ b/tests/components/habitica/fixtures/tasks.json @@ -121,7 +121,8 @@ "createdAt": "2024-07-07T17:51:53.264Z", "updatedAt": "2024-07-12T09:58:45.438Z", "userId": "5f359083-ef78-4af0-985a-0b2c6d05797c", - "id": "e97659e0-2c42-4599-a7bb-00282adc410d" + "id": "e97659e0-2c42-4599-a7bb-00282adc410d", + "alias": "create_a_task" }, { "_id": "564b9ac9-c53d-4638-9e7f-1cd96fe19baa", diff --git a/tests/components/habitica/test_services.py b/tests/components/habitica/test_services.py index 390077e2205..403779bcbfb 100644 --- a/tests/components/habitica/test_services.py +++ b/tests/components/habitica/test_services.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components.habitica.const import ( ATTR_CONFIG_ENTRY, + ATTR_DIRECTION, ATTR_SKILL, ATTR_TASK, DEFAULT_URL, @@ -19,6 +20,8 @@ from homeassistant.components.habitica.const import ( SERVICE_CAST_SKILL, SERVICE_LEAVE_QUEST, SERVICE_REJECT_QUEST, + SERVICE_SCORE_HABIT, + SERVICE_SCORE_REWARD, SERVICE_START_QUEST, ) from homeassistant.config_entries import ConfigEntryState @@ -168,7 +171,7 @@ async def test_cast_skill( }, HTTPStatus.OK, ServiceValidationError, - "Unable to cast skill, could not find the task 'task-not-found", + "Unable to complete action, could not find the task 'task-not-found'", ), ( { @@ -377,3 +380,169 @@ async def test_handle_quests_exceptions( return_response=True, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "service_data", "task_id"), + [ + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "down", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_REWARD, + { + ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + }, + "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "Füge eine Aufgabe zu Habitica hinzu", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ( + SERVICE_SCORE_HABIT, + { + ATTR_TASK: "create_a_task", + ATTR_DIRECTION: "up", + }, + "e97659e0-2c42-4599-a7bb-00282adc410d", + ), + ], + ids=[ + "habit score up", + "habit score down", + "buy reward", + "match task by name", + "match task by alias", + ], +) +async def test_score_task( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service: str, + service_data: dict[str, Any], + task_id: str, +) -> None: + """Test Habitica score task action.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", + json={"success": True, "data": {}}, + ) + + await hass.services.async_call( + DOMAIN, + service, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) + + assert mock_called_with( + mock_habitica, + "post", + f"{DEFAULT_URL}/api/v3/tasks/{task_id}/score/{service_data.get(ATTR_DIRECTION, "up")}", + ) + + +@pytest.mark.parametrize( + ( + "service_data", + "http_status", + "expected_exception", + "expected_exception_msg", + ), + [ + ( + { + ATTR_TASK: "task does not exist", + ATTR_DIRECTION: "up", + }, + HTTPStatus.OK, + ServiceValidationError, + "Unable to complete action, could not find the task 'task does not exist'", + ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + HTTPStatus.TOO_MANY_REQUESTS, + ServiceValidationError, + RATE_LIMIT_EXCEPTION_MSG, + ), + ( + { + ATTR_TASK: "e97659e0-2c42-4599-a7bb-00282adc410d", + ATTR_DIRECTION: "up", + }, + HTTPStatus.BAD_REQUEST, + HomeAssistantError, + REQUEST_EXCEPTION_MSG, + ), + ( + { + ATTR_TASK: "5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b", + ATTR_DIRECTION: "up", + }, + HTTPStatus.UNAUTHORIZED, + HomeAssistantError, + "Unable to buy reward, not enough gold. Your character has 137.63 GP, but the reward costs 10 GP", + ), + ], +) +@pytest.mark.usefixtures("mock_habitica") +async def test_score_task_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_habitica: AiohttpClientMocker, + service_data: dict[str, Any], + http_status: HTTPStatus, + expected_exception: Exception, + expected_exception_msg: str, +) -> None: + """Test Habitica score task action exceptions.""" + + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/e97659e0-2c42-4599-a7bb-00282adc410d/score/up", + json={"success": True, "data": {}}, + status=http_status, + ) + mock_habitica.post( + f"{DEFAULT_URL}/api/v3/tasks/5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b/score/up", + json={"success": True, "data": {}}, + status=http_status, + ) + + with pytest.raises(expected_exception, match=expected_exception_msg): + await hass.services.async_call( + DOMAIN, + SERVICE_SCORE_HABIT, + service_data={ + ATTR_CONFIG_ENTRY: config_entry.entry_id, + **service_data, + }, + return_response=True, + blocking=True, + ) From 433321136de91051ebc879c2f4d03cb9d8454a22 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 12:28:18 +0100 Subject: [PATCH 397/511] Remove incorrect mark fixture in nordpool (#130278) --- tests/components/nordpool/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 305179c531a..d1c1972c568 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -23,7 +23,6 @@ from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @pytest.fixture async def load_int( hass: HomeAssistant, get_data: DeliveryPeriodData From a1a08f77555c58ce0fac689a04470a17b4cc78b0 Mon Sep 17 00:00:00 2001 From: Nicholas Romyn <13968908+nromyn@users.noreply.github.com> Date: Sun, 10 Nov 2024 08:13:01 -0500 Subject: [PATCH 398/511] Ecobee aux cutover threshold (#129474) * removing extra blank space * Adding EcobeeAuxCutoverThreshold First pass. * minor reorg and changes; testing local check-in * Adding entity, setting device class and name * Bumping max value slightly to hopefully accomodate celsius, setting numberMode=box * fixing the entity name for aux cutover threshold * Combined async_add_entities * Using a list comprehension Co-authored-by: Joost Lekkerkerker * fixing stuff with listcomprehension * exchanging call to list.append() to extend with list comprehension * Updating the class name and the entity name to match the device UI. Removing abbreviations from entity names * Fixing tests to match new entity names * respecting 88 column limit * Formatting * Adding test coverage for update/set compressorMinTemp values --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ecobee/number.py | 84 ++++++++++++++++--- homeassistant/components/ecobee/strings.json | 9 +- .../ecobee/fixtures/ecobee-data.json | 1 + tests/components/ecobee/test_number.py | 51 ++++++++++- tests/components/ecobee/test_switch.py | 2 +- 5 files changed, 129 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index ab09407903d..ed3744bf11e 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -6,9 +6,14 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,21 +59,30 @@ async def async_setup_entry( ) -> None: """Set up the ecobee thermostat number entity.""" data: EcobeeData = hass.data[DOMAIN] - _LOGGER.debug("Adding min time ventilators numbers (if present)") - async_add_entities( + assert data is not None + + entities: list[NumberEntity] = [ + EcobeeVentilatorMinTime(data, index, numbers) + for index, thermostat in enumerate(data.ecobee.thermostats) + if thermostat["settings"]["ventilatorType"] != "none" + for numbers in VENTILATOR_NUMBERS + ] + + _LOGGER.debug("Adding compressor min temp number (if present)") + entities.extend( ( - EcobeeVentilatorMinTime(data, index, numbers) + EcobeeCompressorMinTemp(data, index) for index, thermostat in enumerate(data.ecobee.thermostats) - if thermostat["settings"]["ventilatorType"] != "none" - for numbers in VENTILATOR_NUMBERS - ), - True, + if thermostat["settings"]["hasHeatPump"] + ) ) + async_add_entities(entities, True) + class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): - """A number class, representing min time for an ecobee thermostat with ventilator attached.""" + """A number class, representing min time for an ecobee thermostat with ventilator attached.""" entity_description: EcobeeNumberEntityDescription @@ -105,3 +119,53 @@ class EcobeeVentilatorMinTime(EcobeeBaseEntity, NumberEntity): """Set new ventilator Min On Time value.""" self.entity_description.set_fn(self.data, self.thermostat_index, int(value)) self.update_without_throttle = True + + +class EcobeeCompressorMinTemp(EcobeeBaseEntity, NumberEntity): + """Minimum outdoor temperature at which the compressor will operate. + + This applies more to air source heat pumps than geothermal. This serves as a safety + feature (compressors have a minimum operating temperature) as well as + providing the ability to choose fuel in a dual-fuel system (i.e. choose between + electrical heat pump and fossil auxiliary heat depending on Time of Use, Solar, + etc.). + Note that python-ecobee-api refers to this as Aux Cutover Threshold, but Ecobee + uses Compressor Protection Min Temp. + """ + + _attr_device_class = NumberDeviceClass.TEMPERATURE + _attr_has_entity_name = True + _attr_icon = "mdi:thermometer-off" + _attr_mode = NumberMode.BOX + _attr_native_min_value = -25 + _attr_native_max_value = 66 + _attr_native_step = 5 + _attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + _attr_translation_key = "compressor_protection_min_temp" + + def __init__( + self, + data: EcobeeData, + thermostat_index: int, + ) -> None: + """Initialize ecobee compressor min temperature.""" + super().__init__(data, thermostat_index) + self._attr_unique_id = f"{self.base_unique_id}_compressor_protection_min_temp" + self.update_without_throttle = False + + async def async_update(self) -> None: + """Get the latest state from the thermostat.""" + if self.update_without_throttle: + await self.data.update(no_throttle=True) + self.update_without_throttle = False + else: + await self.data.update() + + self._attr_native_value = ( + (self.thermostat["settings"]["compressorProtectionMinTemp"]) / 10 + ) + + def set_native_value(self, value: float) -> None: + """Set new compressor minimum temperature.""" + self.data.ecobee.set_aux_cutover_threshold(self.thermostat_index, value) + self.update_without_throttle = True diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 18929cb45de..8c636bd9b04 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -33,15 +33,18 @@ }, "number": { "ventilator_min_type_home": { - "name": "Ventilator min time home" + "name": "Ventilator minimum time home" }, "ventilator_min_type_away": { - "name": "Ventilator min time away" + "name": "Ventilator minimum time away" + }, + "compressor_protection_min_temp": { + "name": "Compressor minimum temperature" } }, "switch": { "aux_heat_only": { - "name": "Aux heat only" + "name": "Auxiliary heat only" } } }, diff --git a/tests/components/ecobee/fixtures/ecobee-data.json b/tests/components/ecobee/fixtures/ecobee-data.json index 1573484795f..e0e82d68863 100644 --- a/tests/components/ecobee/fixtures/ecobee-data.json +++ b/tests/components/ecobee/fixtures/ecobee-data.json @@ -160,6 +160,7 @@ "hasHumidifier": true, "humidifierMode": "manual", "hasHeatPump": true, + "compressorProtectionMinTemp": 100, "humidity": "30" }, "equipmentStatus": "fan", diff --git a/tests/components/ecobee/test_number.py b/tests/components/ecobee/test_number.py index 5b01fe8c5ba..be65b6dbb30 100644 --- a/tests/components/ecobee/test_number.py +++ b/tests/components/ecobee/test_number.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -VENTILATOR_MIN_HOME_ID = "number.ecobee_ventilator_min_time_home" -VENTILATOR_MIN_AWAY_ID = "number.ecobee_ventilator_min_time_away" +VENTILATOR_MIN_HOME_ID = "number.ecobee_ventilator_minimum_time_home" +VENTILATOR_MIN_AWAY_ID = "number.ecobee_ventilator_minimum_time_away" THERMOSTAT_ID = 0 @@ -26,7 +26,9 @@ async def test_ventilator_min_on_home_attributes(hass: HomeAssistant) -> None: assert state.attributes.get("min") == 0 assert state.attributes.get("max") == 60 assert state.attributes.get("step") == 5 - assert state.attributes.get("friendly_name") == "ecobee Ventilator min time home" + assert ( + state.attributes.get("friendly_name") == "ecobee Ventilator minimum time home" + ) assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES @@ -39,7 +41,9 @@ async def test_ventilator_min_on_away_attributes(hass: HomeAssistant) -> None: assert state.attributes.get("min") == 0 assert state.attributes.get("max") == 60 assert state.attributes.get("step") == 5 - assert state.attributes.get("friendly_name") == "ecobee Ventilator min time away" + assert ( + state.attributes.get("friendly_name") == "ecobee Ventilator minimum time away" + ) assert state.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES @@ -77,3 +81,42 @@ async def test_set_min_time_away(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() mock_set_min_away_time.assert_called_once_with(THERMOSTAT_ID, target_value) + + +COMPRESSOR_MIN_TEMP_ID = "number.ecobee2_compressor_minimum_temperature" + + +async def test_compressor_protection_min_temp_attributes(hass: HomeAssistant) -> None: + """Test the compressor min temp value is correct. + + Ecobee runs in Fahrenheit; the test rig runs in Celsius. Conversions are necessary. + """ + await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get(COMPRESSOR_MIN_TEMP_ID) + assert state.state == "-12.2" + assert ( + state.attributes.get("friendly_name") + == "ecobee2 Compressor minimum temperature" + ) + + +async def test_set_compressor_protection_min_temp(hass: HomeAssistant) -> None: + """Test the number can set minimum compressor operating temp. + + Ecobee runs in Fahrenheit; the test rig runs in Celsius. Conversions are necessary + """ + target_value = 0 + with patch( + "homeassistant.components.ecobee.Ecobee.set_aux_cutover_threshold" + ) as mock_set_compressor_min_temp: + await setup_platform(hass, NUMBER_DOMAIN) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: COMPRESSOR_MIN_TEMP_ID, ATTR_VALUE: target_value}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_compressor_min_temp.assert_called_once_with(1, 32) diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 31c8ce8f72d..b3c4c4f8296 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -118,7 +118,7 @@ async def test_turn_off_20min_ventilator(hass: HomeAssistant) -> None: mock_set_20min_ventilator.assert_called_once_with(THERMOSTAT_ID, False) -DEVICE_ID = "switch.ecobee2_aux_heat_only" +DEVICE_ID = "switch.ecobee2_auxiliary_heat_only" async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None: From 70211ab78e8ff5338d6220fc69ae3020d5205009 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Nov 2024 13:45:46 +0000 Subject: [PATCH 399/511] Bump aiohttp to 3.11.0rc0 (#130284) --- 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 0606cdd3435..3b3c50b3326 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0b5 +aiohttp==3.11.0rc0 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index c18f616abad..143330f5adb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0b5", + "aiohttp==3.11.0rc0", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index d3c60eb302e..aa72a7d23eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0b5 +aiohttp==3.11.0rc0 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From de391fa98bdf0826c364a6edb26460f11288ebb9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 14:58:44 +0100 Subject: [PATCH 400/511] Remove geniushub yaml support after 6 months of deprecation (#130285) * Remove geniushub YAML import after 6 moths of deprecation * Update homeassistant/components/geniushub/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/geniushub/__init__.py | 82 +------- .../components/geniushub/config_flow.py | 12 -- .../components/geniushub/test_config_flow.py | 182 +----------------- 3 files changed, 3 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index f3081e50289..9ca6ecfcfe0 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -9,7 +9,6 @@ import aiohttp from geniushubclient import GeniusHub import voluptuous as vol -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, @@ -21,20 +20,12 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, - callback, -) -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import verify_domain_control -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -45,27 +36,6 @@ SCAN_INTERVAL = timedelta(seconds=60) MAC_ADDRESS_REGEXP = r"^([0-9A-F]{2}:){5}([0-9A-F]{2})$" -CLOUD_API_SCHEMA = vol.Schema( - { - vol.Required(CONF_TOKEN): cv.string, - vol.Required(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), - } -) - - -LOCAL_API_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_MAC): vol.Match(MAC_ADDRESS_REGEXP), - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Any(LOCAL_API_SCHEMA, CLOUD_API_SCHEMA)}, extra=vol.ALLOW_EXTRA -) - ATTR_ZONE_MODE = "mode" ATTR_DURATION = "duration" @@ -100,56 +70,6 @@ PLATFORMS = [ ] -async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: - """Import a config entry from configuration.yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=base_config[DOMAIN], - ) - if ( - result["type"] is FlowResultType.CREATE_ENTRY - or result["reason"] == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Genius Hub", - }, - ) - return - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Genius Hub", - }, - ) - - -async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: - """Set up a Genius Hub system.""" - if DOMAIN in base_config: - hass.async_create_task(_async_import(hass, base_config)) - return True - - type GeniusHubConfigEntry = ConfigEntry[GeniusBroker] diff --git a/homeassistant/components/geniushub/config_flow.py b/homeassistant/components/geniushub/config_flow.py index 601eac6c2f2..b106f9907bb 100644 --- a/homeassistant/components/geniushub/config_flow.py +++ b/homeassistant/components/geniushub/config_flow.py @@ -13,7 +13,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -123,14 +122,3 @@ class GeniusHubConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="cloud_api", errors=errors, data_schema=CLOUD_API_SCHEMA ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - if CONF_HOST in import_data: - result = await self.async_step_local_api(import_data) - else: - result = await self.async_step_cloud_api(import_data) - if result["type"] is FlowResultType.FORM: - assert result["errors"] - return self.async_abort(reason=result["errors"]["base"]) - return result diff --git a/tests/components/geniushub/test_config_flow.py b/tests/components/geniushub/test_config_flow.py index 9234e03e35a..7d1d33a2245 100644 --- a/tests/components/geniushub/test_config_flow.py +++ b/tests/components/geniushub/test_config_flow.py @@ -2,21 +2,14 @@ from http import HTTPStatus import socket -from typing import Any from unittest.mock import AsyncMock from aiohttp import ClientConnectionError, ClientResponseError import pytest from homeassistant.components.geniushub import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import ( - CONF_HOST, - CONF_MAC, - CONF_PASSWORD, - CONF_TOKEN, - CONF_USERNAME, -) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -309,174 +302,3 @@ async def test_cloud_duplicate( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -async def test_import_local_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], -) -> None: - """Test full local import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "10.0.0.130" - assert result["data"] == data - assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff" - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_TOKEN: "abcdef", - }, - { - CONF_TOKEN: "abcdef", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -async def test_import_cloud_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], -) -> None: - """Test full cloud import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Genius hub" - assert result["data"] == data - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - { - CONF_TOKEN: "abcdef", - }, - { - CONF_TOKEN: "abcdef", - CONF_MAC: "aa:bb:cc:dd:ee:ff", - }, - ], -) -@pytest.mark.parametrize( - ("exception", "reason"), - [ - (socket.gaierror, "invalid_host"), - ( - ClientResponseError(AsyncMock(), (), status=HTTPStatus.UNAUTHORIZED), - "invalid_auth", - ), - ( - ClientResponseError(AsyncMock(), (), status=HTTPStatus.NOT_FOUND), - "invalid_host", - ), - (TimeoutError, "cannot_connect"), - (ClientConnectionError, "cannot_connect"), - (Exception, "unknown"), - ], -) -async def test_import_flow_exceptions( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - data: dict[str, Any], - exception: Exception, - reason: str, -) -> None: - """Test import flow exceptions.""" - mock_geniushub_client.request.side_effect = exception - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - - -@pytest.mark.parametrize( - ("data"), - [ - { - CONF_HOST: "10.0.0.130", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "10.0.0.131", - CONF_USERNAME: "test-username1", - CONF_PASSWORD: "test-password", - }, - ], -) -async def test_import_flow_local_duplicate( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - mock_local_config_entry: MockConfigEntry, - data: dict[str, Any], -) -> None: - """Test import flow aborts on local duplicate data.""" - mock_local_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_flow_cloud_duplicate( - hass: HomeAssistant, - mock_geniushub_client: AsyncMock, - mock_cloud_config_entry: MockConfigEntry, -) -> None: - """Test import flow aborts on cloud duplicate data.""" - mock_cloud_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_TOKEN: "abcdef", - }, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" From 7fd9339ad8c291af452025b17570bbf72142a123 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 15:34:08 +0100 Subject: [PATCH 401/511] Remove unused `file` CONFIG_SCHEMA (#130287) --- homeassistant/components/file/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 4139b021422..7bc206057c8 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -7,12 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from .const import DOMAIN -CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) - PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] From 1da4579a09d14938371d365f64daafe7269d826d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Sun, 10 Nov 2024 15:46:50 +0100 Subject: [PATCH 402/511] Add more f-series models to myuplink (#130283) --- homeassistant/components/myuplink/binary_sensor.py | 6 ++++-- homeassistant/components/myuplink/const.py | 2 ++ homeassistant/components/myuplink/helpers.py | 14 ++++++++++++-- homeassistant/components/myuplink/number.py | 6 ++++-- homeassistant/components/myuplink/sensor.py | 6 ++++-- homeassistant/components/myuplink/switch.py | 6 ++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py index 0ba6ac7b078..953859986d0 100644 --- a/homeassistant/components/myuplink/binary_sensor.py +++ b/homeassistant/components/myuplink/binary_sensor.py @@ -12,11 +12,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity, MyUplinkSystemEntity -from .helpers import find_matching_platform +from .helpers import find_matching_platform, transform_model_series CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, BinarySensorEntityDescription]] = { - "F730": { + F_SERIES: { "43161": BinarySensorEntityDescription( key="elect_add", translation_key="elect_add", @@ -50,6 +51,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription 2. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id) diff --git a/homeassistant/components/myuplink/const.py b/homeassistant/components/myuplink/const.py index 3541a8078c3..6fd354a21ec 100644 --- a/homeassistant/components/myuplink/const.py +++ b/homeassistant/components/myuplink/const.py @@ -6,3 +6,5 @@ API_ENDPOINT = "https://api.myuplink.com" OAUTH2_AUTHORIZE = "https://api.myuplink.com/oauth/authorize" OAUTH2_TOKEN = "https://api.myuplink.com/oauth/token" OAUTH2_SCOPES = ["WRITESYSTEM", "READSYSTEM", "offline_access"] + +F_SERIES = "f-series" diff --git a/homeassistant/components/myuplink/helpers.py b/homeassistant/components/myuplink/helpers.py index eb4881c410e..de5486d8dea 100644 --- a/homeassistant/components/myuplink/helpers.py +++ b/homeassistant/components/myuplink/helpers.py @@ -6,6 +6,8 @@ from homeassistant.components.number import NumberEntityDescription from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import Platform +from .const import F_SERIES + def find_matching_platform( device_point: DevicePoint, @@ -86,8 +88,9 @@ PARAMETER_ID_TO_EXCLUDE_F730 = ( "47941", "47975", "48009", - "48042", "48072", + "48442", + "49909", "50113", ) @@ -110,7 +113,7 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool: ): return False return True - if "F730" in model: + if model.lower().startswith("f"): # Entity names containing weekdays are used for advanced scheduling in the # heat pump and should not be exposed in the integration if any(d in device_point.parameter_name.lower() for d in WEEKDAYS): @@ -118,3 +121,10 @@ def skip_entity(model: str, device_point: DevicePoint) -> bool: if device_point.parameter_id in PARAMETER_ID_TO_EXCLUDE_F730: return True return False + + +def transform_model_series(prefix: str) -> str: + """Remap all F-series models.""" + if prefix.lower().startswith("f"): + return F_SERIES + return prefix diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py index 0c7da0c716f..b05ab5d46c9 100644 --- a/homeassistant/components/myuplink/number.py +++ b/homeassistant/components/myuplink/number.py @@ -10,8 +10,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = { "DM": NumberEntityDescription( @@ -22,7 +23,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, NumberEntityDescription] = { } CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, NumberEntityDescription]] = { - "F730": { + F_SERIES: { "40940": NumberEntityDescription( key="degree_minutes", translation_key="degree_minutes", @@ -48,6 +49,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None 3. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( device_point.parameter_id ) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 7feb20bc093..ef827fc1fb1 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -25,8 +25,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { "°C": SensorEntityDescription( @@ -139,7 +140,7 @@ DEVICE_POINT_UNIT_DESCRIPTIONS: dict[str, SensorEntityDescription] = { MARKER_FOR_UNKNOWN_VALUE = -32768 CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SensorEntityDescription]] = { - "F730": { + F_SERIES: { "43108": SensorEntityDescription( key="fan_mode", translation_key="fan_mode", @@ -200,6 +201,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None """ description = None prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) description = CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get( device_point.parameter_id ) diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py index 5c47c8294fe..75ba6bd7819 100644 --- a/homeassistant/components/myuplink/switch.py +++ b/homeassistant/components/myuplink/switch.py @@ -12,11 +12,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MyUplinkConfigEntry, MyUplinkDataCoordinator +from .const import F_SERIES from .entity import MyUplinkEntity -from .helpers import find_matching_platform, skip_entity +from .helpers import find_matching_platform, skip_entity, transform_model_series CATEGORY_BASED_DESCRIPTIONS: dict[str, dict[str, SwitchEntityDescription]] = { - "F730": { + F_SERIES: { "50004": SwitchEntityDescription( key="temporary_lux", translation_key="temporary_lux", @@ -47,6 +48,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None 2. Default to None """ prefix, _, _ = device_point.category.partition(" ") + prefix = transform_model_series(prefix) return CATEGORY_BASED_DESCRIPTIONS.get(prefix, {}).get(device_point.parameter_id) From f10063c9bea102cf5d6a4fcf13911bf7fb82550f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:28:58 +0100 Subject: [PATCH 403/511] Fix translation key for `done` response in conversation (#130247) --- .../components/conversation/default_agent.py | 2 +- .../conversation/test_default_agent.py | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 6b5cef89fd6..a7110c35795 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -294,7 +294,7 @@ class DefaultAgent(ConversationEntity): self.hass, language, DOMAIN, [DOMAIN] ) response_text = translations.get( - f"component.{DOMAIN}.agent.done", "Done" + f"component.{DOMAIN}.conversation.agent.done", "Done" ) response.async_set_speech(response_text) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 14a9b0ca88c..9f54671d8a1 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -418,6 +418,44 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: assert len(callback.mock_calls) == 0 +@pytest.mark.parametrize( + ("language", "expected"), + [("en", "English done"), ("de", "German done"), ("not_translated", "Done")], +) +@pytest.mark.usefixtures("init_components") +async def test_trigger_sentence_response_translation( + hass: HomeAssistant, language: str, expected: str +) -> None: + """Test translation of default response 'done'.""" + hass.config.language = language + + agent = hass.data[DATA_DEFAULT_ENTITY] + assert isinstance(agent, default_agent.DefaultAgent) + + translations = { + "en": {"component.conversation.conversation.agent.done": "English done"}, + "de": {"component.conversation.conversation.agent.done": "German done"}, + "not_translated": {}, + } + + with patch( + "homeassistant.components.conversation.default_agent.translation.async_get_translations", + return_value=translations.get(language), + ): + unregister = agent.register_trigger( + ["test sentence"], AsyncMock(return_value=None) + ) + result = await conversation.async_converse( + hass, "test sentence", None, Context() + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech == { + "plain": {"speech": expected, "extra_data": None} + } + + unregister() + + @pytest.mark.usefixtures("init_components", "sl_setup") async def test_shopping_list_add_item(hass: HomeAssistant) -> None: """Test adding an item to the shopping list through the default agent.""" From ae1203336d6baefafa0a72e4c4fb39a937ce61ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 10 Nov 2024 16:37:53 +0100 Subject: [PATCH 404/511] Add links to deprecation issue message for Home Connect Binary door (#129779) --- .../components/home_connect/binary_sensor.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index f044a3fdfb4..232b581d58b 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import ( IssueSeverity, @@ -192,11 +193,32 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" await super().async_added_to_hass() - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - items = entity_automations + entity_scripts + automations = automations_with_entity(self.hass, self.entity_id) + scripts = scripts_with_entity(self.hass, self.entity_id) + items = automations + scripts if not items: return + + entity_reg: er.EntityRegistry = er.async_get(self.hass) + entity_automations = [ + automation_entity + for automation_id in automations + if (automation_entity := entity_reg.async_get(automation_id)) + ] + entity_scripts = [ + script_entity + for script_id in scripts + if (script_entity := entity_reg.async_get(script_id)) + ] + + items_list = [ + f"- [{item.original_name}](/config/automation/edit/{item.unique_id})" + for item in entity_automations + ] + [ + f"- [{item.original_name}](/config/script/edit/{item.unique_id})" + for item in entity_scripts + ] + async_create_issue( self.hass, DOMAIN, @@ -207,7 +229,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor): translation_key="deprecated_binary_common_door_sensor", translation_placeholders={ "entity": self.entity_id, - "items": "\n".join([f"- {item}" for item in items]), + "items": "\n".join(items_list), }, ) From ee41725b536d3589b899a8ddc78ecd5b3b70855f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 16:51:08 +0100 Subject: [PATCH 405/511] Remove jewish_calendar yaml support after 6 months of deprecation (#130291) --- .../components/jewish_calendar/__init__.py | 64 +--------------- .../components/jewish_calendar/config_flow.py | 19 +---- .../jewish_calendar/test_config_flow.py | 49 ------------ tests/components/jewish_calendar/test_init.py | 75 ------------------- 4 files changed, 2 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 4598cf7cd91..b4535097ef5 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -5,23 +5,17 @@ from __future__ import annotations from functools import partial from hdate import Location -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_TIME_ZONE, Platform, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback 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 ( @@ -32,7 +26,6 @@ from .const import ( DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, - DEFAULT_NAME, DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData @@ -40,32 +33,6 @@ from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.deprecated(DOMAIN), - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DIASPORA, default=DEFAULT_DIASPORA): cv.boolean, - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In( - ["hebrew", "english"] - ), - vol.Optional( - CONF_CANDLE_LIGHT_MINUTES, default=DEFAULT_CANDLE_LIGHT - ): int, - # Default of 0 means use 8.5 degrees / 'three_stars' time. - vol.Optional( - CONF_HAVDALAH_OFFSET_MINUTES, - default=DEFAULT_HAVDALAH_OFFSET_MINUTES, - ): int, - }, - ) - }, - extra=vol.ALLOW_EXTRA, -) - def get_unique_prefix( location: Location, @@ -91,35 +58,6 @@ def get_unique_prefix( return f"{prefix}" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Jewish Calendar component.""" - if DOMAIN not in config: - return True - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - issue_domain=DOMAIN, - breaks_in_ha_version="2024.12.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": DEFAULT_NAME, - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True - - async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 9673fc6cf22..a2eadbf57bd 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -101,23 +101,10 @@ 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, options=_options - ) + return self.async_create_entry(title=DEFAULT_NAME, data=user_input) return self.async_show_form( step_id="user", @@ -126,10 +113,6 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_data) - async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index dbd4ecd802d..e00fe41749f 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,8 +2,6 @@ from unittest.mock import AsyncMock -import pytest - from homeassistant import config_entries, setup from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, @@ -20,12 +18,10 @@ from homeassistant.const import ( CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, CONF_TIME_ZONE, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -59,51 +55,6 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No assert entries[0].data[CONF_TIME_ZONE] == hass.config.time_zone -@pytest.mark.parametrize("diaspora", [True, False]) -@pytest.mark.parametrize("language", ["hebrew", "english"]) -async def test_import_no_options(hass: HomeAssistant, language, diaspora) -> None: - """Test that the import step works.""" - conf = { - DOMAIN: {CONF_NAME: "test", CONF_LANGUAGE: language, CONF_DIASPORA: diaspora} - } - - assert await async_setup_component(hass, DOMAIN, conf.copy()) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert CONF_LANGUAGE in entries[0].data - assert CONF_DIASPORA in entries[0].data - 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: - """Test that the import step works.""" - 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, - } - } - - # Simulate HomeAssistant setting up the component - assert await async_setup_component(hass, DOMAIN, conf.copy()) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - 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( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/jewish_calendar/test_init.py b/tests/components/jewish_calendar/test_init.py index b8454b41a60..cb982afec0f 100644 --- a/tests/components/jewish_calendar/test_init.py +++ b/tests/components/jewish_calendar/test_init.py @@ -1,76 +1 @@ """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, - 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 - 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 - 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 d8b55d39e43e186771ae9d6ae448b87070930a87 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 19:27:11 +0100 Subject: [PATCH 406/511] Remove tibber legacy notify service after 6 months of deprecation (#130292) --- homeassistant/components/tibber/__init__.py | 21 +------- homeassistant/components/tibber/notify.py | 42 ---------------- tests/components/tibber/test_diagnostics.py | 9 ++-- tests/components/tibber/test_notify.py | 20 -------- tests/components/tibber/test_repairs.py | 56 --------------------- 5 files changed, 4 insertions(+), 144 deletions(-) delete mode 100644 tests/components/tibber/test_repairs.py diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index ce05b8070f6..9b5c7ee1168 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -6,15 +6,9 @@ import aiohttp import tibber from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_ACCESS_TOKEN, - CONF_NAME, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -73,19 +67,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Use discovery to load platform legacy notify platform - # The use of the legacy notify service was deprecated with HA Core 2024.6 - # Support will be removed with HA Core 2024.12 - hass.async_create_task( - discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: DOMAIN}, - hass.data[DATA_HASS_CONFIG], - ) - ) - return True diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py index 1c9f86ed502..fdeeeba68ef 100644 --- a/homeassistant/components/tibber/notify.py +++ b/homeassistant/components/tibber/notify.py @@ -2,38 +2,21 @@ from __future__ import annotations -from collections.abc import Callable -from typing import Any - from tibber import Tibber from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, - BaseNotificationService, NotifyEntity, NotifyEntityFeature, - migrate_notify_issue, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as TIBBER_DOMAIN -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> TibberNotificationService: - """Get the Tibber notification service.""" - tibber_connection: Tibber = hass.data[TIBBER_DOMAIN] - return TibberNotificationService(tibber_connection.send_notification) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -41,31 +24,6 @@ async def async_setup_entry( async_add_entities([TibberNotificationEntity(entry.entry_id)]) -class TibberNotificationService(BaseNotificationService): - """Implement the notification service for Tibber.""" - - def __init__(self, notify: Callable) -> None: - """Initialize the service.""" - self._notify = notify - - 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", - service_name=self._service_name, - ) - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - try: - await self._notify(title=title, message=message) - except TimeoutError as exc: - raise HomeAssistantError( - translation_domain=TIBBER_DOMAIN, translation_key="send_message_timeout" - ) from exc - - class TibberNotificationEntity(NotifyEntity): """Implement the notification entity service for Tibber.""" diff --git a/tests/components/tibber/test_diagnostics.py b/tests/components/tibber/test_diagnostics.py index 34ecb63dfec..16c735596d0 100644 --- a/tests/components/tibber/test_diagnostics.py +++ b/tests/components/tibber/test_diagnostics.py @@ -19,12 +19,9 @@ async def test_entry_diagnostics( config_entry, ) -> None: """Test config entry diagnostics.""" - with ( - patch( - "tibber.Tibber.update_info", - return_value=None, - ), - patch("homeassistant.components.tibber.discovery.async_load_platform"), + with patch( + "tibber.Tibber.update_info", + return_value=None, ): assert await async_setup_component(hass, "tibber", {}) diff --git a/tests/components/tibber/test_notify.py b/tests/components/tibber/test_notify.py index 69af92c4d5d..9b731e78bf6 100644 --- a/tests/components/tibber/test_notify.py +++ b/tests/components/tibber/test_notify.py @@ -6,7 +6,6 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.recorder import Recorder -from homeassistant.components.tibber import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -19,18 +18,8 @@ async def test_notification_services( notify_state = hass.states.get("notify.tibber") assert notify_state is not None - # Assert legacy notify service hass been added - assert hass.services.has_service("notify", DOMAIN) - - # Test legacy notify service - service = "tibber" - service_data = {"message": "The message", "title": "A title"} - await hass.services.async_call("notify", service, service_data, blocking=True) calls: MagicMock = mock_tibber_setup.send_notification - calls.assert_called_once_with(message="The message", title="A title") - calls.reset_mock() - # Test notify entity service service = "send_message" service_data = { @@ -44,15 +33,6 @@ async def test_notification_services( calls.side_effect = TimeoutError - with pytest.raises(HomeAssistantError): - # Test legacy notify service - await hass.services.async_call( - "notify", - service="tibber", - service_data={"message": "The message", "title": "A title"}, - blocking=True, - ) - with pytest.raises(HomeAssistantError): # Test notify entity service await hass.services.async_call( diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py deleted file mode 100644 index 5e5fde4569e..00000000000 --- a/tests/components/tibber/test_repairs.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Test loading of the Tibber config entry.""" - -from unittest.mock import MagicMock - -from homeassistant.components.recorder import Recorder -from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir - -from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow -from tests.typing import ClientSessionGenerator - - -async def test_repair_flow( - recorder_mock: Recorder, - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - mock_tibber_setup: MagicMock, - hass_client: ClientSessionGenerator, -) -> None: - """Test unloading the entry.""" - - # Test legacy notify service - service = "tibber" - service_data = {"message": "The message", "title": "A title"} - await hass.services.async_call("notify", service, service_data, blocking=True) - calls: MagicMock = mock_tibber_setup.send_notification - - calls.assert_called_once_with(message="The message", title="A title") - calls.reset_mock() - - http_client = await hass_client() - # Assert the issue is present - assert issue_registry.async_get_issue( - domain="notify", - issue_id=f"migrate_notify_tibber_{service}", - ) - assert len(issue_registry.issues) == 1 - - data = await start_repair_fix_flow( - http_client, "notify", f"migrate_notify_tibber_{service}" - ) - - flow_id = data["flow_id"] - assert data["step_id"] == "confirm" - - # Simulate the users confirmed the repair flow - data = await process_repair_fix_flow(http_client, flow_id) - assert data["type"] == "create_entry" - await hass.async_block_till_done() - - # Assert the issue is no longer present - assert not issue_registry.async_get_issue( - domain="notify", - issue_id=f"migrate_notify_tibber_{service}", - ) - assert len(issue_registry.issues) == 0 From 7f9ec2a79eee5a638a4b294762c53bf76d2528a3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 10 Nov 2024 10:27:40 -0800 Subject: [PATCH 407/511] Ignore WebRTC candidates for nest cameras (#130294) --- homeassistant/components/nest/camera.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 4cb88e63641..0a46d67a3ad 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -19,6 +19,7 @@ from google_nest_sdm.camera_traits import ( from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.exceptions import ApiException +from webrtc_models import RTCIceCandidate from homeassistant.components.camera import ( Camera, @@ -302,6 +303,12 @@ class NestWebRTCEntity(NestCameraBaseEntity): ) self._refresh_unsub[session_id] = refresh.unsub + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidate + ) -> None: + """Ignore WebRTC candidates for Nest cloud based cameras.""" + return + @callback def close_webrtc_session(self, session_id: str) -> None: """Close a WebRTC session.""" From fbc4a87166040e42540c9702806d9d3b82effda8 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Sun, 10 Nov 2024 20:35:01 +0200 Subject: [PATCH 408/511] Remove Jewish Calendar config flow upgrade (#129612) --- .../components/jewish_calendar/__init__.py | 62 +------------------ 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index b4535097ef5..823e9bd59be 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -14,10 +14,8 @@ from homeassistant.const import ( CONF_TIME_ZONE, Platform, ) -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.entity_registry as er +from homeassistant.core import HomeAssistant -from .binary_sensor import BINARY_SENSORS from .const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -26,38 +24,12 @@ from .const import ( DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, DEFAULT_LANGUAGE, - DOMAIN, ) from .entity import JewishCalendarConfigEntry, JewishCalendarData -from .sensor import INFO_SENSORS, TIME_SENSORS PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -def get_unique_prefix( - location: Location, - language: str, - candle_lighting_offset: int | None, - havdalah_offset: int | None, -) -> str: - """Create a prefix for unique ids.""" - # location.altitude was unset before 2024.6 when this method - # was used to create the unique id. As such it would always - # use the default altitude of 754. - config_properties = [ - location.latitude, - location.longitude, - location.timezone, - 754, - location.diaspora, - language, - candle_lighting_offset, - havdalah_offset, - ] - prefix = "_".join(map(str, config_properties)) - return f"{prefix}" - - async def async_setup_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: @@ -91,16 +63,6 @@ async def async_setup_entry( havdalah_offset, ) - # 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) async def update_listener( @@ -118,25 +80,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -@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) From 980b0fa5e693fb5e51640b96d398d1a6ef32bae5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sun, 10 Nov 2024 19:37:41 +0100 Subject: [PATCH 409/511] Deprecate api_call action in Habitica integration (#128119) --- homeassistant/components/habitica/services.py | 14 ++++++++++++++ homeassistant/components/habitica/strings.json | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index df620675699..a50e5f1e6e3 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( @@ -96,6 +97,19 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" async def handle_api_call(call: ServiceCall) -> None: + async_create_issue( + hass, + DOMAIN, + "deprecated_api_call", + breaks_in_ha_version="2025.6.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_api_call", + ) + _LOGGER.warning( + "Deprecated action called: 'habitica.api_call' is deprecated and will be removed in Home Assistant version 2025.6.0" + ) + name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] entries = hass.config_entries.async_entries(DOMAIN) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index fd793675a5c..ac1faf5fcef 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -327,6 +327,10 @@ "deprecated_task_entity": { "title": "The Habitica {task_name} sensor is deprecated", "description": "The Habitica entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to replace the sensor entity with the newly added todo entity.\nWhen you are done migrating you can disable `{entity}`." + }, + "deprecated_api_call": { + "title": "The Habitica action habitica.api_call is deprecated", + "description": "The Habitica action `habitica.api_call` is deprecated and will be removed in Home Assistant 2025.5.0.\n\nPlease update your automations and scripts to use other Habitica actions and entities." } }, "services": { From 73929e6791969e3dd9993574853bcf124d07f4d7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 10 Nov 2024 20:11:42 +0100 Subject: [PATCH 410/511] Avoid Shelly data update during shutdown (#130301) --- homeassistant/components/shelly/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 6332e139244..a66fbb20f48 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -603,7 +603,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): async def _async_update_data(self) -> None: """Fetch data.""" - if self.update_sleep_period(): + if self.update_sleep_period() or self.hass.is_stopping: return if self.sleep_period: From 3a37ff13a6e3076a7b10109025e8d4bcde005a50 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Sun, 10 Nov 2024 20:12:46 +0100 Subject: [PATCH 411/511] Bump eq3btsmart to 1.2.1 (#130297) --- homeassistant/components/eq3btsmart/climate.py | 10 ++++++++-- homeassistant/components/eq3btsmart/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 7b8ccb6c990..9984c4f7229 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -143,6 +143,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _async_on_status_updated(self) -> None: """Handle updated status from the thermostat.""" + if self._thermostat.status is None: + return + self._target_temperature = self._thermostat.status.target_temperature.value self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_current_temperature = self._get_current_temperature() @@ -154,13 +157,16 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _async_on_device_updated(self) -> None: """Handle updated device data from the thermostat.""" + if self._thermostat.device_data is None: + return + device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, ): device_registry.async_update_device( device.id, - sw_version=self._thermostat.device_data.firmware_version, + sw_version=str(self._thermostat.device_data.firmware_version), serial_number=self._thermostat.device_data.device_serial.value, ) @@ -265,7 +271,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): self.async_write_ha_state() try: - await self._thermostat.async_set_temperature(self._target_temperature) + await self._thermostat.async_set_temperature(temperature) except Eq3Exception: _LOGGER.error( "[%s] Failed setting temperature", self._eq3_config.mac_address diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index e25c675bf82..bd3f14939ca 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -23,5 +23,5 @@ "iot_class": "local_polling", "loggers": ["eq3btsmart"], "quality_scale": "silver", - "requirements": ["eq3btsmart==1.2.0", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e09673d4534..7a2aa07342e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -860,7 +860,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.0 +eq3btsmart==1.2.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3db5b00adf..b92442854af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -729,7 +729,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.0 +eq3btsmart==1.2.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From 88c227681d702f1341ced8873ad1b87431192557 Mon Sep 17 00:00:00 2001 From: dotvav Date: Sun, 10 Nov 2024 20:13:31 +0100 Subject: [PATCH 412/511] Bump pypalazzetti to 0.1.11 (#130293) --- homeassistant/components/palazzetti/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/palazzetti/manifest.json b/homeassistant/components/palazzetti/manifest.json index 552289ebeac..aff82275e2e 100644 --- a/homeassistant/components/palazzetti/manifest.json +++ b/homeassistant/components/palazzetti/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/palazzetti", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pypalazzetti==0.1.10"] + "requirements": ["pypalazzetti==0.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a2aa07342e..7cf0190a6aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.10 +pypalazzetti==0.1.11 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92442854af..9332c74adc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1739,7 +1739,7 @@ pyoverkiz==1.14.1 pyownet==0.10.0.post1 # homeassistant.components.palazzetti -pypalazzetti==0.1.10 +pypalazzetti==0.1.11 # homeassistant.components.lcn pypck==0.7.24 From 0468e7e7a3234e37b7b300f02cb555ae68b361b0 Mon Sep 17 00:00:00 2001 From: Richard Cox Date: Sun, 10 Nov 2024 12:23:23 -0700 Subject: [PATCH 413/511] Update Sonarr config flow to standardize ports (#127625) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/sonarr/config_flow.py | 7 ++++ tests/components/sonarr/__init__.py | 2 +- tests/components/sonarr/test_config_flow.py | 32 +++++++++++++++++-- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index c868c04f7d0..e1cedba10e7 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -93,6 +93,13 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + # aiopyarr defaults to the service port if one isn't given + # this is counter to standard practice where http = 80 + # and https = 443. + if CONF_URL in user_input: + url = yarl.URL(user_input[CONF_URL]) + user_input[CONF_URL] = f"{url.scheme}://{url.host}:{url.port}{url.path}" + if self.source == SOURCE_REAUTH: user_input = {**self._get_reauth_entry().data, **user_input} diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py index b6050808a34..660102ed082 100644 --- a/tests/components/sonarr/__init__.py +++ b/tests/components/sonarr/__init__.py @@ -5,6 +5,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL MOCK_REAUTH_INPUT = {CONF_API_KEY: "test-api-key-reauth"} MOCK_USER_INPUT = { - CONF_URL: "http://192.168.1.189:8989", + CONF_URL: "http://192.168.1.189:8989/", CONF_API_KEY: "MOCK_API_KEY", } diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py index 118d5020cba..efbfbd749b3 100644 --- a/tests/components/sonarr/test_config_flow.py +++ b/tests/components/sonarr/test_config_flow.py @@ -50,6 +50,34 @@ async def test_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} +async def test_url_rewrite( + hass: HomeAssistant, + mock_sonarr_config_flow: MagicMock, + mock_setup_entry: None, +) -> None: + """Test the full manual user flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = MOCK_USER_INPUT.copy() + user_input[CONF_URL] = "https://192.168.1.189" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "192.168.1.189" + + assert result["data"] + assert result["data"][CONF_URL] == "https://192.168.1.189:443/" + + async def test_invalid_auth( hass: HomeAssistant, mock_sonarr_config_flow: MagicMock ) -> None: @@ -145,7 +173,7 @@ async def test_full_user_flow_implementation( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_URL] == "http://192.168.1.189:8989" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989/" async def test_full_user_flow_advanced_options( @@ -175,7 +203,7 @@ async def test_full_user_flow_advanced_options( assert result["title"] == "192.168.1.189" assert result["data"] - assert result["data"][CONF_URL] == "http://192.168.1.189:8989" + assert result["data"][CONF_URL] == "http://192.168.1.189:8989/" assert result["data"][CONF_VERIFY_SSL] From 784ad20fb6ed38e6c052beda073bf748a1787dd6 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 10 Nov 2024 20:31:40 +0100 Subject: [PATCH 414/511] Add diagnostics to LinkPlay (#126768) --- .../components/linkplay/diagnostics.py | 17 +++ tests/components/linkplay/__init__.py | 15 +++ tests/components/linkplay/conftest.py | 70 ++++++++++- .../linkplay/fixtures/getPlayerEx.json | 19 +++ .../linkplay/fixtures/getStatusEx.json | 81 ++++++++++++ .../linkplay/snapshots/test_diagnostics.ambr | 115 ++++++++++++++++++ tests/components/linkplay/test_diagnostics.py | 53 ++++++++ 7 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/linkplay/diagnostics.py create mode 100644 tests/components/linkplay/fixtures/getPlayerEx.json create mode 100644 tests/components/linkplay/fixtures/getStatusEx.json create mode 100644 tests/components/linkplay/snapshots/test_diagnostics.ambr create mode 100644 tests/components/linkplay/test_diagnostics.py diff --git a/homeassistant/components/linkplay/diagnostics.py b/homeassistant/components/linkplay/diagnostics.py new file mode 100644 index 00000000000..cfc1346aff4 --- /dev/null +++ b/homeassistant/components/linkplay/diagnostics.py @@ -0,0 +1,17 @@ +"""Diagnostics support for Linkplay.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import LinkPlayConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: LinkPlayConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data + return {"device_info": data.bridge.to_dict()} diff --git a/tests/components/linkplay/__init__.py b/tests/components/linkplay/__init__.py index 5962f7fdaba..f825826f196 100644 --- a/tests/components/linkplay/__init__.py +++ b/tests/components/linkplay/__init__.py @@ -1 +1,16 @@ """Tests for the LinkPlay integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/linkplay/conftest.py b/tests/components/linkplay/conftest.py index be83dd2412d..81ae993f6c3 100644 --- a/tests/components/linkplay/conftest.py +++ b/tests/components/linkplay/conftest.py @@ -1,12 +1,22 @@ """Test configuration and mocks for LinkPlay component.""" -from collections.abc import Generator +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from typing import Any +from unittest import mock from unittest.mock import AsyncMock, patch from aiohttp import ClientSession from linkplay.bridge import LinkPlayBridge, LinkPlayDevice import pytest +from homeassistant.components.linkplay.const import DOMAIN +from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_CLOSE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture +from tests.conftest import AiohttpClientMocker + HOST = "10.0.0.150" HOST_REENTRY = "10.0.0.66" UUID = "FF31F09E-5001-FBDE-0546-2DBFFF31F09E" @@ -24,15 +34,15 @@ def mock_linkplay_factory_bridge() -> Generator[AsyncMock]: ), patch( "homeassistant.components.linkplay.config_flow.linkplay_factory_httpapi_bridge", - ) as factory, + ) as conf_factory, ): bridge = AsyncMock(spec=LinkPlayBridge) bridge.endpoint = HOST bridge.device = AsyncMock(spec=LinkPlayDevice) bridge.device.uuid = UUID bridge.device.name = NAME - factory.return_value = bridge - yield factory + conf_factory.return_value = bridge + yield conf_factory @pytest.fixture @@ -43,3 +53,55 @@ def mock_setup_entry() -> Generator[AsyncMock]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=NAME, + data={CONF_HOST: HOST}, + unique_id=UUID, + ) + + +@pytest.fixture +def mock_player_ex( + mock_player_ex: AsyncMock, +) -> AsyncMock: + """Mock a update_status of the LinkPlayPlayer.""" + mock_player_ex.return_value = load_fixture("getPlayerEx.json", DOMAIN) + return mock_player_ex + + +@pytest.fixture +def mock_status_ex( + mock_status_ex: AsyncMock, +) -> AsyncMock: + """Mock a update_status of the LinkPlayDevice.""" + mock_status_ex.return_value = load_fixture("getStatusEx.json", DOMAIN) + return mock_status_ex + + +@contextmanager +def mock_lp_aiohttp_client() -> Iterator[AiohttpClientMocker]: + """Context manager to mock aiohttp client.""" + mocker = AiohttpClientMocker() + + def create_session(hass: HomeAssistant, *args: Any, **kwargs: Any) -> ClientSession: + session = mocker.create_session(hass.loop) + + async def close_session(event): + """Close session.""" + await session.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, close_session) + + return session + + with mock.patch( + "homeassistant.components.linkplay.async_get_client_session", + side_effect=create_session, + ): + yield mocker diff --git a/tests/components/linkplay/fixtures/getPlayerEx.json b/tests/components/linkplay/fixtures/getPlayerEx.json new file mode 100644 index 00000000000..79d09f942df --- /dev/null +++ b/tests/components/linkplay/fixtures/getPlayerEx.json @@ -0,0 +1,19 @@ +{ + "type": "0", + "ch": "0", + "mode": "0", + "loop": "0", + "eq": "0", + "status": "stop", + "curpos": "0", + "offset_pts": "0", + "totlen": "0", + "Title": "", + "Artist": "", + "Album": "", + "alarmflag": "0", + "plicount": "0", + "plicurr": "0", + "vol": "80", + "mute": "0" +} diff --git a/tests/components/linkplay/fixtures/getStatusEx.json b/tests/components/linkplay/fixtures/getStatusEx.json new file mode 100644 index 00000000000..17eda4aeee8 --- /dev/null +++ b/tests/components/linkplay/fixtures/getStatusEx.json @@ -0,0 +1,81 @@ +{ + "uuid": "FF31F09E5001FBDE05462DBFFF31F09E", + "DeviceName": "Smart Zone 1_54B9", + "GroupName": "Smart Zone 1_54B9", + "ssid": "Smart Zone 1_54B9", + "language": "en_us", + "firmware": "4.6.415145", + "hardware": "A31", + "build": "release", + "project": "SMART_ZONE4_AMP", + "priv_prj": "SMART_ZONE4_AMP", + "project_build_name": "a31rakoit", + "Release": "20220427", + "temp_uuid": "97296CE38DE8CC3D", + "hideSSID": "1", + "SSIDStrategy": "2", + "branch": "A31_stable_4.6", + "group": "0", + "wmrm_version": "4.2", + "internet": "1", + "MAC": "00:22:6C:21:7F:1D", + "STA_MAC": "00:00:00:00:00:00", + "CountryCode": "CN", + "CountryRegion": "1", + "netstat": "0", + "essid": "", + "apcli0": "", + "eth2": "192.168.168.197", + "ra0": "10.10.10.254", + "eth_dhcp": "1", + "VersionUpdate": "0", + "NewVer": "0", + "set_dns_enable": "1", + "mcu_ver": "37", + "mcu_ver_new": "0", + "dsp_ver": "0", + "dsp_ver_new": "0", + "date": "2024:10:29", + "time": "17:13:22", + "tz": "1.0000", + "dst_enable": "1", + "region": "unknown", + "prompt_status": "1", + "iot_ver": "1.0.0", + "upnp_version": "1005", + "cap1": "0x305200", + "capability": "0x28e90b80", + "languages": "0x6", + "streams_all": "0x7bff7ffe", + "streams": "0x7b9831fe", + "external": "0x0", + "plm_support": "0x40152", + "preset_key": "10", + "spotify_active": "0", + "lbc_support": "0", + "privacy_mode": "0", + "WifiChannel": "11", + "RSSI": "0", + "BSSID": "", + "battery": "0", + "battery_percent": "0", + "securemode": "1", + "auth": "WPAPSKWPA2PSK", + "encry": "AES", + "upnp_uuid": "uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E", + "uart_pass_port": "8899", + "communication_port": "8819", + "web_firmware_update_hide": "0", + "ignore_talkstart": "0", + "web_login_result": "-1", + "silenceOTATime": "", + "ignore_silenceOTATime": "1", + "new_tunein_preset_and_alarm": "1", + "iheartradio_new": "1", + "new_iheart_podcast": "1", + "tidal_version": "2.0", + "service_version": "1.0", + "ETH_MAC": "00:22:6C:21:7F:20", + "security": "https/2.0", + "security_version": "2.0" +} diff --git a/tests/components/linkplay/snapshots/test_diagnostics.ambr b/tests/components/linkplay/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d8c52a25649 --- /dev/null +++ b/tests/components/linkplay/snapshots/test_diagnostics.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device_info': dict({ + 'device': dict({ + 'properties': dict({ + 'BSSID': '', + 'CountryCode': 'CN', + 'CountryRegion': '1', + 'DeviceName': 'Smart Zone 1_54B9', + 'ETH_MAC': '00:22:6C:21:7F:20', + 'GroupName': 'Smart Zone 1_54B9', + 'MAC': '00:22:6C:21:7F:1D', + 'NewVer': '0', + 'RSSI': '0', + 'Release': '20220427', + 'SSIDStrategy': '2', + 'STA_MAC': '00:00:00:00:00:00', + 'VersionUpdate': '0', + 'WifiChannel': '11', + 'apcli0': '', + 'auth': 'WPAPSKWPA2PSK', + 'battery': '0', + 'battery_percent': '0', + 'branch': 'A31_stable_4.6', + 'build': 'release', + 'cap1': '0x305200', + 'capability': '0x28e90b80', + 'communication_port': '8819', + 'date': '2024:10:29', + 'dsp_ver': '0', + 'dsp_ver_new': '0', + 'dst_enable': '1', + 'encry': 'AES', + 'essid': '', + 'eth2': '192.168.168.197', + 'eth_dhcp': '1', + 'external': '0x0', + 'firmware': '4.6.415145', + 'group': '0', + 'hardware': 'A31', + 'hideSSID': '1', + 'ignore_silenceOTATime': '1', + 'ignore_talkstart': '0', + 'iheartradio_new': '1', + 'internet': '1', + 'iot_ver': '1.0.0', + 'language': 'en_us', + 'languages': '0x6', + 'lbc_support': '0', + 'mcu_ver': '37', + 'mcu_ver_new': '0', + 'netstat': '0', + 'new_iheart_podcast': '1', + 'new_tunein_preset_and_alarm': '1', + 'plm_support': '0x40152', + 'preset_key': '10', + 'priv_prj': 'SMART_ZONE4_AMP', + 'privacy_mode': '0', + 'project': 'SMART_ZONE4_AMP', + 'project_build_name': 'a31rakoit', + 'prompt_status': '1', + 'ra0': '10.10.10.254', + 'region': 'unknown', + 'securemode': '1', + 'security': 'https/2.0', + 'security_version': '2.0', + 'service_version': '1.0', + 'set_dns_enable': '1', + 'silenceOTATime': '', + 'spotify_active': '0', + 'ssid': 'Smart Zone 1_54B9', + 'streams': '0x7b9831fe', + 'streams_all': '0x7bff7ffe', + 'temp_uuid': '97296CE38DE8CC3D', + 'tidal_version': '2.0', + 'time': '17:13:22', + 'tz': '1.0000', + 'uart_pass_port': '8899', + 'upnp_uuid': 'uuid:FF31F09E-5001-FBDE-0546-2DBFFF31F09E', + 'upnp_version': '1005', + 'uuid': 'FF31F09E5001FBDE05462DBFFF31F09E', + 'web_firmware_update_hide': '0', + 'web_login_result': '-1', + 'wmrm_version': '4.2', + }), + }), + 'endpoint': dict({ + 'endpoint': 'https://10.0.0.150', + }), + 'multiroom': None, + 'player': dict({ + 'properties': dict({ + 'Album': '', + 'Artist': '', + 'Title': '', + 'alarmflag': '0', + 'ch': '0', + 'curpos': '0', + 'eq': '0', + 'loop': '0', + 'mode': '0', + 'mute': '0', + 'offset_pts': '0', + 'plicount': '0', + 'plicurr': '0', + 'status': 'stop', + 'totlen': '0', + 'type': '0', + 'vol': '80', + }), + }), + }), + }) +# --- diff --git a/tests/components/linkplay/test_diagnostics.py b/tests/components/linkplay/test_diagnostics.py new file mode 100644 index 00000000000..369142978a3 --- /dev/null +++ b/tests/components/linkplay/test_diagnostics.py @@ -0,0 +1,53 @@ +"""Tests for the LinkPlay diagnostics.""" + +from unittest.mock import patch + +from linkplay.bridge import LinkPlayMultiroom +from linkplay.consts import API_ENDPOINT +from linkplay.endpoint import LinkPlayApiEndpoint +from syrupy import SnapshotAssertion + +from homeassistant.components.linkplay.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import HOST, mock_lp_aiohttp_client + +from tests.common import MockConfigEntry, load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + with ( + mock_lp_aiohttp_client() as mock_session, + patch.object(LinkPlayMultiroom, "update_status", return_value=None), + ): + endpoints = [ + LinkPlayApiEndpoint(protocol="https", endpoint=HOST, session=None), + LinkPlayApiEndpoint(protocol="http", endpoint=HOST, session=None), + ] + for endpoint in endpoints: + mock_session.get( + API_ENDPOINT.format(str(endpoint), "getPlayerStatusEx"), + text=load_fixture("getPlayerEx.json", DOMAIN), + ) + + mock_session.get( + API_ENDPOINT.format(str(endpoint), "getStatusEx"), + text=load_fixture("getStatusEx.json", DOMAIN), + ) + + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From f7f1830b7e0a13a1de59b9f66bc29c1262bdb551 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Nov 2024 20:34:24 +0100 Subject: [PATCH 415/511] Add support for binary sensor states in Google Assistant (#127652) --- .../components/google_assistant/const.py | 11 ++ .../components/google_assistant/trait.py | 117 +++++++++++++----- .../components/google_assistant/test_trait.py | 87 +++++++++++++ 3 files changed, 182 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 04c85639e07..8132ecaae2c 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -78,6 +78,7 @@ TYPE_AWNING = f"{PREFIX_TYPES}AWNING" TYPE_BLINDS = f"{PREFIX_TYPES}BLINDS" TYPE_CAMERA = f"{PREFIX_TYPES}CAMERA" TYPE_CURTAIN = f"{PREFIX_TYPES}CURTAIN" +TYPE_CARBON_MONOXIDE_DETECTOR = f"{PREFIX_TYPES}CARBON_MONOXIDE_DETECTOR" TYPE_DEHUMIDIFIER = f"{PREFIX_TYPES}DEHUMIDIFIER" TYPE_DOOR = f"{PREFIX_TYPES}DOOR" TYPE_DOORBELL = f"{PREFIX_TYPES}DOORBELL" @@ -93,6 +94,7 @@ TYPE_SCENE = f"{PREFIX_TYPES}SCENE" TYPE_SENSOR = f"{PREFIX_TYPES}SENSOR" TYPE_SETTOP = f"{PREFIX_TYPES}SETTOP" TYPE_SHUTTER = f"{PREFIX_TYPES}SHUTTER" +TYPE_SMOKE_DETECTOR = f"{PREFIX_TYPES}SMOKE_DETECTOR" TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" @@ -136,6 +138,7 @@ EVENT_SYNC_RECEIVED = "google_assistant_sync" DOMAIN_TO_GOOGLE_TYPES = { alarm_control_panel.DOMAIN: TYPE_ALARM, + binary_sensor.DOMAIN: TYPE_SENSOR, button.DOMAIN: TYPE_SCENE, camera.DOMAIN: TYPE_CAMERA, climate.DOMAIN: TYPE_THERMOSTAT, @@ -168,6 +171,14 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, ): TYPE_GARAGE, + ( + binary_sensor.DOMAIN, + binary_sensor.BinarySensorDeviceClass.SMOKE, + ): TYPE_SMOKE_DETECTOR, + ( + binary_sensor.DOMAIN, + binary_sensor.BinarySensorDeviceClass.CO, + ): TYPE_CARBON_MONOXIDE_DETECTOR, (cover.DOMAIN, cover.CoverDeviceClass.AWNING): TYPE_AWNING, (cover.DOMAIN, cover.CoverDeviceClass.CURTAIN): TYPE_CURTAIN, (cover.DOMAIN, cover.CoverDeviceClass.DOOR): TYPE_DOOR, diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index df56885995a..f99f1574038 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -2706,6 +2706,21 @@ class SensorStateTrait(_Trait): ), } + binary_sensor_types = { + binary_sensor.BinarySensorDeviceClass.CO: ( + "CarbonMonoxideLevel", + ["carbon monoxide detected", "no carbon monoxide detected", "unknown"], + ), + binary_sensor.BinarySensorDeviceClass.SMOKE: ( + "SmokeLevel", + ["smoke detected", "no smoke detected", "unknown"], + ), + binary_sensor.BinarySensorDeviceClass.MOISTURE: ( + "WaterLeak", + ["leak", "no leak", "unknown"], + ), + } + name = TRAIT_SENSOR_STATE commands: list[str] = [] @@ -2728,24 +2743,37 @@ class SensorStateTrait(_Trait): @classmethod def supported(cls, domain, features, device_class, _): """Test if state is supported.""" - return domain == sensor.DOMAIN and device_class in cls.sensor_types + return (domain == sensor.DOMAIN and device_class in cls.sensor_types) or ( + domain == binary_sensor.DOMAIN and device_class in cls.binary_sensor_types + ) def sync_attributes(self) -> dict[str, Any]: """Return attributes for a sync request.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - data = self.sensor_types.get(device_class) - if device_class is None or data is None: - return {} + def create_sensor_state( + name: str, + raw_value_unit: str | None = None, + available_states: list[str] | None = None, + ) -> dict[str, Any]: + sensor_state: dict[str, Any] = { + "name": name, + } + if raw_value_unit: + sensor_state["numericCapabilities"] = {"rawValueUnit": raw_value_unit} + if available_states: + sensor_state["descriptiveCapabilities"] = { + "availableStates": available_states + } + return {"sensorStatesSupported": [sensor_state]} - sensor_state = { - "name": data[0], - "numericCapabilities": {"rawValueUnit": data[1]}, - } - - if device_class == sensor.SensorDeviceClass.AQI: - sensor_state["descriptiveCapabilities"] = { - "availableStates": [ + if self.state.domain == sensor.DOMAIN: + sensor_data = self.sensor_types.get(device_class) + if device_class is None or sensor_data is None: + return {} + available_states: list[str] | None = None + if device_class == sensor.SensorDeviceClass.AQI: + available_states = [ "healthy", "moderate", "unhealthy for sensitive groups", @@ -2753,30 +2781,53 @@ class SensorStateTrait(_Trait): "very unhealthy", "hazardous", "unknown", - ], - } - - return {"sensorStatesSupported": [sensor_state]} + ] + return create_sensor_state(sensor_data[0], sensor_data[1], available_states) + binary_sensor_data = self.binary_sensor_types.get(device_class) + if device_class is None or binary_sensor_data is None: + return {} + return create_sensor_state( + binary_sensor_data[0], available_states=binary_sensor_data[1] + ) def query_attributes(self) -> dict[str, Any]: """Return the attributes of this trait for this entity.""" device_class = self.state.attributes.get(ATTR_DEVICE_CLASS) - data = self.sensor_types.get(device_class) - if device_class is None or data is None: + def create_sensor_state( + name: str, raw_value: float | None = None, current_state: str | None = None + ) -> dict[str, Any]: + sensor_state: dict[str, Any] = { + "name": name, + "rawValue": raw_value, + } + if current_state: + sensor_state["currentSensorState"] = current_state + return {"currentSensorStateData": [sensor_state]} + + if self.state.domain == sensor.DOMAIN: + sensor_data = self.sensor_types.get(device_class) + if device_class is None or sensor_data is None: + return {} + try: + value = float(self.state.state) + except ValueError: + value = None + if self.state.state == STATE_UNKNOWN: + value = None + current_state: str | None = None + if device_class == sensor.SensorDeviceClass.AQI: + current_state = self._air_quality_description_for_aqi(value) + return create_sensor_state(sensor_data[0], value, current_state) + + binary_sensor_data = self.binary_sensor_types.get(device_class) + if device_class is None or binary_sensor_data is None: return {} - - try: - value = float(self.state.state) - except ValueError: - value = None - if self.state.state == STATE_UNKNOWN: - value = None - sensor_data = {"name": data[0], "rawValue": value} - - if device_class == sensor.SensorDeviceClass.AQI: - sensor_data["currentSensorState"] = self._air_quality_description_for_aqi( - value - ) - - return {"currentSensorStateData": [sensor_data]} + value = { + STATE_ON: 0, + STATE_OFF: 1, + STATE_UNKNOWN: 2, + }[self.state.state] + return create_sensor_state( + binary_sensor_data[0], current_state=binary_sensor_data[1][value] + ) diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index f5dedc357c1..1e42edf8e7b 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -4069,3 +4069,90 @@ async def test_sensorstate( ) is False ) + + +@pytest.mark.parametrize( + ("state", "identifier"), + [ + (STATE_ON, 0), + (STATE_OFF, 1), + (STATE_UNKNOWN, 2), + ], +) +@pytest.mark.parametrize( + ("device_class", "name", "states"), + [ + ( + binary_sensor.BinarySensorDeviceClass.CO, + "CarbonMonoxideLevel", + ["carbon monoxide detected", "no carbon monoxide detected", "unknown"], + ), + ( + binary_sensor.BinarySensorDeviceClass.SMOKE, + "SmokeLevel", + ["smoke detected", "no smoke detected", "unknown"], + ), + ( + binary_sensor.BinarySensorDeviceClass.MOISTURE, + "WaterLeak", + ["leak", "no leak", "unknown"], + ), + ], +) +async def test_binary_sensorstate( + hass: HomeAssistant, + state: str, + identifier: int, + device_class: binary_sensor.BinarySensorDeviceClass, + name: str, + states: list[str], +) -> None: + """Test SensorState trait support for binary sensor domain.""" + + assert helpers.get_google_type(binary_sensor.DOMAIN, None) is not None + assert trait.SensorStateTrait.supported( + binary_sensor.DOMAIN, None, device_class, None + ) + + trt = trait.SensorStateTrait( + hass, + State( + "binary_sensor.test", + state, + { + "device_class": device_class, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "sensorStatesSupported": [ + { + "name": name, + "descriptiveCapabilities": { + "availableStates": states, + }, + } + ] + } + assert trt.query_attributes() == { + "currentSensorStateData": [ + { + "name": name, + "currentSensorState": states[identifier], + "rawValue": None, + }, + ] + } + + assert helpers.get_google_type(binary_sensor.DOMAIN, None) is not None + assert ( + trait.SensorStateTrait.supported( + binary_sensor.DOMAIN, + None, + binary_sensor.BinarySensorDeviceClass.TAMPER, + None, + ) + is False + ) From c52a893e210cf36f9ae047d7bcdb15b3cc87af20 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 21:10:18 +0100 Subject: [PATCH 416/511] Remove YAML import from lcl integration after 6 months deprecation (#130305) --- homeassistant/components/lcn/__init__.py | 25 +----- homeassistant/components/lcn/config_flow.py | 54 +------------ homeassistant/components/lcn/schemas.py | 88 --------------------- tests/components/lcn/test_config_flow.py | 83 +------------------ tests/components/lcn/test_init.py | 27 ------- 5 files changed, 3 insertions(+), 274 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 5995e06efcc..27f911822b5 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -8,7 +8,7 @@ import logging import pypck from pypck.connection import PchkConnectionManager -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -21,7 +21,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, @@ -39,37 +38,15 @@ from .helpers import ( InputType, async_update_config_entry, generate_unique_id, - import_lcn_config, register_lcn_address_devices, register_lcn_host_device, ) -from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES from .websocket import register_panel_and_ws_api _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the LCN component.""" - if DOMAIN not in config: - return True - - # initialize a config_flow for all LCN configurations read from - # configuration.yaml - config_entries_data = import_lcn_config(config[DOMAIN]) - - for config_entry_data in config_entries_data: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config_entry_data, - ) - ) - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index e78378a61b1..008265e62ae 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -9,7 +9,6 @@ import pypck import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import ( CONF_BASE, CONF_DEVICES, @@ -20,14 +19,12 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN -from .helpers import purge_device_registry, purge_entity_registry _LOGGER = logging.getLogger(__name__) @@ -113,55 +110,6 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 MINOR_VERSION = 1 - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import existing configuration from LCN.""" - # validate the imported connection parameters - if error := await validate_connection(import_data): - async_create_issue( - self.hass, - DOMAIN, - error, - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.ERROR, - translation_key=error, - translation_placeholders={ - "url": "/config/integrations/dashboard/add?domain=lcn" - }, - ) - return self.async_abort(reason=error) - - async_create_issue( - self.hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "LCN", - }, - ) - - # check if we already have a host with the same address configured - if entry := get_config_entry(self.hass, import_data): - entry.source = config_entries.SOURCE_IMPORT - # Cleanup entity and device registry, if we imported from configuration.yaml to - # remove orphans when entities were removed from configuration - purge_entity_registry(self.hass, entry.entry_id, import_data) - purge_device_registry(self.hass, entry.entry_id, import_data) - - self.hass.config_entries.async_update_entry(entry, data=import_data) - return self.async_abort(reason="existing_configuration_updated") - - return self.async_create_entry( - title=f"{import_data[CONF_HOST]}", data=import_data - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> config_entries.ConfigFlowResult: diff --git a/homeassistant/components/lcn/schemas.py b/homeassistant/components/lcn/schemas.py index 3b4d2333970..c9c91b9843d 100644 --- a/homeassistant/components/lcn/schemas.py +++ b/homeassistant/components/lcn/schemas.py @@ -4,20 +4,9 @@ import voluptuous as vol from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.const import ( - CONF_ADDRESS, - CONF_BINARY_SENSORS, - CONF_COVERS, - CONF_HOST, - CONF_LIGHTS, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_SCENE, - CONF_SENSORS, CONF_SOURCE, - CONF_SWITCHES, CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, UnitOfTemperature, ) import homeassistant.helpers.config_validation as cv @@ -25,9 +14,6 @@ from homeassistant.helpers.typing import VolDictType from .const import ( BINSENSOR_PORTS, - CONF_CLIMATES, - CONF_CONNECTIONS, - CONF_DIM_MODE, CONF_DIMMABLE, CONF_LOCKABLE, CONF_MAX_TEMP, @@ -37,12 +23,8 @@ from .const import ( CONF_OUTPUTS, CONF_REGISTER, CONF_REVERSE_TIME, - CONF_SCENES, CONF_SETPOINT, - CONF_SK_NUM_TRIES, CONF_TRANSITION, - DIM_MODES, - DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, @@ -56,7 +38,6 @@ from .const import ( VAR_UNITS, VARIABLES, ) -from .helpers import has_unique_host_names, is_address ADDRESS_SCHEMA = vol.Coerce(tuple) @@ -130,72 +111,3 @@ DOMAIN_DATA_SWITCH: VolDictType = { vol.In(OUTPUT_PORTS + RELAY_PORTS + SETPOINTS + KEYS), ), } - - -# -# Configuration -# - -DOMAIN_DATA_BASE: VolDictType = { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_ADDRESS): is_address, -} - -BINARY_SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_BINARY_SENSOR}) - -CLIMATES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_CLIMATE}) - -COVERS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_COVER}) - -LIGHTS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_LIGHT}) - -SCENES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SCENE}) - -SENSORS_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SENSOR}) - -SWITCHES_SCHEMA = vol.Schema({**DOMAIN_DATA_BASE, **DOMAIN_DATA_SWITCH}) - -CONNECTION_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PORT): cv.port, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SK_NUM_TRIES, default=0): cv.positive_int, - vol.Optional(CONF_DIM_MODE, default="steps50"): vol.All( - vol.Upper, vol.In(DIM_MODES) - ), - vol.Optional(CONF_NAME): cv.string, - } -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_CONNECTIONS): vol.All( - cv.ensure_list, has_unique_host_names, [CONNECTION_SCHEMA] - ), - vol.Optional(CONF_BINARY_SENSORS): vol.All( - cv.ensure_list, [BINARY_SENSORS_SCHEMA] - ), - vol.Optional(CONF_CLIMATES): vol.All( - cv.ensure_list, [CLIMATES_SCHEMA] - ), - vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), - vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]), - vol.Optional(CONF_SCENES): vol.All(cv.ensure_list, [SCENES_SCHEMA]), - vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [SENSORS_SCHEMA] - ), - vol.Optional(CONF_SWITCHES): vol.All( - cv.ensure_list, [SWITCHES_SCHEMA] - ), - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 4ef83aeaf8a..b7967c247ec 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -23,9 +23,7 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -48,83 +46,6 @@ IMPORT_DATA = { } -async def test_step_import( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test for import step.""" - - with ( - patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), - patch("homeassistant.components.lcn.async_setup_entry", return_value=True), - ): - data = IMPORT_DATA.copy() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "pchk" - assert result["data"] == IMPORT_DATA - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - - -async def test_step_import_existing_host( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test for update of config_entry if imported host already exists.""" - - # Create config entry and add it to hass - mock_data = IMPORT_DATA.copy() - mock_data.update({CONF_SK_NUM_TRIES: 3, CONF_DIM_MODE: 50}) - mock_entry = MockConfigEntry(domain=DOMAIN, data=mock_data) - mock_entry.add_to_hass(hass) - # Initialize a config flow with different data but same host address - with patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"): - imported_data = IMPORT_DATA.copy() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_data - ) - - # Check if config entry was updated - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "existing_configuration_updated" - assert mock_entry.source == config_entries.SOURCE_IMPORT - assert mock_entry.data == IMPORT_DATA - assert issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" - ) - - -@pytest.mark.parametrize( - ("error", "reason"), - [ - (PchkAuthenticationError, "authentication_error"), - (PchkLicenseError, "license_error"), - (TimeoutError, "connection_refused"), - ], -) -async def test_step_import_error( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, error, reason -) -> None: - """Test for error in import is handled correctly.""" - with patch( - "homeassistant.components.lcn.PchkConnectionManager.async_connect", - side_effect=error, - ): - data = IMPORT_DATA.copy() - data.update({CONF_HOST: "pchk"}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == reason - assert issue_registry.async_get_issue(DOMAIN, reason) - - async def test_show_form(hass: HomeAssistant) -> None: """Test that the form is served with no input.""" flow = LcnFlowHandler() @@ -140,7 +61,6 @@ async def test_step_user(hass: HomeAssistant) -> None: """Test for user step.""" with ( patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), patch("homeassistant.components.lcn.async_setup_entry", return_value=True), ): data = CONNECTION_DATA.copy() @@ -210,7 +130,6 @@ async def test_step_reconfigure(hass: HomeAssistant, entry: MockConfigEntry) -> with ( patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"), - patch("homeassistant.components.lcn.async_setup", return_value=True), patch("homeassistant.components.lcn.async_setup_entry", return_value=True), ): result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 1bd225c5d47..2327635e356 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -16,7 +16,6 @@ from .conftest import ( MockPchkConnectionManager, create_config_entry, init_integration, - setup_component, ) @@ -83,18 +82,6 @@ async def test_async_setup_entry_update( assert dummy_entity in entity_registry.entities.values() assert dummy_device in device_registry.devices.values() - # setup new entry with same data via import step (should cleanup dummy device) - with patch( - "homeassistant.components.lcn.config_flow.validate_connection", - return_value=None, - ): - await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data - ) - - assert dummy_device not in device_registry.devices.values() - assert dummy_entity not in entity_registry.entities.values() - @pytest.mark.parametrize( "exception", [PchkAuthenticationError, PchkLicenseError, TimeoutError] @@ -114,20 +101,6 @@ async def test_async_setup_entry_raises_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_async_setup_from_configuration_yaml(hass: HomeAssistant) -> None: - """Test a successful setup using data from configuration.yaml.""" - with ( - patch( - "homeassistant.components.lcn.config_flow.validate_connection", - return_value=None, - ), - patch("homeassistant.components.lcn.async_setup_entry") as async_setup_entry, - ): - await setup_component(hass) - - assert async_setup_entry.await_count == 2 - - @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: """Test migration config entry.""" From de5437f61ec31a2803b4c551fff1531b8e80c97a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 22:12:31 +0100 Subject: [PATCH 417/511] Remove YAML warning for thethingsnetwork after warning for 6 months (#130307) --- .../components/thethingsnetwork/__init__.py | 42 +------------------ .../components/thethingsnetwork/strings.json | 6 --- .../components/thethingsnetwork/test_init.py | 16 ------- 3 files changed, 1 insertion(+), 63 deletions(-) diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index 253ce7a052e..d3c6c8356cb 100644 --- a/homeassistant/components/thethingsnetwork/__init__.py +++ b/homeassistant/components/thethingsnetwork/__init__.py @@ -2,55 +2,15 @@ import logging -import voluptuous as vol - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST +from .const import DOMAIN, PLATFORMS, TTN_API_HOST from .coordinator import TTNCoordinator _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - # Configuration via yaml not longer supported - keeping to warn about migration - DOMAIN: vol.Schema( - { - vol.Required(CONF_APP_ID): cv.string, - vol.Required("access_key"): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize of The Things Network component.""" - - if DOMAIN in config: - ir.async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2024.12.0", - is_fixable=False, - severity=ir.IssueSeverity.ERROR, - translation_key="manual_migration", - translation_placeholders={ - "domain": DOMAIN, - "v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102", - "v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710", - }, - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with The Things Network.""" diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json index 98572cb318c..f5a4fcef8fd 100644 --- a/homeassistant/components/thethingsnetwork/strings.json +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -22,11 +22,5 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "manual_migration": { - "description": "Configuring {domain} using YAML was removed as part of migrating to [The Things Network v3]({v2_v3_migration_url}). [The Things Network v2 has shutted down]({v2_deprecation_url}).\n\nPlease remove the {domain} entry from the configuration.yaml and add re-add the integration using the config_flow", - "title": "The {domain} YAML configuration is not supported" - } } } diff --git a/tests/components/thethingsnetwork/test_init.py b/tests/components/thethingsnetwork/test_init.py index 1e0b64c933d..e39c764d5f9 100644 --- a/tests/components/thethingsnetwork/test_init.py +++ b/tests/components/thethingsnetwork/test_init.py @@ -4,22 +4,6 @@ import pytest from ttn_client import TTNAuthError from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component - -from .conftest import DOMAIN - - -async def test_error_configuration( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test issue is logged when deprecated configuration is used.""" - await async_setup_component( - hass, DOMAIN, {DOMAIN: {"app_id": "123", "access_key": "42"}} - ) - await hass.async_block_till_done() - assert issue_registry.async_get_issue(DOMAIN, "manual_migration") @pytest.mark.parametrize(("exception_class"), [TTNAuthError, Exception]) From d7f41ff8a9a4a4f55f58e919020c57aea6eccd8e Mon Sep 17 00:00:00 2001 From: Max Shcherbina <17325179+maxshcherbina@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:13:38 -0500 Subject: [PATCH 418/511] Update generic thermostat strings for clarity and accuracy (#130243) --- homeassistant/components/generic_thermostat/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json index 1ddd41de734..51549dc844e 100644 --- a/homeassistant/components/generic_thermostat/strings.json +++ b/homeassistant/components/generic_thermostat/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Add generic thermostat helper", + "title": "Add generic thermostat", "description": "Create a climate entity that controls the temperature via a switch and sensor.", "data": { "ac_mode": "Cooling mode", @@ -17,8 +17,8 @@ "data_description": { "ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.", "heater": "Switch entity used to cool or heat depending on A/C mode.", - "target_sensor": "Temperature sensor that reflect the current temperature.", - "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.", + "target_sensor": "Temperature sensor that reflects the current temperature.", + "min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.", "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.", "hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5." } From e040eb0ff21e7646a793a0697552aff2a7beb975 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Nov 2024 22:26:00 +0100 Subject: [PATCH 419/511] Remove extra state attributes from some QNAP sensors (#130310) --- homeassistant/components/qnap/sensor.py | 61 ------------------------- 1 file changed, 61 deletions(-) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 526516bfcdd..383a4e5f572 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -13,7 +13,6 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( - ATTR_NAME, PERCENTAGE, EntityCategory, UnitOfDataRate, @@ -375,17 +374,6 @@ class QNAPMemorySensor(QNAPSensor): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"]["memory"] - size = round(float(data["total"]) / 1024, 2) - return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} - return None - class QNAPNetworkSensor(QNAPSensor): """A QNAP sensor that monitors network stats.""" @@ -414,22 +402,6 @@ class QNAPNetworkSensor(QNAPSensor): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"]["nics"][self.monitor_device] - return { - ATTR_IP: data["ip"], - ATTR_MASK: data["mask"], - ATTR_MAC: data["mac"], - ATTR_MAX_SPEED: data["max_speed"], - ATTR_PACKETS_ERR: data["err_packets"], - } - return None - class QNAPSystemSensor(QNAPSensor): """A QNAP sensor that monitors overall system health.""" @@ -455,25 +427,6 @@ class QNAPSystemSensor(QNAPSensor): return None - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["system_stats"] - days = int(data["uptime"]["days"]) - hours = int(data["uptime"]["hours"]) - minutes = int(data["uptime"]["minutes"]) - - return { - ATTR_NAME: data["system"]["name"], - ATTR_MODEL: data["system"]["model"], - ATTR_SERIAL: data["system"]["serial_number"], - ATTR_UPTIME: f"{days:0>2d}d {hours:0>2d}h {minutes:0>2d}m", - } - return None - class QNAPDriveSensor(QNAPSensor): """A QNAP sensor that monitors HDD/SSD drive stats.""" @@ -533,17 +486,3 @@ class QNAPVolumeSensor(QNAPSensor): return used_gb / total_gb * 100 return None - - # Deprecated since Home Assistant 2024.6.0 - # Can be removed completely in 2024.12.0 - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - if self.coordinator.data: - data = self.coordinator.data["volumes"][self.monitor_device] - total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 - - return { - ATTR_VOLUME_SIZE: f"{round(total_gb, 1)} {UnitOfInformation.GIBIBYTES}" - } - return None From 85bf8d1374343d96a76603784ef28787e333b7e8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 10 Nov 2024 23:40:23 +0100 Subject: [PATCH 420/511] Fix Homekit error handling alarm state unknown or unavailable (#130311) --- .../homekit/type_security_systems.py | 12 +++--- .../homekit/test_type_security_systems.py | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 9f3f183f11f..8634589cb5f 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -18,6 +18,8 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import State, callback @@ -152,12 +154,12 @@ class SecuritySystem(HomeAccessory): @callback def async_update_state(self, new_state: State) -> None: """Update security state after state changed.""" - hass_state = None - if new_state and new_state.state == "None": - # Bail out early for no state + hass_state: str | AlarmControlPanelState = new_state.state + if hass_state in {"None", STATE_UNKNOWN, STATE_UNAVAILABLE}: + # Bail out early for no state, unknown or unavailable return - if new_state and new_state.state is not None: - hass_state = AlarmControlPanelState(new_state.state) + if hass_state is not None: + hass_state = AlarmControlPanelState(hass_state) if ( hass_state and (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 8377d847a7a..94b0e68e76d 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -10,7 +10,12 @@ from homeassistant.components.alarm_control_panel import ( ) from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_security_systems import SecuritySystem -from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_CODE, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Event, HomeAssistant from tests.common import async_mock_service @@ -307,3 +312,33 @@ async def test_supported_states(hass: HomeAssistant, hk_driver) -> None: for val in valid_target_values.values(): assert val in test_config.get("target_values") + + +@pytest.mark.parametrize( + ("state"), + [ + (None), + ("None"), + (STATE_UNKNOWN), + (STATE_UNAVAILABLE), + ], +) +async def test_handle_non_alarm_states( + hass: HomeAssistant, hk_driver, events: list[Event], state: str +) -> None: + """Test we can handle states that should not raise.""" + code = "1234" + config = {ATTR_CODE: code} + entity_id = "alarm_control_panel.test" + + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) + acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 11 # AlarmSystem + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 3 From c3492bc0ed6d95de9fe00b4d17f2c616263f49fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 08:14:42 +0100 Subject: [PATCH 421/511] Bump github/codeql-action from 3.27.0 to 3.27.1 (#130323) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 176e010c5b9..2c80c32245c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.0 + uses: github/codeql-action/init@v3.27.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.0 + uses: github/codeql-action/analyze@v3.27.1 with: category: "/language:python" From 0dd208a4b93f409cbda7bfdf40ae93d7611ce043 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Mon, 11 Nov 2024 09:07:47 +0100 Subject: [PATCH 422/511] Add alarm count sensor for Kostal Inverters (#130324) --- homeassistant/components/kostal_plenticore/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index fbbfb03fb3e..67de34f2fce 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -747,6 +748,15 @@ SENSOR_PROCESS_DATA = [ state_class=SensorStateClass.TOTAL_INCREASING, formatter="format_energy", ), + PlenticoreSensorEntityDescription( + module_id="scb:event", + key="Event:ActiveErrorCnt", + name="Active Alarms", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:alert", + formatter="format_round", + ), PlenticoreSensorEntityDescription( module_id="_virt_", key="pv_P", From 1e26cf13d64ea50e904819a296d1a449b5169ede Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 10:59:50 +0100 Subject: [PATCH 423/511] Use runtime data for eq3btsmart (#130334) --- .../components/eq3btsmart/__init__.py | 41 ++++++++++--------- .../components/eq3btsmart/climate.py | 17 +++----- homeassistant/components/eq3btsmart/entity.py | 10 ++--- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index f63e627ea7d..bdba17dcca5 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED +from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ @@ -25,7 +25,10 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type Eq3ConfigEntry = ConfigEntry[Eq3ConfigEntryData] + + +async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: """Handle config entry setup.""" mac_address: str | None = entry.unique_id @@ -53,12 +56,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ble_device=device, ) - eq3_config_entry = Eq3ConfigEntryData(eq3_config=eq3_config, thermostat=thermostat) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = eq3_config_entry - + entry.runtime_data = Eq3ConfigEntryData( + eq3_config=eq3_config, thermostat=thermostat + ) entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_create_background_task( hass, _async_run_thermostat(hass, entry), entry.entry_id ) @@ -66,29 +68,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: """Handle config entry unload.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN].pop(entry.entry_id) - await eq3_config_entry.thermostat.async_disconnect() + await entry.runtime_data.thermostat.async_disconnect() return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: """Handle config entry update.""" await hass.config_entries.async_reload(entry.entry_id) -async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_run_thermostat(hass: HomeAssistant, entry: Eq3ConfigEntry) -> None: """Run the thermostat.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id] - thermostat = eq3_config_entry.thermostat - mac_address = eq3_config_entry.eq3_config.mac_address - scan_interval = eq3_config_entry.eq3_config.scan_interval + thermostat = entry.runtime_data.thermostat + mac_address = entry.runtime_data.eq3_config.mac_address + scan_interval = entry.runtime_data.eq3_config.scan_interval await _async_reconnect_thermostat(hass, entry) @@ -117,13 +117,14 @@ async def _async_run_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None await asyncio.sleep(scan_interval) -async def _async_reconnect_thermostat(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_reconnect_thermostat( + hass: HomeAssistant, entry: Eq3ConfigEntry +) -> None: """Reconnect the thermostat.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][entry.entry_id] - thermostat = eq3_config_entry.thermostat - mac_address = eq3_config_entry.eq3_config.mac_address - scan_interval = eq3_config_entry.eq3_config.scan_interval + thermostat = entry.runtime_data.thermostat + mac_address = entry.runtime_data.eq3_config.mac_address + scan_interval = entry.runtime_data.eq3_config.scan_interval while True: try: diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 9984c4f7229..9153d0f97cf 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -3,7 +3,6 @@ import logging from typing import Any -from eq3btsmart import Thermostat from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode from eq3btsmart.exceptions import Eq3Exception @@ -15,7 +14,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError @@ -25,9 +23,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import Eq3ConfigEntry from .const import ( DEVICE_MODEL, - DOMAIN, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, MANUFACTURER, @@ -38,22 +36,19 @@ from .const import ( TargetTemperatureSelector, ) from .entity import Eq3Entity -from .models import Eq3Config, Eq3ConfigEntryData _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: Eq3ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Handle config entry setup.""" - eq3_config_entry: Eq3ConfigEntryData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - [Eq3Climate(eq3_config_entry.eq3_config, eq3_config_entry.thermostat)], + [Eq3Climate(entry)], ) @@ -80,11 +75,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _attr_preset_mode: str | None = None _target_temperature: float | None = None - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None: + def __init__(self, entry: Eq3ConfigEntry) -> None: """Initialize the climate entity.""" - super().__init__(eq3_config, thermostat) - self._attr_unique_id = dr.format_mac(eq3_config.mac_address) + super().__init__(entry) + self._attr_unique_id = dr.format_mac(self._eq3_config.mac_address) self._attr_device_info = DeviceInfo( name=slugify(self._eq3_config.mac_address), manufacturer=MANUFACTURER, diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index e8c00d4e3cf..020913176fb 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,10 +1,8 @@ """Base class for all eQ-3 entities.""" -from eq3btsmart.thermostat import Thermostat - from homeassistant.helpers.entity import Entity -from .models import Eq3Config +from . import Eq3ConfigEntry class Eq3Entity(Entity): @@ -12,8 +10,8 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, eq3_config: Eq3Config, thermostat: Thermostat) -> None: + def __init__(self, entry: Eq3ConfigEntry) -> None: """Initialize the eq3 entity.""" - self._eq3_config = eq3_config - self._thermostat = thermostat + self._eq3_config = entry.runtime_data.eq3_config + self._thermostat = entry.runtime_data.thermostat From 5497c440d90cbfff668908947ed79202520cec84 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 11:46:11 +0100 Subject: [PATCH 424/511] Prepare eq3btsmart base entity for additional platforms (#130340) --- .../components/eq3btsmart/climate.py | 57 +--------------- homeassistant/components/eq3btsmart/const.py | 1 - homeassistant/components/eq3btsmart/entity.py | 68 ++++++++++++++++++- 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 9153d0f97cf..ae01d0fc9a7 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -18,19 +18,13 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemper from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from . import Eq3ConfigEntry from .const import ( - DEVICE_MODEL, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, - MANUFACTURER, - SIGNAL_THERMOSTAT_CONNECTED, - SIGNAL_THERMOSTAT_DISCONNECTED, CurrentTemperatureSelector, Preset, TargetTemperatureSelector, @@ -75,53 +69,6 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _attr_preset_mode: str | None = None _target_temperature: float | None = None - def __init__(self, entry: Eq3ConfigEntry) -> None: - """Initialize the climate entity.""" - - super().__init__(entry) - self._attr_unique_id = dr.format_mac(self._eq3_config.mac_address) - self._attr_device_info = DeviceInfo( - name=slugify(self._eq3_config.mac_address), - manufacturer=MANUFACTURER, - model=DEVICE_MODEL, - connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, - ) - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - - self._thermostat.register_update_callback(self._async_on_updated) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}", - self._async_on_disconnected, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}", - self._async_on_connected, - ) - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - - self._thermostat.unregister_update_callback(self._async_on_updated) - - @callback - def _async_on_disconnected(self) -> None: - self._attr_available = False - self.async_write_ha_state() - - @callback - def _async_on_connected(self) -> None: - self._attr_available = True - self.async_write_ha_state() - @callback def _async_on_updated(self) -> None: """Handle updated data from the thermostat.""" @@ -132,7 +79,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): if self._thermostat.device_data is not None: self._async_on_device_updated() - self.async_write_ha_state() + super()._async_on_updated() @callback def _async_on_status_updated(self) -> None: diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 111c4d0eba4..bb3c8b58119 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -20,7 +20,6 @@ DEVICE_MODEL = "CC-RT-BLE-EQ" GET_DEVICE_TIMEOUT = 5 # seconds - EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { OperationMode.OFF: HVACMode.OFF, OperationMode.ON: HVACMode.HEAT, diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index 020913176fb..5a229c632b2 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,8 +1,22 @@ """Base class for all eQ-3 entities.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from . import Eq3ConfigEntry +from .const import ( + DEVICE_MODEL, + MANUFACTURER, + SIGNAL_THERMOSTAT_CONNECTED, + SIGNAL_THERMOSTAT_DISCONNECTED, +) class Eq3Entity(Entity): @@ -10,8 +24,60 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, entry: Eq3ConfigEntry) -> None: + def __init__(self, entry: Eq3ConfigEntry, unique_id_key: str | None = None) -> None: """Initialize the eq3 entity.""" self._eq3_config = entry.runtime_data.eq3_config self._thermostat = entry.runtime_data.thermostat + self._attr_device_info = DeviceInfo( + name=slugify(self._eq3_config.mac_address), + manufacturer=MANUFACTURER, + model=DEVICE_MODEL, + connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, + ) + suffix = f"_{unique_id_key}" if unique_id_key else "" + self._attr_unique_id = f"{format_mac(self._eq3_config.mac_address)}{suffix}" + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + self._thermostat.register_update_callback(self._async_on_updated) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_THERMOSTAT_DISCONNECTED}_{self._eq3_config.mac_address}", + self._async_on_disconnected, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_THERMOSTAT_CONNECTED}_{self._eq3_config.mac_address}", + self._async_on_connected, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + + self._thermostat.unregister_update_callback(self._async_on_updated) + + def _async_on_updated(self) -> None: + """Handle updated data from the thermostat.""" + + self.async_write_ha_state() + + @callback + def _async_on_disconnected(self) -> None: + """Handle disconnection from the thermostat.""" + + self._attr_available = False + self.async_write_ha_state() + + @callback + def _async_on_connected(self) -> None: + """Handle connection to the thermostat.""" + + self._attr_available = True + self.async_write_ha_state() From 88480d154a9a53b7227a67bca2aa5875085548b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 12:10:49 +0100 Subject: [PATCH 425/511] Fix typo in BaseBackupManager.async_restore_backup (#130329) --- homeassistant/components/backup/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index b3cb69861b9..8265dade3aa 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -126,7 +126,7 @@ class BaseBackupManager(abc.ABC): @abc.abstractmethod async def async_restore_backup(self, slug: str, **kwargs: Any) -> None: - """Restpre a backup.""" + """Restore a backup.""" @abc.abstractmethod async def async_create_backup(self, **kwargs: Any) -> Backup: From 7a4dac1eb1b504ca0359e0db859315c82ba3a74e Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:46:02 +0100 Subject: [PATCH 426/511] Add Spotify and Tidal to playingmode mapping (#130351) --- homeassistant/components/linkplay/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index a625412852e..ab11a47f07e 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -69,6 +69,8 @@ SOURCE_MAP: dict[PlayingMode, str] = { PlayingMode.FM: "FM Radio", PlayingMode.RCA: "RCA", PlayingMode.UDISK: "USB", + PlayingMode.SPOTIFY: "Spotify", + PlayingMode.TIDAL: "Tidal", PlayingMode.FOLLOWER: "Follower", } From 870bf388e06903d5ca06585df622efcefe421fc7 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:49:56 +0100 Subject: [PATCH 427/511] Add seek support to LinkPlay (#130349) --- homeassistant/components/linkplay/media_player.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index ab11a47f07e..c29c2978522 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -298,6 +298,11 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): except ValueError as err: raise HomeAssistantError(err) from err + @exception_wrap + async def async_media_seek(self, position: float) -> None: + """Seek to a position.""" + await self._bridge.player.seek(round(position)) + @exception_wrap async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" @@ -383,9 +388,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): ) self._attr_source = SOURCE_MAP.get(self._bridge.player.play_mode, "other") - self._attr_media_position = self._bridge.player.current_position / 1000 + self._attr_media_position = self._bridge.player.current_position_in_seconds self._attr_media_position_updated_at = utcnow() - self._attr_media_duration = self._bridge.player.total_length / 1000 + self._attr_media_duration = self._bridge.player.total_length_in_seconds self._attr_media_artist = self._bridge.player.artist self._attr_media_title = self._bridge.player.title self._attr_media_album_name = self._bridge.player.album From 5293fc73d80017f63564f6a6503c50df4406dad5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 13:21:16 +0100 Subject: [PATCH 428/511] Sort some code in cloud preferences (#130345) Sort some code in cloud prefs --- homeassistant/components/cloud/http_api.py | 8 ++-- homeassistant/components/cloud/prefs.py | 48 +++++++++++----------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 844f0e9f11d..4f2ad0ddcf7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -440,16 +440,16 @@ def validate_language_voice(value: tuple[str, str]) -> tuple[str, str]: @websocket_api.websocket_command( { vol.Required("type"): "cloud/update_prefs", - vol.Optional(PREF_ENABLE_GOOGLE): bool, - vol.Optional(PREF_ENABLE_ALEXA): bool, vol.Optional(PREF_ALEXA_REPORT_STATE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool, + vol.Optional(PREF_ENABLE_GOOGLE): bool, vol.Optional(PREF_GOOGLE_REPORT_STATE): bool, vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str), + vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, vol.Optional(PREF_TTS_DEFAULT_VOICE): vol.All( vol.Coerce(tuple), validate_language_voice ), - vol.Optional(PREF_REMOTE_ALLOW_REMOTE_ENABLE): bool, - vol.Optional(PREF_ENABLE_CLOUD_ICE_SERVERS): bool, } ) @websocket_api.async_response diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index a0811393097..ae4b2794e1b 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -163,21 +163,21 @@ class CloudPreferences: async def async_update( self, *, - google_enabled: bool | UndefinedType = UNDEFINED, alexa_enabled: bool | UndefinedType = UNDEFINED, - remote_enabled: bool | UndefinedType = UNDEFINED, - google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, - cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, - cloud_user: str | UndefinedType = UNDEFINED, alexa_report_state: bool | UndefinedType = UNDEFINED, - google_report_state: bool | UndefinedType = UNDEFINED, - tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, - remote_domain: str | None | UndefinedType = UNDEFINED, alexa_settings_version: int | UndefinedType = UNDEFINED, - google_settings_version: int | UndefinedType = UNDEFINED, - google_connected: bool | UndefinedType = UNDEFINED, - remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, cloud_ice_servers_enabled: bool | UndefinedType = UNDEFINED, + cloud_user: str | UndefinedType = UNDEFINED, + cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, + google_connected: bool | UndefinedType = UNDEFINED, + google_enabled: bool | UndefinedType = UNDEFINED, + google_report_state: bool | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + google_settings_version: int | UndefinedType = UNDEFINED, + remote_allow_remote_enable: bool | UndefinedType = UNDEFINED, + remote_domain: str | None | UndefinedType = UNDEFINED, + remote_enabled: bool | UndefinedType = UNDEFINED, + tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -186,21 +186,21 @@ class CloudPreferences: { key: value for key, value in ( - (PREF_ENABLE_GOOGLE, google_enabled), - (PREF_ENABLE_ALEXA, alexa_enabled), - (PREF_ENABLE_REMOTE, remote_enabled), - (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), - (PREF_CLOUDHOOKS, cloudhooks), - (PREF_CLOUD_USER, cloud_user), (PREF_ALEXA_REPORT_STATE, alexa_report_state), - (PREF_GOOGLE_REPORT_STATE, google_report_state), (PREF_ALEXA_SETTINGS_VERSION, alexa_settings_version), - (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), - (PREF_TTS_DEFAULT_VOICE, tts_default_voice), - (PREF_REMOTE_DOMAIN, remote_domain), - (PREF_GOOGLE_CONNECTED, google_connected), - (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), + (PREF_CLOUD_USER, cloud_user), + (PREF_CLOUDHOOKS, cloudhooks), + (PREF_ENABLE_ALEXA, alexa_enabled), (PREF_ENABLE_CLOUD_ICE_SERVERS, cloud_ice_servers_enabled), + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_REMOTE, remote_enabled), + (PREF_GOOGLE_CONNECTED, google_connected), + (PREF_GOOGLE_REPORT_STATE, google_report_state), + (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin), + (PREF_GOOGLE_SETTINGS_VERSION, google_settings_version), + (PREF_REMOTE_ALLOW_REMOTE_ENABLE, remote_allow_remote_enable), + (PREF_REMOTE_DOMAIN, remote_domain), + (PREF_TTS_DEFAULT_VOICE, tts_default_voice), ) if value is not UNDEFINED } @@ -242,6 +242,7 @@ class CloudPreferences: PREF_ALEXA_REPORT_STATE: self.alexa_report_state, PREF_CLOUDHOOKS: self.cloudhooks, PREF_ENABLE_ALEXA: self.alexa_enabled, + PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled, PREF_ENABLE_GOOGLE: self.google_enabled, PREF_ENABLE_REMOTE: self.remote_enabled, PREF_GOOGLE_DEFAULT_EXPOSE: self.google_default_expose, @@ -249,7 +250,6 @@ class CloudPreferences: PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin, PREF_REMOTE_ALLOW_REMOTE_ENABLE: self.remote_allow_remote_enable, PREF_TTS_DEFAULT_VOICE: self.tts_default_voice, - PREF_ENABLE_CLOUD_ICE_SERVERS: self.cloud_ice_servers_enabled, } @property From 829632b0aff80357d52e20b31efa1d54a535fa7f Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Mon, 11 Nov 2024 14:27:52 +0100 Subject: [PATCH 429/511] Add binary sensor platform to eq3btsmart (#130352) --- .../components/eq3btsmart/__init__.py | 1 + .../components/eq3btsmart/binary_sensor.py | 86 +++++++++++++++++++ homeassistant/components/eq3btsmart/const.py | 4 + homeassistant/components/eq3btsmart/entity.py | 12 ++- .../components/eq3btsmart/strings.json | 7 ++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/eq3btsmart/binary_sensor.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index bdba17dcca5..78296c70cef 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -19,6 +19,7 @@ from .const import SIGNAL_THERMOSTAT_CONNECTED, SIGNAL_THERMOSTAT_DISCONNECTED from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ + Platform.BINARY_SENSOR, Platform.CLIMATE, ] diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py new file mode 100644 index 00000000000..27525d47972 --- /dev/null +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -0,0 +1,86 @@ +"""Platform for eq3 binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from eq3btsmart.models import Status + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3BinarySensorEntityDescription(BinarySensorEntityDescription): + """Entity description for eq3 binary sensors.""" + + value_func: Callable[[Status], bool] + + +BINARY_SENSOR_ENTITY_DESCRIPTIONS = [ + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_low_battery, + key=ENTITY_KEY_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_window_open, + key=ENTITY_KEY_WINDOW, + device_class=BinarySensorDeviceClass.WINDOW, + ), + Eq3BinarySensorEntityDescription( + value_func=lambda status: status.is_dst, + key=ENTITY_KEY_DST, + translation_key=ENTITY_KEY_DST, + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3BinarySensorEntity(entry, entity_description) + for entity_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS + ) + + +class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity): + """Base class for eQ-3 binary sensor entities.""" + + entity_description: Eq3BinarySensorEntityDescription + + def __init__( + self, + entry: Eq3ConfigEntry, + entity_description: Eq3BinarySensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + + return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index bb3c8b58119..33d8e6b3cee 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -18,6 +18,10 @@ DOMAIN = "eq3btsmart" MANUFACTURER = "eQ-3 AG" DEVICE_MODEL = "CC-RT-BLE-EQ" +ENTITY_KEY_DST = "dst" +ENTITY_KEY_BATTERY = "battery" +ENTITY_KEY_WINDOW = "window" + GET_DEVICE_TIMEOUT = 5 # seconds EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index 5a229c632b2..e68545c08c7 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -24,7 +24,11 @@ class Eq3Entity(Entity): _attr_has_entity_name = True - def __init__(self, entry: Eq3ConfigEntry, unique_id_key: str | None = None) -> None: + def __init__( + self, + entry: Eq3ConfigEntry, + unique_id_key: str | None = None, + ) -> None: """Initialize the eq3 entity.""" self._eq3_config = entry.runtime_data.eq3_config @@ -81,3 +85,9 @@ class Eq3Entity(Entity): self._attr_available = True self.async_write_ha_state() + + @property + def available(self) -> bool: + """Whether the entity is available.""" + + return self._thermostat.status is not None and self._attr_available diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index 5108baa1bcf..c911be099d5 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -18,5 +18,12 @@ "error": { "invalid_mac_address": "Invalid MAC address" } + }, + "entity": { + "binary_sensor": { + "dst": { + "name": "Daylight saving time" + } + } } } From 41c6eeedca66a2bdb98257746db5b6e94f0a5588 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Nov 2024 15:41:18 +0100 Subject: [PATCH 430/511] Bump deebot-client to 8.4.1 (#130357) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 33977b3b0de..0ab9f9a4612 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==8.4.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cf0190a6aa..ff2e42fe779 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -735,7 +735,7 @@ debugpy==1.8.6 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==8.4.0 +deebot-client==8.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9332c74adc3..7e0be99a682 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -625,7 +625,7 @@ dbus-fast==2.24.3 debugpy==1.8.6 # homeassistant.components.ecovacs -deebot-client==8.4.0 +deebot-client==8.4.1 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 388c5807ea3339d51aea5aac01bd325f4c2ead67 Mon Sep 17 00:00:00 2001 From: Erik Elkins Date: Mon, 11 Nov 2024 09:10:52 -0600 Subject: [PATCH 431/511] Add Switchbot Hub 2, Switchbot Meter Pro and Switchbot Meter Pro (CO2) devices to Switchbot Cloud integration. (#130295) --- .../components/switchbot_cloud/__init__.py | 3 +++ .../components/switchbot_cloud/sensor.py | 23 +++++++++++++++++-- tests/components/switchbot_cloud/test_init.py | 12 ++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index a2738ed446f..625b4698301 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -85,6 +85,9 @@ def make_device_data( "Meter", "MeterPlus", "WoIOSensor", + "Hub 2", + "MeterPro", + "MeterPro(CO2)", ]: devices_data.sensors.append( prepare_device(hass, api, device, coordinators_by_id) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index ac612aea119..90135ad96b3 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,6 +25,7 @@ from .entity import SwitchBotCloudEntity SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_HUMIDITY = "humidity" SENSOR_TYPE_BATTERY = "battery" +SENSOR_TYPE_CO2 = "CO2" METER_PLUS_SENSOR_DESCRIPTIONS = ( SensorEntityDescription( @@ -43,6 +48,16 @@ METER_PLUS_SENSOR_DESCRIPTIONS = ( ), ) +METER_PRO_CO2_SENSOR_DESCRIPTIONS = ( + *METER_PLUS_SENSOR_DESCRIPTIONS, + SensorEntityDescription( + key=SENSOR_TYPE_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CO2, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -55,7 +70,11 @@ async def async_setup_entry( async_add_entities( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors - for description in METER_PLUS_SENSOR_DESCRIPTIONS + for description in ( + METER_PRO_CO2_SENSOR_DESCRIPTIONS + if device.device_type == "MeterPro(CO2)" + else METER_PLUS_SENSOR_DESCRIPTIONS + ) ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 25ea370efe5..43431ae04c0 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -50,6 +50,18 @@ async def test_setup_entry_success( remoteType="DIY Plug", hubDeviceId="test-hub-id", ), + Remote( + deviceId="meter-pro-1", + deviceName="meter-pro-name-1", + deviceType="MeterPro(CO2)", + hubDeviceId="test-hub-id", + ), + Remote( + deviceId="hub2-1", + deviceName="hub2-name-1", + deviceType="Hub 2", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass) From c96f1c87a627efec413a8d140f373bcd8153df8a Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:30:27 +0100 Subject: [PATCH 432/511] Bump python-linkplay to 0.0.20 (#130348) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 9ddb6abf093..e74d22b8207 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.0.18"], + "requirements": ["python-linkplay==0.0.20"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index ff2e42fe779..4582dc3f50d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2362,7 +2362,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.18 +python-linkplay==0.0.20 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e0be99a682..4495e8a2c21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1889,7 +1889,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.7 # homeassistant.components.linkplay -python-linkplay==0.0.18 +python-linkplay==0.0.20 # homeassistant.components.matter python-matter-server==6.6.0 From e797149a168e81ae8af18bb1ebb3da7f60de7afb Mon Sep 17 00:00:00 2001 From: Olivier Corradi <1655848+corradio@users.noreply.github.com> Date: Mon, 11 Nov 2024 17:34:29 +0100 Subject: [PATCH 433/511] Rename "CO2 Signal" display name to Electricity Maps for consistency (#130242) * Update strings.json for Electricity Maps * Update strings.json * Update config_flow.py * Update test_config_flow.py * Fix test --- homeassistant/components/co2signal/config_flow.py | 2 +- tests/components/co2signal/test_config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 622c09f0d38..0d357cce199 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -168,7 +168,7 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_create_entry( - title=get_extra_name(data) or "CO2 Signal", + title=get_extra_name(data) or "Electricity Maps", data=data, ) diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 92d9450b670..f8f94d44126 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -44,7 +44,7 @@ async def test_form_home(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "CO2 Signal" + assert result2["title"] == "Electricity Maps" assert result2["data"] == { "api_key": "api_key", } @@ -185,7 +185,7 @@ async def test_form_error_handling( await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "CO2 Signal" + assert result["title"] == "Electricity Maps" assert result["data"] == { "api_key": "api_key", } From e56dec2c8efd8786e6e9fc1ab19670602174c8e0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Nov 2024 17:35:54 +0100 Subject: [PATCH 434/511] Bump spotifyaio to 0.8.8 (#130372) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index afe352904ce..8f8f7e0d588 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -9,6 +9,6 @@ "iot_class": "cloud_polling", "loggers": ["spotipy"], "quality_scale": "silver", - "requirements": ["spotifyaio==0.8.7"], + "requirements": ["spotifyaio==0.8.8"], "zeroconf": ["_spotify-connect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4582dc3f50d..fe737af17e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2713,7 +2713,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.7 +spotifyaio==0.8.8 # homeassistant.components.sql sqlparse==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4495e8a2c21..ae4d027dc8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2165,7 +2165,7 @@ speak2mary==1.4.0 speedtest-cli==2.1.3 # homeassistant.components.spotify -spotifyaio==0.8.7 +spotifyaio==0.8.8 # homeassistant.components.sql sqlparse==0.5.0 From 0cc50bc7bc267407bb9ab5296365391d56739b54 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Nov 2024 18:09:06 +0100 Subject: [PATCH 435/511] Fix copy-paste error in STATISTIC_UNIT_TO_UNIT_CONVERTER (#130375) --- homeassistant/components/recorder/statistics.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 9a66c4542b5..e5fbfe0e8c5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -134,7 +134,6 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { for unit in BloodGlugoseConcentrationConverter.VALID_UNITS }, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, - **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS}, **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS}, From b19c44b4a54ac6b29cf4d7f8c3b416ca9451e289 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Nov 2024 19:01:47 +0100 Subject: [PATCH 436/511] Update pydantic to 1.10.19 (#130373) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3b3c50b3326..285de399e5d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -127,7 +127,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.18 +pydantic==1.10.19 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index 241fff89ac3..166fd965e2c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ license-expression==30.4.0 mock-open==1.4.0 mypy-dev==1.14.0a2 pre-commit==4.0.0 -pydantic==1.10.18 +pydantic==1.10.19 pylint==3.3.1 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 37d0ea1d105..c5611069bf5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -160,7 +160,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.18 +pydantic==1.10.19 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 3f34ddd74fc0e4a50382cad2b840f6e1cb854cb0 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 11 Nov 2024 20:07:12 +0100 Subject: [PATCH 437/511] Bump lcn-frontend to 0.2.2 (#130383) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 6ce41a2d08d..695a35df871 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.1"] + "requirements": ["pypck==0.7.24", "lcn-frontend==0.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index fe737af17e7..526fa853ffc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1268,7 +1268,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.1 +lcn-frontend==0.2.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae4d027dc8f..c19e6bb241d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1064,7 +1064,7 @@ lacrosse-view==1.0.3 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.1 +lcn-frontend==0.2.2 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 From f3708549f018c1a99c0f482d676b1e4b72603aaa Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 11 Nov 2024 20:08:38 +0100 Subject: [PATCH 438/511] Code cleanup for LCN integration (#130385) --- homeassistant/components/lcn/helpers.py | 136 ---------------------- homeassistant/components/lcn/strings.json | 12 -- 2 files changed, 148 deletions(-) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 7da047682ac..6a9c63ea212 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -9,7 +9,6 @@ import re from typing import cast import pypck -import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,17 +18,12 @@ from homeassistant.const import ( CONF_DEVICES, CONF_DOMAIN, CONF_ENTITIES, - CONF_HOST, - CONF_IP_ADDRESS, CONF_LIGHTS, CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_RESOURCE, CONF_SENSORS, CONF_SOURCE, CONF_SWITCHES, - CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -37,19 +31,13 @@ from homeassistant.helpers.typing import ConfigType from .const import ( BINSENSOR_PORTS, - CONF_ACKNOWLEDGE, CONF_CLIMATES, - CONF_CONNECTIONS, - CONF_DIM_MODE, - CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_OUTPUT, CONF_SCENES, - CONF_SK_NUM_TRIES, CONF_SOFTWARE_SERIAL, CONNECTION, - DEFAULT_NAME, DOMAIN, LED_PORTS, LOGICOP_PORTS, @@ -146,110 +134,6 @@ def generate_unique_id( return unique_id -def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: - """Convert lcn settings from configuration.yaml to config_entries data. - - Create a list of config_entry data structures like: - - "data": { - "host": "pchk", - "ip_address": "192.168.2.41", - "port": 4114, - "username": "lcn", - "password": "lcn, - "sk_num_tries: 0, - "dim_mode: "STEPS200", - "acknowledge": False, - "devices": [ - { - "address": (0, 7, False) - "name": "", - "hardware_serial": -1, - "software_serial": -1, - "hardware_type": -1 - }, ... - ], - "entities": [ - { - "address": (0, 7, False) - "name": "Light_Output1", - "resource": "output1", - "domain": "light", - "domain_data": { - "output": "OUTPUT1", - "dimmable": True, - "transition": 5000.0 - } - }, ... - ] - } - """ - data = {} - for connection in lcn_config[CONF_CONNECTIONS]: - host = { - CONF_HOST: connection[CONF_NAME], - CONF_IP_ADDRESS: connection[CONF_HOST], - CONF_PORT: connection[CONF_PORT], - CONF_USERNAME: connection[CONF_USERNAME], - CONF_PASSWORD: connection[CONF_PASSWORD], - CONF_SK_NUM_TRIES: connection[CONF_SK_NUM_TRIES], - CONF_DIM_MODE: connection[CONF_DIM_MODE], - CONF_ACKNOWLEDGE: False, - CONF_DEVICES: [], - CONF_ENTITIES: [], - } - data[connection[CONF_NAME]] = host - - for confkey, domain_config in lcn_config.items(): - if confkey == CONF_CONNECTIONS: - continue - domain = DOMAIN_LOOKUP[confkey] - # loop over entities in configuration.yaml - for domain_data in domain_config: - # remove name and address from domain_data - entity_name = domain_data.pop(CONF_NAME) - address, host_name = domain_data.pop(CONF_ADDRESS) - - if host_name is None: - host_name = DEFAULT_NAME - - # check if we have a new device config - for device_config in data[host_name][CONF_DEVICES]: - if address == device_config[CONF_ADDRESS]: - break - else: # create new device_config - device_config = { - CONF_ADDRESS: address, - CONF_NAME: "", - CONF_HARDWARE_SERIAL: -1, - CONF_SOFTWARE_SERIAL: -1, - CONF_HARDWARE_TYPE: -1, - } - - data[host_name][CONF_DEVICES].append(device_config) - - # insert entity config - resource = get_resource(domain, domain_data).lower() - for entity_config in data[host_name][CONF_ENTITIES]: - if ( - address == entity_config[CONF_ADDRESS] - and resource == entity_config[CONF_RESOURCE] - and domain == entity_config[CONF_DOMAIN] - ): - break - else: # create new entity_config - entity_config = { - CONF_ADDRESS: address, - CONF_NAME: entity_name, - CONF_RESOURCE: resource, - CONF_DOMAIN: domain, - CONF_DOMAIN_DATA: domain_data.copy(), - } - data[host_name][CONF_ENTITIES].append(entity_config) - - return list(data.values()) - - def purge_entity_registry( hass: HomeAssistant, entry_id: str, imported_entry_data: ConfigType ) -> None: @@ -436,26 +320,6 @@ def get_device_config( return None -def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: - """Validate that all connection names are unique. - - Use 'pchk' as default connection_name (or add a numeric suffix if - pchk' is already in use. - """ - suffix = 0 - for host in hosts: - if host.get(CONF_NAME) is None: - if suffix == 0: - host[CONF_NAME] = DEFAULT_NAME - else: - host[CONF_NAME] = f"{DEFAULT_NAME}{suffix:d}" - suffix += 1 - - schema = vol.Schema(vol.Unique()) - schema([host.get(CONF_NAME) for host in hosts]) - return hosts - - def is_address(value: str) -> tuple[AddressType, str]: """Validate the given address string. diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index ae0b1b01f9a..088a3654500 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -63,18 +63,6 @@ } }, "issues": { - "authentication_error": { - "title": "Authentication failed.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure username and password are correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "license_error": { - "title": "Maximum number of connections was reached.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure sufficient PCHK licenses are registered and restart Home Assistant.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "connection_refused": { - "title": "Unable to connect to PCHK.", - "description": "Configuring LCN using YAML is being removed but there was an error importing your YAML configuration.\n\nEnsure the connection (IP and port) to the LCN bus coupler is correct.\n\nConsider removing the LCN YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, "deprecated_regulatorlock_sensor": { "title": "Deprecated LCN regulator lock binary sensor", "description": "Your LCN regulator lock binary sensor entity `{entity}` is beeing used in automations or scripts. A regulator lock switch entity is available and should be used going forward.\n\nPlease adjust your automations or scripts to fix this issue." From 906bdda6fac574c2dd7959628afb019afa4f3bd4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:09:26 +0100 Subject: [PATCH 439/511] Use report_usage in integrations (#130366) --- homeassistant/components/media_source/__init__.py | 4 ++-- homeassistant/components/recorder/pool.py | 6 +++--- homeassistant/components/zeroconf/usage.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 604f9b7cc88..3ea8f581245 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,7 +18,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import report_usage from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) @@ -156,7 +156,7 @@ async def async_resolve_media( raise Unresolvable("Media Source not loaded") if target_media_player is UNDEFINED: - report( + report_usage( "calls media_source.async_resolve_media without passing an entity_id", exclude_integrations={DOMAIN}, ) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 30f8fa8d07a..fc2a8ccb1cc 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -16,7 +16,7 @@ from sqlalchemy.pool import ( StaticPool, ) -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import ReportBehavior, report_usage from homeassistant.util.loop import raise_for_blocking_call _LOGGER = logging.getLogger(__name__) @@ -108,14 +108,14 @@ class RecorderPool(SingletonThreadPool, NullPool): # raise_for_blocking_call will raise an exception def _do_get_db_connection_protected(self) -> ConnectionPoolEntry: - report( + report_usage( ( "accesses the database without the database executor; " f"{ADVISE_MSG} " "for faster database operations" ), exclude_integrations={"recorder"}, - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) return NullPool._create_connection(self) # noqa: SLF001 diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index b9d51cd3c36..8ddfdbd592d 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -4,7 +4,7 @@ from typing import Any import zeroconf -from homeassistant.helpers.frame import report +from homeassistant.helpers.frame import ReportBehavior, report_usage from .models import HaZeroconf @@ -16,14 +16,14 @@ def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: """ def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf: - report( + report_usage( ( "attempted to create another Zeroconf instance. Please use the shared" " Zeroconf via await" " homeassistant.components.zeroconf.async_get_instance(hass)" ), exclude_integrations={"zeroconf"}, - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) return hass_zc From c89bf6a9aa6334b8bdd5b05db0fdab550cb10c18 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:12:32 +0100 Subject: [PATCH 440/511] Update pillow to 11.0.0 (#130194) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 + 15 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index fabb2c30190..7c85ca63467 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.4.0"] + "requirements": ["pydoods==1.0.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b02a8fa2520..c1fbc16d9be 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==10.4.0"] + "requirements": ["av==13.1.0", "Pillow==11.0.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 963721a0476..bb8c33ba749 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 520bd0550cc..43c151c7c23 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.25.2", "Pillow==10.4.0"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.0.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 1e70c4d3e10..f13799422df 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 14f2d093f37..3fcc895c2b9 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.4.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.0.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 2f39644d6d3..af00a1fdfed 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.4.0"] + "requirements": ["Pillow==11.0.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 875c98acb6d..7d08367cf7d 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.4.0", "simplehound==0.3"] + "requirements": ["Pillow==11.0.0", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 91ce27badd3..86fd83ad088 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.1.3", - "Pillow==10.4.0" + "Pillow==11.0.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 285de399e5d..ec2dc977989 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -44,7 +44,7 @@ mutagen==1.47.0 orjson==3.10.11 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.4.0 +Pillow==11.0.0 propcache==0.2.0 psutil-home-assistant==0.0.1 PyJWT==2.9.0 diff --git a/pyproject.toml b/pyproject.toml index 143330f5adb..4a9192d7767 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ "PyJWT==2.9.0", # PyJWT has loose dependency. We want the latest one. "cryptography==43.0.1", - "Pillow==10.4.0", + "Pillow==11.0.0", "propcache==0.2.0", "pyOpenSSL==24.2.1", "orjson==3.10.11", diff --git a/requirements.txt b/requirements.txt index aa72a7d23eb..19f8ac9ee22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ Jinja2==3.1.4 lru-dict==1.3.0 PyJWT==2.9.0 cryptography==43.0.1 -Pillow==10.4.0 +Pillow==11.0.0 propcache==0.2.0 pyOpenSSL==24.2.1 orjson==3.10.11 diff --git a/requirements_all.txt b/requirements_all.txt index 526fa853ffc..83bf653e424 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.4.0 +Pillow==11.0.0 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c19e6bb241d..db4fea6aa0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,7 +33,7 @@ Mastodon.py==1.8.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.4.0 +Pillow==11.0.0 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/script/licenses.py b/script/licenses.py index f4d534365bc..464a2fc456b 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -84,6 +84,7 @@ OSI_APPROVED_LICENSES_SPDX = { "LGPL-3.0-only", "LGPL-3.0-or-later", "MIT", + "MIT-CMU", "MPL-1.1", "MPL-2.0", "PSF-2.0", From c54369fe93d28eebd25000ba6b22180c5cbc9fcb Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 11 Nov 2024 20:13:20 +0100 Subject: [PATCH 441/511] Add go2rtc to devcontainer (#130380) --- Dockerfile.dev | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile.dev b/Dockerfile.dev index d05c6df425c..48f582a1581 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -35,6 +35,9 @@ RUN \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* +# Add go2rtc binary +COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc + # Install uv RUN pip3 install uv From ebe62501d660c6fcfa8c96ae9076ad2c68cbff23 Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 11 Nov 2024 20:14:12 +0100 Subject: [PATCH 442/511] Bump Weheat wh-python to 2024.11.02 (#130337) --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index d32e0ce4047..ef89a2f1acb 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.09.23"] + "requirements": ["weheat==2024.11.02"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83bf653e424..608b025f5eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.23 +weheat==2024.11.02 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index db4fea6aa0e..631cc0b0343 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2382,7 +2382,7 @@ webio-api==0.1.8 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.23 +weheat==2024.11.02 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From 313309a7e04f98f4e39006a839006d2eb2338a7f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 20:24:51 +0100 Subject: [PATCH 443/511] Remove deprecated YAML loaders (#130364) --- homeassistant/util/yaml/loader.py | 63 ------------------------------- tests/util/yaml/test_init.py | 25 ------------ 2 files changed, 88 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 39ac17d94f9..39d38a8f47d 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -25,7 +25,6 @@ except ImportError: from propcache import cached_property from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.frame import report from .const import SECRET_YAML from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass @@ -144,37 +143,6 @@ class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin): self.secrets = secrets -class SafeLoader(FastSafeLoader): - """Provided for backwards compatibility. Logs when instantiated.""" - - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.__init__(*args, **kwargs) - - @classmethod - def add_constructor(cls, tag: str, constructor: Callable) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.add_constructor(tag, constructor) - - @classmethod - def add_multi_constructor( - cls, tag_prefix: str, multi_constructor: Callable - ) -> None: - """Log a warning and call super.""" - SafeLoader.__report_deprecated() - FastSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) - - @staticmethod - def __report_deprecated() -> None: - """Log deprecation warning.""" - report( - "uses deprecated 'SafeLoader' instead of 'FastSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - - class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): """Python safe loader.""" @@ -184,37 +152,6 @@ class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin): self.secrets = secrets -class SafeLineLoader(PythonSafeLoader): - """Provided for backwards compatibility. Logs when instantiated.""" - - def __init__(*args: Any, **kwargs: Any) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.__init__(*args, **kwargs) - - @classmethod - def add_constructor(cls, tag: str, constructor: Callable) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.add_constructor(tag, constructor) - - @classmethod - def add_multi_constructor( - cls, tag_prefix: str, multi_constructor: Callable - ) -> None: - """Log a warning and call super.""" - SafeLineLoader.__report_deprecated() - PythonSafeLoader.add_multi_constructor(tag_prefix, multi_constructor) - - @staticmethod - def __report_deprecated() -> None: - """Log deprecation warning.""" - report( - "uses deprecated 'SafeLineLoader' instead of 'PythonSafeLoader', " - "which will stop working in HA Core 2024.6," - ) - - type LoaderType = FastSafeLoader | PythonSafeLoader diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 8db3f49ab8e..12a7eca5f9d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -494,31 +494,6 @@ def mock_integration_frame() -> Generator[Mock]: yield correct_frame -@pytest.mark.parametrize( - ("loader_class", "message"), - [ - (yaml.loader.SafeLoader, "'SafeLoader' instead of 'FastSafeLoader'"), - ( - yaml.loader.SafeLineLoader, - "'SafeLineLoader' instead of 'PythonSafeLoader'", - ), - ], -) -@pytest.mark.usefixtures("mock_integration_frame") -async def test_deprecated_loaders( - caplog: pytest.LogCaptureFixture, - loader_class: type, - message: str, -) -> None: - """Test instantiating the deprecated yaml loaders logs a warning.""" - with ( - pytest.raises(TypeError), - patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()), - ): - loader_class() - assert (f"Detected that integration 'hue' uses deprecated {message}") in caplog.text - - @pytest.mark.usefixtures("try_both_loaders") def test_string_annotated() -> None: """Test strings are annotated with file + line.""" From e97a5f927c552855bd5f145c3382c469eecd487b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:26:45 -0500 Subject: [PATCH 444/511] Bump aiorussound to 4.1.0 (#130382) --- .../components/russound_rio/const.py | 2 +- .../components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_player.py | 28 +++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../russound_rio/test_media_player.py | 24 ++++++++-------- 6 files changed, 29 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 1b38dc8ce5c..af52e89d399 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -17,7 +17,7 @@ RUSSOUND_RIO_EXCEPTIONS = ( ) -CONNECT_TIMEOUT = 5 +CONNECT_TIMEOUT = 15 MP_FEATURES_BY_FLAG = { FeatureFlag.COMMANDS_ZONE_MUTE_OFF_ON: MediaPlayerEntityFeature.VOLUME_MUTE diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 96fc0fb53db..ab77ca3ab6a 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.0.5"] + "requirements": ["aiorussound==4.1.0"] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 561f3b008c7..45818d3e25b 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from aiorussound import Controller -from aiorussound.models import Source +from aiorussound.models import PlayStatus, Source from aiorussound.rio import ZoneControlSurface from homeassistant.components.media_player import ( @@ -132,20 +132,18 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState | None: """Return the state of the device.""" status = self._zone.status - mode = self._source.mode - if status == "ON": - if mode == "playing": - return MediaPlayerState.PLAYING - if mode == "paused": - return MediaPlayerState.PAUSED - if mode == "transitioning": - return MediaPlayerState.BUFFERING - if mode == "stopped": - return MediaPlayerState.IDLE - return MediaPlayerState.ON - if status == "OFF": + play_status = self._source.play_status + if not status: return MediaPlayerState.OFF - return None + if play_status == PlayStatus.PLAYING: + return MediaPlayerState.PLAYING + if play_status == PlayStatus.PAUSED: + return MediaPlayerState.PAUSED + if play_status == PlayStatus.TRANSITIONING: + return MediaPlayerState.BUFFERING + if play_status == PlayStatus.STOPPED: + return MediaPlayerState.IDLE + return MediaPlayerState.ON @property def source(self): @@ -184,7 +182,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): Value is returned based on a range (0..50). Therefore float divide by 50 to get to the required range. """ - return float(self._zone.volume or "0") / 50.0 + return self._zone.volume / 50.0 @command async def async_turn_off(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 608b025f5eb..b46c6dbfef4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -357,7 +357,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==4.0.5 +aiorussound==4.1.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 631cc0b0343..c4ae704eca6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==4.0.5 +aiorussound==4.1.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 38ef603c21d..e720e2c7f65 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiorussound.models import CallbackType +from aiorussound.models import CallbackType, PlayStatus import pytest from homeassistant.const import ( @@ -28,29 +28,29 @@ async def mock_state_update(client: AsyncMock) -> None: @pytest.mark.parametrize( - ("zone_status", "source_mode", "media_player_state"), + ("zone_status", "source_play_status", "media_player_state"), [ - ("ON", None, STATE_ON), - ("ON", "playing", STATE_PLAYING), - ("ON", "paused", STATE_PAUSED), - ("ON", "transitioning", STATE_BUFFERING), - ("ON", "stopped", STATE_IDLE), - ("OFF", None, STATE_OFF), - ("OFF", "stopped", STATE_OFF), + (True, None, STATE_ON), + (True, PlayStatus.PLAYING, STATE_PLAYING), + (True, PlayStatus.PAUSED, STATE_PAUSED), + (True, PlayStatus.TRANSITIONING, STATE_BUFFERING), + (True, PlayStatus.STOPPED, STATE_IDLE), + (False, None, STATE_OFF), + (False, PlayStatus.STOPPED, STATE_OFF), ], ) async def test_entity_state( hass: HomeAssistant, mock_russound_client: AsyncMock, mock_config_entry: MockConfigEntry, - zone_status: str, - source_mode: str | None, + zone_status: bool, + source_play_status: PlayStatus | None, media_player_state: str, ) -> None: """Test media player state.""" await setup_integration(hass, mock_config_entry) mock_russound_client.controllers[1].zones[1].status = zone_status - mock_russound_client.sources[1].mode = source_mode + mock_russound_client.sources[1].play_status = source_play_status await mock_state_update(mock_russound_client) await hass.async_block_till_done() From 96c12fdd10e4be6d88195fa4800a1dc6f7c32a6c Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Mon, 11 Nov 2024 20:40:37 +0100 Subject: [PATCH 445/511] Update tuya-device-sharing-sdk to version 0.2.1 (#130333) --- homeassistant/components/tuya/__init__.py | 13 ++++++++++--- homeassistant/components/tuya/entity.py | 7 ++++++- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 47143f3595c..c8a639cd239 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -146,14 +146,21 @@ class DeviceListener(SharingDeviceListener): self.hass = hass self.manager = manager - def update_device(self, device: CustomerDevice) -> None: + def update_device( + self, device: CustomerDevice, updated_status_properties: list[str] | None + ) -> None: """Update device status.""" LOGGER.debug( - "Received update for device %s: %s", + "Received update for device %s: %s (updated properties: %s)", device.id, self.manager.device_map[device.id].status, + updated_status_properties, + ) + dispatcher_send( + self.hass, + f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}", + updated_status_properties, ) - dispatcher_send(self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{device.id}") def add_device(self, device: CustomerDevice) -> None: """Add device added listener.""" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 4d3710f7570..cc258560067 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -283,10 +283,15 @@ class TuyaEntity(Entity): async_dispatcher_connect( self.hass, f"{TUYA_HA_SIGNAL_UPDATE_ENTITY}_{self.device.id}", - self.async_write_ha_state, + self._handle_state_update, ) ) + async def _handle_state_update( + self, updated_status_properties: list[str] | None + ) -> None: + self.async_write_ha_state() + def _send_command(self, commands: list[dict[str, Any]]) -> None: """Send command to the device.""" LOGGER.debug("Sending commands for device %s: %s", self.device.id, commands) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 305a74160de..b53e6fa27d8 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -43,5 +43,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["tuya_iot"], - "requirements": ["tuya-device-sharing-sdk==0.1.9"] + "requirements": ["tuya-device-sharing-sdk==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b46c6dbfef4..45c7b6f46b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2873,7 +2873,7 @@ ttls==1.8.3 ttn_client==1.2.0 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.1.9 +tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu twentemilieu==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4ae704eca6..80d3d806eb7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2286,7 +2286,7 @@ ttls==1.8.3 ttn_client==1.2.0 # homeassistant.components.tuya -tuya-device-sharing-sdk==0.1.9 +tuya-device-sharing-sdk==0.2.1 # homeassistant.components.twentemilieu twentemilieu==2.0.1 From e388e9f3964ee763c73aef37a3a035daf8c4350d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Nov 2024 13:48:49 -0600 Subject: [PATCH 446/511] Fix missing title placeholders in powerwall reauth (#130389) --- homeassistant/components/powerwall/config_flow.py | 6 +++++- tests/components/powerwall/test_config_flow.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index bacbff63211..0c39392ca19 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -251,8 +251,8 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): """Handle reauth confirmation.""" errors: dict[str, str] | None = {} description_placeholders: dict[str, str] = {} + reauth_entry = self._get_reauth_entry() if user_input is not None: - reauth_entry = self._get_reauth_entry() errors, _, description_placeholders = await self._async_try_connect( {CONF_IP_ADDRESS: reauth_entry.data[CONF_IP_ADDRESS], **user_input} ) @@ -261,6 +261,10 @@ class PowerwallConfigFlow(ConfigFlow, domain=DOMAIN): reauth_entry, data_updates=user_input ) + self.context["title_placeholders"] = { + "name": reauth_entry.title, + "ip_address": reauth_entry.data[CONF_IP_ADDRESS], + } return self.async_show_form( step_id="reauth_confirm", data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py index 5074a289d19..1ff1470f81c 100644 --- a/tests/components/powerwall/test_config_flow.py +++ b/tests/components/powerwall/test_config_flow.py @@ -339,6 +339,11 @@ async def test_form_reauth(hass: HomeAssistant) -> None: result = await entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "ip_address": VALID_CONFIG[CONF_IP_ADDRESS], + "name": entry.title, + } mock_powerwall = await _mock_powerwall_site_name(hass, "My site") From f1ce7ee8cefb3f2e78808b92f04dbb327f75700b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:02:09 +0100 Subject: [PATCH 447/511] Adjust logging for OptionsFlow deprecation (#130360) --- .../silabs_multiprotocol_addon.py | 1 - homeassistant/config_entries.py | 7 ++++--- tests/test_config_entries.py | 16 ++++++++++------ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 14ae57391ef..2b08031405f 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -318,7 +318,6 @@ class OptionsFlowHandler(OptionsFlow, ABC): self.start_task: asyncio.Task | None = None self.stop_task: asyncio.Task | None = None self._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None - self.config_entry = config_entry self.original_addon_config: dict[str, Any] | None = None self.revert_reason: str | None = None diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 64eadeb0d7e..f1748c6b7fb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3158,11 +3158,12 @@ class OptionsFlow(ConfigEntryBaseFlow): @config_entry.setter def config_entry(self, value: ConfigEntry) -> None: """Set the config entry value.""" - report( + report_usage( "sets option flow config_entry explicitly, which is deprecated " "and will stop working in 2025.12", - error_if_integration=False, - error_if_core=True, + core_behavior=ReportBehavior.ERROR, + core_integration_behavior=ReportBehavior.ERROR, + custom_integration_behavior=ReportBehavior.LOG, ) self._config_entry = value diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index eb2a719eab8..41af8af3f21 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -7501,6 +7501,7 @@ async def test_options_flow_config_entry( assert result["reason"] == "abort" +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_options_flow_deprecated_config_entry_setter( @@ -7509,13 +7510,15 @@ async def test_options_flow_deprecated_config_entry_setter( caplog: pytest.LogCaptureFixture, ) -> None: """Test that setting config_entry explicitly still works.""" - original_entry = MockConfigEntry(domain="hue", data={}) + original_entry = MockConfigEntry(domain="my_integration", data={}) original_entry.add_to_hass(hass) mock_setup_entry = AsyncMock(return_value=True) - mock_integration(hass, MockModule("hue", async_setup_entry=mock_setup_entry)) - mock_platform(hass, "hue.config_flow", None) + mock_integration( + hass, MockModule("my_integration", async_setup_entry=mock_setup_entry) + ) + mock_platform(hass, "my_integration.config_flow", None) class TestFlow(config_entries.ConfigFlow): """Test flow.""" @@ -7549,15 +7552,16 @@ async def test_options_flow_deprecated_config_entry_setter( return _OptionsFlow(config_entry) - with mock_config_flow("hue", TestFlow): + with mock_config_flow("my_integration", TestFlow): result = await hass.config_entries.options.async_init(original_entry.entry_id) options_flow = hass.config_entries.options._progress.get(result["flow_id"]) assert options_flow.config_entry is original_entry assert ( - "Detected that integration 'hue' sets option flow config_entry explicitly, " - "which is deprecated and will stop working in 2025.12" in caplog.text + "Detected that custom integration 'my_integration' sets option flow " + "config_entry explicitly, which is deprecated and will stop working " + "in 2025.12" in caplog.text ) From 8b547551e27ad6962b084f25d7cc277b22f9b003 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Mon, 11 Nov 2024 21:05:41 +0100 Subject: [PATCH 448/511] Bump ruff to 0.7.3 (#130390) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f89dadda43d..519674b9894 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.7.3 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index bab89d20584..23f584dd0de 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.7.2 +ruff==0.7.3 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 745159d61d3..9bad1e8aecc 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.2 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From d1c3e1caa9a27a40025e3031d92c0408553deb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 11 Nov 2024 21:05:52 +0100 Subject: [PATCH 449/511] Bump Tibber 0.30.8 (#130388) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index d1bfefec484..bc9304ab59d 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.7"] + "requirements": ["pyTibber==0.30.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 45c7b6f46b5..67c7c991146 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1738,7 +1738,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.7 +pyTibber==0.30.8 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80d3d806eb7..048f0ac7d76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1415,7 +1415,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.7 +pyTibber==0.30.8 # homeassistant.components.dlink pyW215==0.7.0 From 3eab72b2aab4d8184e351953322f4a1c300d331e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Nov 2024 23:02:48 +0100 Subject: [PATCH 450/511] Improve exception handling in Nord Pool (#130386) * Improve exception handling in Nord Pool * Improve auth string * Remove auth --- .../components/nordpool/config_flow.py | 14 +++--- .../components/nordpool/coordinator.py | 12 ++--- tests/components/nordpool/test_config_flow.py | 45 ++----------------- tests/components/nordpool/test_coordinator.py | 38 +++++++--------- 4 files changed, 31 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index a9a834d8225..1d75d825e47 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -4,7 +4,12 @@ from __future__ import annotations from typing import Any -from pynordpool import Currency, NordPoolClient, NordPoolError +from pynordpool import ( + Currency, + NordPoolClient, + NordPoolEmptyResponseError, + NordPoolError, +) from pynordpool.const import AREAS import voluptuous as vol @@ -53,17 +58,16 @@ async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, """Test fetch data from Nord Pool.""" client = NordPoolClient(async_get_clientsession(hass)) try: - data = await client.async_get_delivery_period( + await client.async_get_delivery_period( dt_util.now(), Currency(user_input[CONF_CURRENCY]), user_input[CONF_AREAS], ) + except NordPoolEmptyResponseError: + return {"base": "no_data"} except NordPoolError: return {"base": "cannot_connect"} - if not data.raw: - return {"base": "no_data"} - return {} diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index 27016ae2b4b..fa4e9ca2548 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -9,8 +9,8 @@ from typing import TYPE_CHECKING from pynordpool import ( Currency, DeliveryPeriodData, - NordPoolAuthenticationError, NordPoolClient, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -19,7 +19,7 @@ from homeassistant.const import CONF_CURRENCY from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import CONF_AREAS, DOMAIN, LOGGER @@ -75,8 +75,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): Currency(self.config_entry.data[CONF_CURRENCY]), self.config_entry.data[CONF_AREAS], ) - except NordPoolAuthenticationError as error: - LOGGER.error("Authentication error: %s", error) + except NordPoolEmptyResponseError as error: + LOGGER.debug("Empty response error: %s", error) self.async_set_update_error(error) return except NordPoolResponseError as error: @@ -88,8 +88,4 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodData]): self.async_set_update_error(error) return - if not data.raw: - self.async_set_update_error(UpdateFailed("No data")) - return - self.async_set_updated_data(data) diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index d17db619b02..cfdfc63aca7 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -2,13 +2,12 @@ from __future__ import annotations -from dataclasses import replace from unittest.mock import patch from pynordpool import ( DeliveryPeriodData, - NordPoolAuthenticationError, NordPoolConnectionError, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -71,7 +70,7 @@ async def test_single_config_entry( ("error_message", "p_error"), [ (NordPoolConnectionError, "cannot_connect"), - (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolEmptyResponseError, "no_data"), (NordPoolError, "cannot_connect"), (NordPoolResponseError, "cannot_connect"), ], @@ -116,44 +115,6 @@ async def test_cannot_connect( assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} -@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") -async def test_empty_data(hass: HomeAssistant, get_data: DeliveryPeriodData) -> None: - """Test empty data error.""" - - 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 - - invalid_data = replace(get_data, raw={}) - - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=invalid_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["errors"] == {"base": "no_data"} - - with patch( - "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", - return_value=get_data, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input=ENTRY_CONFIG, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Nord Pool" - assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} - - @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") async def test_reconfigure( hass: HomeAssistant, @@ -193,7 +154,7 @@ async def test_reconfigure( ("error_message", "p_error"), [ (NordPoolConnectionError, "cannot_connect"), - (NordPoolAuthenticationError, "cannot_connect"), + (NordPoolEmptyResponseError, "no_data"), (NordPoolError, "cannot_connect"), (NordPoolResponseError, "cannot_connect"), ], diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 9cff34adb1f..d2d912b1b99 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -9,6 +9,7 @@ from freezegun.api import FrozenDateTimeFactory from pynordpool import ( DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolEmptyResponseError, NordPoolError, NordPoolResponseError, ) @@ -18,14 +19,13 @@ from homeassistant.components.nordpool.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed -@pytest.mark.freeze_time("2024-11-05T12:00:00+00:00") +@pytest.mark.freeze_time("2024-11-05T10:00:00+00:00") async def test_coordinator( hass: HomeAssistant, get_data: DeliveryPeriodData, @@ -51,7 +51,7 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "0.94949" + assert state.state == "0.92737" mock_data.reset_mock() mock_data.side_effect = NordPoolError("error") @@ -74,6 +74,17 @@ async def test_coordinator( assert "Authentication error" in caplog.text mock_data.reset_mock() + assert "Empty response" not in caplog.text + mock_data.side_effect = NordPoolEmptyResponseError("Empty response") + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + mock_data.assert_called_once() + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "Empty response" in caplog.text + mock_data.reset_mock() + assert "Response error" not in caplog.text mock_data.side_effect = NordPoolResponseError("Response error") freezer.tick(timedelta(hours=1)) @@ -85,25 +96,6 @@ async def test_coordinator( assert "Response error" in caplog.text mock_data.reset_mock() - mock_data.return_value = DeliveryPeriodData( - raw={}, - requested_date="2024-11-05", - updated_at=dt_util.utcnow(), - entries=[], - block_prices=[], - currency="SEK", - exchange_rate=1, - area_average={}, - ) - mock_data.side_effect = None - freezer.tick(timedelta(hours=1)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - mock_data.assert_called_once() - state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == STATE_UNAVAILABLE - mock_data.reset_mock() - mock_data.return_value = get_data mock_data.side_effect = None freezer.tick(timedelta(hours=1)) @@ -111,4 +103,4 @@ async def test_coordinator( await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81983" + assert state.state == "1.81645" From 60bf0f6b06b7c9901a02f74ac8869378f3df4409 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 12 Nov 2024 16:26:28 +0900 Subject: [PATCH 451/511] Fix fan's warning TURN_ON, TURN_OFF (#130327) Co-authored-by: yunseon.park --- homeassistant/components/lg_thinq/fan.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py index 187cc74b3eb..edcadf2598a 100644 --- a/homeassistant/components/lg_thinq/fan.py +++ b/homeassistant/components/lg_thinq/fan.py @@ -72,8 +72,11 @@ class ThinQFanEntity(ThinQEntity, FanEntity): super().__init__(coordinator, entity_description, property_id) self._ordered_named_fan_speeds = [] - self._attr_supported_features |= FanEntityFeature.SET_SPEED - + self._attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + ) if (fan_modes := self.data.fan_modes) is not None: self._attr_speed_count = len(fan_modes) if self.speed_count == 4: @@ -98,7 +101,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity): self._attr_percentage = 0 _LOGGER.debug( - "[%s:%s] update status: %s -> %s (percntage=%s)", + "[%s:%s] update status: %s -> %s (percentage=%s)", self.coordinator.device_name, self.property_id, self.data.is_on, @@ -120,7 +123,7 @@ class ThinQFanEntity(ThinQEntity, FanEntity): return _LOGGER.debug( - "[%s:%s] async_set_percentage. percntage=%s, value=%s", + "[%s:%s] async_set_percentage. percentage=%s, value=%s", self.coordinator.device_name, self.property_id, percentage, From 22aed924618f2c9d63736985f57d2af2cb8468fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Nov 2024 01:29:01 -0600 Subject: [PATCH 452/511] Bump aiohttp to 3.11.0rc1 (#130320) --- 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 ec2dc977989..a40c8745877 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc0 +aiohttp==3.11.0rc1 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index 4a9192d7767..adc85c0f4f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc0", + "aiohttp==3.11.0rc1", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 19f8ac9ee22..53d6b13a4ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc0 +aiohttp==3.11.0rc1 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 7045b776b6cd47ee06548f4687b7a34ec1c1c4b8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Nov 2024 09:25:13 +0100 Subject: [PATCH 453/511] Use report_usage in helpers (#130365) --- homeassistant/helpers/config_validation.py | 12 ++++++------ homeassistant/helpers/event.py | 6 +++--- homeassistant/helpers/service.py | 6 +++--- homeassistant/helpers/template.py | 6 +++--- homeassistant/helpers/update_coordinator.py | 12 ++++-------- 5 files changed, 19 insertions(+), 23 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 81ac10f86cc..2b35ebade76 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -719,14 +719,14 @@ def template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value should be a string") if not (hass := _async_get_hass_or_none()): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "validates schema outside the event loop, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) template_value = template_helper.Template(str(value), hass) @@ -748,14 +748,14 @@ def dynamic_template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "validates schema outside the event loop, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) template_value = template_helper.Template(str(value), hass) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 02ea8103192..61a798dbd75 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -997,14 +997,14 @@ class TrackTemplateResultInfo: continue # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "calls async_track_template_result with template without hass, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) track_template_.template.hass = hass diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 33e8f3d3d6e..e3da52604cb 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1277,14 +1277,14 @@ def async_register_entity_service( schema = cv.make_entity_service_schema(schema) elif not cv.is_entity_service_schema(schema): # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage - report( + report_usage( ( "registers an entity service with a non entity service schema " "which will stop working in HA Core 2025.9" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) service_func: str | HassJob[..., Any] diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 753464c35d5..2eab666bbd4 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -515,18 +515,18 @@ class Template: will be non optional in Home Assistant Core 2025.10. """ # pylint: disable-next=import-outside-toplevel - from .frame import report + from .frame import ReportBehavior, report_usage if not isinstance(template, str): raise TypeError("Expected template to be a string") if not hass: - report( + report_usage( ( "creates a template object without passing hass, " "which will stop working in HA Core 2025.10" ), - error_if_core=False, + core_behavior=ReportBehavior.LOG, ) self.template: str = template.strip() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index f5c2a2a1288..87d55891e90 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -29,7 +29,7 @@ from homeassistant.util.dt import utcnow from . import entity, event from .debounce import Debouncer -from .frame import report +from .frame import report_usage from .typing import UNDEFINED, UndefinedType REQUEST_REFRESH_DEFAULT_COOLDOWN = 10 @@ -286,24 +286,20 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): to ensure that multiple retries do not cause log spam. """ if self.config_entry is None: - report( + report_usage( "uses `async_config_entry_first_refresh`, which is only supported " "for coordinators with a config entry and will stop working in " - "Home Assistant 2025.11", - error_if_core=True, - error_if_integration=False, + "Home Assistant 2025.11" ) elif ( self.config_entry.state is not config_entries.ConfigEntryState.SETUP_IN_PROGRESS ): - report( + report_usage( "uses `async_config_entry_first_refresh`, which is only supported " f"when entry state is {config_entries.ConfigEntryState.SETUP_IN_PROGRESS}, " f"but it is in state {self.config_entry.state}, " "This will stop working in Home Assistant 2025.11", - error_if_core=True, - error_if_integration=False, ) if await self.__wrap_async_setup(): await self._async_refresh( From 7758d8ba48e8d19674a39b10c48a58ef31f5281b Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Tue, 12 Nov 2024 11:42:25 +0100 Subject: [PATCH 454/511] Add switch platform to eq3btsmart (#130363) --- .../components/eq3btsmart/__init__.py | 1 + homeassistant/components/eq3btsmart/const.py | 3 + .../components/eq3btsmart/icons.json | 32 +++++++ .../components/eq3btsmart/strings.json | 11 +++ homeassistant/components/eq3btsmart/switch.py | 94 +++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 homeassistant/components/eq3btsmart/icons.json create mode 100644 homeassistant/components/eq3btsmart/switch.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 78296c70cef..86c555ec151 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.SWITCH, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 33d8e6b3cee..64bc1cf497c 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -21,6 +21,9 @@ DEVICE_MODEL = "CC-RT-BLE-EQ" ENTITY_KEY_DST = "dst" ENTITY_KEY_BATTERY = "battery" ENTITY_KEY_WINDOW = "window" +ENTITY_KEY_LOCK = "lock" +ENTITY_KEY_BOOST = "boost" +ENTITY_KEY_AWAY = "away" GET_DEVICE_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json new file mode 100644 index 00000000000..fb0862f14bc --- /dev/null +++ b/homeassistant/components/eq3btsmart/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "binary_sensor": { + "dst": { + "default": "mdi:sun-clock", + "state": { + "off": "mdi:sun-clock-outline" + } + } + }, + "switch": { + "away": { + "default": "mdi:home-account", + "state": { + "on": "mdi:home-export" + } + }, + "lock": { + "default": "mdi:lock", + "state": { + "off": "mdi:lock-off" + } + }, + "boost": { + "default": "mdi:fire", + "state": { + "off": "mdi:fire-off" + } + } + } + } +} diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index c911be099d5..03c3b21b964 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -24,6 +24,17 @@ "dst": { "name": "Daylight saving time" } + }, + "switch": { + "lock": { + "name": "Lock" + }, + "boost": { + "name": "Boost" + }, + "away": { + "name": "Away" + } } } } diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py new file mode 100644 index 00000000000..7525d8ca494 --- /dev/null +++ b/homeassistant/components/eq3btsmart/switch.py @@ -0,0 +1,94 @@ +"""Platform for eq3 switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from eq3btsmart import Thermostat +from eq3btsmart.models import Status + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3SwitchEntityDescription(SwitchEntityDescription): + """Entity description for eq3 switch entities.""" + + toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] + value_func: Callable[[Status], bool] + + +SWITCH_ENTITY_DESCRIPTIONS = [ + Eq3SwitchEntityDescription( + key=ENTITY_KEY_LOCK, + translation_key=ENTITY_KEY_LOCK, + toggle_func=lambda thermostat: thermostat.async_set_locked, + value_func=lambda status: status.is_locked, + ), + Eq3SwitchEntityDescription( + key=ENTITY_KEY_BOOST, + translation_key=ENTITY_KEY_BOOST, + toggle_func=lambda thermostat: thermostat.async_set_boost, + value_func=lambda status: status.is_boost, + ), + Eq3SwitchEntityDescription( + key=ENTITY_KEY_AWAY, + translation_key=ENTITY_KEY_AWAY, + toggle_func=lambda thermostat: thermostat.async_set_away, + value_func=lambda status: status.is_away, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3SwitchEntity(entry, entity_description) + for entity_description in SWITCH_ENTITY_DESCRIPTIONS + ) + + +class Eq3SwitchEntity(Eq3Entity, SwitchEntity): + """Base class for eq3 switch entities.""" + + entity_description: Eq3SwitchEntityDescription + + def __init__( + self, + entry: Eq3ConfigEntry, + entity_description: Eq3SwitchEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + + await self.entity_description.toggle_func(self._thermostat)(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + + await self.entity_description.toggle_func(self._thermostat)(False) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + + return self.entity_description.value_func(self._thermostat.status) From cb9cc0f801118ae73e2cef959fdec274cd645293 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 12 Nov 2024 11:53:14 +0100 Subject: [PATCH 455/511] Go2rtc bump and set ffmpeg logs to debug (#130371) --- Dockerfile | 2 +- homeassistant/components/go2rtc/__init__.py | 83 ++------ homeassistant/components/go2rtc/const.py | 1 - homeassistant/components/go2rtc/server.py | 8 +- script/hassfest/docker.py | 2 +- tests/components/go2rtc/test_init.py | 223 +++----------------- 6 files changed, 51 insertions(+), 268 deletions(-) diff --git a/Dockerfile b/Dockerfile index 903a121c032..15574192093 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,7 +55,7 @@ RUN \ "armv7") go2rtc_suffix='arm' ;; \ *) go2rtc_suffix=${BUILD_ARCH} ;; \ esac \ - && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.6/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ + && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.7/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \ && chmod +x /bin/go2rtc \ # Verify go2rtc can be executed && go2rtc --version diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 04b5b9f9317..fc91ef5e546 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,8 +1,5 @@ """The go2rtc component.""" -from __future__ import annotations - -from dataclasses import dataclass import logging import shutil @@ -41,13 +38,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import ( - CONF_DEBUG_UI, - DEBUG_UI_URL_MESSAGE, - DOMAIN, - HA_MANAGED_RTSP_PORT, - HA_MANAGED_URL, -) +from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL from .server import Server _LOGGER = logging.getLogger(__name__) @@ -94,22 +85,13 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_DATA_GO2RTC: HassKey[Go2RtcData] = HassKey(DOMAIN) +_DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) -@dataclass(frozen=True) -class Go2RtcData: - """Data for go2rtc.""" - - url: str - managed: bool - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up WebRTC.""" url: str | None = None - managed = False if DOMAIN not in config and DEFAULT_CONFIG_DOMAIN not in config: await _remove_go2rtc_entries(hass) return True @@ -144,9 +126,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) url = HA_MANAGED_URL - managed = True - hass.data[_DATA_GO2RTC] = Go2RtcData(url, managed) + hass.data[_DATA_GO2RTC] = url discovery_flow.async_create_flow( hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={} ) @@ -161,32 +142,28 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - data = hass.data[_DATA_GO2RTC] + url = hass.data[_DATA_GO2RTC] # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), data.url) + client = Go2RtcRestClient(async_get_clientsession(hass), url) await client.validate_server_version() except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( - f"Could not connect to go2rtc instance on {data.url}" + f"Could not connect to go2rtc instance on {url}" ) from err - _LOGGER.warning( - "Could not connect to go2rtc instance on %s (%s)", data.url, err - ) + _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False except Go2RtcVersionError as err: raise ConfigEntryNotReady( f"The go2rtc server version is not supported, {err}" ) from err except Exception as err: # noqa: BLE001 - _LOGGER.warning( - "Could not connect to go2rtc instance on %s (%s)", data.url, err - ) + _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False - provider = WebRTCProvider(hass, data) + provider = WebRTCProvider(hass, url) async_register_webrtc_provider(hass, provider) return True @@ -204,12 +181,12 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, data: Go2RtcData) -> None: + def __init__(self, hass: HomeAssistant, url: str) -> None: """Initialize the WebRTC provider.""" self._hass = hass - self._data = data + self._url = url self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, data.url) + self._rest_client = Go2RtcRestClient(self._session, url) self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -231,7 +208,7 @@ class WebRTCProvider(CameraWebRTCProvider): ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" self._sessions[session_id] = ws_client = Go2RtcWsClient( - self._session, self._data.url, source=camera.entity_id + self._session, self._url, source=camera.entity_id ) if not (stream_source := await camera.stream_source()): @@ -242,34 +219,18 @@ class WebRTCProvider(CameraWebRTCProvider): streams = await self._rest_client.streams.list() - if self._data.managed: - # HA manages the go2rtc instance - stream_original_name = f"{camera.entity_id}_original" - stream_redirect_sources = [ - f"rtsp://127.0.0.1:{HA_MANAGED_RTSP_PORT}/{stream_original_name}", - f"ffmpeg:{stream_original_name}#audio=opus", - ] - - if ( - (stream_org := streams.get(stream_original_name)) is None - or not any( - stream_source == producer.url for producer in stream_org.producers - ) - or (stream_redirect := streams.get(camera.entity_id)) is None - or stream_redirect_sources != [p.url for p in stream_redirect.producers] - ): - await self._rest_client.streams.add(stream_original_name, stream_source) - await self._rest_client.streams.add( - camera.entity_id, stream_redirect_sources - ) - - # go2rtc instance is managed outside HA - elif (stream_org := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream_org.producers + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers ): await self._rest_client.streams.add( camera.entity_id, - [stream_source, f"ffmpeg:{camera.entity_id}#audio=opus"], + [ + stream_source, + # We are setting any ffmpeg rtsp related logs to debug + # Connection problems to the camera will be logged by the first stream + # Therefore setting it to debug will not hide any important logs + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) @callback diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index 3c4dc9a9500..d33ae3e3897 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,4 +6,3 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" -HA_MANAGED_RTSP_PORT = 18554 diff --git a/homeassistant/components/go2rtc/server.py b/homeassistant/components/go2rtc/server.py index 91f4433546c..6699ee4d8a2 100644 --- a/homeassistant/components/go2rtc/server.py +++ b/homeassistant/components/go2rtc/server.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import HA_MANAGED_API_PORT, HA_MANAGED_RTSP_PORT, HA_MANAGED_URL +from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL _LOGGER = logging.getLogger(__name__) _TERMINATE_TIMEOUT = 5 @@ -33,7 +33,7 @@ api: listen: "{api_ip}:{api_port}" rtsp: - listen: "127.0.0.1:{rtsp_port}" + listen: "127.0.0.1:18554" webrtc: listen: ":18555/tcp" @@ -68,9 +68,7 @@ def _create_temp_file(api_ip: str) -> str: with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file: file.write( _GO2RTC_CONFIG_FORMAT.format( - api_ip=api_ip, - api_port=HA_MANAGED_API_PORT, - rtsp_port=HA_MANAGED_RTSP_PORT, + api_ip=api_ip, api_port=HA_MANAGED_API_PORT ).encode() ) return file.name diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 083cdaba1a9..9d38d8f7128 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -112,7 +112,7 @@ LABEL "com.github.actions.icon"="terminal" LABEL "com.github.actions.color"="gray-dark" """ -_GO2RTC_VERSION = "1.9.6" +_GO2RTC_VERSION = "1.9.7" def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index ec586776142..9388110366e 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator import logging from typing import NamedTuple -from unittest.mock import AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from go2rtc_client import Stream @@ -238,7 +238,11 @@ async def _test_setup_and_signaling( await test() rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + entity_id, + [ + "rtsp://stream", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) # Stream exists but the source is different @@ -252,7 +256,11 @@ async def _test_setup_and_signaling( await test() rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] + entity_id, + [ + "rtsp://stream", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + ], ) # If the stream is already added, the stream should not be added again. @@ -296,7 +304,7 @@ async def _test_setup_and_signaling( ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_managed( +async def test_setup_go_binary( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -308,131 +316,15 @@ async def test_setup_managed( config: ConfigType, ui_enabled: bool, ) -> None: - """Test the go2rtc setup with managed go2rtc instance.""" + """Test the go2rtc config entry with binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry - camera = init_test_integration - entity_id = camera.entity_id - stream_name_original = f"{camera.entity_id}_original" - assert camera.frontend_stream_type == StreamType.HLS + def after_setup() -> None: + server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) + server_start.assert_called_once() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED - server.assert_called_once_with(hass, "/usr/bin/go2rtc", enable_ui=ui_enabled) - server_start.assert_called_once() - - receive_message_callback = Mock(spec_set=WebRTCSendMessage) - - async def test() -> None: - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - ws_client.send.assert_called_once_with( - WebRTCOffer( - OFFER_SDP, - camera.async_get_webrtc_client_configuration().configuration.ice_servers, - ) - ) - ws_client.subscribe.assert_called_once() - - # Simulate the answer from the go2rtc server - callback = ws_client.subscribe.call_args[0][0] - callback(WebRTCAnswer(ANSWER_SDP)) - receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - - await test() - - stream_added_calls = [ - call(stream_name_original, "rtsp://stream"), - call( - entity_id, - [ - f"rtsp://127.0.0.1:18554/{stream_name_original}", - f"ffmpeg:{stream_name_original}#audio=opus", - ], - ), - ] - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream original missing - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ) - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream original source different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://different")]), - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ), - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # Stream source different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://stream")]), - entity_id: Stream([Producer("rtsp://different")]), - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - assert rest_client.streams.add.call_args_list == stream_added_calls - - # If the stream is already added, the stream should not be added again. - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - stream_name_original: Stream([Producer("rtsp://stream")]), - entity_id: Stream( - [ - Producer(f"rtsp://127.0.0.1:18554/{stream_name_original}"), - Producer(f"ffmpeg:{stream_name_original}#audio=opus"), - ] - ), - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) - - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + await _test_setup_and_signaling( + hass, rest_client, ws_client, config, after_setup, init_test_integration ) await hass.async_stop() @@ -448,7 +340,7 @@ async def test_setup_managed( ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_self_hosted( +async def test_setup_go( hass: HomeAssistant, rest_client: AsyncMock, ws_client: Mock, @@ -458,83 +350,16 @@ async def test_setup_self_hosted( mock_is_docker_env: Mock, has_go2rtc_entry: bool, ) -> None: - """Test the go2rtc with selfhosted go2rtc instance.""" + """Test the go2rtc config entry without binary.""" assert (len(hass.config_entries.async_entries(DOMAIN)) == 1) == has_go2rtc_entry config = {DOMAIN: {CONF_URL: "http://localhost:1984/"}} - camera = init_test_integration - entity_id = camera.entity_id - assert camera.frontend_stream_type == StreamType.HLS + def after_setup() -> None: + server.assert_not_called() - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done(wait_background_tasks=True) - config_entries = hass.config_entries.async_entries(DOMAIN) - assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED - server.assert_not_called() - - receive_message_callback = Mock(spec_set=WebRTCSendMessage) - - async def test() -> None: - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - ws_client.send.assert_called_once_with( - WebRTCOffer( - OFFER_SDP, - camera.async_get_webrtc_client_configuration().configuration.ice_servers, - ) - ) - ws_client.subscribe.assert_called_once() - - # Simulate the answer from the go2rtc server - callback = ws_client.subscribe.call_args[0][0] - callback(WebRTCAnswer(ANSWER_SDP)) - receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - - await test() - - rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] - ) - - # Stream exists but the source is different - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://different")]) - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - rest_client.streams.add.assert_called_once_with( - entity_id, ["rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus"] - ) - - # If the stream is already added, the stream should not be added again. - rest_client.streams.add.reset_mock() - rest_client.streams.list.return_value = { - entity_id: Stream([Producer("rtsp://stream")]) - } - - receive_message_callback.reset_mock() - ws_client.reset_mock() - await test() - - rest_client.streams.add.assert_not_called() - assert isinstance(camera._webrtc_provider, WebRTCProvider) - - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + await _test_setup_and_signaling( + hass, rest_client, ws_client, config, after_setup, init_test_integration ) mock_get_binary.assert_not_called() From ac0c75a598e4e7ee2c27b37e19a9ec5cefb8cd5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 12 Nov 2024 15:27:53 +0100 Subject: [PATCH 456/511] Add upload capability to the backup integration (#128546) * Add upload capability to the backup integration * Limit context switch * rename * coverage for http * Test receiving a backup file * Update test_manager.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/backup/http.py | 37 ++++++++++-- homeassistant/components/backup/manager.py | 70 ++++++++++++++++++++++ tests/components/backup/test_http.py | 57 +++++++++++++++++- tests/components/backup/test_manager.py | 38 +++++++++++- 4 files changed, 195 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index 4cc4e61c9e4..42693035bd3 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -2,23 +2,26 @@ from __future__ import annotations +import asyncio from http import HTTPStatus +from typing import cast +from aiohttp import BodyPartReader from aiohttp.hdrs import CONTENT_DISPOSITION from aiohttp.web import FileResponse, Request, Response -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify -from .const import DOMAIN -from .manager import BaseBackupManager +from .const import DATA_MANAGER @callback def async_register_http_views(hass: HomeAssistant) -> None: """Register the http views.""" hass.http.register_view(DownloadBackupView) + hass.http.register_view(UploadBackupView) class DownloadBackupView(HomeAssistantView): @@ -36,7 +39,7 @@ class DownloadBackupView(HomeAssistantView): if not request["hass_user"].is_admin: return Response(status=HTTPStatus.UNAUTHORIZED) - manager: BaseBackupManager = request.app[KEY_HASS].data[DOMAIN] + manager = request.app[KEY_HASS].data[DATA_MANAGER] backup = await manager.async_get_backup(slug=slug) if backup is None or not backup.path.exists(): @@ -48,3 +51,29 @@ class DownloadBackupView(HomeAssistantView): CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar" }, ) + + +class UploadBackupView(HomeAssistantView): + """Generate backup view.""" + + url = "/api/backup/upload" + name = "api:backup:upload" + + @require_admin + async def post(self, request: Request) -> Response: + """Upload a backup file.""" + manager = request.app[KEY_HASS].data[DATA_MANAGER] + reader = await request.multipart() + contents = cast(BodyPartReader, await reader.next()) + + try: + await manager.async_receive_backup(contents=contents) + except OSError as err: + return Response( + body=f"Can't write backup file {err}", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + except asyncio.CancelledError: + return Response(status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(status=HTTPStatus.CREATED) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8265dade3aa..4300f75eed0 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -9,11 +9,15 @@ import hashlib import io import json from pathlib import Path +from queue import SimpleQueue +import shutil import tarfile from tarfile import TarError +from tempfile import TemporaryDirectory import time from typing import Any, Protocol, cast +import aiohttp from securetar import SecureTarFile, atomic_contents_add from homeassistant.backup_restore import RESTORE_BACKUP_FILE @@ -147,6 +151,15 @@ class BaseBackupManager(abc.ABC): async def async_remove_backup(self, *, slug: str, **kwargs: Any) -> None: """Remove a backup.""" + @abc.abstractmethod + async def async_receive_backup( + self, + *, + contents: aiohttp.BodyPartReader, + **kwargs: Any, + ) -> None: + """Receive and store a backup file from upload.""" + class BackupManager(BaseBackupManager): """Backup manager for the Backup integration.""" @@ -222,6 +235,63 @@ class BackupManager(BaseBackupManager): LOGGER.debug("Removed backup located at %s", backup.path) self.backups.pop(slug) + async def async_receive_backup( + self, + *, + contents: aiohttp.BodyPartReader, + **kwargs: Any, + ) -> None: + """Receive and store a backup file from upload.""" + queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = ( + SimpleQueue() + ) + temp_dir_handler = await self.hass.async_add_executor_job(TemporaryDirectory) + target_temp_file = Path( + temp_dir_handler.name, contents.filename or "backup.tar" + ) + + def _sync_queue_consumer() -> None: + with target_temp_file.open("wb") as file_handle: + while True: + if (_chunk_future := queue.get()) is None: + break + _chunk, _future = _chunk_future + if _future is not None: + self.hass.loop.call_soon_threadsafe(_future.set_result, None) + file_handle.write(_chunk) + + fut: asyncio.Future[None] | None = None + try: + fut = self.hass.async_add_executor_job(_sync_queue_consumer) + megabytes_sending = 0 + while chunk := await contents.read_chunk(BUF_SIZE): + megabytes_sending += 1 + if megabytes_sending % 5 != 0: + queue.put_nowait((chunk, None)) + continue + + chunk_future = self.hass.loop.create_future() + queue.put_nowait((chunk, chunk_future)) + await asyncio.wait( + (fut, chunk_future), + return_when=asyncio.FIRST_COMPLETED, + ) + if fut.done(): + # The executor job failed + break + + queue.put_nowait(None) # terminate queue consumer + finally: + if fut is not None: + await fut + + def _move_and_cleanup() -> None: + shutil.move(target_temp_file, self.backup_dir / target_temp_file.name) + temp_dir_handler.cleanup() + + await self.hass.async_add_executor_job(_move_and_cleanup) + await self.load_backups() + async def async_create_backup(self, **kwargs: Any) -> Backup: """Generate a backup.""" if self.backing_up: diff --git a/tests/components/backup/test_http.py b/tests/components/backup/test_http.py index 93ecb27bc97..76b1f76b55b 100644 --- a/tests/components/backup/test_http.py +++ b/tests/components/backup/test_http.py @@ -1,8 +1,11 @@ """Tests for the Backup integration.""" +import asyncio +from io import StringIO from unittest.mock import patch from aiohttp import web +import pytest from homeassistant.core import HomeAssistant @@ -49,12 +52,12 @@ async def test_downloading_backup_not_found( assert resp.status == 404 -async def test_non_admin( +async def test_downloading_as_non_admin( hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_admin_user: MockUser, ) -> None: - """Test downloading a backup file that does not exist.""" + """Test downloading a backup file when you are not an admin.""" hass_admin_user.groups = [] await setup_backup_integration(hass) @@ -62,3 +65,53 @@ async def test_non_admin( resp = await client.get("/api/backup/download/abc123") assert resp.status == 401 + + +async def test_uploading_a_backup_file( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test uploading a backup file.""" + await setup_backup_integration(hass) + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + ) as async_receive_backup_mock: + resp = await client.post( + "/api/backup/upload", + data={"file": StringIO("test")}, + ) + assert resp.status == 201 + assert async_receive_backup_mock.called + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (OSError("Boom!"), "Can't write backup file Boom!"), + (asyncio.CancelledError("Boom!"), ""), + ], +) +async def test_error_handling_uploading_a_backup_file( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + error: Exception, + message: str, +) -> None: + """Test error handling when uploading a backup file.""" + await setup_backup_integration(hass) + + client = await hass_client() + + with patch( + "homeassistant.components.backup.manager.BackupManager.async_receive_backup", + side_effect=error, + ): + resp = await client.post( + "/api/backup/upload", + data={"file": StringIO("test")}, + ) + assert resp.status == 500 + assert await resp.text() == message diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a4dba5c6936..a3f70267643 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -3,8 +3,10 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch +import aiohttp +from multidict import CIMultiDict, CIMultiDictProxy import pytest from homeassistant.components.backup import BackupManager @@ -335,6 +337,40 @@ async def test_loading_platforms_when_running_async_post_backup_actions( assert "Loaded 1 platforms" in caplog.text +async def test_async_receive_backup( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test receiving a backup file.""" + manager = BackupManager(hass) + + size = 2 * 2**16 + protocol = Mock(_reading_paused=False) + stream = aiohttp.StreamReader(protocol, 2**16) + stream.feed_data(b"0" * size + b"\r\n--:--") + stream.feed_eof() + + open_mock = mock_open() + + with patch("pathlib.Path.open", open_mock), patch("shutil.move") as mover_mock: + await manager.async_receive_backup( + contents=aiohttp.BodyPartReader( + b"--:", + CIMultiDictProxy( + CIMultiDict( + { + aiohttp.hdrs.CONTENT_DISPOSITION: "attachment; filename=abc123.tar" + } + ) + ), + stream, + ) + ) + assert open_mock.call_count == 1 + assert mover_mock.call_count == 1 + assert mover_mock.mock_calls[0].args[1].name == "abc123.tar" + + async def test_async_trigger_restore( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, From 167025a18c032998517e4a7762bf1a10997b49bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:03:37 +0100 Subject: [PATCH 457/511] Simplify modern_forms config flow (#130441) * Simplify modern_forms config flow * Rename variable * Drop CONF_NAME --- .../components/modern_forms/config_flow.py | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/modern_forms/config_flow.py b/homeassistant/components/modern_forms/config_flow.py index dee08736234..33e814efb51 100644 --- a/homeassistant/components/modern_forms/config_flow.py +++ b/homeassistant/components/modern_forms/config_flow.py @@ -9,11 +9,13 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_ZEROCONF, ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +USER_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a ModernForms config flow.""" @@ -55,17 +57,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None, prepare: bool = False ) -> ConfigFlowResult: """Config flow handler for ModernForms.""" - source = self.context["source"] - # Request user input, unless we are preparing discovery flow if user_input is None: user_input = {} if not prepare: - if source == SOURCE_ZEROCONF: - return self._show_confirm_dialog() - return self._show_setup_form() + if self.source == SOURCE_ZEROCONF: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.name}, + ) + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + ) - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: user_input[CONF_HOST] = self.host user_input[CONF_MAC] = self.mac @@ -75,18 +81,21 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): try: device = await device.update() except ModernFormsConnectionError: - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: return self.async_abort(reason="cannot_connect") - return self._show_setup_form({"base": "cannot_connect"}) + return self.async_show_form( + step_id="user", + data_schema=USER_SCHEMA, + errors={"base": "cannot_connect"}, + ) user_input[CONF_MAC] = device.info.mac_address - user_input[CONF_NAME] = device.info.device_name # Check if already configured await self.async_set_unique_id(user_input[CONF_MAC]) self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) title = device.info.device_name - if source == SOURCE_ZEROCONF: + if self.source == SOURCE_ZEROCONF: title = self.name if prepare: @@ -96,19 +105,3 @@ class ModernFormsFlowHandler(ConfigFlow, domain=DOMAIN): title=title, data={CONF_HOST: user_input[CONF_HOST], CONF_MAC: user_input[CONF_MAC]}, ) - - def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required(CONF_HOST): str}), - errors=errors or {}, - ) - - def _show_confirm_dialog(self, errors: dict | None = None) -> ConfigFlowResult: - """Show the confirm dialog to the user.""" - return self.async_show_form( - step_id="zeroconf_confirm", - description_placeholders={"name": self.name}, - errors=errors or {}, - ) From 285468d85f7911b55a0450981ddb669d50009ffc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 12 Nov 2024 18:44:32 +0100 Subject: [PATCH 458/511] Fix translation in statistics (#130455) * Fix translation in statistics * Update homeassistant/components/statistics/strings.json --- homeassistant/components/statistics/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json index a060c88da24..3e6fec9d986 100644 --- a/homeassistant/components/statistics/strings.json +++ b/homeassistant/components/statistics/strings.json @@ -23,10 +23,10 @@ "state_characteristic": { "description": "Read the documention for further details on available options and how to use them.", "data": { - "state_characteristic": "State_characteristic" + "state_characteristic": "Statistic characteristic" }, "data_description": { - "state_characteristic": "The characteristic that should be used as the state of the statistics sensor." + "state_characteristic": "The statistic characteristic that should be used as the state of the sensor." } }, "options": { From 388473ecd7adaec1658caac9f05208ee9c319223 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 12 Nov 2024 19:55:27 +0100 Subject: [PATCH 459/511] Add diagnostics to Nord Pool (#130461) --- .../components/nordpool/diagnostics.py | 16 + .../nordpool/snapshots/test_diagnostics.ambr | 283 ++++++++++++++++++ tests/components/nordpool/test_diagnostics.py | 23 ++ 3 files changed, 322 insertions(+) create mode 100644 homeassistant/components/nordpool/diagnostics.py create mode 100644 tests/components/nordpool/snapshots/test_diagnostics.ambr create mode 100644 tests/components/nordpool/test_diagnostics.py diff --git a/homeassistant/components/nordpool/diagnostics.py b/homeassistant/components/nordpool/diagnostics.py new file mode 100644 index 00000000000..3160c2bfa6d --- /dev/null +++ b/homeassistant/components/nordpool/diagnostics.py @@ -0,0 +1,16 @@ +"""Diagnostics support for Nord Pool.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import NordPoolConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: NordPoolConfigEntry +) -> dict[str, Any]: + """Return diagnostics for Nord Pool config entry.""" + return {"raw": entry.runtime_data.data.raw} diff --git a/tests/components/nordpool/snapshots/test_diagnostics.ambr b/tests/components/nordpool/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..dde2eca0022 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_diagnostics.ambr @@ -0,0 +1,283 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'raw': dict({ + 'areaAverages': list([ + dict({ + 'areaCode': 'SE3', + 'price': 900.74, + }), + dict({ + 'areaCode': 'SE4', + 'price': 1166.12, + }), + ]), + 'areaStates': list([ + dict({ + 'areas': list([ + 'SE3', + 'SE4', + ]), + 'state': 'Final', + }), + ]), + 'blockPriceAggregates': list([ + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 422.87, + 'max': 1406.14, + 'min': 61.69, + }), + 'SE4': dict({ + 'average': 497.97, + 'max': 1648.25, + 'min': 65.19, + }), + }), + 'blockName': 'Off-peak 1', + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 1315.97, + 'max': 2512.65, + 'min': 925.05, + }), + 'SE4': dict({ + 'average': 1735.59, + 'max': 3533.03, + 'min': 1081.72, + }), + }), + 'blockName': 'Peak', + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + }), + dict({ + 'averagePricePerArea': dict({ + 'SE3': dict({ + 'average': 610.79, + 'max': 835.53, + 'min': 289.14, + }), + 'SE4': dict({ + 'average': 793.98, + 'max': 1112.57, + 'min': 349.21, + }), + }), + 'blockName': 'Off-peak 2', + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + }), + ]), + 'currency': 'SEK', + 'deliveryAreas': list([ + 'SE3', + 'SE4', + ]), + 'deliveryDateCET': '2024-11-05', + 'exchangeRate': 11.6402, + 'market': 'DayAhead', + 'multiAreaEntries': list([ + dict({ + 'deliveryEnd': '2024-11-05T00:00:00Z', + 'deliveryStart': '2024-11-04T23:00:00Z', + 'entryPerArea': dict({ + 'SE3': 250.73, + 'SE4': 283.79, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T01:00:00Z', + 'deliveryStart': '2024-11-05T00:00:00Z', + 'entryPerArea': dict({ + 'SE3': 76.36, + 'SE4': 81.36, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T02:00:00Z', + 'deliveryStart': '2024-11-05T01:00:00Z', + 'entryPerArea': dict({ + 'SE3': 73.92, + 'SE4': 79.15, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T03:00:00Z', + 'deliveryStart': '2024-11-05T02:00:00Z', + 'entryPerArea': dict({ + 'SE3': 61.69, + 'SE4': 65.19, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T04:00:00Z', + 'deliveryStart': '2024-11-05T03:00:00Z', + 'entryPerArea': dict({ + 'SE3': 64.6, + 'SE4': 68.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T05:00:00Z', + 'deliveryStart': '2024-11-05T04:00:00Z', + 'entryPerArea': dict({ + 'SE3': 453.27, + 'SE4': 516.71, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T06:00:00Z', + 'deliveryStart': '2024-11-05T05:00:00Z', + 'entryPerArea': dict({ + 'SE3': 996.28, + 'SE4': 1240.85, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T07:00:00Z', + 'deliveryStart': '2024-11-05T06:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1406.14, + 'SE4': 1648.25, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T08:00:00Z', + 'deliveryStart': '2024-11-05T07:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1346.54, + 'SE4': 1570.5, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T09:00:00Z', + 'deliveryStart': '2024-11-05T08:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1150.28, + 'SE4': 1345.37, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T10:00:00Z', + 'deliveryStart': '2024-11-05T09:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1031.32, + 'SE4': 1206.51, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T11:00:00Z', + 'deliveryStart': '2024-11-05T10:00:00Z', + 'entryPerArea': dict({ + 'SE3': 927.37, + 'SE4': 1085.8, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T12:00:00Z', + 'deliveryStart': '2024-11-05T11:00:00Z', + 'entryPerArea': dict({ + 'SE3': 925.05, + 'SE4': 1081.72, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T13:00:00Z', + 'deliveryStart': '2024-11-05T12:00:00Z', + 'entryPerArea': dict({ + 'SE3': 949.49, + 'SE4': 1130.38, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T14:00:00Z', + 'deliveryStart': '2024-11-05T13:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1042.03, + 'SE4': 1256.91, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T15:00:00Z', + 'deliveryStart': '2024-11-05T14:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1258.89, + 'SE4': 1765.82, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T16:00:00Z', + 'deliveryStart': '2024-11-05T15:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1816.45, + 'SE4': 2522.55, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T17:00:00Z', + 'deliveryStart': '2024-11-05T16:00:00Z', + 'entryPerArea': dict({ + 'SE3': 2512.65, + 'SE4': 3533.03, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T18:00:00Z', + 'deliveryStart': '2024-11-05T17:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1819.83, + 'SE4': 2524.06, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T19:00:00Z', + 'deliveryStart': '2024-11-05T18:00:00Z', + 'entryPerArea': dict({ + 'SE3': 1011.77, + 'SE4': 1804.46, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T20:00:00Z', + 'deliveryStart': '2024-11-05T19:00:00Z', + 'entryPerArea': dict({ + 'SE3': 835.53, + 'SE4': 1112.57, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T21:00:00Z', + 'deliveryStart': '2024-11-05T20:00:00Z', + 'entryPerArea': dict({ + 'SE3': 796.19, + 'SE4': 1051.69, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T22:00:00Z', + 'deliveryStart': '2024-11-05T21:00:00Z', + 'entryPerArea': dict({ + 'SE3': 522.3, + 'SE4': 662.44, + }), + }), + dict({ + 'deliveryEnd': '2024-11-05T23:00:00Z', + 'deliveryStart': '2024-11-05T22:00:00Z', + 'entryPerArea': dict({ + 'SE3': 289.14, + 'SE4': 349.21, + }), + }), + ]), + 'updatedAt': '2024-11-04T12:15:03.9456464Z', + 'version': 3, + }), + }) +# --- diff --git a/tests/components/nordpool/test_diagnostics.py b/tests/components/nordpool/test_diagnostics.py new file mode 100644 index 00000000000..4639186ecf1 --- /dev/null +++ b/tests/components/nordpool/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Test Nord Pool diagnostics.""" + +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_int: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, load_int) == snapshot + ) From 6bfc0cbb0c1db6ade27290bf86cd29487af30ece Mon Sep 17 00:00:00 2001 From: Kelvin Dekker <143089625+KelvinDekker@users.noreply.github.com> Date: Tue, 12 Nov 2024 21:33:52 +0100 Subject: [PATCH 460/511] Fix typo in file strings (#130465) --- homeassistant/components/file/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 60ebf451f78..8806c67cd96 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -18,7 +18,7 @@ }, "data_description": { "file_path": "The local file path to retrieve the sensor value from", - "value_template": "A template to render the the sensors value based on the file content", + "value_template": "A template to render the sensors value based on the file content", "unit_of_measurement": "Unit of measurement for the sensor" } }, From 5c52e865a0e95a83a94162e21424cd0be2d372c9 Mon Sep 17 00:00:00 2001 From: mrspouse <55619185+mrspouse@users.noreply.github.com> Date: Tue, 12 Nov 2024 20:48:42 +0000 Subject: [PATCH 461/511] Correct spelling of BloodGlucoseConcentrationConverter (#130449) * Correct spelling of BloodGlucoseConcentrationConverter * Correct spelling of BloodGlucoseConcentrationConverter --- homeassistant/components/recorder/statistics.py | 6 +++--- homeassistant/components/recorder/websocket_api.py | 4 ++-- homeassistant/components/sensor/const.py | 4 ++-- homeassistant/util/unit_conversion.py | 2 +- tests/util/test_unit_conversion.py | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e5fbfe0e8c5..7243af9d4d5 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -130,8 +130,8 @@ QUERY_STATISTICS_SUMMARY_SUM = ( STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { **{ - unit: BloodGlugoseConcentrationConverter - for unit in BloodGlugoseConcentrationConverter.VALID_UNITS + unit: BloodGlucoseConcentrationConverter + for unit in BloodGlucoseConcentrationConverter.VALID_UNITS }, **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS}, **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS}, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 8b8d1cfb0c6..f4dce73fa47 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -56,7 +56,7 @@ UPDATE_STATISTICS_METADATA_TIME_OUT = 10 UNIT_SCHEMA = vol.Schema( { vol.Optional("blood_glucose_concentration"): vol.In( - BloodGlugoseConcentrationConverter.VALID_UNITS + BloodGlucoseConcentrationConverter.VALID_UNITS ), vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index ee6167a5643..f4573f873a2 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -48,7 +48,7 @@ from homeassistant.helpers.deprecation import ( ) from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -501,7 +501,7 @@ STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, - SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlugoseConcentrationConverter, + SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, SensorDeviceClass.DATA_RATE: DataRateConverter, diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 95d8fbc9df1..1bf3561e66a 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -174,7 +174,7 @@ class DistanceConverter(BaseUnitConverter): } -class BloodGlugoseConcentrationConverter(BaseUnitConverter): +class BloodGlucoseConcentrationConverter(BaseUnitConverter): """Utility to convert blood glucose concentration values.""" UNIT_CLASS = "blood_glucose_concentration" diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index a57cdde821f..609809a96e8 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -33,7 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( BaseUnitConverter, - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -61,7 +61,7 @@ INVALID_SYMBOL = "bob" _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( - BloodGlugoseConcentrationConverter, + BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, DistanceConverter, @@ -83,7 +83,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { - BloodGlugoseConcentrationConverter: ( + BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, 18, @@ -138,7 +138,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { - BloodGlugoseConcentrationConverter: [ + BloodGlucoseConcentrationConverter: [ ( 90, UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, From 4ff8b8015cdb5450f26707230194049a0af682ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Nov 2024 22:07:26 -0600 Subject: [PATCH 462/511] Bump aiohttp to 3.11.0rc2 (#130484) --- 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 a40c8745877..956ea032fe7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc1 +aiohttp==3.11.0rc2 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index adc85c0f4f7..8e588ce0b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc1", + "aiohttp==3.11.0rc2", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 53d6b13a4ab..ac7c00b8050 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc1 +aiohttp==3.11.0rc2 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From a9f468509b7660737c79337aa11f815b6a0744ff Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Nov 2024 01:14:39 -0500 Subject: [PATCH 463/511] Bump zwave-js-server-python to 0.59.1 (#130468) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index e3f643486a0..3631bf1163b 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -10,7 +10,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.59.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 67c7c991146..b7a979050bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3087,7 +3087,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.0 +zwave-js-server-python==0.59.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 048f0ac7d76..ec6be67d4b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2461,7 +2461,7 @@ zeversolar==0.3.2 zha==0.0.37 # homeassistant.components.zwave_js -zwave-js-server-python==0.59.0 +zwave-js-server-python==0.59.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 8b505a2273aeab31dd89ac86ce2cbb1b78f99e74 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 13 Nov 2024 07:35:51 +0100 Subject: [PATCH 464/511] Bump reolink_aio to 0.11.0 (#130481) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 23a46c5e1c9..22fd625770f 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.10.4"] + "requirements": ["reolink-aio==0.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7a979050bf..0009c93f673 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2553,7 +2553,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.4 +reolink-aio==0.11.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec6be67d4b4..7ad45aae832 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,7 +2044,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.10.4 +reolink-aio==0.11.0 # homeassistant.components.rflink rflink==0.0.66 From fdb773c9216be11a342ca8a4aa3dd9749e065622 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 07:55:13 +0100 Subject: [PATCH 465/511] Add title to water heater component (#130446) --- homeassistant/components/water_heater/strings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 741b277d84d..07e132a0b5b 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,4 +1,5 @@ { + "title": "Water heater", "device_automation": { "action_type": { "turn_on": "[%key:common::device_automation::action_type::turn_on%]", @@ -7,7 +8,7 @@ }, "entity_component": { "_": { - "name": "Water heater", + "name": "[%key:component::water_heater::title%]", "state": { "off": "[%key:common::state::off%]", "eco": "Eco", From 5cce369ce82a4ece9a2ec3888751974626eb16de Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 07:55:33 +0100 Subject: [PATCH 466/511] Bump aiowithings to 3.1.2 (#130469) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index a0a86be5da3..c24bdb743bf 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.1"] + "requirements": ["aiowithings==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0009c93f673..a5898c91708 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.1 +aiowithings==3.1.2 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ad45aae832..a7f382e0251 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.1 +aiowithings==3.1.2 # homeassistant.components.yandex_transport aioymaps==1.2.5 From 827875473bb133451005d4987aa07edc2a984a36 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:54:37 +0100 Subject: [PATCH 467/511] Fix RecursionError in Husqvarna Automower coordinator (#123085) * reach maximum recursion depth exceeded in tests * second background task * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * test * modify test * tests * use correct exception * reset mock * use recursion_limit * remove unneeded ticks * test TimeoutException * set lower recursionlimit * remove not that important comment and move the other * test that we connect and listen successfully * Simulate hass shutting down * skip testing against the recursion limit * Update homeassistant/components/husqvarna_automower/coordinator.py Co-authored-by: Martin Hjelmare * mock * Remove comment * Revert "mock" This reverts commit e8ddaea3d79ed1aceb696a055cc42ad08b4febca. * Move patch to decorator * Make execution of patched methods predictable * Parametrize test, make mocked start_listening block * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare Co-authored-by: Erik --- .../husqvarna_automower/coordinator.py | 30 ++++--- .../husqvarna_automower/conftest.py | 8 ++ .../husqvarna_automower/test_init.py | 81 +++++++++++++++---- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 458ff50dac9..c19f37a040d 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -8,6 +8,7 @@ from aioautomower.exceptions import ( ApiException, AuthException, HusqvarnaWSServerHandshakeError, + TimeoutException, ) from aioautomower.model import MowerAttributes from aioautomower.session import AutomowerSession @@ -22,6 +23,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) MAX_WS_RECONNECT_TIME = 600 SCAN_INTERVAL = timedelta(minutes=8) +DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): @@ -40,8 +42,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib update_interval=SCAN_INTERVAL, ) self.api = api - self.ws_connected: bool = False + self.reconnect_time = DEFAULT_RECONNECT_TIME async def _async_update_data(self) -> dict[str, MowerAttributes]: """Subscribe for websocket and poll data from the API.""" @@ -66,24 +68,28 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib hass: HomeAssistant, entry: ConfigEntry, automower_client: AutomowerSession, - reconnect_time: int = 2, ) -> None: """Listen with the client.""" try: await automower_client.auth.websocket_connect() - reconnect_time = 2 + # Reset reconnect time after successful connection + self.reconnect_time = DEFAULT_RECONNECT_TIME await automower_client.start_listening() except HusqvarnaWSServerHandshakeError as err: _LOGGER.debug( - "Failed to connect to websocket. Trying to reconnect: %s", err + "Failed to connect to websocket. Trying to reconnect: %s", + err, + ) + except TimeoutException as err: + _LOGGER.debug( + "Failed to listen to websocket. Trying to reconnect: %s", + err, ) - if not hass.is_stopping: - await asyncio.sleep(reconnect_time) - reconnect_time = min(reconnect_time * 2, MAX_WS_RECONNECT_TIME) - await self.client_listen( - hass=hass, - entry=entry, - automower_client=automower_client, - reconnect_time=reconnect_time, + await asyncio.sleep(self.reconnect_time) + self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME) + entry.async_create_background_task( + hass, + self.client_listen(hass, entry, automower_client), + "reconnect_task", ) diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 2814e1558d1..0202cec05b9 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,5 +1,6 @@ """Test helpers for Husqvarna Automower.""" +import asyncio from collections.abc import Generator import time from unittest.mock import AsyncMock, patch @@ -101,10 +102,17 @@ async def setup_credentials(hass: HomeAssistant) -> None: def mock_automower_client(values) -> Generator[AsyncMock]: """Mock a Husqvarna Automower client.""" + async def listen() -> None: + """Mock listen.""" + listen_block = asyncio.Event() + await listen_block.wait() + pytest.fail("Listen was not cancelled!") + mock = AsyncMock(spec=AutomowerSession) mock.auth = AsyncMock(side_effect=ClientWebSocketResponse) mock.commands = AsyncMock(spec_set=_MowerCommands) mock.get_status.return_value = values + mock.start_listening = AsyncMock(side_effect=listen) with patch( "homeassistant.components.husqvarna_automower.AutomowerSession", diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ca0c2a04af1..ae688571d2c 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -1,14 +1,16 @@ """Tests for init module.""" -from datetime import datetime, timedelta +from asyncio import Event +from datetime import datetime import http import time -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ( ApiException, AuthException, HusqvarnaWSServerHandshakeError, + TimeoutException, ) from aioautomower.model import MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory @@ -127,28 +129,77 @@ async def test_update_failed( assert entry.state is entry_state +@patch( + "homeassistant.components.husqvarna_automower.coordinator.DEFAULT_RECONNECT_TIME", 0 +) +@pytest.mark.parametrize( + ("method_path", "exception", "error_msg"), + [ + ( + ["auth", "websocket_connect"], + HusqvarnaWSServerHandshakeError, + "Failed to connect to websocket.", + ), + ( + ["start_listening"], + TimeoutException, + "Failed to listen to websocket.", + ), + ], +) async def test_websocket_not_available( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, + method_path: list[str], + exception: type[Exception], + error_msg: str, ) -> None: - """Test trying reload the websocket.""" - mock_automower_client.start_listening.side_effect = HusqvarnaWSServerHandshakeError( - "Boom" - ) + """Test trying to reload the websocket.""" + calls = [] + mock_called = Event() + mock_stall = Event() + + async def mock_function(): + mock_called.set() + await mock_stall.wait() + # Raise the first time the method is awaited + if not calls: + calls.append(None) + raise exception("Boom") + if mock_side_effect: + await mock_side_effect() + + # Find the method to mock + mock = mock_automower_client + for itm in method_path: + mock = getattr(mock, itm) + mock_side_effect = mock.side_effect + mock.side_effect = mock_function + + # Setup integration and verify log error message await setup_integration(hass, mock_config_entry) - assert "Failed to connect to websocket. Trying to reconnect: Boom" in caplog.text - assert mock_automower_client.auth.websocket_connect.call_count == 1 - assert mock_automower_client.start_listening.call_count == 1 - assert mock_config_entry.state is ConfigEntryState.LOADED - freezer.tick(timedelta(seconds=2)) - async_fire_time_changed(hass) + await mock_called.wait() + mock_called.clear() + # Allow the exception to be raised + mock_stall.set() + assert mock.call_count == 1 await hass.async_block_till_done() - assert mock_automower_client.auth.websocket_connect.call_count == 2 - assert mock_automower_client.start_listening.call_count == 2 - assert mock_config_entry.state is ConfigEntryState.LOADED + assert f"{error_msg} Trying to reconnect: Boom" in caplog.text + + # Simulate a successful connection + caplog.clear() + await mock_called.wait() + mock_called.clear() + await hass.async_block_till_done() + assert mock.call_count == 2 + assert "Trying to reconnect: Boom" not in caplog.text + + # Simulate hass shutting down + await hass.async_stop() + assert mock.call_count == 2 async def test_device_info( From 3092297979cd11c176f85bd1129a8f801577daae Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 13 Nov 2024 09:55:52 +0100 Subject: [PATCH 468/511] Bump go2rtc-client to 0.1.1 (#130498) --- homeassistant/components/go2rtc/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/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index ea9308e5e18..201b7168847 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/go2rtc", "integration_type": "system", "iot_class": "local_polling", - "requirements": ["go2rtc-client==0.1.0"], + "requirements": ["go2rtc-client==0.1.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 956ea032fe7..7a0e43b299e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ ciso8601==2.3.1 cryptography==43.0.1 dbus-fast==2.24.3 fnv-hash-fast==1.0.2 -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 diff --git a/requirements_all.txt b/requirements_all.txt index a5898c91708..9a27f4d3b04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -993,7 +993,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7f382e0251..38704005179 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -843,7 +843,7 @@ gios==5.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.0 +go2rtc-client==0.1.1 # homeassistant.components.goalzero goalzero==0.2.2 From 0ac00ef0920067d241265393eb89ddd11e9ce65c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 13 Nov 2024 10:55:28 +0100 Subject: [PATCH 469/511] Fix legacy _attr_state handling in AlarmControlPanel (#130479) --- .../alarm_control_panel/__init__.py | 14 ++- .../alarm_control_panel/test_init.py | 93 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 2946fc64941..a9e433a3650 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -6,7 +6,7 @@ import asyncio from datetime import timedelta from functools import partial import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final from propcache import cached_property import voluptuous as vol @@ -221,9 +221,15 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A @property def state(self) -> str | None: """Return the current state.""" - if (alarm_state := self.alarm_state) is None: - return None - return alarm_state + if (alarm_state := self.alarm_state) is not None: + return alarm_state + if self._attr_state is not None: + # Backwards compatibility for integrations that set state directly + # Should be removed in 2025.11 + if TYPE_CHECKING: + assert isinstance(self._attr_state, str) + return self._attr_state + return None @cached_property def alarm_state(self) -> AlarmControlPanelState | None: diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py index 90b23f87ab1..89a2a2a2b1a 100644 --- a/tests/components/alarm_control_panel/test_init.py +++ b/tests/components/alarm_control_panel/test_init.py @@ -489,3 +489,96 @@ async def test_alarm_control_panel_log_deprecated_state_warning_using_attr_state ) # Test we only log once assert "Entities should implement the 'alarm_state' property and" not in caplog.text + + +async def test_alarm_control_panel_deprecated_state_does_not_break_state( + hass: HomeAssistant, + code_format: CodeFormat | None, + supported_features: AlarmControlPanelEntityFeature, + code_arm_required: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test using _attr_state attribute does not break state.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [ALARM_CONTROL_PANEL_DOMAIN] + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + class MockLegacyAlarmControlPanel(MockAlarmControlPanel): + """Mocked alarm control entity.""" + + def __init__( + self, + supported_features: AlarmControlPanelEntityFeature = AlarmControlPanelEntityFeature( + 0 + ), + code_format: CodeFormat | None = None, + code_arm_required: bool = True, + ) -> None: + """Initialize the alarm control.""" + self._attr_state = "armed_away" + super().__init__(supported_features, code_format, code_arm_required) + + def alarm_disarm(self, code: str | None = None) -> None: + """Mock alarm disarm calls.""" + self._attr_state = "disarmed" + + entity = MockLegacyAlarmControlPanel( + supported_features=supported_features, + code_format=code_format, + code_arm_required=code_arm_required, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test alarm control panel platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{ALARM_CONTROL_PANEL_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + with patch.object( + MockLegacyAlarmControlPanel, + "__module__", + "tests.custom_components.test.alarm_control_panel", + ): + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "armed_away" + + with patch.object( + MockLegacyAlarmControlPanel, + "__module__", + "tests.custom_components.test.alarm_control_panel", + ): + await help_test_async_alarm_control_panel_service( + hass, entity.entity_id, SERVICE_ALARM_DISARM + ) + + state = hass.states.get(entity.entity_id) + assert state is not None + assert state.state == "disarmed" From 2eaaadd736e73ca4b90611ed13297572d990bf63 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 13 Nov 2024 11:01:05 +0100 Subject: [PATCH 470/511] Add go2rtc recommended version (#130508) --- .pre-commit-config.yaml | 2 +- homeassistant/components/go2rtc/__init__.py | 31 ++++++++++-- homeassistant/components/go2rtc/const.py | 1 + homeassistant/components/go2rtc/strings.json | 8 +++ script/hassfest/docker.py | 5 +- tests/components/go2rtc/conftest.py | 6 ++- tests/components/go2rtc/test_init.py | 52 ++++++++++++++++++-- 7 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/go2rtc/strings.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 519674b9894..56fbabe8087 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -90,7 +90,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$ + files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml|homeassistant/components/go2rtc/const\.py)$ - id: hassfest-mypy-config name: hassfest-mypy-config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index fc91ef5e546..f1f6e44abc1 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -4,6 +4,7 @@ import logging import shutil from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError +from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.ws import ( @@ -32,13 +33,23 @@ from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + issue_registry as ir, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey from homeassistant.util.package import is_docker_env -from .const import CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, HA_MANAGED_URL +from .const import ( + CONF_DEBUG_UI, + DEBUG_UI_URL_MESSAGE, + DOMAIN, + HA_MANAGED_URL, + RECOMMENDED_VERSION, +) from .server import Server _LOGGER = logging.getLogger(__name__) @@ -147,7 +158,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Validate the server URL try: client = Go2RtcRestClient(async_get_clientsession(hass), url) - await client.validate_server_version() + version = await client.validate_server_version() + if version < AwesomeVersion(RECOMMENDED_VERSION): + ir.async_create_issue( + hass, + DOMAIN, + "recommended_version", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="recommended_version", + translation_placeholders={ + "recommended_version": RECOMMENDED_VERSION, + "current_version": str(version), + }, + ) except Go2RtcClientError as err: if isinstance(err.__cause__, _RETRYABLE_ERRORS): raise ConfigEntryNotReady( diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py index d33ae3e3897..3c1c84c42b5 100644 --- a/homeassistant/components/go2rtc/const.py +++ b/homeassistant/components/go2rtc/const.py @@ -6,3 +6,4 @@ CONF_DEBUG_UI = "debug_ui" DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time." HA_MANAGED_API_PORT = 11984 HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/" +RECOMMENDED_VERSION = "1.9.7" diff --git a/homeassistant/components/go2rtc/strings.json b/homeassistant/components/go2rtc/strings.json new file mode 100644 index 00000000000..e350c19af96 --- /dev/null +++ b/homeassistant/components/go2rtc/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "recommended_version": { + "title": "Outdated go2rtc server detected", + "description": "We detected that you are using an outdated go2rtc server version. For the best experience, we recommend updating the go2rtc server to version `{recommended_version}`.\nCurrently you are using version `{current_version}`." + } + } +} diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 9d38d8f7128..137bbc7ff66 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from pathlib import Path from homeassistant import core +from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION as GO2RTC_VERSION from homeassistant.const import Platform from homeassistant.util import executor, thread from script.gen_requirements_all import gather_recursive_requirements @@ -112,8 +113,6 @@ LABEL "com.github.actions.icon"="terminal" LABEL "com.github.actions.color"="gray-dark" """ -_GO2RTC_VERSION = "1.9.7" - def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: package_versions: dict[str, str] = {} @@ -197,7 +196,7 @@ def _generate_files(config: Config) -> list[File]: DOCKERFILE_TEMPLATE.format( timeout=timeout, **package_versions, - go2rtc=_GO2RTC_VERSION, + go2rtc=GO2RTC_VERSION, ), config.root / "Dockerfile", ), diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index 42b363b2324..abb139b89bf 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -3,9 +3,11 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch +from awesomeversion import AwesomeVersion from go2rtc_client.rest import _StreamClient, _WebRTCClient import pytest +from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION from homeassistant.components.go2rtc.server import Server GO2RTC_PATH = "homeassistant.components.go2rtc" @@ -23,7 +25,9 @@ def rest_client() -> Generator[AsyncMock]: client = mock_client.return_value client.streams = streams = Mock(spec_set=_StreamClient) streams.list.return_value = {} - client.validate_server_version = AsyncMock() + client.validate_server_version = AsyncMock( + return_value=AwesomeVersion(RECOMMENDED_VERSION) + ) client.webrtc = Mock(spec_set=_WebRTCClient) yield client diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 9388110366e..0f1cac6942d 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -6,6 +6,7 @@ from typing import NamedTuple from unittest.mock import AsyncMock, Mock, patch from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError +from awesomeversion import AwesomeVersion from go2rtc_client import Stream from go2rtc_client.exceptions import Go2RtcClientError, Go2RtcVersionError from go2rtc_client.models import Producer @@ -36,10 +37,12 @@ from homeassistant.components.go2rtc.const import ( CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, + RECOMMENDED_VERSION, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component @@ -199,6 +202,7 @@ async def init_test_integration( async def _test_setup_and_signaling( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, config: ConfigType, @@ -211,6 +215,7 @@ async def _test_setup_and_signaling( assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done(wait_background_tasks=True) + assert issue_registry.async_get_issue(DOMAIN, "recommended_version") is None config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 assert config_entries[0].state == ConfigEntryState.LOADED @@ -306,6 +311,7 @@ async def _test_setup_and_signaling( @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) async def test_setup_go_binary( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, server: AsyncMock, @@ -324,7 +330,13 @@ async def test_setup_go_binary( server_start.assert_called_once() await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + hass, + issue_registry, + rest_client, + ws_client, + config, + after_setup, + init_test_integration, ) await hass.async_stop() @@ -340,8 +352,9 @@ async def test_setup_go_binary( ], ) @pytest.mark.parametrize("has_go2rtc_entry", [True, False]) -async def test_setup_go( +async def test_setup( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, rest_client: AsyncMock, ws_client: Mock, server: Mock, @@ -359,7 +372,13 @@ async def test_setup_go( server.assert_not_called() await _test_setup_and_signaling( - hass, rest_client, ws_client, config, after_setup, init_test_integration + hass, + issue_registry, + rest_client, + ws_client, + config, + after_setup, + init_test_integration, ) mock_get_binary.assert_not_called() @@ -711,3 +730,30 @@ async def test_config_entry_remove(hass: HomeAssistant) -> None: assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert not await hass.config_entries.async_setup(config_entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + +@pytest.mark.parametrize("config", [{DOMAIN: {CONF_URL: "http://localhost:1984"}}]) +@pytest.mark.usefixtures("server") +async def test_setup_with_recommended_version_repair( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + rest_client: AsyncMock, + config: ConfigType, +) -> None: + """Test setup integration entry fails.""" + rest_client.validate_server_version.return_value = AwesomeVersion("1.9.5") + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify the issue is created + issue = issue_registry.async_get_issue(DOMAIN, "recommended_version") + assert issue + assert issue.is_fixable is False + assert issue.is_persistent is False + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.issue_id == "recommended_version" + assert issue.translation_key == "recommended_version" + assert issue.translation_placeholders == { + "recommended_version": RECOMMENDED_VERSION, + "current_version": "1.9.5", + } From a06e7e31b9fb7629fe654515eb85e6722eb19807 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Nov 2024 11:06:38 +0100 Subject: [PATCH 471/511] Bump github/codeql-action from 3.27.1 to 3.27.3 (#130489) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.27.1 to 3.27.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.27.1...v3.27.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2c80c32245c..48e37717232 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.2.2 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.27.1 + uses: github/codeql-action/init@v3.27.3 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.27.1 + uses: github/codeql-action/analyze@v3.27.3 with: category: "/language:python" From e90893e2bc25e4f1c08ad699b4b17d985ffba394 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 11:43:31 +0100 Subject: [PATCH 472/511] Fix Music Assistant manifest (#130515) --- homeassistant/components/music_assistant/manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 23401f30abc..65e6652407f 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -4,9 +4,8 @@ "after_dependencies": ["media_source", "media_player"], "codeowners": ["@music-assistant"], "config_flow": true, - "documentation": "https://music-assistant.io", + "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", - "issue_tracker": "https://github.com/music-assistant/hass-music-assistant/issues", "loggers": ["music_assistant"], "requirements": ["music-assistant-client==1.0.5"], "zeroconf": ["_mass._tcp.local."] From b270e4556c395af63b325d3a0681d12e4f904e0f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 12:16:07 +0100 Subject: [PATCH 473/511] Avoid core manifest to have an issue tracker (#130514) --- script/hassfest/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 6d2f4087f59..4013c8a6c19 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -268,7 +268,6 @@ INTEGRATION_MANIFEST_SCHEMA = vol.Schema( ) ], vol.Required("documentation"): vol.All(vol.Url(), documentation_url), - vol.Optional("issue_tracker"): vol.Url(), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), vol.Optional("requirements"): [str], vol.Optional("dependencies"): [str], @@ -304,6 +303,7 @@ def manifest_schema(value: dict[str, Any]) -> vol.Schema: CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend( { vol.Optional("version"): vol.All(str, verify_version), + vol.Optional("issue_tracker"): vol.Url(), vol.Optional("import_executor"): bool, } ) From b78453b85b524ff422774fff2b549ac7cde23f55 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Nov 2024 12:21:15 +0100 Subject: [PATCH 474/511] Bump aiowithings to 3.1.3 (#130504) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index c24bdb743bf..f9e8328ae53 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==3.1.2"] + "requirements": ["aiowithings==3.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a27f4d3b04..334d36f0840 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.2 +aiowithings==3.1.3 # homeassistant.components.yandex_transport aioymaps==1.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38704005179..c8d4fb15883 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -399,7 +399,7 @@ aiowatttime==0.1.1 aiowebostv==0.4.2 # homeassistant.components.withings -aiowithings==3.1.2 +aiowithings==3.1.3 # homeassistant.components.yandex_transport aioymaps==1.2.5 From ab11b8467808831a53318b8eb42cd2c1f7e3eb00 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:01:54 +0100 Subject: [PATCH 475/511] Improve type hints in fritzbox config flow (#130509) --- homeassistant/components/fritzbox/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 76754fc5082..ffec4a9ea29 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -43,10 +43,11 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _name: str + def __init__(self) -> None: """Initialize flow.""" self._host: str | None = None - self._name: str | None = None self._password: str | None = None self._username: str | None = None @@ -158,7 +159,6 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): result = await self.async_try_connect() if result == RESULT_SUCCESS: - assert self._name is not None return self._get_entry(self._name) if result != RESULT_INVALID_AUTH: return self.async_abort(reason=result) From 8300afc00d434dc53e172e7b3f2270915593b3fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 13 Nov 2024 13:45:52 +0100 Subject: [PATCH 476/511] Improve type hints in fritz config flow (#130511) * Improve type hints in fritz config flow * Improve coverage * Apply suggestions from code review Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --------- Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- homeassistant/components/fritz/config_flow.py | 14 ++++++----- tests/components/fritz/test_config_flow.py | 24 +++++++++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index ec9ffdd7554..920ecda1c52 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -57,6 +57,8 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str + @staticmethod @callback def async_get_options_flow( @@ -67,7 +69,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize FRITZ!Box Tools flow.""" - self._host: str | None = None self._name: str = "" self._password: str = "" self._use_tls: bool = False @@ -112,7 +113,6 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): async def async_check_configured_entry(self) -> ConfigEntry | None: """Check if entry is configured.""" - assert self._host current_host = await self.hass.async_add_executor_job( socket.gethostbyname, self._host ) @@ -154,15 +154,17 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" ssdp_location: ParseResult = urlparse(discovery_info.ssdp_location or "") - self._host = ssdp_location.hostname + host = ssdp_location.hostname + if not host or ipaddress.ip_address(host).is_link_local: + return self.async_abort(reason="ignore_ip6_link_local") + + self._host = host self._name = ( discovery_info.upnp.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) or discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] ) - if not self._host or ipaddress.ip_address(self._host).is_link_local: - return self.async_abort(reason="ignore_ip6_link_local") - + uuid: str | None if uuid := discovery_info.upnp.get(ssdp.ATTR_UPNP_UDN): if uuid.startswith("uuid:"): uuid = uuid[5:] diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index e3fae8c083e..84f1b240b88 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -10,6 +10,7 @@ from fritzconnection.core.exceptions import ( ) import pytest +from homeassistant.components import ssdp from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, @@ -22,7 +23,6 @@ from homeassistant.components.fritz.const import ( ERROR_UNKNOWN, FRITZ_AUTH_EXCEPTIONS, ) -from homeassistant.components.ssdp import ATTR_UPNP_UDN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_HOST, @@ -644,7 +644,7 @@ async def test_ssdp_already_in_progress_host( MOCK_NO_UNIQUE_ID = dataclasses.replace(MOCK_SSDP_DATA) MOCK_NO_UNIQUE_ID.upnp = MOCK_NO_UNIQUE_ID.upnp.copy() - del MOCK_NO_UNIQUE_ID.upnp[ATTR_UPNP_UDN] + del MOCK_NO_UNIQUE_ID.upnp[ssdp.ATTR_UPNP_UDN] result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_SSDP}, data=MOCK_NO_UNIQUE_ID ) @@ -737,3 +737,23 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_OLD_DISCOVERY: False, CONF_CONSIDER_HOME: 37, } + + +async def test_ssdp_ipv6_link_local(hass: HomeAssistant) -> None: + """Test ignoring ipv6-link-local while ssdp discovery.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="https://[fe80::1ff:fe23:4567:890a]:12345/test", + upnp={ + ssdp.ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ssdp.ATTR_UPNP_UDN: "uuid:only-a-test", + }, + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "ignore_ip6_link_local" From f6bc5f050ec92cac140013b76e025d8ff94f24ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 13 Nov 2024 14:28:19 +0100 Subject: [PATCH 477/511] Bump millheater to 0.12.2 (#130454) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 16e7bf552ba..6316eb72096 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.8", "mill-local==0.3.0"] + "requirements": ["millheater==0.12.2", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 334d36f0840..e562f218f83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1373,7 +1373,7 @@ microBeesPy==0.3.2 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.8 +millheater==0.12.2 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8d4fb15883..d74f9f8ba95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1142,7 +1142,7 @@ microBeesPy==0.3.2 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.8 +millheater==0.12.2 # homeassistant.components.minio minio==7.1.12 From 72b976f8322ad867aafe15eaa103f58f71d06a56 Mon Sep 17 00:00:00 2001 From: dunnmj Date: Wed, 13 Nov 2024 13:29:04 +0000 Subject: [PATCH 478/511] Add Sky remote integration (#124507) Co-authored-by: Kyle Cooke Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/brands/sky.json | 5 + .../components/sky_remote/__init__.py | 39 ++++++ .../components/sky_remote/config_flow.py | 64 +++++++++ homeassistant/components/sky_remote/const.py | 6 + .../components/sky_remote/manifest.json | 10 ++ homeassistant/components/sky_remote/remote.py | 70 ++++++++++ .../components/sky_remote/strings.json | 21 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 ++- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sky_remote/__init__.py | 13 ++ tests/components/sky_remote/conftest.py | 47 +++++++ .../components/sky_remote/test_config_flow.py | 125 ++++++++++++++++++ tests/components/sky_remote/test_init.py | 59 +++++++++ tests/components/sky_remote/test_remote.py | 46 +++++++ 17 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/sky.json create mode 100644 homeassistant/components/sky_remote/__init__.py create mode 100644 homeassistant/components/sky_remote/config_flow.py create mode 100644 homeassistant/components/sky_remote/const.py create mode 100644 homeassistant/components/sky_remote/manifest.json create mode 100644 homeassistant/components/sky_remote/remote.py create mode 100644 homeassistant/components/sky_remote/strings.json create mode 100644 tests/components/sky_remote/__init__.py create mode 100644 tests/components/sky_remote/conftest.py create mode 100644 tests/components/sky_remote/test_config_flow.py create mode 100644 tests/components/sky_remote/test_init.py create mode 100644 tests/components/sky_remote/test_remote.py diff --git a/CODEOWNERS b/CODEOWNERS index 022eda00123..76422734c92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1344,6 +1344,8 @@ build.json @home-assistant/supervisor /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sky_hub/ @rogerselwyn +/homeassistant/components/sky_remote/ @dunnmj @saty9 +/tests/components/sky_remote/ @dunnmj @saty9 /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @tkdrob @fletcherau diff --git a/homeassistant/brands/sky.json b/homeassistant/brands/sky.json new file mode 100644 index 00000000000..3ab0cbbe5bd --- /dev/null +++ b/homeassistant/brands/sky.json @@ -0,0 +1,5 @@ +{ + "domain": "sky", + "name": "Sky", + "integrations": ["sky_hub", "sky_remote"] +} diff --git a/homeassistant/components/sky_remote/__init__.py b/homeassistant/components/sky_remote/__init__.py new file mode 100644 index 00000000000..4daad78c558 --- /dev/null +++ b/homeassistant/components/sky_remote/__init__.py @@ -0,0 +1,39 @@ +"""The Sky Remote Control integration.""" + +import logging + +from skyboxremote import RemoteControl, SkyBoxConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +PLATFORMS = [Platform.REMOTE] + +_LOGGER = logging.getLogger(__name__) + + +type SkyRemoteConfigEntry = ConfigEntry[RemoteControl] + + +async def async_setup_entry(hass: HomeAssistant, entry: SkyRemoteConfigEntry) -> bool: + """Set up Sky remote.""" + host = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + _LOGGER.debug("Setting up Host: %s, Port: %s", host, port) + remote = RemoteControl(host, port) + try: + await remote.check_connectable() + except SkyBoxConnectionError as e: + raise ConfigEntryNotReady from e + + entry.runtime_data = remote + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py new file mode 100644 index 00000000000..a55dfb2a52b --- /dev/null +++ b/homeassistant/components/sky_remote/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for sky_remote.""" + +import logging +from typing import Any + +from skyboxremote import RemoteControl, SkyBoxConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + } +) + + +async def async_find_box_port(host: str) -> int: + """Find port box uses for communication.""" + logging.debug("Attempting to find port to connect to %s on", host) + remote = RemoteControl(host, DEFAULT_PORT) + try: + await remote.check_connectable() + except SkyBoxConnectionError: + # Try legacy port if the default one failed + remote = RemoteControl(host, LEGACY_PORT) + await remote.check_connectable() + return LEGACY_PORT + return DEFAULT_PORT + + +class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sky Remote.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + + errors: dict[str, str] = {} + if user_input is not None: + logging.debug("user_input: %s", user_input) + self._async_abort_entries_match(user_input) + try: + port = await async_find_box_port(user_input[CONF_HOST]) + except SkyBoxConnectionError: + logging.exception("while finding port of skybox") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data={**user_input, CONF_PORT: port}, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/sky_remote/const.py b/homeassistant/components/sky_remote/const.py new file mode 100644 index 00000000000..e67744a741b --- /dev/null +++ b/homeassistant/components/sky_remote/const.py @@ -0,0 +1,6 @@ +"""Constants.""" + +DOMAIN = "sky_remote" + +DEFAULT_PORT = 49160 +LEGACY_PORT = 5900 diff --git a/homeassistant/components/sky_remote/manifest.json b/homeassistant/components/sky_remote/manifest.json new file mode 100644 index 00000000000..b00ff309b10 --- /dev/null +++ b/homeassistant/components/sky_remote/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sky_remote", + "name": "Sky Remote Control", + "codeowners": ["@dunnmj", "@saty9"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sky_remote", + "integration_type": "device", + "iot_class": "assumed_state", + "requirements": ["skyboxremote==0.0.6"] +} diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py new file mode 100644 index 00000000000..05a464f73a6 --- /dev/null +++ b/homeassistant/components/sky_remote/remote.py @@ -0,0 +1,70 @@ +"""Home Assistant integration to control a sky box using the remote platform.""" + +from collections.abc import Iterable +import logging +from typing import Any + +from skyboxremote import VALID_KEYS, RemoteControl + +from homeassistant.components.remote import RemoteEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SkyRemoteConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: SkyRemoteConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Sky remote platform.""" + async_add_entities( + [SkyRemote(config.runtime_data, config.entry_id)], + True, + ) + + +class SkyRemote(RemoteEntity): + """Representation of a Sky Remote.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, remote: RemoteControl, unique_id: str) -> None: + """Initialize the Sky Remote.""" + self._remote = remote + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="SKY", + model="Sky Box", + name=remote.host, + ) + + def turn_on(self, activity: str | None = None, **kwargs: Any) -> None: + """Send the power on command.""" + self.send_command(["sky"]) + + def turn_off(self, activity: str | None = None, **kwargs: Any) -> None: + """Send the power command.""" + self.send_command(["power"]) + + def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a list of commands to the device.""" + for cmd in command: + if cmd not in VALID_KEYS: + raise ServiceValidationError( + f"{cmd} is not in Valid Keys: {VALID_KEYS}" + ) + try: + self._remote.send_keys(command) + except ValueError as err: + _LOGGER.error("Invalid command: %s. Error: %s", command, err) + return + _LOGGER.debug("Successfully sent command %s", command) diff --git a/homeassistant/components/sky_remote/strings.json b/homeassistant/components/sky_remote/strings.json new file mode 100644 index 00000000000..af794490c43 --- /dev/null +++ b/homeassistant/components/sky_remote/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Add Sky Remote", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Sky device" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cbd30b560ce..78e16126542 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -537,6 +537,7 @@ FLOWS = { "simplefin", "simplepush", "simplisafe", + "sky_remote", "skybell", "slack", "sleepiq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a1fdb9478f3..33a7d02776f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5608,11 +5608,22 @@ "config_flow": false, "iot_class": "local_push" }, - "sky_hub": { - "name": "Sky Hub", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "sky": { + "name": "Sky", + "integrations": { + "sky_hub": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Sky Hub" + }, + "sky_remote": { + "integration_type": "device", + "config_flow": true, + "iot_class": "assumed_state", + "name": "Sky Remote Control" + } + } }, "skybeacon": { "name": "Skybeacon", diff --git a/requirements_all.txt b/requirements_all.txt index e562f218f83..97416c7ea39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2673,6 +2673,9 @@ simplisafe-python==2024.01.0 # homeassistant.components.sisyphus sisyphus-control==3.1.4 +# homeassistant.components.sky_remote +skyboxremote==0.0.6 + # homeassistant.components.slack slackclient==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74f9f8ba95..3ffc1547722 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2131,6 +2131,9 @@ simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2024.01.0 +# homeassistant.components.sky_remote +skyboxremote==0.0.6 + # homeassistant.components.slack slackclient==2.5.0 diff --git a/tests/components/sky_remote/__init__.py b/tests/components/sky_remote/__init__.py new file mode 100644 index 00000000000..83d68330d5b --- /dev/null +++ b/tests/components/sky_remote/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Sky Remote component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_mock_entry(hass: HomeAssistant, entry: MockConfigEntry): + """Initialize a mock config entry.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() diff --git a/tests/components/sky_remote/conftest.py b/tests/components/sky_remote/conftest.py new file mode 100644 index 00000000000..d6c453d81f7 --- /dev/null +++ b/tests/components/sky_remote/conftest.py @@ -0,0 +1,47 @@ +"""Test mocks and fixtures.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.sky_remote.const import DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +SAMPLE_CONFIG = {CONF_HOST: "example.com", CONF_PORT: DEFAULT_PORT} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry(domain=DOMAIN, data=SAMPLE_CONFIG) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Stub out setup function.""" + with patch( + "homeassistant.components.sky_remote.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_remote_control(request: pytest.FixtureRequest) -> Generator[MagicMock]: + """Mock skyboxremote library.""" + with ( + patch( + "homeassistant.components.sky_remote.RemoteControl" + ) as mock_remote_control, + patch( + "homeassistant.components.sky_remote.config_flow.RemoteControl", + mock_remote_control, + ), + ): + mock_remote_control._instance_mock = MagicMock(host="example.com") + mock_remote_control._instance_mock.check_connectable = AsyncMock(True) + mock_remote_control.return_value = mock_remote_control._instance_mock + yield mock_remote_control diff --git a/tests/components/sky_remote/test_config_flow.py b/tests/components/sky_remote/test_config_flow.py new file mode 100644 index 00000000000..aaeda20788c --- /dev/null +++ b/tests/components/sky_remote/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Sky Remote config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from skyboxremote import LEGACY_PORT, SkyBoxConnectionError + +from homeassistant.components.sky_remote.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import SAMPLE_CONFIG + + +async def test_user_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_remote_control +) -> None: + """Test we can setup an entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == SAMPLE_CONFIG + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_device_exists_abort( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test we abort flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: mock_config_entry.data[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_remote_control", [LEGACY_PORT], indirect=True) +async def test_user_flow_legacy_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_remote_control, +) -> None: + """Test we can setup an entry with a legacy port.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + async def mock_check_connectable(): + if mock_remote_control.call_args[0][1] == LEGACY_PORT: + return True + raise SkyBoxConnectionError("Wrong port") + + mock_remote_control._instance_mock.check_connectable = mock_check_connectable + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {**SAMPLE_CONFIG, CONF_PORT: LEGACY_PORT} + + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("mock_remote_control", [6], indirect=True) +async def test_user_flow_unconnectable( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_remote_control, +) -> None: + """Test we can setup an entry.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + mock_remote_control._instance_mock.check_connectable = AsyncMock( + side_effect=SkyBoxConnectionError("Example") + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + assert len(mock_setup_entry.mock_calls) == 0 + + mock_remote_control._instance_mock.check_connectable = AsyncMock(True) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: SAMPLE_CONFIG[CONF_HOST]}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == SAMPLE_CONFIG + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/sky_remote/test_init.py b/tests/components/sky_remote/test_init.py new file mode 100644 index 00000000000..fe316baa6bf --- /dev/null +++ b/tests/components/sky_remote/test_init.py @@ -0,0 +1,59 @@ +"""Tests for the Sky Remote component.""" + +from unittest.mock import AsyncMock + +from skyboxremote import SkyBoxConnectionError + +from homeassistant.components.sky_remote.const import DEFAULT_PORT, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_mock_entry + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_remote_control, + device_registry: dr.DeviceRegistry, +) -> None: + """Test successful setup of entry.""" + await setup_mock_entry(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + mock_remote_control.assert_called_once_with("example.com", DEFAULT_PORT) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert device_entry is not None + assert device_entry.name == "example.com" + + +async def test_setup_unconnectable_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_remote_control, +) -> None: + """Test unsuccessful setup of entry.""" + mock_remote_control._instance_mock.check_connectable = AsyncMock( + side_effect=SkyBoxConnectionError() + ) + + await setup_mock_entry(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_remote_control +) -> None: + """Test unload an entry.""" + await setup_mock_entry(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sky_remote/test_remote.py b/tests/components/sky_remote/test_remote.py new file mode 100644 index 00000000000..301375bc039 --- /dev/null +++ b/tests/components/sky_remote/test_remote.py @@ -0,0 +1,46 @@ +"""Test sky_remote remote.""" + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_mock_entry + +ENTITY_ID = "remote.example_com" + + +async def test_send_command( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test "send_command" method.""" + await setup_mock_entry(hass, mock_config_entry) + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["sky"]}, + blocking=True, + ) + mock_remote_control._instance_mock.send_keys.assert_called_once_with(["sky"]) + + +async def test_send_invalid_command( + hass: HomeAssistant, mock_config_entry, mock_remote_control +) -> None: + """Test "send_command" method.""" + await setup_mock_entry(hass, mock_config_entry) + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["apple"]}, + blocking=True, + ) + mock_remote_control._instance_mock.send_keys.assert_not_called() From ac4cb52dbbda03307a938a2c561a2afcbb2365a8 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 13 Nov 2024 14:04:23 +0000 Subject: [PATCH 479/511] Bump ring-doorbell to 0.9.12 (#130419) --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 63c47cb2979..e431c680081 100644 --- a/homeassistant/components/ring/manifest.json +++ b/homeassistant/components/ring/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_polling", "loggers": ["ring_doorbell"], "quality_scale": "silver", - "requirements": ["ring-doorbell==0.9.9"] + "requirements": ["ring-doorbell==0.9.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 97416c7ea39..3de766e93c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2562,7 +2562,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.9 +ring-doorbell==0.9.12 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ffc1547722..b492a6f7020 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2050,7 +2050,7 @@ reolink-aio==0.11.0 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell==0.9.9 +ring-doorbell==0.9.12 # homeassistant.components.roku rokuecp==0.19.3 From 093b16c7235a0ee69d88ff102e2838a747a96692 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Nov 2024 16:16:49 +0100 Subject: [PATCH 480/511] Make WS command backup/generate send events (#130524) * Make WS command backup/generate send events * Update backup.create service --- homeassistant/components/backup/__init__.py | 4 +- homeassistant/components/backup/manager.py | 62 ++++++++++-- homeassistant/components/backup/websocket.py | 11 ++- tests/components/backup/conftest.py | 73 ++++++++++++++ .../backup/snapshots/test_websocket.ambr | 17 +++- tests/components/backup/test_manager.py | 99 ++++++++----------- tests/components/backup/test_websocket.py | 18 ++-- 7 files changed, 199 insertions(+), 85 deletions(-) create mode 100644 tests/components/backup/conftest.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 200cb4a3f65..907fda4c7f8 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -32,7 +32,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_handle_create_service(call: ServiceCall) -> None: """Service handler for creating backups.""" - await backup_manager.async_create_backup() + await backup_manager.async_create_backup(on_progress=None) + if backup_task := backup_manager.backup_task: + await backup_task hass.services.async_register(DOMAIN, "create", async_handle_create_service) diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 4300f75eed0..ddc0a1eac3f 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -4,6 +4,7 @@ from __future__ import annotations import abc import asyncio +from collections.abc import Callable from dataclasses import asdict, dataclass import hashlib import io @@ -34,6 +35,13 @@ from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER BUF_SIZE = 2**20 * 4 # 4MB +@dataclass(slots=True) +class NewBackup: + """New backup class.""" + + slug: str + + @dataclass(slots=True) class Backup: """Backup class.""" @@ -49,6 +57,15 @@ class Backup: return {**asdict(self), "path": self.path.as_posix()} +@dataclass(slots=True) +class BackupProgress: + """Backup progress class.""" + + done: bool + stage: str | None + success: bool | None + + class BackupPlatformProtocol(Protocol): """Define the format that backup platforms can have.""" @@ -65,7 +82,7 @@ class BaseBackupManager(abc.ABC): def __init__(self, hass: HomeAssistant) -> None: """Initialize the backup manager.""" self.hass = hass - self.backing_up = False + self.backup_task: asyncio.Task | None = None self.backups: dict[str, Backup] = {} self.loaded_platforms = False self.platforms: dict[str, BackupPlatformProtocol] = {} @@ -133,7 +150,12 @@ class BaseBackupManager(abc.ABC): """Restore a backup.""" @abc.abstractmethod - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def async_create_backup( + self, + *, + on_progress: Callable[[BackupProgress], None] | None, + **kwargs: Any, + ) -> NewBackup: """Generate a backup.""" @abc.abstractmethod @@ -292,17 +314,36 @@ class BackupManager(BaseBackupManager): await self.hass.async_add_executor_job(_move_and_cleanup) await self.load_backups() - async def async_create_backup(self, **kwargs: Any) -> Backup: + async def async_create_backup( + self, + *, + on_progress: Callable[[BackupProgress], None] | None, + **kwargs: Any, + ) -> NewBackup: """Generate a backup.""" - if self.backing_up: + if self.backup_task: raise HomeAssistantError("Backup already in progress") + backup_name = f"Core {HAVERSION}" + date_str = dt_util.now().isoformat() + slug = _generate_slug(date_str, backup_name) + self.backup_task = self.hass.async_create_task( + self._async_create_backup(backup_name, date_str, slug, on_progress), + name="backup_manager_create_backup", + eager_start=False, # To ensure the task is not started before we return + ) + return NewBackup(slug=slug) + async def _async_create_backup( + self, + backup_name: str, + date_str: str, + slug: str, + on_progress: Callable[[BackupProgress], None] | None, + ) -> Backup: + """Generate a backup.""" + success = False try: - self.backing_up = True await self.async_pre_backup_actions() - backup_name = f"Core {HAVERSION}" - date_str = dt_util.now().isoformat() - slug = _generate_slug(date_str, backup_name) backup_data = { "slug": slug, @@ -329,9 +370,12 @@ class BackupManager(BaseBackupManager): if self.loaded_backups: self.backups[slug] = backup LOGGER.debug("Generated new backup with slug %s", slug) + success = True return backup finally: - self.backing_up = False + if on_progress: + on_progress(BackupProgress(done=True, stage=None, success=success)) + self.backup_task = None await self.async_post_backup_actions() def _mkdir_and_generate_backup_contents( diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 3ac8a7ace3e..a7c61b7c66c 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -8,6 +8,7 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from .const import DATA_MANAGER, LOGGER +from .manager import BackupProgress @callback @@ -40,7 +41,7 @@ async def handle_info( msg["id"], { "backups": list(backups.values()), - "backing_up": manager.backing_up, + "backing_up": manager.backup_task is not None, }, ) @@ -113,7 +114,11 @@ async def handle_create( msg: dict[str, Any], ) -> None: """Generate a backup.""" - backup = await hass.data[DATA_MANAGER].async_create_backup() + + def on_progress(progress: BackupProgress) -> None: + connection.send_message(websocket_api.event_message(msg["id"], progress)) + + backup = await hass.data[DATA_MANAGER].async_create_backup(on_progress=on_progress) connection.send_result(msg["id"], backup) @@ -127,7 +132,6 @@ async def handle_backup_start( ) -> None: """Backup start notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = True LOGGER.debug("Backup start notification") try: @@ -149,7 +153,6 @@ async def handle_backup_end( ) -> None: """Backup end notification.""" manager = hass.data[DATA_MANAGER] - manager.backing_up = False LOGGER.debug("Backup end notification") try: diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py new file mode 100644 index 00000000000..631c774e63c --- /dev/null +++ b/tests/components/backup/conftest.py @@ -0,0 +1,73 @@ +"""Test fixtures for the Backup integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="mocked_json_bytes") +def mocked_json_bytes_fixture() -> Generator[Mock]: + """Mock json_bytes.""" + with patch( + "homeassistant.components.backup.manager.json_bytes", + return_value=b"{}", # Empty JSON + ) as mocked_json_bytes: + yield mocked_json_bytes + + +@pytest.fixture(name="mocked_tarfile") +def mocked_tarfile_fixture() -> Generator[Mock]: + """Mock tarfile.""" + with patch( + "homeassistant.components.backup.manager.SecureTarFile" + ) as mocked_tarfile: + yield mocked_tarfile + + +@pytest.fixture(name="mock_backup_generation") +def mock_backup_generation_fixture( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> Generator[None]: + """Mock backup generator.""" + + def _mock_iterdir(path: Path) -> list[Path]: + if not path.name.endswith("testing_config"): + return [] + return [ + Path("test.txt"), + Path(".DS_Store"), + Path(".storage"), + ] + + with ( + patch("pathlib.Path.iterdir", _mock_iterdir), + patch("pathlib.Path.stat", MagicMock(st_size=123)), + patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), + patch( + "pathlib.Path.is_dir", + lambda x: x.name == ".storage", + ), + patch( + "pathlib.Path.exists", + lambda x: x != Path(hass.config.path("backups")), + ), + patch( + "pathlib.Path.is_symlink", + lambda _: False, + ), + patch( + "pathlib.Path.mkdir", + MagicMock(), + ), + patch( + "homeassistant.components.backup.manager.HAVERSION", + "2025.1.0", + ), + ): + yield diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 096df37d704..42eb524e529 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -210,16 +210,23 @@ dict({ 'id': 1, 'result': dict({ - 'date': '1970-01-01T00:00:00.000Z', - 'name': 'Test', - 'path': 'abc123.tar', - 'size': 0.0, - 'slug': 'abc123', + 'slug': '27f5c632', }), 'success': True, 'type': 'result', }) # --- +# name: test_generate[without_hassio].1 + dict({ + 'event': dict({ + 'done': True, + 'stage': None, + 'success': True, + }), + 'id': 1, + 'type': 'event', + }) +# --- # name: test_info[with_hassio] dict({ 'error': dict({ diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index a3f70267643..9d24964aedf 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pathlib import Path +import asyncio from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch import aiohttp @@ -10,7 +10,10 @@ from multidict import CIMultiDict, CIMultiDictProxy import pytest from homeassistant.components.backup import BackupManager -from homeassistant.components.backup.manager import BackupPlatformProtocol +from homeassistant.components.backup.manager import ( + BackupPlatformProtocol, + BackupProgress, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -20,59 +23,30 @@ from .common import TEST_BACKUP from tests.common import MockPlatform, mock_platform -async def _mock_backup_generation(manager: BackupManager): +async def _mock_backup_generation( + manager: BackupManager, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Mock backup generator.""" - def _mock_iterdir(path: Path) -> list[Path]: - if not path.name.endswith("testing_config"): - return [] - return [ - Path("test.txt"), - Path(".DS_Store"), - Path(".storage"), - ] + progress: list[BackupProgress] = [] - with ( - patch( - "homeassistant.components.backup.manager.SecureTarFile" - ) as mocked_tarfile, - patch("pathlib.Path.iterdir", _mock_iterdir), - patch("pathlib.Path.stat", MagicMock(st_size=123)), - patch("pathlib.Path.is_file", lambda x: x.name != ".storage"), - patch( - "pathlib.Path.is_dir", - lambda x: x.name == ".storage", - ), - patch( - "pathlib.Path.exists", - lambda x: x != manager.backup_dir, - ), - patch( - "pathlib.Path.is_symlink", - lambda _: False, - ), - patch( - "pathlib.Path.mkdir", - MagicMock(), - ), - patch( - "homeassistant.components.backup.manager.json_bytes", - return_value=b"{}", # Empty JSON - ) as mocked_json_bytes, - patch( - "homeassistant.components.backup.manager.HAVERSION", - "2025.1.0", - ), - ): - await manager.async_create_backup() + def on_progress(_progress: BackupProgress) -> None: + """Mock progress callback.""" + progress.append(_progress) - assert mocked_json_bytes.call_count == 1 - backup_json_dict = mocked_json_bytes.call_args[0][0] - assert isinstance(backup_json_dict, dict) - assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} - assert manager.backup_dir.as_posix() in str( - mocked_tarfile.call_args_list[0][0][0] - ) + assert manager.backup_task is None + await manager.async_create_backup(on_progress=on_progress) + assert manager.backup_task is not None + assert progress == [] + + await manager.backup_task + assert progress == [BackupProgress(done=True, stage=None, success=True)] + + assert mocked_json_bytes.call_count == 1 + backup_json_dict = mocked_json_bytes.call_args[0][0] + assert isinstance(backup_json_dict, dict) + assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} + assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) async def _setup_mock_domain( @@ -176,21 +150,26 @@ async def test_getting_backup_that_does_not_exist( async def test_async_create_backup_when_backing_up(hass: HomeAssistant) -> None: """Test generate backup.""" + event = asyncio.Event() manager = BackupManager(hass) - manager.backing_up = True + manager.backup_task = hass.async_create_task(event.wait()) with pytest.raises(HomeAssistantError, match="Backup already in progress"): - await manager.async_create_backup() + await manager.async_create_backup(on_progress=None) + event.set() +@pytest.mark.usefixtures("mock_backup_generation") async def test_async_create_backup( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + mocked_json_bytes: Mock, + mocked_tarfile: Mock, ) -> None: """Test generate backup.""" manager = BackupManager(hass) manager.loaded_backups = True - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) assert "Generated new backup with slug " in caplog.text assert "Creating backup directory" in caplog.text @@ -247,7 +226,9 @@ async def test_not_loading_bad_platforms( ) -async def test_exception_plaform_pre(hass: HomeAssistant) -> None: +async def test_exception_plaform_pre( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Test exception in pre step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -264,10 +245,12 @@ async def test_exception_plaform_pre(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) -async def test_exception_plaform_post(hass: HomeAssistant) -> None: +async def test_exception_plaform_post( + hass: HomeAssistant, mocked_json_bytes: Mock, mocked_tarfile: Mock +) -> None: """Test exception in post step.""" manager = BackupManager(hass) manager.loaded_backups = True @@ -284,7 +267,7 @@ async def test_exception_plaform_post(hass: HomeAssistant) -> None: ) with pytest.raises(HomeAssistantError): - await _mock_backup_generation(manager) + await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile) async def test_loading_platforms_when_running_async_pre_backup_actions( diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 125ba8adaad..3e031f172ae 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -115,29 +116,30 @@ async def test_remove( @pytest.mark.parametrize( - "with_hassio", + ("with_hassio", "number_of_messages"), [ - pytest.param(True, id="with_hassio"), - pytest.param(False, id="without_hassio"), + pytest.param(True, 1, id="with_hassio"), + pytest.param(False, 2, id="without_hassio"), ], ) +@pytest.mark.usefixtures("mock_backup_generation") async def test_generate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, with_hassio: bool, + number_of_messages: int, ) -> None: """Test generating a backup.""" await setup_backup_integration(hass, with_hassio=with_hassio) client = await hass_ws_client(hass) + freezer.move_to("2024-11-13 12:01:00+01:00") await hass.async_block_till_done() - with patch( - "homeassistant.components.backup.manager.BackupManager.async_create_backup", - return_value=TEST_BACKUP, - ): - await client.send_json_auto_id({"type": "backup/generate"}) + await client.send_json_auto_id({"type": "backup/generate"}) + for _ in range(number_of_messages): assert snapshot == await client.receive_json() From 5f68d405b2fa0f08959dcb38a33444c6c330ee94 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Nov 2024 17:26:27 +0100 Subject: [PATCH 481/511] Update huum to 0.7.12 (#130527) --- homeassistant/components/huum/__init__.py | 15 ++++----------- homeassistant/components/huum/climate.py | 12 +++++------- homeassistant/components/huum/config_flow.py | 7 ++----- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/huum/conftest.py | 6 ------ 7 files changed, 14 insertions(+), 32 deletions(-) delete mode 100644 tests/components/huum/conftest.py diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index c533ca34ef3..75faf1923df 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -3,30 +3,23 @@ from __future__ import annotations import logging -import sys + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS -if sys.version_info < (3, 13): - from huum.exceptions import Forbidden, NotAuthenticated - from huum.huum import Huum - _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huum from a config entry.""" - if sys.version_info >= (3, 13): - raise HomeAssistantError( - "Huum is not supported on Python 3.13. Please use Python 3.12." - ) - username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index b659e33038a..df740aea3d1 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -3,9 +3,13 @@ from __future__ import annotations import logging -import sys from typing import Any +from huum.const import SaunaStatus +from huum.exceptions import SafetyException +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -20,12 +24,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -if sys.version_info < (3, 13): - from huum.const import SaunaStatus - from huum.exceptions import SafetyException - from huum.huum import Huum - from huum.schemas import HuumStatusResponse - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 10c31378184..6a5fd96b99d 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -3,9 +3,10 @@ from __future__ import annotations import logging -import sys from typing import Any +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -14,10 +15,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN -if sys.version_info < (3, 13): - from huum.exceptions import Forbidden, NotAuthenticated - from huum.huum import Huum - _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 025d1b97f21..38562e1a072 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.11;python_version<'3.13'"] + "requirements": ["huum==0.7.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3de766e93c7..00984b9a5a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1148,7 +1148,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11;python_version<'3.13' +huum==0.7.12 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b492a6f7020..ffda690bc33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -971,7 +971,7 @@ httplib2==0.20.4 huawei-lte-api==1.10.0 # homeassistant.components.huum -huum==0.7.11;python_version<'3.13' +huum==0.7.12 # homeassistant.components.hyperion hyperion-py==0.7.5 diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py deleted file mode 100644 index da66cc54b72..00000000000 --- a/tests/components/huum/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Skip test collection for Python 3.13.""" - -import sys - -if sys.version_info >= (3, 13): - collect_ignore_glob = ["test_*.py"] From 7fd337d67f2ff1b1cfcbc61c36c1b7583a6cfcee Mon Sep 17 00:00:00 2001 From: Brig Lamoreaux Date: Wed, 13 Nov 2024 10:42:26 -0700 Subject: [PATCH 482/511] fix translation in srp_energy (#130540) --- homeassistant/components/srp_energy/strings.json | 3 ++- tests/components/srp_energy/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 191d10a70dd..eca4f465435 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -17,7 +17,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "unknown": "Unexpected error" } }, "entity": { diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index 149e08014ac..e3abb3c98df 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -100,10 +100,6 @@ async def test_form_invalid_auth( assert result["errors"] == {"base": "invalid_auth"} -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.srp_energy.config.abort.unknown"], -) async def test_form_unknown_error( hass: HomeAssistant, mock_srp_energy_config_flow: MagicMock, From 0a5a2de78e0677c1e146909b482b4299d7c4b172 Mon Sep 17 00:00:00 2001 From: Sheldon Ip <4224778+sheldonip@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:46:52 -0800 Subject: [PATCH 483/511] Fix translations in subaru (#130486) --- homeassistant/components/subaru/strings.json | 4 ++-- tests/components/subaru/test_config_flow.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 78625192e4a..00da729dccd 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -37,13 +37,13 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "incorrect_pin": "Incorrect PIN", "bad_pin_format": "PIN should be 4 digits", - "two_factor_request_failed": "Request for 2FA code failed, please try again", "bad_validation_code_format": "Validation code should be 6 digits", "incorrect_validation_code": "Incorrect validation code" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "two_factor_request_failed": "Request for 2FA code failed, please try again" } }, "options": { diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index d930aafbdfb..6abc544c92a 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -192,10 +192,6 @@ async def test_two_factor_request_success( assert len(mock_two_factor_request.mock_calls) == 1 -@pytest.mark.parametrize( # Remove when translations fixed - "ignore_translations", - ["component.subaru.config.abort.two_factor_request_failed"], -) async def test_two_factor_request_fail( hass: HomeAssistant, two_factor_start_form ) -> None: From ed5560aec235ee6e31d6bcf836d00243ff36c035 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Nov 2024 19:28:53 +0100 Subject: [PATCH 484/511] Update base image to Python 3.13 and deprecated 3.12 (#130425) --- .github/workflows/builder.yml | 2 +- Dockerfile.dev | 2 +- build.yaml | 10 +++++----- homeassistant/const.py | 4 ++-- pyproject.toml | 1 + 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 7c08df39000..cc100c48fd8 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.13" PIP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60 UV_SYSTEM_PYTHON: "true" diff --git a/Dockerfile.dev b/Dockerfile.dev index 48f582a1581..5a3f1a2ae64 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.12 +FROM mcr.microsoft.com/devcontainers/python:1-3.13 SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/build.yaml b/build.yaml index 13618740ab8..a8755bbbf5c 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/const.py b/homeassistant/const.py index 558e7ec2b0b..4082a076b94 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -29,9 +29,9 @@ PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" diff --git a/pyproject.toml b/pyproject.toml index 8e588ce0b0e..a9b958e0805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Home Automation", ] requires-python = ">=3.12.0" From c35ef6bda34aa8c01cae6ea6863cae24a5009fc8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Nov 2024 12:32:14 -0600 Subject: [PATCH 485/511] Bump aiohttp to 3.11.0 (#130542) --- 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 7a0e43b299e..abaf269103e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.1.0 aiodns==3.2.0 aiohasupervisor==0.2.1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.11.0rc2 +aiohttp==3.11.0 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index a9b958e0805..ebf22a93d7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.2.1", - "aiohttp==3.11.0rc2", + "aiohttp==3.11.0", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index ac7c00b8050..b97c8dc57a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.2.1 -aiohttp==3.11.0rc2 +aiohttp==3.11.0 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 4002bc3c257507b82d08abcc836de767ba57c5d3 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:03:34 +0100 Subject: [PATCH 486/511] Downgrade devcontainer to Python 3.12 again (#130562) --- Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 5a3f1a2ae64..48f582a1581 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.13 +FROM mcr.microsoft.com/devcontainers/python:1-3.12 SHELL ["/bin/bash", "-o", "pipefail", "-c"] From 51c6ee97b19706eb56bb440a3b5155e3b34f3afd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 13 Nov 2024 15:50:08 -0600 Subject: [PATCH 487/511] Upgrade to hassil 2.0 (#130544) * Working on hassil 2.0 * Bump to hassil 2.0 * Update snapshots * Remove debug logging --- .../components/conversation/default_agent.py | 88 +++++-------------- homeassistant/components/conversation/http.py | 8 +- .../components/conversation/manifest.json | 2 +- .../components/conversation/trigger.py | 5 +- homeassistant/package_constraints.txt | 4 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- script/hassfest/docker/Dockerfile | 2 +- .../snapshots/test_websocket.ambr | 4 +- .../conversation/snapshots/test_http.ambr | 4 +- .../conversation/test_default_agent.py | 28 +++--- tests/components/conversation/test_trace.py | 2 +- 12 files changed, 53 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index a7110c35795..4838d19537a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.recognize import ( MISSING_ENTITY, - MatchEntity, RecognizeResult, - UnmatchedTextEntity, recognize_all, + recognize_best, ) +from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity from hassil.util import merge_dict from home_assistant_intents import ErrorKey, get_intents, get_languages import yaml @@ -499,6 +499,7 @@ class DefaultAgent(ConversationEntity): maybe_result: RecognizeResult | None = None best_num_matched_entities = 0 best_num_unmatched_entities = 0 + best_num_unmatched_ranges = 0 for result in recognize_all( user_input.text, lang_intents.intents, @@ -517,10 +518,14 @@ class DefaultAgent(ConversationEntity): num_matched_entities += 1 num_unmatched_entities = 0 + num_unmatched_ranges = 0 for unmatched_entity in result.unmatched_entities_list: if isinstance(unmatched_entity, UnmatchedTextEntity): if unmatched_entity.text != MISSING_ENTITY: num_unmatched_entities += 1 + elif isinstance(unmatched_entity, UnmatchedRangeEntity): + num_unmatched_ranges += 1 + num_unmatched_entities += 1 else: num_unmatched_entities += 1 @@ -532,15 +537,24 @@ class DefaultAgent(ConversationEntity): (num_matched_entities == best_num_matched_entities) and (num_unmatched_entities < best_num_unmatched_entities) ) + or ( + # Prefer unmatched ranges + (num_matched_entities == best_num_matched_entities) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges > best_num_unmatched_ranges) + ) or ( # More literal text matched (num_matched_entities == best_num_matched_entities) and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) and (result.text_chunks_matched > maybe_result.text_chunks_matched) ) or ( # Prefer match failures with entities (result.text_chunks_matched == maybe_result.text_chunks_matched) + and (num_unmatched_entities == best_num_unmatched_entities) + and (num_unmatched_ranges == best_num_unmatched_ranges) and ( ("name" in result.entities) or ("name" in result.unmatched_entities) @@ -550,6 +564,7 @@ class DefaultAgent(ConversationEntity): maybe_result = result best_num_matched_entities = num_matched_entities best_num_unmatched_entities = num_unmatched_entities + best_num_unmatched_ranges = num_unmatched_ranges return maybe_result @@ -562,76 +577,15 @@ class DefaultAgent(ConversationEntity): language: str, ) -> RecognizeResult | None: """Search intents for a strict match to user input.""" - custom_found = False - name_found = False - best_results: list[RecognizeResult] = [] - best_name_quality: int | None = None - best_text_chunks_matched: int | None = None - for result in recognize_all( + return recognize_best( user_input.text, lang_intents.intents, slot_lists=slot_lists, intent_context=intent_context, language=language, - ): - # Prioritize user intents - is_custom = ( - result.intent_metadata is not None - and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE) - ) - - if custom_found and not is_custom: - continue - - if not custom_found and is_custom: - custom_found = True - # Clear builtin results - name_found = False - best_results = [] - best_name_quality = None - best_text_chunks_matched = None - - # Prioritize results with a "name" slot - name = result.entities.get("name") - is_name = name and not name.is_wildcard - - if name_found and not is_name: - continue - - if not name_found and is_name: - name_found = True - # Clear non-name results - best_results = [] - best_text_chunks_matched = None - - if is_name: - # Prioritize results with a better "name" slot - name_quality = len(cast(MatchEntity, name).value.split()) - if (best_name_quality is None) or (name_quality > best_name_quality): - best_name_quality = name_quality - # Clear worse name results - best_results = [] - best_text_chunks_matched = None - elif name_quality < best_name_quality: - continue - - # Prioritize results with more literal text - # This causes wildcards to match last. - if (best_text_chunks_matched is None) or ( - result.text_chunks_matched > best_text_chunks_matched - ): - best_results = [result] - best_text_chunks_matched = result.text_chunks_matched - elif result.text_chunks_matched == best_text_chunks_matched: - # Accumulate results with the same number of literal text matched. - # We will resolve the ambiguity below. - best_results.append(result) - - if best_results: - # Successful strict match - return best_results[0] - - return None + best_metadata_key=METADATA_CUSTOM_SENTENCE, + best_slot_name="name", + ) async def _build_speech( self, diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index df1ffc7f74f..5e5800ad6f1 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -6,12 +6,8 @@ from collections.abc import Iterable from typing import Any from aiohttp import web -from hassil.recognize import ( - MISSING_ENTITY, - RecognizeResult, - UnmatchedRangeEntity, - UnmatchedTextEntity, -) +from hassil.recognize import MISSING_ENTITY, RecognizeResult +from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity import voluptuous as vol from homeassistant.components import http, websocket_api diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 8b5c6ef173f..1676cdf8254 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.4", "home-assistant-intents==2024.11.6"] + "requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"] } diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index ec7ecc76da0..a4f64ffbad9 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -4,7 +4,8 @@ from __future__ import annotations from typing import Any -from hassil.recognize import PUNCTUATION, RecognizeResult +from hassil.recognize import RecognizeResult +from hassil.util import PUNCTUATION_ALL import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM @@ -20,7 +21,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN def has_no_punctuation(value: list[str]) -> list[str]: """Validate result does not contain punctuation.""" for sentence in value: - if PUNCTUATION.search(sentence): + if PUNCTUATION_ALL.search(sentence): raise vol.Invalid("sentence should not contain punctuation") return value diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index abaf269103e..04e28fef58a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,10 +32,10 @@ go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 habluetooth==3.6.0 hass-nabucasa==0.84.0 -hassil==1.7.4 +hassil==2.0.1 home-assistant-bluetooth==1.13.0 home-assistant-frontend==20241106.2 -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 00984b9a5a6..e9b5cb8129f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1093,7 +1093,7 @@ hass-nabucasa==0.84.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.7.4 +hassil==2.0.1 # homeassistant.components.jewish_calendar hdate==0.10.9 @@ -1130,7 +1130,7 @@ holidays==0.60 home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffda690bc33..de08e2db395 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -928,7 +928,7 @@ habluetooth==3.6.0 hass-nabucasa==0.84.0 # homeassistant.components.conversation -hassil==1.7.4 +hassil==2.0.1 # homeassistant.components.jewish_calendar hdate==0.10.9 @@ -956,7 +956,7 @@ holidays==0.60 home-assistant-frontend==20241106.2 # homeassistant.components.conversation -home-assistant-intents==2024.11.6 +home-assistant-intents==2024.11.13 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 9bad1e8aecc..c921cf0e186 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 131444c17ac..b806c6faf23 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -697,7 +697,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called are', + 'speech': 'Sorry, I am not aware of any area called Are', }), }), }), @@ -741,7 +741,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Sorry, I am not aware of any area called are', + 'speech': 'Sorry, I am not aware of any area called Are', }), }), }), diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 08aca43aba5..d9d859113f8 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -639,7 +639,7 @@ 'details': dict({ 'brightness': dict({ 'name': 'brightness', - 'text': '100%', + 'text': '100', 'value': 100, }), 'name': dict({ @@ -654,7 +654,7 @@ 'match': True, 'sentence_template': '[] brightness [to] ', 'slots': dict({ - 'brightness': '100%', + 'brightness': '100', 'name': 'test light', }), 'source': 'builtin', diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 9f54671d8a1..3c6b463670a 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -770,8 +770,8 @@ async def test_error_no_device_on_floor_exposed( ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on test light on the ground floor", None, Context(), None @@ -838,8 +838,8 @@ async def test_error_no_domain(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on the fans", None, Context(), None @@ -873,8 +873,8 @@ async def test_error_no_domain_exposed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "turn on the fans", None, Context(), None @@ -1047,8 +1047,8 @@ async def test_error_no_device_class(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open the windows", None, Context(), None @@ -1096,8 +1096,8 @@ async def test_error_no_device_class_exposed(hass: HomeAssistant) -> None: ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open all the windows", None, Context(), None @@ -1207,8 +1207,8 @@ async def test_error_no_device_class_on_floor_exposed( ) with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[recognize_result], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=recognize_result, ): result = await conversation.async_converse( hass, "open ground floor windows", None, Context(), None @@ -1229,8 +1229,8 @@ async def test_error_no_device_class_on_floor_exposed( async def test_error_no_intent(hass: HomeAssistant) -> None: """Test response with an intent match failure.""" with patch( - "homeassistant.components.conversation.default_agent.recognize_all", - return_value=[], + "homeassistant.components.conversation.default_agent.recognize_best", + return_value=None, ): result = await conversation.async_converse( hass, "do something", None, Context(), None diff --git a/tests/components/conversation/test_trace.py b/tests/components/conversation/test_trace.py index 59cd10d2510..7c00b9a80b2 100644 --- a/tests/components/conversation/test_trace.py +++ b/tests/components/conversation/test_trace.py @@ -56,7 +56,7 @@ async def test_converation_trace( "intent_name": "HassListAddItem", "slots": { "name": "Shopping List", - "item": "apples ", + "item": "apples", }, } From 6a3b4a6a237382e640c87e0f3f644385e65abb6a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 00:49:39 +0100 Subject: [PATCH 488/511] Adjust minimum scapy version to 2.6.1 (#130565) --- homeassistant/package_constraints.txt | 4 ++-- script/gen_requirements_all.py | 4 ++-- tests/components/dhcp/conftest.py | 21 --------------------- 3 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 tests/components/dhcp/conftest.py diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 04e28fef58a..5bc539beb86 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -181,8 +181,8 @@ chacha20poly1305-reuseable>=0.13.0 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 pycountry>=23.12.11 -# scapy<2.5.0 will not work with python3.12 -scapy>=2.5.0 +# scapy==2.6.0 causes CI failures due to a race condition +scapy>=2.6.1 # tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # Only tuf>=4 includes a constraint to <1.0. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c5611069bf5..7d53741c661 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -214,8 +214,8 @@ chacha20poly1305-reuseable>=0.13.0 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 pycountry>=23.12.11 -# scapy<2.5.0 will not work with python3.12 -scapy>=2.5.0 +# scapy==2.6.0 causes CI failures due to a race condition +scapy>=2.6.1 # tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # Only tuf>=4 includes a constraint to <1.0. diff --git a/tests/components/dhcp/conftest.py b/tests/components/dhcp/conftest.py deleted file mode 100644 index b0fa3f573c5..00000000000 --- a/tests/components/dhcp/conftest.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Tests for the dhcp integration.""" - -import os -import pathlib - - -def pytest_sessionstart(session): - """Try to avoid flaky FileExistsError in CI. - - Called after the Session object has been created and - before performing collection and entering the run test loop. - - This is needed due to a race condition in scapy v2.6.0 - See https://github.com/secdev/scapy/pull/4558 - - Can be removed when scapy 2.6.1 is released. - """ - for sub_dir in (".cache", ".config"): - path = pathlib.Path(os.path.join(os.path.expanduser("~"), sub_dir)) - if not path.exists(): - path.mkdir(mode=0o700, exist_ok=True) From 4aad614497a3dc951ed7c616355b2e551137afef Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Thu, 14 Nov 2024 21:43:59 +1300 Subject: [PATCH 489/511] Bump aioruckus to 0.42 (#130487) --- homeassistant/components/ruckus_unleashed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 2066b65221e..8d56f3a5563 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus"], - "requirements": ["aioruckus==0.41"] + "requirements": ["aioruckus==0.42"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9b5cb8129f..a68fc1a828c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -354,7 +354,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.41 +aioruckus==0.42 # homeassistant.components.russound_rio aiorussound==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de08e2db395..7501398f4d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -336,7 +336,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.41 +aioruckus==0.42 # homeassistant.components.russound_rio aiorussound==4.1.0 From 4200913d03489f67e8ca332dda0800c6d1303588 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Nov 2024 02:45:08 -0600 Subject: [PATCH 490/511] Fix non-thread-safe operation in powerview number (#130557) --- homeassistant/components/hunterdouglas_powerview/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py index f893b04b2d1..fb8c9f76d79 100644 --- a/homeassistant/components/hunterdouglas_powerview/number.py +++ b/homeassistant/components/hunterdouglas_powerview/number.py @@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber): self.entity_description = description self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" - def set_native_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" self._attr_native_value = value self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) From 2fda4c82de226f5d6e90bc3b81caa35c74756275 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 14 Nov 2024 18:46:24 +1000 Subject: [PATCH 491/511] Force login prompt in Tesla Fleet (#130576) --- homeassistant/components/tesla_fleet/oauth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index 00976abf56f..8b43460436b 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -49,6 +49,7 @@ class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementati def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" return { + "prompt": "login", "scope": " ".join(SCOPES), "code_challenge": self.code_challenge, # PKCE } @@ -83,4 +84,4 @@ class TeslaUserImplementation(AuthImplementation): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"scope": " ".join(SCOPES)} + return {"prompt": "login", "scope": " ".join(SCOPES)} From 938b1eca2299130b28467632aa0b09aaa9c408c9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 14 Nov 2024 03:52:28 -0500 Subject: [PATCH 492/511] Fix when the Roborock map is being provisioned (#130574) --- homeassistant/components/roborock/coordinator.py | 7 +++++-- homeassistant/components/roborock/select.py | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 20bc50f9855..fe592074f71 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from datetime import timedelta import logging @@ -107,8 +106,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): async def _async_update_data(self) -> DeviceProp: """Update data via library.""" try: - await asyncio.gather(*(self._update_device_prop(), self.get_rooms())) + # Update device props and standard api information + await self._update_device_prop() + # Set the new map id from the updated device props self._set_current_map() + # Get the rooms for that map id. + await self.get_rooms() except RoborockException as ex: raise UpdateFailed(ex) from ex return self.roborock_device_info.props diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 3dfe0e72a7b..73cb95d2d7c 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -135,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): RoborockCommand.LOAD_MULTI_MAP, [map_id], ) + # Update the current map id manually so that nothing gets broken + # if another service hits the api. + self.coordinator.current_map = map_id # We need to wait after updating the map # so that other commands will be executed correctly. await asyncio.sleep(MAP_SLEEP) @@ -148,6 +151,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): @property def current_option(self) -> str | None: """Get the current status of the select entity from device_status.""" - if (current_map := self.coordinator.current_map) is not None: + if ( + (current_map := self.coordinator.current_map) is not None + and current_map in self.coordinator.maps + ): # 63 means it is searching for a map. return self.coordinator.maps[current_map].name return None From 2c1d1f577718dd08b0779e7ce786609c2c1df002 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 14 Nov 2024 09:09:58 +0000 Subject: [PATCH 493/511] Do not trigger events for updated ring events (#130430) --- homeassistant/components/ring/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py index e6d9d25542f..71a4bc8aea5 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity) @callback def _handle_coordinator_update(self) -> None: - if alert := self._get_coordinator_alert(): + if (alert := self._get_coordinator_alert()) and not alert.is_update: self._async_handle_event(alert.kind) super()._handle_coordinator_update() From 58fd917cb763e876353437e9ab46304cd429872b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 14 Nov 2024 04:11:44 -0500 Subject: [PATCH 494/511] Disable brightness from devices with no display in Cambridge Audio (#130369) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- homeassistant/components/cambridge_audio/select.py | 7 ++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index edacd17f54d..c359ca14a21 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.8.4"], + "requirements": ["aiostreammagic==2.8.5"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py index ca6eebdec6b..c99abc853e5 100644 --- a/homeassistant/components/cambridge_audio/select.py +++ b/homeassistant/components/cambridge_audio/select.py @@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = ( CambridgeAudioSelectEntityDescription( key="display_brightness", translation_key="display_brightness", - options=[x.value for x in DisplayBrightness], + options=[ + DisplayBrightness.BRIGHT.value, + DisplayBrightness.DIM.value, + DisplayBrightness.OFF.value, + ], entity_category=EntityCategory.CONFIG, + load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE, value_fn=lambda client: client.display.brightness, set_value_fn=lambda client, value: client.set_display_brightness( DisplayBrightness(value) diff --git a/requirements_all.txt b/requirements_all.txt index a68fc1a828c..32f111781da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.4 +aiostreammagic==2.8.5 # homeassistant.components.switcher_kis aioswitcher==4.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7501398f4d3..237c70c8afb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.8.4 +aiostreammagic==2.8.5 # homeassistant.components.switcher_kis aioswitcher==4.4.0 From 245fc246d85931c9697b9e1ba586fdde2e10325b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 14 Nov 2024 04:13:29 -0500 Subject: [PATCH 495/511] Ensure ZHA setup works with container installs (#130470) --- homeassistant/components/zha/config_flow.py | 36 +++++++++-------- tests/components/zha/test_config_flow.py | 43 ++++++++++++++++----- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1c7e0d105c4..f3f7f38772d 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.util import dt as dt_util @@ -104,25 +105,26 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: yellow_radio.description = "Yellow Zigbee module" yellow_radio.manufacturer = "Nabu Casa" - # Present the multi-PAN addon as a setup option, if it's available - multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( - hass - ) - - try: - addon_info = await multipan_manager.async_get_addon_info() - except (AddonError, KeyError): - addon_info = None - - if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: - addon_port = ListPortInfo( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - skip_link_detection=True, + if is_hassio(hass): + # Present the multi-PAN addon as a setup option, if it's available + multipan_manager = ( + await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass) ) - addon_port.description = "Multiprotocol add-on" - addon_port.manufacturer = "Nabu Casa" - ports.append(addon_port) + try: + addon_info = await multipan_manager.async_get_addon_info() + except (AddonError, KeyError): + addon_info = None + + if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED: + addon_port = ListPortInfo( + device=silabs_multiprotocol_addon.get_zigbee_socket(), + skip_link_detection=True, + ) + + addon_port.description = "Multiprotocol add-on" + addon_port.manufacturer = "Nabu Casa" + ports.append(addon_port) return ports diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 1382c5c2569..87ba46a4ced 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -21,7 +21,7 @@ import zigpy.types from homeassistant import config_entries from homeassistant.components import ssdp, usb, zeroconf -from homeassistant.components.hassio import AddonState +from homeassistant.components.hassio import AddonError, AddonState from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha.const import ( @@ -1878,10 +1878,23 @@ async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None: ) +async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None: + """Test config flow serial port name when this is not a hassio install.""" + + with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False), + patch("serial.tools.list_ports.comports", MagicMock(return_value=[])), + ): + ports = await config_flow.list_serial_ports(hass) + + assert ports == [] + + async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: """Test config flow serial port name for multiprotocol add-on.""" with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), patch( "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" ) as async_get_addon_info, @@ -1889,16 +1902,28 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> ): async_get_addon_info.return_value.state = AddonState.RUNNING async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" + ports = await config_flow.list_serial_ports(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_USER}, - ) + assert len(ports) == 1 + assert ports[0].description == "Multiprotocol add-on" + assert ports[0].manufacturer == "Nabu Casa" + assert ports[0].device == "socket://core-silabs-multiprotocol:9999" - assert ( - result["data_schema"].schema["path"].container[0] - == "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" - ) + +async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None: + """Test config flow serial port listing when addon info fails to load.""" + + with ( + patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True), + patch( + "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info", + side_effect=AddonError, + ), + patch("serial.tools.list_ports.comports", MagicMock(return_value=[])), + ): + ports = await config_flow.list_serial_ports(hass) + + assert ports == [] @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) From 301043ec387f581c8aedba8c7ac7475c53349048 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 10:27:45 +0100 Subject: [PATCH 496/511] Add require_webrtc_support decorator (#130519) --- homeassistant/components/camera/webrtc.py | 93 ++++++++++++----------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 0612c96e40c..d627a888169 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -6,7 +6,7 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Awaitable, Callable, Iterable from dataclasses import asdict, dataclass, field -from functools import cache, partial +from functools import cache, partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol @@ -205,6 +205,49 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None: ) +type WsCommandWithCamera = Callable[ + [websocket_api.ActiveConnection, dict[str, Any], Camera], + Awaitable[None], +] + + +def require_webrtc_support( + error_code: str, +) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]: + """Validate that the camera supports WebRTC.""" + + def decorate( + func: WsCommandWithCamera, + ) -> websocket_api.AsyncWebSocketCommandHandler: + """Decorate func.""" + + @wraps(func) + async def validate( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: + """Validate that the camera supports WebRTC.""" + entity_id = msg["entity_id"] + camera = get_camera_from_entity_id(hass, entity_id) + if camera.frontend_stream_type != StreamType.WEB_RTC: + connection.send_error( + msg["id"], + error_code, + ( + "Camera does not support WebRTC," + f" frontend_stream_type={camera.frontend_stream_type}" + ), + ) + return + + await func(connection, msg, camera) + + return validate + + return decorate + + @websocket_api.websocket_command( { vol.Required("type"): "camera/webrtc/offer", @@ -213,8 +256,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None: } ) @websocket_api.async_response +@require_webrtc_support("webrtc_offer_failed") async def ws_webrtc_offer( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle the signal path for a WebRTC stream. @@ -226,20 +270,7 @@ async def ws_webrtc_offer( Async friendly. """ - entity_id = msg["entity_id"] offer = msg["offer"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_offer_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - session_id = ulid() connection.subscriptions[msg["id"]] = partial( camera.close_webrtc_session, session_id @@ -278,23 +309,11 @@ async def ws_webrtc_offer( } ) @websocket_api.async_response +@require_webrtc_support("webrtc_get_client_config_failed") async def ws_get_client_config( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle get WebRTC client config websocket command.""" - entity_id = msg["entity_id"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_get_client_config_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - config = camera.async_get_webrtc_client_configuration().to_frontend_dict() connection.send_result( msg["id"], @@ -311,23 +330,11 @@ async def ws_get_client_config( } ) @websocket_api.async_response +@require_webrtc_support("webrtc_candidate_failed") async def ws_candidate( - hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] + connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera ) -> None: """Handle WebRTC candidate websocket command.""" - entity_id = msg["entity_id"] - camera = get_camera_from_entity_id(hass, entity_id) - if camera.frontend_stream_type != StreamType.WEB_RTC: - connection.send_error( - msg["id"], - "webrtc_candidate_failed", - ( - "Camera does not support WebRTC," - f" frontend_stream_type={camera.frontend_stream_type}" - ), - ) - return - await camera.async_on_webrtc_candidate( msg["session_id"], RTCIceCandidate(msg["candidate"]) ) From 46cfe6aa32d30f9d8ecdb29742b3568d871d403f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 10:28:04 +0100 Subject: [PATCH 497/511] Refactor camera WebRTC tests (#130581) --- tests/components/camera/test_webrtc.py | 65 +++++++++++++------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/components/camera/test_webrtc.py b/tests/components/camera/test_webrtc.py index ba5cf35c52f..29fb9d61c4e 100644 --- a/tests/components/camera/test_webrtc.py +++ b/tests/components/camera/test_webrtc.py @@ -139,42 +139,46 @@ async def init_test_integration( return test_camera -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider( hass: HomeAssistant, ) -> None: """Test registering a WebRTC provider.""" - await async_setup_component(hass, "camera", {}) - camera = get_camera_from_entity_id(hass, "camera.demo_camera") - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} provider = SomeTestProvider() unregister = async_register_webrtc_provider(hass, provider) await hass.async_block_till_done() - assert camera.frontend_stream_type is StreamType.WEB_RTC + assert camera.camera_capabilities.frontend_stream_types == { + StreamType.HLS, + StreamType.WEB_RTC, + } # Mark stream as unsupported provider._is_supported = False # Manually refresh the provider await camera.async_refresh_providers() - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} # Mark stream as supported provider._is_supported = True # Manually refresh the provider await camera.async_refresh_providers() - assert camera.frontend_stream_type is StreamType.WEB_RTC + assert camera.camera_capabilities.frontend_stream_types == { + StreamType.HLS, + StreamType.WEB_RTC, + } unregister() await hass.async_block_till_done() - assert camera.frontend_stream_type is StreamType.HLS + assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS} -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") async def test_async_register_webrtc_provider_twice( hass: HomeAssistant, register_test_provider: SomeTestProvider, @@ -192,13 +196,11 @@ async def test_async_register_webrtc_provider_camera_not_loaded( async_register_webrtc_provider(hass, SomeTestProvider()) -@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_async_register_ice_server( hass: HomeAssistant, ) -> None: """Test registering an ICE server.""" - await async_setup_component(hass, "camera", {}) - # Clear any existing ICE servers hass.data[DATA_ICE_SERVERS].clear() @@ -216,7 +218,7 @@ async def test_async_register_ice_server( unregister = async_register_ice_servers(hass, get_ice_servers) assert not called - camera = get_camera_from_entity_id(hass, "camera.demo_camera") + camera = get_camera_from_entity_id(hass, "camera.async") config = camera.async_get_webrtc_client_configuration() assert config.configuration.ice_servers == [ @@ -277,7 +279,7 @@ async def test_async_register_ice_server( assert config.configuration.ice_servers == [] -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -286,7 +288,7 @@ async def test_ws_get_client_config( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -320,7 +322,7 @@ async def test_ws_get_client_config( async_register_ice_servers(hass, get_ice_server) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -370,7 +372,7 @@ async def test_ws_get_client_config_sync_offer( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_get_client_config_custom_config( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -384,7 +386,7 @@ async def test_ws_get_client_config_custom_config( client = await hass_ws_client(hass) await client.send_json_auto_id( - {"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} + {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"} ) msg = await client.receive_json() @@ -435,7 +437,7 @@ def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]: unsub() -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -444,7 +446,7 @@ async def test_websocket_webrtc_offer( await client.send_json_auto_id( { "type": "camera/webrtc/offer", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "offer": WEBRTC_OFFER, } ) @@ -555,11 +557,11 @@ async def test_websocket_webrtc_offer_webrtc_provider( mock_async_close_session.assert_called_once_with(session_id) -@pytest.mark.usefixtures("mock_camera_webrtc") async def test_websocket_webrtc_offer_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test WebRTC with a camera entity that does not exist.""" + await async_setup_component(hass, "camera", {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -578,7 +580,7 @@ async def test_websocket_webrtc_offer_invalid_entity( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_websocket_webrtc_offer_missing_offer( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -605,7 +607,6 @@ async def test_websocket_webrtc_offer_missing_offer( (TimeoutError(), "Timeout handling WebRTC offer"), ], ) -@pytest.mark.usefixtures("mock_camera_webrtc_frontendtype_only") async def test_websocket_webrtc_offer_failure( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -949,7 +950,7 @@ async def test_rtsp_to_webrtc_offer_not_accepted( unsub() -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_candidate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -957,13 +958,13 @@ async def test_ws_webrtc_candidate( client = await hass_ws_client(hass) session_id = "session_id" candidate = "candidate" - with patch( - "homeassistant.components.camera.Camera.async_on_webrtc_candidate" + with patch.object( + get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate" ) as mock_on_webrtc_candidate: await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "session_id": session_id, "candidate": candidate, } @@ -976,7 +977,7 @@ async def test_ws_webrtc_candidate( ) -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_candidate_not_supported( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -985,7 +986,7 @@ async def test_ws_webrtc_candidate_not_supported( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.sync", "session_id": "session_id", "candidate": "candidate", } @@ -1028,11 +1029,11 @@ async def test_ws_webrtc_candidate_webrtc_provider( ) -@pytest.mark.usefixtures("mock_camera_webrtc") async def test_ws_webrtc_candidate_invalid_entity( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test ws WebRTC candidate command with a camera entity that does not exist.""" + await async_setup_component(hass, "camera", {}) client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -1052,7 +1053,7 @@ async def test_ws_webrtc_candidate_invalid_entity( } -@pytest.mark.usefixtures("mock_camera_webrtc") +@pytest.mark.usefixtures("mock_test_webrtc_cameras") async def test_ws_webrtc_canidate_missing_candidate( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1061,7 +1062,7 @@ async def test_ws_webrtc_canidate_missing_candidate( await client.send_json_auto_id( { "type": "camera/webrtc/candidate", - "entity_id": "camera.demo_camera", + "entity_id": "camera.async", "session_id": "session_id", } ) From 93f79be2f4a83f3dd420a99a59076e2c61d7683f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 14 Nov 2024 10:35:03 +0100 Subject: [PATCH 498/511] Update uptime deviation for Vodafone Station (#130571) Update sensor.py --- homeassistant/components/vodafone_station/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index fb76253eb3d..307fcaf0ea8 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES from .coordinator import VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -UPTIME_DEVIATION = 45 +UPTIME_DEVIATION = 60 @dataclass(frozen=True, kw_only=True) From d0a58b68e8d35d2dea7bfdf14fd7a6a45b10fb99 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Nov 2024 10:48:25 +0100 Subject: [PATCH 499/511] Bump reolink-aio to 0.11.1 (#130600) --- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 22fd625770f..7921bdb6ed5 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.11.0"] + "requirements": ["reolink-aio==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32f111781da..9ad6a1199f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2553,7 +2553,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.0 +reolink-aio==0.11.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 237c70c8afb..68d1c393fc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2044,7 +2044,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.11.0 +reolink-aio==0.11.1 # homeassistant.components.rflink rflink==0.0.66 From 3201142fd8c3f84a7440c5ce4d76fd6597d8e9ed Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 11:01:26 +0100 Subject: [PATCH 500/511] Fix hassfest by adding go2rtc reqs (#130602) --- script/hassfest/docker.py | 2 ++ script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 137bbc7ff66..0eb72b91c02 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -161,6 +161,8 @@ def _generate_hassfest_dockerimage( packages.update( gather_recursive_requirements(platform.value, already_checked_domains) ) + # Add go2rtc requirements as this file needs the go2rtc integration + packages.update(gather_recursive_requirements("go2rtc", already_checked_domains)) return File( _HASSFEST_TEMPLATE.format( diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index c921cf0e186..fe18c4dd486 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From a748897bd23b29be81b81487405c335ba217d7c2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 14 Nov 2024 12:44:06 +0100 Subject: [PATCH 501/511] Update hassfest image to Python 3.13 (#130607) --- script/hassfest/docker.py | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 0eb72b91c02..57d86bc4def 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -80,7 +80,7 @@ WORKDIR /config _HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:3.12-alpine +FROM python:3.13-alpine ENV \ UV_SYSTEM_PYTHON=true \ diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index fe18c4dd486..0fa0a1a89fa 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -1,7 +1,7 @@ # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:3.12-alpine +FROM python:3.13-alpine ENV \ UV_SYSTEM_PYTHON=true \ From a949d18c30f86beabc21c73bae5e04d88da64bb8 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Thu, 14 Nov 2024 13:04:22 +0100 Subject: [PATCH 502/511] Bump eq3btsmart to 1.4.1 (#130426) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index bd3f14939ca..b30f806bf63 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -23,5 +23,5 @@ "iot_class": "local_polling", "loggers": ["eq3btsmart"], "quality_scale": "silver", - "requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9ad6a1199f2..3b46bf19ae6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -860,7 +860,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.1 +eq3btsmart==1.4.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68d1c393fc1..b27979b23f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -729,7 +729,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.2.1 +eq3btsmart==1.4.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From eea782bbfe230168df52d8a30ceac94e463d2c98 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 14 Nov 2024 13:28:38 +0100 Subject: [PATCH 503/511] Add acaia integration (#130059) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/acaia/__init__.py | 29 +++ homeassistant/components/acaia/button.py | 61 +++++ homeassistant/components/acaia/config_flow.py | 149 +++++++++++ homeassistant/components/acaia/const.py | 4 + homeassistant/components/acaia/coordinator.py | 86 +++++++ homeassistant/components/acaia/entity.py | 40 +++ homeassistant/components/acaia/icons.json | 15 ++ homeassistant/components/acaia/manifest.json | 29 +++ homeassistant/components/acaia/strings.json | 38 +++ homeassistant/generated/bluetooth.py | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/acaia/__init__.py | 14 + tests/components/acaia/conftest.py | 80 ++++++ .../acaia/snapshots/test_button.ambr | 139 ++++++++++ .../components/acaia/snapshots/test_init.ambr | 33 +++ tests/components/acaia/test_button.py | 83 ++++++ tests/components/acaia/test_config_flow.py | 242 ++++++++++++++++++ tests/components/acaia/test_init.py | 65 +++++ 22 files changed, 1142 insertions(+) create mode 100644 homeassistant/components/acaia/__init__.py create mode 100644 homeassistant/components/acaia/button.py create mode 100644 homeassistant/components/acaia/config_flow.py create mode 100644 homeassistant/components/acaia/const.py create mode 100644 homeassistant/components/acaia/coordinator.py create mode 100644 homeassistant/components/acaia/entity.py create mode 100644 homeassistant/components/acaia/icons.json create mode 100644 homeassistant/components/acaia/manifest.json create mode 100644 homeassistant/components/acaia/strings.json create mode 100644 tests/components/acaia/__init__.py create mode 100644 tests/components/acaia/conftest.py create mode 100644 tests/components/acaia/snapshots/test_button.ambr create mode 100644 tests/components/acaia/snapshots/test_init.ambr create mode 100644 tests/components/acaia/test_button.py create mode 100644 tests/components/acaia/test_config_flow.py create mode 100644 tests/components/acaia/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 76422734c92..8fd34a357c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -40,6 +40,8 @@ build.json @home-assistant/supervisor # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 +/homeassistant/components/acaia/ @zweckj +/tests/components/acaia/ @zweckj /homeassistant/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu /homeassistant/components/acmeda/ @atmurray diff --git a/homeassistant/components/acaia/__init__.py b/homeassistant/components/acaia/__init__.py new file mode 100644 index 00000000000..dfdb4cb935d --- /dev/null +++ b/homeassistant/components/acaia/__init__.py @@ -0,0 +1,29 @@ +"""Initialize the Acaia component.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AcaiaConfigEntry, AcaiaCoordinator + +PLATFORMS = [ + Platform.BUTTON, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool: + """Set up acaia as config entry.""" + + coordinator = AcaiaCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py new file mode 100644 index 00000000000..50671eecbba --- /dev/null +++ b/homeassistant/components/acaia/button.py @@ -0,0 +1,61 @@ +"""Button entities for Acaia scales.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from aioacaia.acaiascale import AcaiaScale + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import AcaiaConfigEntry +from .entity import AcaiaEntity + + +@dataclass(kw_only=True, frozen=True) +class AcaiaButtonEntityDescription(ButtonEntityDescription): + """Description for acaia button entities.""" + + press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]] + + +BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = ( + AcaiaButtonEntityDescription( + key="tare", + translation_key="tare", + press_fn=lambda scale: scale.tare(), + ), + AcaiaButtonEntityDescription( + key="reset_timer", + translation_key="reset_timer", + press_fn=lambda scale: scale.reset_timer(), + ), + AcaiaButtonEntityDescription( + key="start_stop", + translation_key="start_stop", + press_fn=lambda scale: scale.start_stop_timer(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AcaiaConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up button entities and services.""" + + coordinator = entry.runtime_data + async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS) + + +class AcaiaButton(AcaiaEntity, ButtonEntity): + """Representation of an Acaia button.""" + + entity_description: AcaiaButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_fn(self._scale) diff --git a/homeassistant/components/acaia/config_flow.py b/homeassistant/components/acaia/config_flow.py new file mode 100644 index 00000000000..36727059c8a --- /dev/null +++ b/homeassistant/components/acaia/config_flow.py @@ -0,0 +1,149 @@ +"""Config flow for Acaia integration.""" + +import logging +from typing import Any + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice +from aioacaia.helpers import is_new_scale +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for acaia.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered: dict[str, Any] = {} + self._discovered_devices: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + + errors: dict[str, str] = {} + + if user_input is not None: + mac = format_mac(user_input[CONF_ADDRESS]) + try: + is_new_style_scale = await is_new_scale(mac) + except AcaiaDeviceNotFound: + errors["base"] = "device_not_found" + except AcaiaError: + _LOGGER.exception("Error occurred while connecting to the scale") + errors["base"] = "unknown" + except AcaiaUnknownDevice: + return self.async_abort(reason="unsupported_device") + else: + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + if not errors: + return self.async_create_entry( + title=self._discovered_devices[user_input[CONF_ADDRESS]], + data={ + CONF_ADDRESS: mac, + CONF_IS_NEW_STYLE_SCALE: is_new_style_scale, + }, + ) + + for device in async_discovered_service_info(self.hass): + self._discovered_devices[device.address] = device.name + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + options = [ + SelectOptionDict( + value=device_mac, + label=f"{device_name} ({device_mac})", + ) + for device_mac, device_name in self._discovered_devices.items() + ] + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): SelectSelector( + SelectSelectorConfig( + options=options, + mode=SelectSelectorMode.DROPDOWN, + ) + ) + } + ), + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle a discovered Bluetooth device.""" + + self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address) + self._discovered[CONF_NAME] = discovery_info.name + + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured() + + try: + self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale( + discovery_info.address + ) + except AcaiaDeviceNotFound: + _LOGGER.debug("Device not found during discovery") + return self.async_abort(reason="device_not_found") + except AcaiaError: + _LOGGER.debug( + "Error occurred while connecting to the scale during discovery", + exc_info=True, + ) + return self.async_abort(reason="unknown") + except AcaiaUnknownDevice: + _LOGGER.debug("Unsupported device during discovery") + return self.async_abort(reason="unsupported_device") + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle confirmation of Bluetooth discovery.""" + + if user_input is not None: + return self.async_create_entry( + title=self._discovered[CONF_NAME], + data={ + CONF_ADDRESS: self._discovered[CONF_ADDRESS], + CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE], + }, + ) + + self.context["title_placeholders"] = placeholders = { + CONF_NAME: self._discovered[CONF_NAME] + } + + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=placeholders, + ) diff --git a/homeassistant/components/acaia/const.py b/homeassistant/components/acaia/const.py new file mode 100644 index 00000000000..c603578763d --- /dev/null +++ b/homeassistant/components/acaia/const.py @@ -0,0 +1,4 @@ +"""Constants for component.""" + +DOMAIN = "acaia" +CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale" diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py new file mode 100644 index 00000000000..bd915b42408 --- /dev/null +++ b/homeassistant/components/acaia/coordinator.py @@ -0,0 +1,86 @@ +"""Coordinator for Acaia integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioacaia.acaiascale import AcaiaScale +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_IS_NEW_STYLE_SCALE + +SCAN_INTERVAL = timedelta(seconds=15) + +_LOGGER = logging.getLogger(__name__) + +type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator] + + +class AcaiaCoordinator(DataUpdateCoordinator[None]): + """Class to handle fetching data from the scale.""" + + config_entry: AcaiaConfigEntry + + def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name="acaia coordinator", + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + + self._scale = AcaiaScale( + address_or_ble_device=entry.data[CONF_ADDRESS], + name=entry.title, + is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], + notify_callback=self.async_update_listeners, + ) + + @property + def scale(self) -> AcaiaScale: + """Return the scale object.""" + return self._scale + + async def _async_update_data(self) -> None: + """Fetch data.""" + + # scale is already connected, return + if self._scale.connected: + return + + # scale is not connected, try to connect + try: + await self._scale.connect(setup_tasks=False) + except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex: + _LOGGER.debug( + "Could not connect to scale: %s, Error: %s", + self.config_entry.data[CONF_ADDRESS], + ex, + ) + self._scale.device_disconnected_handler(notify=False) + return + + # connected, set up background tasks + if not self._scale.heartbeat_task or self._scale.heartbeat_task.done(): + self._scale.heartbeat_task = self.config_entry.async_create_background_task( + hass=self.hass, + target=self._scale.send_heartbeats(), + name="acaia_heartbeat_task", + ) + + if not self._scale.process_queue_task or self._scale.process_queue_task.done(): + self._scale.process_queue_task = ( + self.config_entry.async_create_background_task( + hass=self.hass, + target=self._scale.process_queue(), + name="acaia_process_queue_task", + ) + ) diff --git a/homeassistant/components/acaia/entity.py b/homeassistant/components/acaia/entity.py new file mode 100644 index 00000000000..8a2108d2687 --- /dev/null +++ b/homeassistant/components/acaia/entity.py @@ -0,0 +1,40 @@ +"""Base class for Acaia entities.""" + +from dataclasses import dataclass + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AcaiaCoordinator + + +@dataclass +class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]): + """Common elements for all entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AcaiaCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._scale = coordinator.scale + self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._scale.mac)}, + manufacturer="Acaia", + model=self._scale.model, + suggested_area="Kitchen", + ) + + @property + def available(self) -> bool: + """Returns whether entity is available.""" + return super().available and self._scale.connected diff --git a/homeassistant/components/acaia/icons.json b/homeassistant/components/acaia/icons.json new file mode 100644 index 00000000000..aeab07ee912 --- /dev/null +++ b/homeassistant/components/acaia/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "button": { + "tare": { + "default": "mdi:scale-balance" + }, + "reset_timer": { + "default": "mdi:timer-refresh" + }, + "start_stop": { + "default": "mdi:timer-play" + } + } + } +} diff --git a/homeassistant/components/acaia/manifest.json b/homeassistant/components/acaia/manifest.json new file mode 100644 index 00000000000..c907a70a38e --- /dev/null +++ b/homeassistant/components/acaia/manifest.json @@ -0,0 +1,29 @@ +{ + "domain": "acaia", + "name": "Acaia", + "bluetooth": [ + { + "manufacturer_id": 16962 + }, + { + "local_name": "ACAIA*" + }, + { + "local_name": "PYXIS-*" + }, + { + "local_name": "LUNAR-*" + }, + { + "local_name": "PROCHBT001" + } + ], + "codeowners": ["@zweckj"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/acaia", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["aioacaia"], + "requirements": ["aioacaia==0.1.6"] +} diff --git a/homeassistant/components/acaia/strings.json b/homeassistant/components/acaia/strings.json new file mode 100644 index 00000000000..f6a1aeb66fd --- /dev/null +++ b/homeassistant/components/acaia/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "flow_title": "{name}", + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "unsupported_device": "This device is not supported." + }, + "error": { + "device_not_found": "Device could not be found.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + } + } + }, + "entity": { + "button": { + "tare": { + "name": "Tare" + }, + "reset_timer": { + "name": "Reset timer" + }, + "start_stop": { + "name": "Start/stop timer" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c4612898cb2..a105efc2685 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -8,6 +8,26 @@ from __future__ import annotations from typing import Final BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ + { + "domain": "acaia", + "manufacturer_id": 16962, + }, + { + "domain": "acaia", + "local_name": "ACAIA*", + }, + { + "domain": "acaia", + "local_name": "PYXIS-*", + }, + { + "domain": "acaia", + "local_name": "LUNAR-*", + }, + { + "domain": "acaia", + "local_name": "PROCHBT001", + }, { "domain": "airthings_ble", "manufacturer_id": 820, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 78e16126542..ffe61b915c6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -24,6 +24,7 @@ FLOWS = { ], "integration": [ "abode", + "acaia", "accuweather", "acmeda", "adax", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 33a7d02776f..f007db87868 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -11,6 +11,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "acaia": { + "name": "Acaia", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "accuweather": { "name": "AccuWeather", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 3b46bf19ae6..cdba146d251 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -172,6 +172,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.acaia +aioacaia==0.1.6 + # homeassistant.components.airq aioairq==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b27979b23f2..39fb7f17d80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -160,6 +160,9 @@ aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs aio-georss-gdacs==0.10 +# homeassistant.components.acaia +aioacaia==0.1.6 + # homeassistant.components.airq aioairq==0.3.2 diff --git a/tests/components/acaia/__init__.py b/tests/components/acaia/__init__.py new file mode 100644 index 00000000000..f4eaa39e615 --- /dev/null +++ b/tests/components/acaia/__init__.py @@ -0,0 +1,14 @@ +"""Common test tools for the acaia integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the acaia integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/acaia/conftest.py b/tests/components/acaia/conftest.py new file mode 100644 index 00000000000..1dc6ff31051 --- /dev/null +++ b/tests/components/acaia/conftest.py @@ -0,0 +1,80 @@ +"""Common fixtures for the acaia tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from aioacaia.acaiascale import AcaiaDeviceState +from aioacaia.const import UnitMass as AcaiaUnitOfMass +import pytest + +from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.acaia.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_verify() -> Generator[AsyncMock]: + """Override is_new_scale check.""" + with patch( + "homeassistant.components.acaia.config_flow.is_new_scale", return_value=True + ) as mock_verify: + yield mock_verify + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="LUNAR-DDEEFF", + domain=DOMAIN, + version=1, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_IS_NEW_STYLE_SCALE: True, + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock +) -> None: + """Set up the acaia integration for testing.""" + await setup_integration(hass, mock_config_entry) + + +@pytest.fixture +def mock_scale() -> Generator[MagicMock]: + """Return a mocked acaia scale client.""" + with ( + patch( + "homeassistant.components.acaia.coordinator.AcaiaScale", + autospec=True, + ) as scale_mock, + ): + scale = scale_mock.return_value + scale.connected = True + scale.mac = "aa:bb:cc:dd:ee:ff" + scale.model = "Lunar" + scale.timer_running = True + scale.heartbeat_task = None + scale.process_queue_task = None + scale.device_state = AcaiaDeviceState( + battery_level=42, units=AcaiaUnitOfMass.GRAMS + ) + scale.weight = 123.45 + yield scale diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr new file mode 100644 index 00000000000..7e2624923af --- /dev/null +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_buttons[entry_button_reset_timer] + 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.lunar_ddeeff_reset_timer', + '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': 'Reset timer', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reset_timer', + 'unique_id': 'aa:bb:cc:dd:ee:ff_reset_timer', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[entry_button_start_stop_timer] + 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.lunar_ddeeff_start_stop_timer', + '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': 'Start/stop timer', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'start_stop', + 'unique_id': 'aa:bb:cc:dd:ee:ff_start_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[entry_button_tare] + 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.lunar_ddeeff_tare', + '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': 'Tare', + 'platform': 'acaia', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tare', + 'unique_id': 'aa:bb:cc:dd:ee:ff_tare', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[state_button_reset_timer] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Reset timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_reset_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[state_button_start_stop_timer] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[state_button_tare] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Tare', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_tare', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr new file mode 100644 index 00000000000..1cc3d8dbbc0 --- /dev/null +++ b/tests/components/acaia/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'kitchen', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'acaia', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Acaia', + 'model': 'Lunar', + 'model_id': None, + 'name': 'LUNAR-DDEEFF', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'Kitchen', + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py new file mode 100644 index 00000000000..62eb8b61b8a --- /dev/null +++ b/tests/components/acaia/test_button.py @@ -0,0 +1,83 @@ +"""Tests for the acaia buttons.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +BUTTONS = ( + "tare", + "reset_timer", + "start_stop_timer", +) + + +async def test_buttons( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the acaia buttons.""" + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state == snapshot(name=f"state_button_{button}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry == snapshot(name=f"entry_button_{button}") + + +async def test_button_presses( + hass: HomeAssistant, + mock_scale: MagicMock, +) -> None: + """Test the acaia button presses.""" + + for button in BUTTONS: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: f"button.lunar_ddeeff_{button}", + }, + blocking=True, + ) + + function = getattr(mock_scale, button) + function.assert_called_once() + + +async def test_buttons_unavailable_on_disconnected_scale( + hass: HomeAssistant, + mock_scale: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the acaia buttons are unavailable when the scale is disconnected.""" + + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state.state == STATE_UNKNOWN + + mock_scale.connected = False + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for button in BUTTONS: + state = hass.states.get(f"button.lunar_ddeeff_{button}") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/acaia/test_config_flow.py b/tests/components/acaia/test_config_flow.py new file mode 100644 index 00000000000..2bf4b1dbe8a --- /dev/null +++ b/tests/components/acaia/test_config_flow.py @@ -0,0 +1,242 @@ +"""Test the acaia config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice +import pytest + +from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +service_info = BluetoothServiceInfo( + name="LUNAR-DDEEFF", + address="aa:bb:cc:dd:ee:ff", + rssi=-63, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", +) + + +@pytest.fixture +def mock_discovered_service_info() -> Generator[AsyncMock]: + """Override getting Bluetooth service info.""" + with patch( + "homeassistant.components.acaia.config_flow.async_discovered_service_info", + return_value=[service_info], + ) as mock_discovered_service_info: + yield mock_discovered_service_info + + +async def test_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + user_input = { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + } + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "LUNAR-DDEEFF" + assert result2["data"] == { + **user_input, + CONF_IS_NEW_STYLE_SCALE: True, + } + + +async def test_bluetooth_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, +) -> None: + """Test we can discover a device.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == service_info.name + assert result2["data"] == { + CONF_ADDRESS: service_info.address, + CONF_IS_NEW_STYLE_SCALE: True, + } + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AcaiaDeviceNotFound("Error"), "device_not_found"), + (AcaiaError, "unknown"), + (AcaiaUnknownDevice, "unsupported_device"), + ], +) +async def test_bluetooth_discovery_errors( + hass: HomeAssistant, + mock_verify: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test abortions of Bluetooth discovery.""" + mock_verify.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == error + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Ensure we can't add the same device twice.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_already_configured_bluetooth_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure configure device is not discovered again.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AcaiaDeviceNotFound("Error"), "device_not_found"), + (AcaiaError, "unknown"), + ], +) +async def test_recoverable_config_flow_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test recoverable errors.""" + mock_verify.side_effect = exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": error} + + # recover + mock_verify.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + assert result3["type"] is FlowResultType.CREATE_ENTRY + + +async def test_unsupported_device( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_verify: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_verify.side_effect = AcaiaUnknownDevice + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unsupported_device" + + +async def test_no_bluetooth_devices( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovered_service_info: AsyncMock, +) -> None: + """Test flow aborts on unsupported device.""" + mock_discovered_service_info.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" diff --git a/tests/components/acaia/test_init.py b/tests/components/acaia/test_init.py new file mode 100644 index 00000000000..8ad988d3b9b --- /dev/null +++ b/tests/components/acaia/test_init.py @@ -0,0 +1,65 @@ +"""Test init of acaia integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.acaia.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test loading and unloading the integration.""" + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", [AcaiaError, AcaiaDeviceNotFound("Boom"), TimeoutError] +) +async def test_update_exception_leads_to_active_disconnect( + hass: HomeAssistant, + mock_scale: MagicMock, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test scale gets disconnected on exception.""" + + mock_scale.connect.side_effect = exception + mock_scale.connected = False + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_scale.device_disconnected_handler.assert_called_once() + + +async def test_device( + mock_scale: MagicMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Snapshot the device from registry.""" + + device = device_registry.async_get_device({(DOMAIN, mock_scale.mac)}) + assert device + assert device == snapshot From 3d84e35268e4024604f7a55acc15ef091788f228 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 14 Nov 2024 14:27:19 +0100 Subject: [PATCH 504/511] Move lcn non-config_entry related code to async_setup (#130603) * Move non-config_entry related code to async_setup * Remove action unload --- homeassistant/components/lcn/__init__.py | 32 +++++++++++------------- homeassistant/components/lcn/services.py | 8 ++++++ 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 27f911822b5..eb26ef48e4e 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -20,7 +20,8 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, @@ -41,15 +42,26 @@ from .helpers import ( register_lcn_address_devices, register_lcn_host_device, ) -from .services import SERVICES +from .services import register_services from .websocket import register_panel_and_ws_api _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the LCN component.""" + hass.data.setdefault(DOMAIN, {}) + + await register_services(hass) + await register_panel_and_ws_api(hass) + + return True + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" - hass.data.setdefault(DOMAIN, {}) if config_entry.entry_id in hass.data[DOMAIN]: return False @@ -109,15 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) lcn_connection.register_for_inputs(input_received) - # register service calls - for service_name, service in SERVICES: - if not hass.services.has_service(DOMAIN, service_name): - hass.services.async_register( - DOMAIN, service_name, service(hass).async_call_service, service.schema - ) - - await register_panel_and_ws_api(hass) - return True @@ -168,11 +171,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> host = hass.data[DOMAIN].pop(config_entry.entry_id) await host[CONNECTION].async_close() - # unregister service calls - if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload - for service_name, _ in SERVICES: - hass.services.async_remove(DOMAIN, service_name) - return unload_ok diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 611a7353bcd..92f5863c47e 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -429,3 +429,11 @@ SERVICES = ( (LcnService.DYN_TEXT, DynText), (LcnService.PCK, Pck), ) + + +async def register_services(hass: HomeAssistant) -> None: + """Register services for LCN.""" + for service_name, service in SERVICES: + hass.services.async_register( + DOMAIN, service_name, service(hass).async_call_service, service.schema + ) From 01332a542cbcc01ff8cfd4ae1bff6b8f4d4c01fe Mon Sep 17 00:00:00 2001 From: Thibaut Date: Thu, 14 Nov 2024 15:23:55 +0100 Subject: [PATCH 505/511] Removing myself from template codeowners (#130617) * Removing myself as codeowners * Fix --------- Co-authored-by: Joostlek --- CODEOWNERS | 4 ++-- homeassistant/components/template/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8fd34a357c0..e204463695e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1489,8 +1489,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike -/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core -/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core +/homeassistant/components/template/ @PhracturedBlue @home-assistant/core +/tests/components/template/ @PhracturedBlue @home-assistant/core /homeassistant/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_wall_connector/ @einarhauks diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index 57188aebaa3..f1225f74f06 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "after_dependencies": ["group"], - "codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], + "codeowners": ["@PhracturedBlue", "@home-assistant/core"], "config_flow": true, "dependencies": ["blueprint"], "documentation": "https://www.home-assistant.io/integrations/template", From 61d0de3042dccf94332440e406ff27532e7e6163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 14 Nov 2024 15:27:10 +0100 Subject: [PATCH 506/511] Bump aioairzone to 0.9.6 (#130559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone to v0.9.6 Signed-off-by: Álvaro Fernández Rojas * Remove _async_migrator_mac_empty and improve tests Signed-off-by: Álvaro Fernández Rojas * Remove WebServer empty mac fixes as requested by @epenet Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 10fb20bb2ce..6bf374087a6 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.5"] + "requirements": ["aioairzone==0.9.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index cdba146d251..65ef5f1ebf2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39fb7f17d80..b61e65f3c68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.10 # homeassistant.components.airzone -aioairzone==0.9.5 +aioairzone==0.9.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0c44c632d47242cf5c9dacd7cf992e73114384c4 Mon Sep 17 00:00:00 2001 From: Lennard Beers Date: Thu, 14 Nov 2024 15:38:38 +0100 Subject: [PATCH 507/511] Add number platform to eq3btsmart (#130429) --- .../components/eq3btsmart/__init__.py | 1 + homeassistant/components/eq3btsmart/const.py | 7 + .../components/eq3btsmart/icons.json | 17 ++ homeassistant/components/eq3btsmart/models.py | 3 - homeassistant/components/eq3btsmart/number.py | 158 ++++++++++++++++++ .../components/eq3btsmart/strings.json | 17 ++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/eq3btsmart/number.py diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 86c555ec151..84b27161edd 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -21,6 +21,7 @@ from .models import Eq3Config, Eq3ConfigEntryData PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.NUMBER, Platform.SWITCH, ] diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index 64bc1cf497c..78292940e60 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -24,6 +24,11 @@ ENTITY_KEY_WINDOW = "window" ENTITY_KEY_LOCK = "lock" ENTITY_KEY_BOOST = "boost" ENTITY_KEY_AWAY = "away" +ENTITY_KEY_COMFORT = "comfort" +ENTITY_KEY_ECO = "eco" +ENTITY_KEY_OFFSET = "offset" +ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature" +ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout" GET_DEVICE_TIMEOUT = 5 # seconds @@ -77,3 +82,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" + +EQ3BT_STEP = 0.5 diff --git a/homeassistant/components/eq3btsmart/icons.json b/homeassistant/components/eq3btsmart/icons.json index fb0862f14bc..e6eb7532f37 100644 --- a/homeassistant/components/eq3btsmart/icons.json +++ b/homeassistant/components/eq3btsmart/icons.json @@ -8,6 +8,23 @@ } } }, + "number": { + "comfort": { + "default": "mdi:sun-thermometer" + }, + "eco": { + "default": "mdi:snowflake-thermometer" + }, + "offset": { + "default": "mdi:thermometer-plus" + }, + "window_open_temperature": { + "default": "mdi:window-open-variant" + }, + "window_open_timeout": { + "default": "mdi:timer-refresh" + } + }, "switch": { "away": { "default": "mdi:home-account", diff --git a/homeassistant/components/eq3btsmart/models.py b/homeassistant/components/eq3btsmart/models.py index 8ea0955dbdd..858465effa8 100644 --- a/homeassistant/components/eq3btsmart/models.py +++ b/homeassistant/components/eq3btsmart/models.py @@ -2,7 +2,6 @@ from dataclasses import dataclass -from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP from eq3btsmart.thermostat import Thermostat from .const import ( @@ -23,8 +22,6 @@ class Eq3Config: target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR external_temp_sensor: str = "" scan_interval: int = DEFAULT_SCAN_INTERVAL - default_away_hours: float = DEFAULT_AWAY_HOURS - default_away_temperature: float = DEFAULT_AWAY_TEMP @dataclass(slots=True) diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py new file mode 100644 index 00000000000..2e069180fa3 --- /dev/null +++ b/homeassistant/components/eq3btsmart/number.py @@ -0,0 +1,158 @@ +"""Platform for eq3 number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from eq3btsmart import Thermostat +from eq3btsmart.const import ( + EQ3BT_MAX_OFFSET, + EQ3BT_MAX_TEMP, + EQ3BT_MIN_OFFSET, + EQ3BT_MIN_TEMP, +) +from eq3btsmart.models import Presets + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Eq3ConfigEntry +from .const import ( + ENTITY_KEY_COMFORT, + ENTITY_KEY_ECO, + ENTITY_KEY_OFFSET, + ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + EQ3BT_STEP, +) +from .entity import Eq3Entity + + +@dataclass(frozen=True, kw_only=True) +class Eq3NumberEntityDescription(NumberEntityDescription): + """Entity description for eq3 number entities.""" + + value_func: Callable[[Presets], float] + value_set_func: Callable[ + [Thermostat], + Callable[[float], Awaitable[None]], + ] + mode: NumberMode = NumberMode.BOX + entity_category: EntityCategory | None = EntityCategory.CONFIG + + +NUMBER_ENTITY_DESCRIPTIONS = [ + Eq3NumberEntityDescription( + key=ENTITY_KEY_COMFORT, + value_func=lambda presets: presets.comfort_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, + translation_key=ENTITY_KEY_COMFORT, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_ECO, + value_func=lambda presets: presets.eco_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, + translation_key=ENTITY_KEY_ECO, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + value_func=lambda presets: presets.window_open_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, + translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, + native_min_value=EQ3BT_MIN_TEMP, + native_max_value=EQ3BT_MAX_TEMP, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_OFFSET, + value_func=lambda presets: presets.offset_temperature.value, + value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, + translation_key=ENTITY_KEY_OFFSET, + native_min_value=EQ3BT_MIN_OFFSET, + native_max_value=EQ3BT_MAX_OFFSET, + native_step=EQ3BT_STEP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + ), + Eq3NumberEntityDescription( + key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, + value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, + translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, + native_min_value=0, + native_max_value=60, + native_step=5, + native_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: Eq3ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the entry.""" + + async_add_entities( + Eq3NumberEntity(entry, entity_description) + for entity_description in NUMBER_ENTITY_DESCRIPTIONS + ) + + +class Eq3NumberEntity(Eq3Entity, NumberEntity): + """Base class for all eq3 number entities.""" + + entity_description: Eq3NumberEntityDescription + + def __init__( + self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription + ) -> None: + """Initialize the entity.""" + + super().__init__(entry, entity_description.key) + self.entity_description = entity_description + + @property + def native_value(self) -> float: + """Return the state of the entity.""" + + if TYPE_CHECKING: + assert self._thermostat.status is not None + assert self._thermostat.status.presets is not None + + return self.entity_description.value_func(self._thermostat.status.presets) + + async def async_set_native_value(self, value: float) -> None: + """Set the state of the entity.""" + + await self.entity_description.value_set_func(self._thermostat)(value) + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + + return ( + self._thermostat.status is not None + and self._thermostat.status.presets is not None + and self._attr_available + ) diff --git a/homeassistant/components/eq3btsmart/strings.json b/homeassistant/components/eq3btsmart/strings.json index 03c3b21b964..acfd5082f45 100644 --- a/homeassistant/components/eq3btsmart/strings.json +++ b/homeassistant/components/eq3btsmart/strings.json @@ -25,6 +25,23 @@ "name": "Daylight saving time" } }, + "number": { + "comfort": { + "name": "Comfort temperature" + }, + "eco": { + "name": "Eco temperature" + }, + "offset": { + "name": "Offset temperature" + }, + "window_open_temperature": { + "name": "Window open temperature" + }, + "window_open_timeout": { + "name": "Window open timeout" + } + }, "switch": { "lock": { "name": "Lock" From 472414a8d6bd231ce9f5c661248a2fdfd97eabb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:17:08 +0100 Subject: [PATCH 508/511] Add missing translation string to smarty (#130624) --- homeassistant/components/smarty/strings.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/smarty/strings.json b/homeassistant/components/smarty/strings.json index 188459b4f16..341a300a26e 100644 --- a/homeassistant/components/smarty/strings.json +++ b/homeassistant/components/smarty/strings.json @@ -28,6 +28,10 @@ "deprecated_yaml_import_issue_auth_error": { "title": "YAML import failed due to an authentication error", "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "YAML import failed due to a connection error", + "description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." } }, "entity": { From c7ee7dc880a0952dcc8b447f70747980bbb56f88 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:26:05 +0100 Subject: [PATCH 509/511] Refactor translation checks (#130585) * Refactor translation checks * Adjust * Improve * Restore await * Delay pytest.fail until the end of the test --- tests/components/conftest.py | 155 ++++++++++++++++++++--------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 5535ec3b976..363d39a2e63 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -26,7 +26,12 @@ from homeassistant.config_entries import ( ) from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType +from homeassistant.data_entry_flow import ( + FlowContext, + FlowHandler, + FlowManager, + FlowResultType, +) from homeassistant.helpers.translation import async_get_translations if TYPE_CHECKING: @@ -557,12 +562,12 @@ def _validate_translation_placeholders( description_placeholders is None or placeholder not in description_placeholders ): - pytest.fail( + ignore_translations[full_key] = ( f"Description not found for placeholder `{placeholder}` in {full_key}" ) -async def _ensure_translation_exists( +async def _validate_translation( hass: HomeAssistant, ignore_translations: dict[str, StoreInfo], category: str, @@ -588,7 +593,7 @@ async def _ensure_translation_exists( ignore_translations[full_key] = "used" return - pytest.fail( + ignore_translations[full_key] = ( f"Translation not found for {component}: `{category}.{key}`. " f"Please add to homeassistant/components/{component}/strings.json" ) @@ -604,84 +609,106 @@ def ignore_translations() -> str | list[str]: return [] +async def _check_config_flow_result_translations( + manager: FlowManager, + flow: FlowHandler, + result: FlowResult[FlowContext, str], + ignore_translations: dict[str, str], +) -> None: + if isinstance(manager, ConfigEntriesFlowManager): + category = "config" + integration = flow.handler + elif isinstance(manager, OptionsFlowManager): + category = "options" + integration = flow.hass.config_entries.async_get_entry(flow.handler).domain + else: + return + + # Check if this flow has been seen before + # Gets set to False on first run, and to True on subsequent runs + setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) + + if result["type"] is FlowResultType.FORM: + if step_id := result.get("step_id"): + # neither title nor description are required + # - title defaults to integration name + # - description is optional + for header in ("title", "description"): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"step.{step_id}.{header}", + result["description_placeholders"], + translation_required=False, + ) + if errors := result.get("errors"): + for error in errors.values(): + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"error.{error}", + result["description_placeholders"], + ) + return + + if result["type"] is FlowResultType.ABORT: + # We don't need translations for a discovery flow which immediately + # aborts, since such flows won't be seen by users + if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: + return + await _validate_translation( + flow.hass, + ignore_translations, + category, + integration, + f"abort.{result["reason"]}", + result["description_placeholders"], + ) + + @pytest.fixture(autouse=True) -def check_config_translations(ignore_translations: str | list[str]) -> Generator[None]: - """Ensure config_flow translations are available.""" +def check_translations(ignore_translations: str | list[str]) -> Generator[None]: + """Check that translation requirements are met. + + Current checks: + - data entry flow results (ConfigFlow/OptionsFlow) + """ if not isinstance(ignore_translations, list): ignore_translations = [ignore_translations] _ignore_translations = {k: "unused" for k in ignore_translations} - _original = FlowManager._async_handle_step - async def _async_handle_step( + # Keep reference to original functions + _original_flow_manager_async_handle_step = FlowManager._async_handle_step + + # Prepare override functions + async def _flow_manager_async_handle_step( self: FlowManager, flow: FlowHandler, *args ) -> FlowResult: - result = await _original(self, flow, *args) - if isinstance(self, ConfigEntriesFlowManager): - category = "config" - component = flow.handler - elif isinstance(self, OptionsFlowManager): - category = "options" - component = flow.hass.config_entries.async_get_entry(flow.handler).domain - else: - return result - - # Check if this flow has been seen before - # Gets set to False on first run, and to True on subsequent runs - setattr(flow, "__flow_seen_before", hasattr(flow, "__flow_seen_before")) - - if result["type"] is FlowResultType.FORM: - if step_id := result.get("step_id"): - # neither title nor description are required - # - title defaults to integration name - # - description is optional - for header in ("title", "description"): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"step.{step_id}.{header}", - result["description_placeholders"], - translation_required=False, - ) - if errors := result.get("errors"): - for error in errors.values(): - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"error.{error}", - result["description_placeholders"], - ) - return result - - if result["type"] is FlowResultType.ABORT: - # We don't need translations for a discovery flow which immediately - # aborts, since such flows won't be seen by users - if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: - return result - await _ensure_translation_exists( - flow.hass, - _ignore_translations, - category, - component, - f"abort.{result["reason"]}", - result["description_placeholders"], - ) - + result = await _original_flow_manager_async_handle_step(self, flow, *args) + await _check_config_flow_result_translations( + self, flow, result, _ignore_translations + ) return result + # Use override functions with patch( "homeassistant.data_entry_flow.FlowManager._async_handle_step", - _async_handle_step, + _flow_manager_async_handle_step, ): yield + # Run final checks unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"] if unused_ignore: pytest.fail( f"Unused ignore translations: {', '.join(unused_ignore)}. " "Please remove them from the ignore_translations fixture." ) + for description in _ignore_translations.values(): + if description not in {"used", "unused"}: + pytest.fail(description) From cd1272008507c7cb82155a8d7509c95067290774 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 14 Nov 2024 16:31:33 +0100 Subject: [PATCH 510/511] Add Python version to issue ID (#130611) --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index dcfb6685627..1034223051c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -515,7 +515,7 @@ async def async_from_config_dict( issue_registry.async_create_issue( hass, core.DOMAIN, - "python_version", + f"python_version_{required_python_version}", is_fixable=False, severity=issue_registry.IssueSeverity.WARNING, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, From 1ce8bfdaa438949da707d94ff7b12ff7b20ce0cc Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Thu, 14 Nov 2024 16:34:17 +0100 Subject: [PATCH 511/511] Use test helpers for acaia buttons (#130626) --- .../acaia/snapshots/test_button.ambr | 60 +++++++++---------- tests/components/acaia/test_button.py | 33 ++++++---- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr index 7e2624923af..cd91ca1a17a 100644 --- a/tests/components/acaia/snapshots/test_button.ambr +++ b/tests/components/acaia/snapshots/test_button.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_buttons[entry_button_reset_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_start_stop_timer] +# name: test_buttons[button.lunar_ddeeff_reset_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Reset timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_reset_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -65,7 +78,20 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[entry_button_tare] +# name: test_buttons[button.lunar_ddeeff_start_stop_timer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', + }), + 'context': , + 'entity_id': 'button.lunar_ddeeff_start_stop_timer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_buttons[button.lunar_ddeeff_tare-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -98,33 +124,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_buttons[state_button_reset_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Reset timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_reset_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_start_stop_timer] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'LUNAR-DDEEFF Start/stop timer', - }), - 'context': , - 'entity_id': 'button.lunar_ddeeff_start_stop_timer', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_buttons[state_button_tare] +# name: test_buttons[button.lunar_ddeeff_tare-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'LUNAR-DDEEFF Tare', diff --git a/tests/components/acaia/test_button.py b/tests/components/acaia/test_button.py index 62eb8b61b8a..f68f85e253d 100644 --- a/tests/components/acaia/test_button.py +++ b/tests/components/acaia/test_button.py @@ -1,21 +1,24 @@ """Tests for the acaia buttons.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -import pytest from syrupy import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import async_fire_time_changed - -pytestmark = pytest.mark.usefixtures("init_integration") +from . import setup_integration +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform BUTTONS = ( "tare", @@ -28,24 +31,25 @@ async def test_buttons( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia buttons.""" - for button in BUTTONS: - state = hass.states.get(f"button.lunar_ddeeff_{button}") - assert state - assert state == snapshot(name=f"state_button_{button}") - entry = entity_registry.async_get(state.entity_id) - assert entry - assert entry == snapshot(name=f"entry_button_{button}") + with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BUTTON]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_button_presses( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test the acaia button presses.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: await hass.services.async_call( BUTTON_DOMAIN, @@ -63,10 +67,13 @@ async def test_button_presses( async def test_buttons_unavailable_on_disconnected_scale( hass: HomeAssistant, mock_scale: MagicMock, + mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test the acaia buttons are unavailable when the scale is disconnected.""" + await setup_integration(hass, mock_config_entry) + for button in BUTTONS: state = hass.states.get(f"button.lunar_ddeeff_{button}") assert state