From cff3d3d6acc0feb2ef58d0d3178c42e56906193b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Jun 2025 18:51:19 +0000 Subject: [PATCH 001/127] Bump version to 2025.7.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 0abdcd59b77..02631a8f365 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 87dec7a8429..24e290faed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0.dev0" +version = "2025.7.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From a296324c30b151e873528a81ce0124da637dea21 Mon Sep 17 00:00:00 2001 From: Fabio Natanael Kepler Date: Thu, 26 Jun 2025 16:12:15 +0100 Subject: [PATCH 002/127] Fix playing TTS and local media source over DLNA (#134903) Co-authored-by: Erik Montnemery --- homeassistant/components/http/auth.py | 2 +- homeassistant/components/image/__init__.py | 37 +++++++++++++++++-- .../components/media_source/local_source.py | 25 +++++++++++-- homeassistant/components/tts/__init__.py | 15 ++++++++ tests/components/http/test_auth.py | 8 +++- tests/components/image/test_init.py | 21 +++++++++++ .../media_source/test_local_source.py | 12 ++++++ tests/components/tts/test_init.py | 23 ++++++++++++ 8 files changed, 134 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7e00cc70eaa..227ee074439 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -223,7 +223,7 @@ async def async_setup_auth( # We first start with a string check to avoid parsing query params # for every request. elif ( - request.method == "GET" + request.method in ["GET", "HEAD"] and SIGN_QUERY_PARAM in request.query_string and async_validate_signed_request(request) ): diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 644d335bbca..0a3b9bf9af7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -288,8 +288,10 @@ class ImageView(HomeAssistantView): """Initialize an image view.""" self.component = component - async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: - """Start a GET request.""" + async def _authenticate_request( + self, request: web.Request, entity_id: str + ) -> ImageEntity: + """Authenticate request and return image entity.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound @@ -306,6 +308,31 @@ class ImageView(HomeAssistantView): # Invalid sigAuth or image entity access token raise web.HTTPForbidden + return image_entity + + async def head(self, request: web.Request, entity_id: str) -> web.Response: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + """ + image_entity = await self._authenticate_request(request, entity_id) + + # Don't use `handle` as we don't care about the stream case, we only want + # to verify that the image exists. + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError from ex + + return web.Response( + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + image_entity = await self._authenticate_request(request, entity_id) return await self.handle(request, image_entity) async def handle( @@ -317,7 +344,11 @@ class ImageView(HomeAssistantView): except (HomeAssistantError, ValueError) as ex: raise web.HTTPInternalServerError from ex - return web.Response(body=image.content, content_type=image.content_type) + return web.Response( + body=image.content, + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) async def async_get_still_stream( diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 7916f72c6b9..4e3d6ff59db 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView): self.hass = hass self.source = source - async def get( - self, request: web.Request, source_dir_id: str, location: str - ) -> web.FileResponse: - """Start a GET request.""" + async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: + """Validate media path and return it if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView): if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound + return media_path + + async def head( + self, request: web.Request, source_dir_id: str, location: str + ) -> None: + """Handle a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the location exists or not. + """ + await self._validate_media_path(source_dir_id, location) + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.FileResponse: + """Handle a GET request.""" + media_path = await self._validate_media_path(source_dir_id, location) return web.FileResponse(media_path) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8292df07ef8..c8e6e0f67fb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager + async def head(self, request: web.Request, token: str) -> web.StreamResponse: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the token (file) exists and return its content type. + """ + stream = self.manager.token_to_stream.get(token) + + if stream is None: + return web.Response(status=HTTPStatus.NOT_FOUND) + + return web.Response(content_type=stream.content_type) + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 8bf2e66a286..ca66b8fef4b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -305,16 +305,22 @@ async def test_auth_access_signed_path_with_refresh_token( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id ) + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id # Use signature on other path + req = await client.head(f"/another_path?{signed_path.split('?')[1]}") + assert req.status == HTTPStatus.UNAUTHORIZED + req = await client.get(f"/another_path?{signed_path.split('?')[1]}") assert req.status == HTTPStatus.UNAUTHORIZED - # We only allow GET + # We only allow GET and HEAD req = await client.post(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 3bcf0df52e3..bb8762f17e2 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -174,10 +174,22 @@ async def test_fetch_image_authenticated( """Test fetching an image with an authenticated client.""" client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 + + resp = await client.head("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 resp = await client.get("/api/image_proxy/image.unknown") assert resp.status == HTTPStatus.NOT_FOUND @@ -260,10 +272,19 @@ async def test_fetch_image_url_success( client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 @respx.mock diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d3ae95736a5..1823165d906 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -105,6 +105,9 @@ async def test_media_view( client = await hass_client() # Protects against non-existent files + resp = await client.head("/media/local/invalid.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/invalid.txt") assert resp.status == HTTPStatus.NOT_FOUND @@ -112,14 +115,23 @@ async def test_media_view( assert resp.status == HTTPStatus.NOT_FOUND # Protects against non-media files + resp = await client.head("/media/local/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Protects against unknown local media sources + resp = await client.head("/media/unknown_source/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/unknown_source/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Fetch available media + resp = await client.head("/media/local/test.mp3") + assert resp.status == HTTPStatus.OK + resp = await client.get("/media/local/test.mp3") assert resp.status == HTTPStatus.OK diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ccb62959eba..22fb10209b0 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -916,6 +916,29 @@ async def test_web_view_wrong_file( assert req.status == HTTPStatus.NOT_FOUND +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_file_with_head_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, +) -> None: + """Set up a TTS platform and receive wrong file from web.""" + client = await hass_client() + + url = ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ) + + req = await client.head(url) + assert req.status == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("setup", "expected_url_suffix"), [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], From d4b548b16969b7b2728d91aafd9ac5c9cdf246a8 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Thu, 26 Jun 2025 15:30:03 +0200 Subject: [PATCH 003/127] Fixed issue when tests (should) fail in Smarla (#146102) * Fixed issue when tests (should) fail * Use usefixture decorator * Throw ConfigEntryError instead of AuthFailed --- homeassistant/components/smarla/__init__.py | 4 ++-- tests/components/smarla/test_config_flow.py | 20 ++++++++++---------- tests/components/smarla/test_init.py | 3 +++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index 2de3fcfa242..533acb3375b 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from .const import HOST, PLATFORMS @@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - # Check if token still has access if not await connection.refresh_token(): - raise ConfigEntryAuthFailed("Invalid authentication") + raise ConfigEntryError("Invalid authentication") federwiege = Federwiege(hass.loop, connection) federwiege.register() diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py index a2bd5b36fc0..beccf6e4b95 100644 --- a/tests/components/smarla/test_config_flow.py +++ b/tests/components/smarla/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.smarla.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -12,9 +14,8 @@ from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_config_flow( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_config_flow(hass: HomeAssistant) -> None: """Test creating a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -35,9 +36,8 @@ async def test_config_flow( assert result["result"].unique_id == MOCK_SERIAL_NUMBER -async def test_malformed_token( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_malformed_token(hass: HomeAssistant) -> None: """Test we show user form on malformed token input.""" with patch( "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError @@ -60,9 +60,8 @@ async def test_malformed_token( assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_invalid_auth( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) -> None: """Test we show user form on invalid auth.""" with patch.object( mock_connection, "refresh_token", new=AsyncMock(return_value=False) @@ -85,8 +84,9 @@ async def test_invalid_auth( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") async def test_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test we abort config flow if Smarla device already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/smarla/test_init.py b/tests/components/smarla/test_init.py index b9d291f582d..9523772d914 100644 --- a/tests/components/smarla/test_init.py +++ b/tests/components/smarla/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,6 +12,7 @@ from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_federwiege") async def test_init_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock ) -> None: From aec812a475c9e1499ba42a2fe5a936f08e84447e Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Thu, 26 Jun 2025 19:59:49 +0200 Subject: [PATCH 004/127] Create a new client session for air-Q to fix cookie polution (#147027) --- homeassistant/components/airq/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 743d12d40e5..3ab41978b05 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) self.airq = AirQ( entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session ) From 71f281cc140dc132593fc12b8846b99fef1c426d Mon Sep 17 00:00:00 2001 From: hanwg Date: Thu, 26 Jun 2025 22:43:09 +0800 Subject: [PATCH 005/127] Fix Telegram bot default target when sending messages (#147470) * handle targets * updated error message * validate chat id for single target * add validation for chat id * handle empty target * handle empty target --- .../components/telegram_bot/__init__.py | 24 +++++-- homeassistant/components/telegram_bot/bot.py | 62 ++++++++++--------- .../components/telegram_bot/strings.json | 6 ++ .../telegram_bot/test_telegram_bot.py | 47 ++++++++++++-- 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 5bdc670d69c..cab147162aa 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -29,6 +29,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + HomeAssistantError, ServiceValidationError, ) from homeassistant.helpers import config_validation as cv @@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif msgtype == SERVICE_DELETE_MESSAGE: await notify_service.delete_message(context=service.context, **kwargs) elif msgtype == SERVICE_LEAVE_CHAT: - messages = await notify_service.leave_chat( - context=service.context, **kwargs - ) + await notify_service.leave_chat(context=service.context, **kwargs) elif msgtype == SERVICE_SET_MESSAGE_REACTION: await notify_service.set_message_reaction(context=service.context, **kwargs) else: @@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) - if service.return_response and messages: + if service.return_response and messages is not None: + target: list[int] | None = service.data.get(ATTR_TARGET) + if not target: + target = notify_service.get_target_chat_ids(None) + + failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages] + if failed_chat_ids: + raise HomeAssistantError( + f"Failed targets: {failed_chat_ids}", + translation_domain=DOMAIN, + translation_key="failed_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join([str(i) for i in failed_chat_ids]), + "bot_name": config_entry.title, + }, + ) + return { "chats": [ {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() ] } + return None # Register notification services diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 4a00aff8d3f..a3feb120460 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -287,24 +287,32 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target: Any) -> list[int]: + def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: """Validate chat_id targets or return default target (first). :param target: optional list of integers ([12234, -12345]) :return list of chat_id targets (integers) """ allowed_chat_ids: list[int] = self._get_allowed_chat_ids() - default_user: int = allowed_chat_ids[0] - if target is not None: - if isinstance(target, int): - target = [target] - chat_ids = [t for t in target if t in allowed_chat_ids] - if chat_ids: - return chat_ids - _LOGGER.warning( - "Disallowed targets: %s, using default: %s", target, default_user + + if target is None: + return [allowed_chat_ids[0]] + + chat_ids = [target] if isinstance(target, int) else target + valid_chat_ids = [ + chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids + ] + if not valid_chat_ids: + raise ServiceValidationError( + "Invalid chat IDs", + translation_domain=DOMAIN, + translation_key="invalid_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), + "bot_name": self.config.title, + }, ) - return [default_user] + return valid_chat_ids def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -414,9 +422,9 @@ class TelegramNotificationService: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) - if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + if isinstance(out, Message): chat_id = out.chat_id - message_id = out[ATTR_MESSAGEID] + message_id = out.message_id self._last_message_id[chat_id] = message_id _LOGGER.debug( "Last message ID: %s (from chat_id %s)", @@ -424,7 +432,7 @@ class TelegramNotificationService: chat_id, ) - event_data = { + event_data: dict[str, Any] = { ATTR_CHAT_ID: chat_id, ATTR_MESSAGEID: message_id, } @@ -437,10 +445,6 @@ class TelegramNotificationService: self.hass.bus.async_fire( EVENT_TELEGRAM_SENT, event_data, context=context ) - elif not isinstance(out, bool): - _LOGGER.warning( - "Update last message: out_type:%s, out=%s", type(out), out - ) except TelegramError as exc: _LOGGER.error( "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg @@ -460,7 +464,7 @@ class TelegramNotificationService: text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) msg = await self._send_msg( self.bot.send_message, @@ -488,7 +492,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted: bool = await self._send_msg( @@ -513,7 +517,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -620,7 +624,7 @@ class TelegramNotificationService: msg_ids = {} if file_content: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: @@ -738,7 +742,7 @@ class TelegramNotificationService: msg_ids = {} if stickerid: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", @@ -769,7 +773,7 @@ class TelegramNotificationService: longitude = float(longitude) params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) @@ -803,7 +807,7 @@ class TelegramNotificationService: params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) msg = await self._send_msg( self.bot.send_poll, @@ -826,12 +830,12 @@ class TelegramNotificationService: async def leave_chat( self, - chat_id: Any = None, + chat_id: int | None = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) return await self._send_msg( self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context @@ -839,14 +843,14 @@ class TelegramNotificationService: async def set_message_reaction( self, - chat_id: int, reaction: str, + chat_id: int | None = None, is_big: bool = False, context: Context | None = None, **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index e932d010894..a51d4a371f1 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -895,6 +895,12 @@ "missing_allowed_chat_ids": { "message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}." }, + "invalid_chat_ids": { + "message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}." + }, + "failed_chat_ids": { + "message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured." + }, "missing_input": { "message": "{field} is required." }, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index fd313867561..190fed07ae3 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -677,13 +677,35 @@ async def test_send_message_with_config_entry( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: send message to invalid chat id + + with pytest.raises(HomeAssistantError) as err: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, + ATTR_MESSAGE: "mock message", + ATTR_TARGET: [123456, 1], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "failed_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: send message to valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, { CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, ATTR_MESSAGE: "mock message", - ATTR_TARGET: 1, + ATTR_TARGET: 123456, }, blocking=True, return_response=True, @@ -767,6 +789,23 @@ async def test_delete_message( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: delete message with invalid chat id + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MESSAGE, + {ATTR_CHAT_ID: 1, ATTR_MESSAGEID: "last"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "invalid_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: delete message with valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -808,7 +847,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_MESSAGE, - {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -822,7 +861,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_CAPTION, - {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -836,7 +875,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_REPLYMARKUP, - {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) From 7d0e99da43d0766b66a9023cde0c35bde0d479f0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 15:12:23 -0700 Subject: [PATCH 006/127] Fixes in Google AI TTS (#147501) * Fix Google AI not using correct config options after subentries migration * Fixes in Google AI TTS * Fix tests by @IvanLH * Change type name. --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../__init__.py | 13 + .../config_flow.py | 105 +++-- .../const.py | 16 +- .../strings.json | 29 +- .../google_generative_ai_conversation/tts.py | 92 ++--- homeassistant/config_entries.py | 5 + .../conftest.py | 9 +- .../test_config_flow.py | 65 ++- .../test_init.py | 56 ++- .../test_tts.py | 385 ++++++------------ 10 files changed, 412 insertions(+), 363 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 40d441929a3..1802073f760 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import mimetypes from pathlib import Path +from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError @@ -36,10 +37,12 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -242,6 +245,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) + if use_existing: + hass.config_entries.async_add_subentry( + parent_entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) conversation_entity = entity_registry.async_get_entity_id( "conversation", DOMAIN, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 4b7c7a0dd47..bb526f95a21 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,13 +47,17 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -66,12 +70,6 @@ STEP_API_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -123,10 +121,16 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -172,10 +176,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": LLMSubentryFlowHandler, + "tts": LLMSubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): +class LLMSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" last_rendered_recommended = False @@ -202,7 +209,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input is None: if self._is_new: - options = RECOMMENDED_OPTIONS.copy() + options: dict[str, Any] + if self._subentry_type == "tts": + options = RECOMMENDED_TTS_OPTIONS.copy() + else: + options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: # If this is a reconfiguration, we need to copy the existing options # so that we can show the current values in the form. @@ -216,7 +227,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) - # Don't allow to save options that enable the Google Seearch tool with an Assist API + # Don't allow to save options that enable the Google Search tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True @@ -240,7 +251,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, self._is_new, options, self._genai_client + self.hass, self._is_new, self._subentry_type, options, self._genai_client ) return self.async_show_form( step_id="set_options", data_schema=vol.Schema(schema), errors=errors @@ -253,6 +264,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, is_new: bool, + subentry_type: str, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -270,26 +282,39 @@ async def google_generative_ai_config_option_schema( suggested_llm_apis = [suggested_llm_apis] if is_new: + if CONF_NAME in options: + default_name = options[CONF_NAME] + elif subentry_type == "tts": + default_name = DEFAULT_TTS_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { - vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + vol.Required(CONF_NAME, default=default_name): str, } else: schema = {} + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ) schema.update( { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -310,7 +335,7 @@ async def google_generative_ai_config_option_schema( if ( api_model.display_name and api_model.name - and "tts" not in api_model.name + and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions and "generateContent" in api_model.supported_actions @@ -341,12 +366,17 @@ async def google_generative_ai_config_option_schema( ) ) + if subentry_type == "tts": + default_model = RECOMMENDED_TTS_MODEL + else: + default_model = RECOMMENDED_CHAT_MODEL + schema.update( { vol.Optional( CONF_CHAT_MODEL, description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, + default=default_model, ): SelectSelector( SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models) ), @@ -396,13 +426,18 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, - vol.Optional( - CONF_USE_GOOGLE_SEARCH_TOOL, - description={ - "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), - }, - default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, - ): bool, } ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, + } + ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 0735e9015c2..9f4132a1e3e 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -2,17 +2,20 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_TTS_NAME = "Google AI TTS" -ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" -RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" +RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" @@ -31,3 +34,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_RECOMMENDED: True, +} + +RECOMMENDED_TTS_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index e523aecbaec..eef595ad05d 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -29,7 +29,6 @@ "reconfigure": "Reconfigure conversation agent" }, "entry_type": "Conversation agent", - "step": { "set_options": { "data": { @@ -61,6 +60,34 @@ "error": { "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } + }, + "tts": { + "initiate_flow": { + "user": "Add Text-to-Speech service", + "reconfigure": "Reconfigure Text-to-Speech service" + }, + "entry_type": "Text-to-Speech", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 50baec67db2..174f0a50dc3 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress import io -import logging from typing import Any import wave from google.genai import types +from google.genai.errors import APIError, ClientError +from propcache.api import cached_property from homeassistant.components.tts import ( ATTR_VOICE, @@ -19,12 +21,10 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -32,15 +32,23 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up TTS entity.""" - tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) - async_add_entities([tts_entity]) + """Set up TTS entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "tts": + continue + + async_add_entities( + [GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) -class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): +class GoogleGenerativeAITextToSpeechEntity( + TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity +): """Google Generative AI text-to-speech entity.""" - _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages _attr_supported_languages = [ "ar-EG", @@ -68,6 +76,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): "uk-UA", "vi-VN", ] + # Unused, but required by base class. + # The Gemini TTS models detect the input language automatically. _attr_default_language = "en-US" # See https://ai.google.dev/gemini-api/docs/speech-generation#voices _supported_voices = [ @@ -106,53 +116,41 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): ) ] - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Google Generative AI Conversation speech entity.""" - self.entry = entry - self._attr_name = "Google Generative AI TTS" - self._attr_unique_id = f"{entry.entry_id}_tts" - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) - self._genai_client = entry.runtime_data - self._default_voice_id = self._supported_voices[0].voice_id - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: + def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" return self._supported_voices + @cached_property + def default_options(self) -> Mapping[str, Any]: + """Return a mapping with the default options.""" + return { + ATTR_VOICE: self._supported_voices[0].voice_id, + } + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" - try: - response = self._genai_client.models.generate_content( - model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), - contents=message, - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name=options.get( - ATTR_VOICE, self._default_voice_id - ) - ) - ) - ), - ), + config = self.create_generate_content_config() + config.response_modalities = ["AUDIO"] + config.speech_config = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options[ATTR_VOICE] + ) + ) + ) + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=config, ) - data = response.candidates[0].content.parts[0].inline_data.data mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except Exception as exc: - _LOGGER.warning( - "Error during processing of TTS request %s", exc, exc_info=True - ) + except (APIError, ClientError, ValueError) as exc: + LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc return "wav", self._convert_to_wav(data, mime_type) @@ -192,7 +190,7 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): """ if not mime_type.startswith("audio/L"): - _LOGGER.warning("Received unexpected MIME type %s", mime_type) + LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") bits_per_sample = 16 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c2481ae3fa3..ca3a78f8046 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3420,6 +3420,11 @@ class ConfigSubentryFlow( """Return config entry id.""" return self.handler[0] + @property + def _subentry_type(self) -> str: + """Return type of subentry we are editing/creating.""" + return self.handler[1] + @callback def _get_entry(self) -> ConfigEntry: """Return the config entry linked to the current context.""" diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 36d99cd2764..afea41bbb26 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -34,7 +35,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "data": {}, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) entry.runtime_data = Mock() diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index e02d85e41c4..b43c8a42275 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -6,9 +6,6 @@ import pytest from requests.exceptions import Timeout from homeassistant import config_entries -from homeassistant.components.google_generative_ai_conversation.config_flow import ( - RECOMMENDED_OPTIONS, -) from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, CONF_DANGEROUS_BLOCK_THRESHOLD, @@ -23,12 +20,15 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME @@ -115,10 +115,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -172,19 +178,64 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options +async def test_creating_tts_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a TTS subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "tts"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock TTS" + assert result2["data"] == RECOMMENDED_TTS_OPTIONS + + assert len(mock_config_entry.subentries) == 3 + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == "tts" + assert new_subentry.data == RECOMMENDED_TTS_OPTIONS + assert new_subentry.title == "Mock TTS" + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component: None, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 8de678213c2..a8a1e2840e3 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -7,7 +7,11 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion -from homeassistant.components.google_generative_ai_conversation.const import DOMAIN +from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TTS_NAME, + DOMAIN, + RECOMMENDED_TTS_OPTIONS, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -469,13 +473,27 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -493,7 +511,7 @@ async def test_migration_from_v1_to_v2( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" @@ -591,11 +609,15 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 1 + assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + subentry = list(entry.subentries.values())[1] + assert subentry.subentry_type == "tts" + assert subentry.data == RECOMMENDED_TTS_OPTIONS + assert subentry.title == DEFAULT_TTS_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -680,13 +702,27 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -704,7 +740,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 4f197f0535f..108ac82947c 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -9,30 +9,37 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from google.genai import types +from google.genai.errors import APIError import pytest from homeassistant.components import tts -from homeassistant.components.google_generative_ai_conversation.tts import ( - ATTR_MODEL, +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, DOMAIN, - RECOMMENDED_TTS_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from . import API_ERROR_500 - from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +API_ERROR_500 = APIError("test", response=MagicMock()) +TEST_CHAT_MODEL = "models/some-tts-model" + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: @@ -63,20 +70,22 @@ def mock_genai_client() -> Generator[AsyncMock]: """Mock genai_client.""" client = Mock() client.aio.models.get = AsyncMock() - client.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) ) - ) - ), + ), + ) ) ) with patch( @@ -90,17 +99,29 @@ def mock_genai_client() -> Generator[AsyncMock]: async def setup_fixture( hass: HomeAssistant, config: dict[str, Any], - request: pytest.FixtureRequest, mock_genai_client: AsyncMock, ) -> None: """Set up the test environment.""" - if request.param == "mock_setup": - await mock_setup(hass, config) - if request.param == "mock_config_entry_setup": - await mock_config_entry_setup(hass, config) - else: - raise RuntimeError("Invalid setup fixture") + config_entry = MockConfigEntry(domain=DOMAIN, data=config, version=2) + config_entry.add_to_hass(hass) + sub_entry = ConfigSubentry( + data={ + tts.CONF_LANG: "en-US", + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + }, + subentry_type="tts", + title="Google AI TTS", + subentry_id="test_subentry_tts_id", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() @@ -112,105 +133,38 @@ def config_fixture() -> dict[str, Any]: } -async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock setup.""" - assert await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} - ) - - -async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock config entry setup.""" - default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry( - domain=DOMAIN, data=default_config | config, version=2 - ) - - client_mock = Mock() - client_mock.models.get = None - client_mock.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), - ) - ) - ), - ) - ) - config_entry.runtime_data = client_mock - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - - @pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), + "service_data", [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, - }, - ), + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, ], - indirect=["setup"], ) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, service_data: dict[str, Any], ) -> None: """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.reset_mock() await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -221,10 +175,9 @@ async def test_tts_service_speak( == HTTPStatus.OK ) voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") - model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=model_id, + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], @@ -233,109 +186,52 @@ async def test_tts_service_speak( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "de-DE", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "it-IT", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_lang_config( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with languages in the config.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak_error( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], ) -> None: """Test service call with HTTP response 500.""" + service_data = { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + } tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + tts_entity._genai_client.aio.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.side_effect = API_ERROR_500 await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -346,70 +242,39 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE) + + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=types.SpeechConfig( voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_without_options( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with HTTP response 200.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) From b9a7371996ae5a7afce6f5298fd884cc920650d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Jun 2025 08:20:26 +0200 Subject: [PATCH 007/127] Set end date for when allowing unique id collisions in config entries (#147516) * Set end date for when allowing unique id collisions in config entries * Update test --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ca3a78f8046..e76b7ae099f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1646,6 +1646,7 @@ class ConfigEntriesFlowManager( report_usage( "creates a config entry when another entry with the same unique ID " "exists", + breaks_in_ha_version="2026.3", core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 45bb956b7a1..dc893e4c5fd 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8823,7 +8823,7 @@ async def test_create_entry_existing_unique_id( log_text = ( f"Detected that integration '{domain}' creates a config entry " - "when another entry with the same unique ID exists. Please " - "create a bug report at https:" + "when another entry with the same unique ID exists. This will stop " + "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log From 150f41641b492d2bdaf67a802941c3e2e465018f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 11:52:14 +0300 Subject: [PATCH 008/127] Improve config flow strings for Alexa Devices (#147523) --- homeassistant/components/alexa_devices/strings.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index eb279e28d35..b3bb699d003 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -1,8 +1,7 @@ { "common": { - "data_country": "Country code", "data_code": "One-time password (OTP code)", - "data_description_country": "The country of your Amazon account.", + "data_description_country": "The country where your Amazon account is registered.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." @@ -12,10 +11,10 @@ "step": { "user": { "data": { - "country": "[%key:component::alexa_devices::common::data_country%]", + "country": "[%key:common::config_flow::data::country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::alexa_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_code%]" }, "data_description": { "country": "[%key:component::alexa_devices::common::data_description_country%]", From e627811f7a29d7de0d7213b09cccd97f9f8eff6a Mon Sep 17 00:00:00 2001 From: Anders Peter Fugmann Date: Thu, 26 Jun 2025 12:56:46 +0200 Subject: [PATCH 009/127] Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) --- homeassistant/components/dlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8afc44a082e..00867e98511 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyW215"], - "requirements": ["pyW215==0.7.0"] + "requirements": ["pyW215==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76ef5d07d10..cc59f822156 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1817,7 +1817,7 @@ pySDCP==1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.w800rf32 pyW800rf32==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9557e405e98..684b28b88d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1525,7 +1525,7 @@ pyRFXtrx==0.31.1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From 2c4ea0d584bf3c1d3f093fbcd3571f72ed4ee9a7 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 11:07:07 +0200 Subject: [PATCH 010/127] Fix wind direction state class sensor for AEMET (#147535) --- homeassistant/components/aemet/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index a3aeab9deb9..2e7e977cf3d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( From 6b2aaf3fdb258dd3281d77a34bc6d771f6a63ae6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 10:55:31 +0200 Subject: [PATCH 011/127] Show current Lametric version if there is no newer version (#147538) --- homeassistant/components/lametric/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py index d486d9d27ba..3d93f919c58 100644 --- a/homeassistant/components/lametric/update.py +++ b/homeassistant/components/lametric/update.py @@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity): def latest_version(self) -> str | None: """Return the latest version of the entity.""" if not self.coordinator.data.update: - return None + return self.coordinator.data.os_version return self.coordinator.data.update.version From 03f9caf3eb92d66cde134c03f55a198fce5ee446 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 20:59:02 +0300 Subject: [PATCH 012/127] Add action exceptions to Alexa Devices (#147546) --- .../components/alexa_devices/notify.py | 2 + .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/strings.json | 8 +++ .../components/alexa_devices/switch.py | 2 + .../components/alexa_devices/utils.py | 40 +++++++++++++ tests/components/alexa_devices/test_utils.py | 56 +++++++++++++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/alexa_devices/utils.py create mode 100644 tests/components/alexa_devices/test_utils.py diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 46db294377a..08f2e214f38 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity): entity_description: AmazonNotifyEntityDescription + @alexa_api_call async def async_send_message( self, message: str, title: str | None = None, **kwargs: Any ) -> None: diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 881a02bc6d3..afd12ca1df2 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -26,7 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index b3bb699d003..d092cfaa2ae 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -70,5 +70,13 @@ "name": "Do not disturb" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_retrieve_data": { + "message": "Error retrieving data: {error}" + } } } diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index b8f78134feb..e53ea40965a 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): entity_description: AmazonSwitchEntityDescription + @alexa_api_call async def _switch_set_state(self, state: bool) -> None: """Set desired switch state.""" method = getattr(self.coordinator.api, self.entity_description.method) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py new file mode 100644 index 00000000000..4d1365d1d41 --- /dev/null +++ b/homeassistant/components/alexa_devices/utils.py @@ -0,0 +1,40 @@ +"""Utils for Alexa Devices.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import AmazonEntity + + +def alexa_api_call[_T: AmazonEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch Alexa API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except CannotConnect as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data", + translation_placeholders={"error": repr(err)}, + ) from err + + return cmd_wrapper diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py new file mode 100644 index 00000000000..12009719a2f --- /dev/null +++ b/tests/components/alexa_devices/test_utils.py @@ -0,0 +1,56 @@ +"""Tests for Alexa Devices utils.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +import pytest + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +ENTITY_ID = "switch.echo_test_do_not_disturb" + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_connect", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + ], +) +async def test_alexa_api_call_exceptions( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test alexa_api_call decorator for exceptions.""" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + mock_amazon_devices_client.set_do_not_disturb.side_effect = side_effect + + # Call API + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} From cfa6746115eefc6c5130778da7971bc2a1a2bb6e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 02:55:58 +0300 Subject: [PATCH 013/127] Fix unload for Alexa Devices (#147548) --- homeassistant/components/alexa_devices/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index aff4c1bb391..fe623c10b33 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.api.close() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + coordinator = entry.runtime_data + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await coordinator.api.close() + + return unload_ok From 914bb3aa76759b34b5b8a5242ce8de0293c2aa79 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 19:30:41 -0700 Subject: [PATCH 014/127] Use default title for migrated Google Generative AI entries (#147551) --- .../components/google_generative_ai_conversation/__init__.py | 2 ++ .../google_generative_ai_conversation/config_flow.py | 3 ++- .../components/google_generative_ai_conversation/const.py | 1 + .../components/google_generative_ai_conversation/test_init.py | 4 ++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1802073f760..7890af59f88 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, @@ -289,6 +290,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_TITLE, options={}, version=2, ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index bb526f95a21..ad90cbcf553 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,6 +47,7 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, @@ -116,7 +117,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) return self.async_create_entry( - title="Google Generative AI", + title=DEFAULT_TITLE, data=user_input, subentries=[ { diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 9f4132a1e3e..72665cd3437 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm DOMAIN = "google_generative_ai_conversation" +DEFAULT_TITLE = "Google Generative AI" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index a8a1e2840e3..46a2d634b81 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -8,6 +8,7 @@ from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_TTS_OPTIONS, @@ -473,6 +474,7 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry @@ -609,6 +611,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" @@ -702,6 +705,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry From 5fe2e4b6ed7a7eba687dac86ea8704ad316ce97d Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 01:50:47 -0700 Subject: [PATCH 015/127] Include subentries in Google Generative AI diagnostics (#147558) --- .../diagnostics.py | 1 + .../conftest.py | 2 + .../snapshots/test_diagnostics.ambr | 40 ++++++++++++++----- .../test_diagnostics.py | 6 +-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py index 13643da7e00..34b9f762355 100644 --- a/homeassistant/components/google_generative_ai_conversation/diagnostics.py +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": entry.data, "options": entry.options, + "subentries": dict(entry.subentries), }, TO_REDACT, ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index afea41bbb26..331afc723ae 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -34,12 +34,14 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "data": {}, "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, + "subentry_id": "ulid-conversation", "unique_id": None, }, { "data": {}, "subentry_type": "tts", "title": DEFAULT_TTS_NAME, + "subentry_id": "ulid-tts", "unique_id": None, }, ], diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index a31827c7acc..48091d83a00 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,17 +5,35 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.5-flash', - 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, - 'prompt': 'Speak like a pirate', - 'recommended': False, - 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, + }), + 'subentries': dict({ + 'ulid-conversation': dict({ + 'data': dict({ + 'chat_model': 'models/gemini-2.5-flash', + 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'max_tokens': 1500, + 'prompt': 'Speak like a pirate', + 'recommended': False, + 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'subentry_id': 'ulid-conversation', + 'subentry_type': 'conversation', + 'title': 'Google AI Conversation', + 'unique_id': None, + }), + 'ulid-tts': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-tts', + 'subentry_type': 'tts', + 'title': 'Google AI TTS', + 'unique_id': None, + }), }), 'title': 'Google Generative AI Conversation', }) diff --git a/tests/components/google_generative_ai_conversation/test_diagnostics.py b/tests/components/google_generative_ai_conversation/test_diagnostics.py index ebc1b5e52a5..0f193238669 100644 --- a/tests/components/google_generative_ai_conversation/test_diagnostics.py +++ b/tests/components/google_generative_ai_conversation/test_diagnostics.py @@ -35,10 +35,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - mock_config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE, From 1e81e5990e220f31c7658fe2f7d38aa41f11d7f9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 10:11:25 +0300 Subject: [PATCH 016/127] Bump zwave-js-server-python to 0.65.0 (#147561) * Bump zwave-js-server-python to 0.65.0 * update tests --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 082a3dd9f95..93d585d72a2 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cc59f822156..abb3b15be3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3205,7 +3205,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 684b28b88d6..d6f5cc7ee06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2637,7 +2637,7 @@ zeversolar==0.3.2 zha==0.0.61 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 3f1f9b737bd..d6aed0b6d22 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,8 +5649,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert entry.unique_id == "1234" @@ -5684,8 +5685,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert ( "Failed to get server version, cannot update config entry" @@ -5738,8 +5740,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) client.async_send_command.reset_mock() From f28d6582c69c26a03e778aa4ae9e5ddb7b1001cc Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 08:53:16 -0700 Subject: [PATCH 017/127] Refactor in Google AI TTS in preparation for STT (#147562) --- .../helpers.py | 73 +++++++++++++++++++ .../google_generative_ai_conversation/tts.py | 64 +--------------- 2 files changed, 75 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/helpers.py diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py new file mode 100644 index 00000000000..3d053aa9f1a --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -0,0 +1,73 @@ +"""Helper classes for Google Generative AI integration.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import wave + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER + + +def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = _parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + +# Below code is from https://aistudio.google.com/app/generate-speech +# when you select "Get SDK code to generate speech". +def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 174f0a50dc3..9bd7d547100 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -3,10 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from contextlib import suppress -import io from typing import Any -import wave from google.genai import types from google.genai.errors import APIError, ClientError @@ -25,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav async def async_setup_entry( @@ -152,62 +150,4 @@ class GoogleGenerativeAITextToSpeechEntity( except (APIError, ClientError, ValueError) as exc: LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc - return "wav", self._convert_to_wav(data, mime_type) - - def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: - """Generate a WAV file header for the given audio data and parameters. - - Args: - audio_data: The raw audio data as a bytes object. - mime_type: Mime type of the audio data. - - Returns: - A bytes object representing the WAV file header. - - """ - parameters = self._parse_audio_mime_type(mime_type) - - wav_buffer = io.BytesIO() - with wave.open(wav_buffer, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(parameters["bits_per_sample"] // 8) - wf.setframerate(parameters["rate"]) - wf.writeframes(audio_data) - - return wav_buffer.getvalue() - - def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: - """Parse bits per sample and rate from an audio MIME type string. - - Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". - - Args: - mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). - - Returns: - A dictionary with "bits_per_sample" and "rate" keys. Values will be - integers if found, otherwise None. - - """ - if not mime_type.startswith("audio/L"): - LOGGER.warning("Received unexpected MIME type %s", mime_type) - raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") - - bits_per_sample = 16 - rate = 24000 - - # Extract rate from parameters - parts = mime_type.split(";") - for param in parts: # Skip the main type part - param = param.strip() - if param.lower().startswith("rate="): - # Handle cases like "rate=" with no value or non-integer value and keep rate as default - with suppress(ValueError, IndexError): - rate_str = param.split("=", 1)[1] - rate = int(rate_str) - elif param.startswith("audio/L"): - # Keep bits_per_sample as default if conversion fails - with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) - - return {"bits_per_sample": bits_per_sample, "rate": rate} + return "wav", convert_to_wav(data, mime_type) From d523f854048e8bab3cf1ba126aeabc64325bb224 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 10:47:24 +0200 Subject: [PATCH 018/127] Fix sending commands to Matter vacuum (#147567) --- homeassistant/components/matter/vacuum.py | 64 +++++++++++-------- .../matter/snapshots/test_vacuum.ambr | 4 +- tests/components/matter/test_vacuum.py | 58 ++++++++++------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 96c6ba212de..141400c384b 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -62,14 +62,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _last_accepted_commands: list[int] | None = None _supported_run_modes: ( - dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None + dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self.send_device_command(clusters.OperationalState.Commands.Stop()) + # We simply set the RvcRunMode to the first runmode + # that has the idle tag to stop the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.IDLE: + # stop the vacuum by changing the run mode to idle + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" @@ -83,15 +94,30 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Start or resume the cleaning task.""" if TYPE_CHECKING: assert self._last_accepted_commands is not None + + accepted_operational_commands = self._last_accepted_commands if ( clusters.RvcOperationalState.Commands.Resume.command_id - in self._last_accepted_commands + in accepted_operational_commands + and self.state == VacuumActivity.PAUSED ): + # vacuum is paused and supports resume command await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) - else: - await self.send_device_command(clusters.OperationalState.Commands.Start()) + return + + # We simply set the RvcRunMode to the first runmode + # that has the cleaning tag to start the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.CLEANING: + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_pause(self) -> None: """Pause the cleaning task.""" @@ -130,6 +156,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: state = VacuumActivity.IDLE + elif ModeTag.MAPPING in tags: + state = VacuumActivity.CLEANING self._attr_activity = state @callback @@ -143,7 +171,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): return self._last_accepted_commands = accepted_operational_commands supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE + supported_features |= VacuumEntityFeature.STOP + # optional battery attribute = battery feature if self.get_matter_attribute_value( clusters.PowerSource.Attributes.BatPercentRemaining @@ -153,7 +184,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE # create a map of supported run modes - run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = ( + run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = ( self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.SupportedModes ) @@ -165,22 +196,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): in accepted_operational_commands ): supported_features |= VacuumEntityFeature.PAUSE - if ( - clusters.OperationalState.Commands.Stop.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.STOP - if ( - clusters.OperationalState.Commands.Start.command_id - in accepted_operational_commands - ): - # note that start has been replaced by resume in rev2 of the spec - supported_features |= VacuumEntityFeature.START - if ( - clusters.RvcOperationalState.Commands.Resume.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.START if ( clusters.RvcOperationalState.Commands.GoHome.command_id in accepted_operational_commands @@ -202,10 +217,7 @@ DISCOVERY_SCHEMAS = [ clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), - optional_attributes=( - clusters.RvcCleanMode.Attributes.CurrentMode, - clusters.PowerSource.Attributes.BatPercentRemaining, - ), + optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index cb859147d75..71e0f75614d 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -28,7 +28,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Vacuum', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.mock_vacuum', diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 2642ff39ef8..b464e9f1cd3 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -61,7 +60,29 @@ async def test_vacuum_actions( ) matter_client.send_device_command.reset_mock() - # test start/resume action + # test start action (from idle state) + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + matter_client.send_device_command.reset_mock() + + # test resume action (from paused state) + # first set the operational state to paused + set_node_attribute(matter_node, 1, 97, 4, 0x02) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( "vacuum", "start", @@ -98,25 +119,6 @@ async def test_vacuum_actions( matter_client.send_device_command.reset_mock() # test stop action - # stop command is not supported by the vacuum fixture - with pytest.raises( - ServiceNotSupported, - match="Entity vacuum.mock_vacuum does not support action vacuum.stop", - ): - await hass.services.async_call( - "vacuum", - "stop", - { - "entity_id": entity_id, - }, - blocking=True, - ) - - # update accepted command list to add support for stop command - set_node_attribute( - matter_node, 1, 97, 65529, [clusters.OperationalState.Commands.Stop.command_id] - ) - await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( "vacuum", "stop", @@ -129,7 +131,7 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Stop(), + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=0), ) matter_client.send_device_command.reset_mock() @@ -209,11 +211,21 @@ async def test_vacuum_updates( assert state assert state.state == "idle" + # confirm state is 'cleaning' by setting; + # - the operational state to 0x00 + # - the run mode is set to a mode which has mapping tag + set_node_attribute(matter_node, 1, 97, 4, 0) + set_node_attribute(matter_node, 1, 84, 1, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "cleaning" + # confirm state is 'unknown' by setting; # - the operational state to 0x00 # - the run mode is set to a mode which has neither cleaning or idle tag set_node_attribute(matter_node, 1, 97, 4, 0) - set_node_attribute(matter_node, 1, 84, 1, 2) + set_node_attribute(matter_node, 1, 84, 1, 5) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state From ae062b230c22a8187b21785a3fa2f115b7697bc2 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 12:06:36 +0300 Subject: [PATCH 019/127] Remove obsolete routing info when migrating a Z-Wave network (#147568) --- homeassistant/components/zwave_js/api.py | 2 +- homeassistant/components/zwave_js/config_flow.py | 4 +++- tests/components/zwave_js/test_api.py | 6 +++--- tests/components/zwave_js/test_config_flow.py | 10 +++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 168df5edcaa..a17f13e0d07 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -3105,7 +3105,7 @@ async def websocket_restore_nvm( driver.once("driver ready", set_driver_ready), ] - await controller.async_restore_nvm_base64(msg["data"]) + await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) with suppress(TimeoutError): async with asyncio.timeout(DRIVER_READY_TIMEOUT): diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 5e8e7022839..35b54aa2e49 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1400,7 +1400,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): driver.once("driver ready", set_driver_ready), ] try: - await controller.async_restore_nvm(self.backup_data) + await controller.async_restore_nvm( + self.backup_data, {"preserveRoutes": False} + ) except FailedCommand as err: raise AbortFlow(f"Failed to restore network: {err}") from err else: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index d6aed0b6d22..bac0162ba74 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,7 +5649,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5685,7 +5685,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5740,7 +5740,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index dd8838e0775..a7bb02d5920 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -900,7 +900,7 @@ async def test_usb_discovery_migration( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -1031,7 +1031,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3501,7 +3501,7 @@ async def test_reconfigure_migrate_with_addon( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3686,7 +3686,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3835,7 +3835,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, From 17fd850fa6bd09858ba04f52dd6ec0e863918d48 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 13:15:02 +0300 Subject: [PATCH 020/127] Hide unnamed paths when selecting a USB Z-Wave adapter (#147571) * Hide unnamed paths when selecting a USB Z-Wave adapter * remove pointless sorting --- .../components/zwave_js/config_flow.py | 16 +-- tests/components/zwave_js/test_config_flow.py | 102 +++++++++++++++++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 35b54aa2e49..2c37ee4b554 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -138,13 +138,15 @@ def get_usb_ports() -> dict[str, str]: ) port_descriptions[dev_path] = human_name - # Sort the dictionary by description, putting "n/a" last - return dict( - sorted( - port_descriptions.items(), - key=lambda x: x[1].lower().startswith("n/a"), - ) - ) + # Filter out "n/a" descriptions only if there are other ports available + non_na_ports = { + path: desc + for path, desc in port_descriptions.items() + if not desc.lower().startswith("n/a") + } + + # If we have non-"n/a" ports, return only those; otherwise return all ports as-is + return non_na_ports if non_na_ports else port_descriptions async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a7bb02d5920..2e41a176a9c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -4435,8 +4435,8 @@ async def test_configure_addon_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -async def test_get_usb_ports_sorting() -> None: - """Test that get_usb_ports sorts ports with 'n/a' descriptions last.""" +async def test_get_usb_ports_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions when other ports are available.""" mock_ports = [ ListPortInfo("/dev/ttyUSB0"), ListPortInfo("/dev/ttyUSB1"), @@ -4453,13 +4453,105 @@ async def test_get_usb_ports_sorting() -> None: descriptions = list(result.values()) - # Verify that descriptions containing "n/a" are at the end - + # Verify that only non-"n/a" descriptions are returned assert descriptions == [ "Device A - /dev/ttyUSB1, s/n: n/a", "Device B - /dev/ttyUSB3, s/n: n/a", + ] + + +async def test_get_usb_ports_all_na() -> None: + """Test that get_usb_ports returns all ports as-is when only 'n/a' descriptions exist.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "N/A" + mock_ports[2].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that all ports are returned since they all have "n/a" descriptions + assert len(descriptions) == 3 + # Verify that all descriptions contain "n/a" (case-insensitive) + assert all("n/a" in desc.lower() for desc in descriptions) + # Verify that all expected device paths are present + device_paths = [desc.split(" - ")[1].split(",")[0] for desc in descriptions] + assert "/dev/ttyUSB0" in device_paths + assert "/dev/ttyUSB1" in device_paths + assert "/dev/ttyUSB2" in device_paths + + +async def test_get_usb_ports_mixed_case_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions with different case variations.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ListPortInfo("/dev/ttyUSB3"), + ListPortInfo("/dev/ttyUSB4"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "Device A" + mock_ports[2].description = "N/A" + mock_ports[3].description = "n/A" + mock_ports[4].description = "Device B" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that only non-"n/a" descriptions are returned (case-insensitive filtering) + assert descriptions == [ + "Device A - /dev/ttyUSB1, s/n: n/a", + "Device B - /dev/ttyUSB4, s/n: n/a", + ] + + +async def test_get_usb_ports_empty_list() -> None: + """Test that get_usb_ports handles empty port list.""" + with patch("serial.tools.list_ports.comports", return_value=[]): + result = get_usb_ports() + + # Verify that empty dict is returned + assert result == {} + + +async def test_get_usb_ports_single_na_port() -> None: + """Test that get_usb_ports returns single 'n/a' port when it's the only one available.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single "n/a" port is returned + assert descriptions == [ "n/a - /dev/ttyUSB0, s/n: n/a", - "N/A - /dev/ttyUSB2, s/n: n/a", + ] + + +async def test_get_usb_ports_single_valid_port() -> None: + """Test that get_usb_ports returns single valid port.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "Device A" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single valid port is returned + assert descriptions == [ + "Device A - /dev/ttyUSB0, s/n: n/a", ] From 398dd3ae4638a05d83622b51497853a1c05e462a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 12:49:33 +0200 Subject: [PATCH 021/127] Set right model in OpenAI conversation (#147575) --- .../openai_conversation/conversation.py | 2 +- .../openai_conversation/conftest.py | 24 +++++--- .../snapshots/test_init.ambr | 55 +++++++++++++++++++ .../openai_conversation/test_init.py | 23 +++++++- 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e63bbf32c35..e590a72cadb 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -247,7 +247,7 @@ class OpenAIConversationEntity( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="OpenAI", - model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), entry_type=dr.DeviceEntryType.SERVICE, ) if self.subentry.data.get(CONF_LLM_HASS_API): diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index aa17c333a79..b8944d837be 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,10 +1,12 @@ """Tests helpers.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -14,7 +16,15 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_subentry_data() -> dict[str, Any]: + """Mock subentry data.""" + return {} + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( title="OpenAI", @@ -24,12 +34,12 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, version=2, subentries_data=[ - { - "data": {}, - "subentry_type": "conversation", - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - } + ConfigSubentryData( + data=mock_subentry_data, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ) ], ) entry.add_to_hass(hass) diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000..8648e47474e --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_devices[mock_subentry_data0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-4o-mini', + 'model_id': None, + 'name': 'OpenAI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[mock_subentry_data1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-1o', + 'model_id': None, + 'name': 'OpenAI Conversation', + '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/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d209554e8d3..b7f2a5434eb 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -13,8 +13,10 @@ from openai.types.image import Image from openai.types.images_response import ImagesResponse from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.openai_conversation import CONF_FILENAMES +from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -806,3 +808,22 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + + +@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert exception when invalid config entry is provided.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 1 + device = devices[0] + assert device == snapshot(exclude=props("identifiers")) + subentry = next(iter(mock_config_entry.subentries.values())) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} From 153e1e43e86271979d7537c0deaddaa3d0e0c26b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 11:49:06 +0200 Subject: [PATCH 022/127] Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) --- .../components/music_assistant/button.py | 6 --- .../snapshots/test_button.ambr | 2 +- .../components/music_assistant/test_button.py | 42 ++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 7969954e443..445ef2c3e98 100644 --- a/homeassistant/components/music_assistant/button.py +++ b/homeassistant/components/music_assistant/button.py @@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): translation_key="favorite_now_playing", ) - @property - def available(self) -> bool: - """Return availability of entity.""" - # mark the button as unavailable if the player has no current media item - return super().available and self.player.current_media is not None - @catch_musicassistant_error async def async_press(self) -> None: """Handle the button press command.""" diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr index ac9e4c660f6..d064916e044 100644 --- a/tests/components/music_assistant/snapshots/test_button.ambr +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -140,6 +140,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 8a1a4b0e241..5a326b1d8ea 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -2,14 +2,20 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import EventType +import pytest from syrupy.assertion 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.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) async def test_button_entities( @@ -46,3 +52,35 @@ async def test_button_press_action( "music/favorites/add_item", item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", ) + + # test again without current_media + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].current_media = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + # test again without active source + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].active_source = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="Player has no active source"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) From 4cc10ca2e2711c71ce3864e33ba2758f2b13fcc6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:44:15 +0200 Subject: [PATCH 023/127] Set Google AI model as device model (#147582) * Set Google AI model as device model * fix --- .../entity.py | 9 ++- .../google_generative_ai_conversation/tts.py | 6 +- .../snapshots/test_init.ambr | 66 +++++++++++++++++++ .../test_init.py | 14 ++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 66acb6b158a..dea875212ef 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -301,7 +301,12 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + def __init__( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + default_model: str = RECOMMENDED_CHAT_MODEL, + ) -> None: """Initialize the agent.""" self.entry = entry self.subentry = subentry @@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Google", - model="Generative AI", + model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1], entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bd7d547100..9bc5b0c6cb6 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -15,7 +15,7 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +114,10 @@ class GoogleGenerativeAITextToSpeechEntity( ) ] + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the TTS entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL) + @callback def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f89871ff131..5722713bc56 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,70 @@ # serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-conversation', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-tts', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash-preview-tts', + 'model_id': None, + 'name': 'Google AI TTS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- # name: test_generate_content_file_processing_succeeds list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 46a2d634b81..85d6c70b658 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -762,3 +762,17 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + + +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert that devices are created correctly.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot From 1f57bba9cd13cb11329e9891647a7d280b939090 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:48 +0200 Subject: [PATCH 024/127] Add default conversation name for OpenAI integration (#147597) --- homeassistant/components/openai_conversation/__init__.py | 2 ++ homeassistant/components/openai_conversation/const.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index a5b13ded375..e14a8aabc1b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -49,6 +49,7 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_NAME, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -351,6 +352,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f90c05eed79..3f1c0dc7429 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -6,12 +6,12 @@ DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" -CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" From c7677b91da7224382d675091d1557ea77ea37997 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:13 +0200 Subject: [PATCH 025/127] Add default title to migrated Claude entry (#147598) --- homeassistant/components/anthropic/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c13c82f0020..c537a000c14 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -17,7 +17,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -123,6 +129,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_CONVERSATION_NAME, options={}, version=2, ) From a233b6b1e38568ce4b1d39f8e907b05bc40f26c8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:05:26 +0200 Subject: [PATCH 026/127] Add default title to migrated Ollama entry (#147599) --- homeassistant/components/ollama/__init__.py | 2 ++ homeassistant/components/ollama/const.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 90d2012766d..f174c709b65 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN, ) @@ -138,6 +139,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index ebace6404b2..3175525c70d 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -2,6 +2,8 @@ DOMAIN = "ollama" +DEFAULT_NAME = "Ollama" + CONF_MODEL = "model" CONF_PROMPT = "prompt" CONF_THINK = "think" From 9cc75f345860f556a18b057caa25091bb0c7c6ed Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Jun 2025 17:46:45 +0200 Subject: [PATCH 027/127] Update frontend to 20250626.0 (#147601) --- 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 0028bda57be..8e4ea47da5b 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==20250625.0"] + "requirements": ["home-assistant-frontend==20250626.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 725033f814e..5839a3ae014 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index abb3b15be3d..9bc728320a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6f5cc7ee06..8a5f97014e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From f8207a2e0e2e00c065257d5ca30942c63c91fe12 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 17:05:59 +0200 Subject: [PATCH 028/127] Remove default icon for wind direction sensor for Buienradar (#147603) * Fix wind direction state class sensor * Remove default icon for wind direction sensor --- homeassistant/components/buienradar/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 586543de129..b32e630ef5c 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="windazimuth", translation_key="windazimuth", native_unit_of_measurement=DEGREE, - icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT_ANGLE, ), From c8422c9fb8dd31cea18f64c5bd3ae8160b4203b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 18:21:56 +0200 Subject: [PATCH 029/127] Improve explanation on how to get API token in Telegram (#147605) --- homeassistant/components/telegram_bot/config_flow.py | 6 +++++- homeassistant/components/telegram_bot/strings.json | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index d9b334a4ac1..67981cbd704 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -293,10 +293,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow to create a new config entry for a Telegram bot.""" + description_placeholders: dict[str, str] = { + "botfather_username": "@BotFather", + "botfather_url": "https://t.me/botfather", + } if not user_input: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders=description_placeholders, ) # prevent duplicates @@ -305,7 +310,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} bot_name = await self._validate_bot( user_input, errors, description_placeholders ) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index a51d4a371f1..17b2e6f24d6 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -2,11 +2,10 @@ "config": { "step": { "user": { - "title": "Telegram bot setup", - "description": "Create a new Telegram bot", + "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_key%]", + "api_key": "[%key:common::config_flow::data::api_token%]", "proxy_url": "Proxy URL" }, "data_description": { From 4df1f702bfede0d6864708e0844760b171611763 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:52:58 +0200 Subject: [PATCH 030/127] Fix asset url in Habitica integration (#147612) --- homeassistant/components/habitica/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index f9874c711f0..d7cede1db03 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" SITE_DATA_URL = "https://habitica.com/user/settings/siteData" FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password" SIGN_UP_URL = "https://habitica.com/register" -HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" +HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png" DOMAIN = "habitica" From 26521f8cc0dbe28fb3404882339879ac272c22bd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:55:38 +0200 Subject: [PATCH 031/127] Hide Telegram bot proxy URL behind section (#147613) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> --- .../components/telegram_bot/config_flow.py | 53 +++++++++++++++---- .../components/telegram_bot/const.py | 2 +- .../components/telegram_bot/strings.json | 34 +++++++++--- .../telegram_bot/test_config_flow.py | 26 ++++++--- .../telegram_bot/test_telegram_bot.py | 2 + 5 files changed, 91 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 67981cbd704..1a77a5b9a81 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -58,6 +58,7 @@ from .const import ( PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) @@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema( autocomplete="current-password", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema( translation_key="platforms", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): import_data[CONF_TRUSTED_NETWORKS] = ",".join( import_data[CONF_TRUSTED_NETWORKS] ) + import_data[SECTION_ADVANCED_SETTINGS] = { + CONF_PROXY_URL: import_data.get(CONF_PROXY_URL) + } try: config_flow_result: ConfigFlowResult = await self.async_step_user( import_data @@ -332,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), }, options={ # this value may come from yaml import @@ -444,7 +462,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: self._step_user_data[CONF_PLATFORM], CONF_API_KEY: self._step_user_data[CONF_API_KEY], - CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL), + CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), CONF_URL: user_input.get(CONF_URL), CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS], }, @@ -509,9 +529,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - self._get_reconfigure_entry().data, + { + **self._get_reconfigure_entry().data, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: self._get_reconfigure_entry().data.get( + CONF_PROXY_URL + ), + }, + }, ), ) + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -527,7 +557,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - user_input, + { + **user_input, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + }, + }, ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index d6da96d9a28..0f1d5193e2c 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -7,7 +7,7 @@ DOMAIN = "telegram_bot" PLATFORM_BROADCAST = "broadcast" PLATFORM_POLLING = "polling" PLATFORM_WEBHOOKS = "webhooks" - +SECTION_ADVANCED_SETTINGS = "advanced_settings" SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_BOT_COUNT = "bot_count" diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 17b2e6f24d6..4187b6311d9 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -5,13 +5,22 @@ "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_token%]", - "proxy_url": "Proxy URL" + "api_key": "[%key:common::config_flow::data::api_token%]" }, "data_description": { "platform": "Telegram bot implementation", - "api_key": "The API token of your bot.", - "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + "api_key": "The API token of your bot." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "proxy_url": "Proxy URL" + }, + "data_description": { + "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + } + } } }, "webhooks": { @@ -29,12 +38,21 @@ "title": "Telegram bot setup", "description": "Reconfigure Telegram bot", "data": { - "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]" }, "data_description": { - "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]", + "data": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]" + }, + "data_description": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]" + } + } } }, "reauth_confirm": { diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 0287ccc5dfa..e13fab8f28b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.telegram_bot.const import ( PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigSubentry @@ -89,7 +90,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -104,7 +107,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -131,7 +136,9 @@ async def test_reconfigure_flow_webhooks( result["flow_id"], { CONF_PLATFORM: PLATFORM_WEBHOOKS, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -197,9 +204,7 @@ async def test_reconfigure_flow_webhooks( ] -async def test_create_entry( - hass: HomeAssistant, -) -> None: +async def test_create_entry(hass: HomeAssistant) -> None: """Test user flow.""" # test: no input @@ -225,7 +230,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -245,7 +252,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "https://proxy", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://proxy", + }, }, ) await hass.async_block_till_done() @@ -535,6 +544,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 190fed07ae3..6590bbed1cf 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -51,6 +51,7 @@ from homeassistant.components.telegram_bot.const import ( CONF_CONFIG_ENTRY_ID, DOMAIN, PLATFORM_BROADCAST, + SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, @@ -722,6 +723,7 @@ async def test_send_message_no_chat_id_error( data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch("homeassistant.components.telegram_bot.config_flow.Bot.get_me"): From c0ec987b07423ebad79a0b930e47b88fef302ade Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:52:29 +0200 Subject: [PATCH 032/127] Fix meaters not being added after a reload (#147614) --- homeassistant/components/meater/__init__.py | 6 ++- .../components/meater/coordinator.py | 6 ++- tests/components/meater/test_init.py | 44 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 212e8a2a33a..9f35d941b65 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[MEATER_DATA] = ( + hass.data[MEATER_DATA] - entry.runtime_data.found_probes + ) + return unload_ok diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py index 042a3c87b0c..9a9910f6e1a 100644 --- a/homeassistant/components/meater/coordinator.py +++ b/homeassistant/components/meater/coordinator.py @@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): ) session = async_get_clientsession(hass) self.client = MeaterApi(session) + self.found_probes: set[str] = set() async def _async_setup(self) -> None: """Set up the Meater Coordinator.""" @@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): raise UpdateFailed( "Too many requests have been made to the API, rate limiting is in place" ) from err - - return {device.id: device for device in devices} + res = {device.id: device for device in devices} + self.found_probes.update(set(res.keys())) + return res diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py index 52f6b29d488..8f4e4e75a86 100644 --- a/tests/components/meater/test_init.py +++ b/tests/components/meater/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion from homeassistant.components.meater.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import PROBE_ID @@ -26,3 +28,43 @@ async def test_device_info( device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) assert device_entry is not None assert device_entry == snapshot + + +async def test_load_unload( + hass: HomeAssistant, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unload of Meater integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) From 6a7385590ae54d212d156b73cdeb06da1819f513 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jun 2025 18:03:11 +0000 Subject: [PATCH 033/127] Bump version to 2025.7.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 02631a8f365..cf48d8b2427 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 24e290faed2..dfb0fc741fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b0" +version = "2025.7.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From cb359da79e6ed87ae65d3a3ebcb35d9d1677f60f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:03:14 +0200 Subject: [PATCH 034/127] Make entities unavailable when machine is physically off in lamarzocco (#147426) --- homeassistant/components/lamarzocco/entity.py | 20 ++++++++++++- homeassistant/components/lamarzocco/number.py | 4 --- homeassistant/components/lamarzocco/sensor.py | 6 ++-- tests/components/lamarzocco/test_sensor.py | 28 +++++++++++++++++-- tests/components/lamarzocco/test_switch.py | 26 +++++++++++++++-- 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6dc024645ce..6f9de083286 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,8 +2,10 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, MachineState, WidgetType +from pylamarzocco.models import MachineStatus from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity( """Common elements for all entities.""" _attr_has_entity_name = True + _unavailable_when_machine_off = True def __init__( self, @@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity( if connections: self._attr_device_info.update(DeviceInfo(connections=connections)) + @property + def available(self) -> bool: + """Return True if entity is available.""" + machine_state = ( + cast( + MachineStatus, + self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS], + ).status + if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config + else MachineState.OFF + ) + return super().available and not ( + self._unavailable_when_machine_off and machine_state is MachineState.OFF + ) + class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Common elements for all entities.""" diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f8cb8b1d6fe..b235cc7c5f9 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c76f51c3488..a432f5b8dae 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", @@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): """Sensor for La Marzocco statistics.""" + _unavailable_when_machine_off = False + @property def native_value(self) -> StateType | datetime | None: """Return the value of the sensor.""" diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 183d3f2daa6..dee2fa0b79c 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,11 +2,11 @@ from unittest.mock import MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import MachineState, ModelName, WidgetType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines( entry = entity_registry.async_get(state.entity_id) assert entry + + +async def test_sensors_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + SWITCHES_UNAVAILABLE = ( + ("sensor.gs012345_steam_boiler_ready_time", True), + ("sensor.gs012345_coffee_boiler_ready_time", True), + ("sensor.gs012345_total_coffees_made", False), + ) + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + + for sensor, available in SWITCHES_UNAVAILABLE: + state = hass.states.get(sensor) + assert state + assert (state.state == STATE_UNAVAILABLE) == available diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 0f1c4fd6ebb..c715c23b78f 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, patch -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import MachineState, SmartStandByType, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,3 +197,25 @@ async def test_switch_exceptions( blocking=True, ) assert exc_info.value.translation_key == "auto_on_off_error" + + +async def test_switches_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + + switches = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for switch in switches: + state = hass.states.get(switch.entity_id) + assert state + assert state.state == STATE_UNAVAILABLE From f93ab8d5198464c2a31d587d8882c8d81ebf4a7d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 27 Jun 2025 03:50:45 -0400 Subject: [PATCH 035/127] Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549) Co-authored-by: Norbert Rittel --- .../firmware_config_flow.py | 100 ++++++++++++-- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow.py | 122 ++++++++++++++++-- .../test_config_flow_failures.py | 69 +++++++++- .../test_config_flow.py | 31 +++-- .../homeassistant_yellow/test_config_flow.py | 24 +++- 9 files changed, 318 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 7519e0ae394..a5e35749e1b 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,7 +7,10 @@ import asyncio import logging from typing import Any -from ha_silabs_firmware_client import FirmwareUpdateClient +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing +from universal_silabs_flasher.common import Version +from universal_silabs_flasher.firmware import NabuCasaMetadata from homeassistant.components.hassio import ( AddonError, @@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - session = async_get_clientsession(self.hass) - client = FirmwareUpdateClient(fw_update_url, session) - manifest = await client.async_update_data() - - fw_meta = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + # We 100% need to install new firmware only if the wrong firmware is + # currently installed + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type + != expected_installed_firmware_type ) - fw_data = await client.async_fetch_firmware(fw_meta) + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning( + "Failed to fetch firmware update manifest", exc_info=True + ) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to index download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to image download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + # Otherwise, fail + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( hass=self.hass, @@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): }, ) + async def async_step_pre_confirm_zigbee( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm Zigbee setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_zigbee() + async def async_step_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -409,7 +483,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_start_task = None - return self.async_show_progress_done(next_step_id="confirm_otbr") + return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + + async def async_step_pre_confirm_otbr( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm OTBR setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_otbr() async def async_step_confirm_otbr( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 99172c963b8..d9c086cb040 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -36,7 +36,8 @@ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 997edb54b18..197cb2ff2ce 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 08c8a56c30d..f87a45febe4 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -92,7 +92,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -145,7 +146,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 980052f9ffb..b43f890b4e3 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -117,7 +117,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 530308fdf41..d5039f3b0bd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -6,6 +6,7 @@ import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, FirmwareMetadata, @@ -80,7 +81,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( @@ -137,7 +138,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_confirm_zigbee() + return await self.async_step_pre_confirm_zigbee() async def async_step_install_thread_firmware( self, user_input: dict[str, Any] | None = None @@ -208,6 +209,7 @@ def mock_firmware_info( *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, + probe_fw_version: str | None = "2.4.4.0", otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -217,6 +219,7 @@ def mock_firmware_info( version=None, ), flash_app_type: ApplicationType = ApplicationType.EZSP, + flash_fw_version: str | None = "7.4.4.0", ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) @@ -243,7 +246,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 460800, + "fw_type": "openthread_rcp", + "fw_variant": None, + "metadata_version": 2, + "ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", ), FirmwareMetadata( @@ -251,7 +261,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), ], @@ -263,7 +280,7 @@ def mock_firmware_info( probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used firmware_type=probe_app_type, - firmware_version=None, + firmware_version=probe_fw_version, owners=[], source="probe", ) @@ -274,7 +291,7 @@ def mock_firmware_info( flashed_firmware_info = FirmwareInfo( device=TEST_DEVICE, firmware_type=flash_app_type, - firmware_version="7.4.4.0", + firmware_version=flash_fw_version, owners=[create_mock_owner()], source="probe", ) @@ -333,7 +350,7 @@ def mock_firmware_info( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager + yield mock_otbr_manager, mock_update_client async def consume_progress_flow( @@ -411,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +async def test_config_flow_firmware_index_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if index download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + hass, + # The correct firmware is already installed + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client): + # Mock the firmware download to fail + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_firmware_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if firmware download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_doesnt_downgrade( + hass: HomeAssistant, +) -> None: + """Test flow exits early, without downgrading firmware.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + probe_app_type=ApplicationType.EZSP, + # An newer version is probed than what we offer + probe_fw_version="7.5.0.0", + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware" + ) as mock_async_flash_silabs_firmware, + ): + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 + + async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: """Test the config flow, skip installing the addon if necessary.""" result = await hass.config_entries.flow.async_init( @@ -480,7 +582,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -564,7 +666,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -631,7 +733,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 65a5f58b17d..442cf8aea50 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState @@ -109,7 +110,7 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -147,7 +148,7 @@ async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -178,7 +179,7 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -209,7 +210,7 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): async def install_addon() -> None: mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( @@ -270,7 +271,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -341,6 +342,64 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non assert pick_thread_progress_result["reason"] == "unsupported_firmware" +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_index_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if OTA index download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if firmware download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9dcac0732c9..4df3efab360 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -75,7 +75,7 @@ async def test_config_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) @@ -100,14 +100,22 @@ async def test_config_flow( ), ), ): - result = await hass.config_entries.flow.async_configure( + confirm_result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) - config_entry = result["result"] + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, @@ -171,7 +179,7 @@ async def test_options_flow( assert result["description_placeholders"]["model"] == model async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() with ( patch( @@ -190,13 +198,20 @@ async def test_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index cd4a1941050..7f622e0ed8f 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -348,7 +348,7 @@ async def test_firmware_options_flow( assert result["description_placeholders"]["model"] == "Home Assistant Yellow" async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() async def mock_install_firmware_step( self, @@ -360,11 +360,16 @@ async def test_firmware_options_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", return_value=None, @@ -385,13 +390,22 @@ async def test_firmware_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": fw_type.value, From 28dfc997f3fe561e1dfcae35562b67fbfb368ea7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 09:02:12 +0300 Subject: [PATCH 036/127] Do not factory reset old Z-Wave controller during migration (#147576) * Do not factory reset old Z-Wave controller during migration * PR comments * remove obsolete test --- .../components/zwave_js/config_flow.py | 63 +-- .../components/zwave_js/strings.json | 6 +- tests/components/zwave_js/test_config_flow.py | 365 +----------------- 3 files changed, 7 insertions(+), 427 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 2c37ee4b554..a109719965c 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -845,11 +845,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - if user_input is not None: - self._migrating = True - return await self.async_step_backup_nvm() - - return self.async_show_form(step_id="intent_migrate") + self._migrating = True + return await self.async_step_backup_nvm() async def async_step_backup_nvm( self, user_input: dict[str, Any] | None = None @@ -904,7 +901,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_instruct_unplug( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reset the current controller, and instruct the user to unplug it.""" + """Instruct the user to unplug the old controller.""" if user_input is not None: if self.usb_path: @@ -914,63 +911,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() - try: - driver = self._get_driver() - except AbortFlow: - return self.async_abort(reason="config_entry_not_loaded") - - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - - unsubscribe = driver.once("driver ready", set_driver_ready) - - # reset the old controller - try: - await driver.async_hard_reset() - except FailedCommand as err: - unsubscribe() - _LOGGER.error("Failed to reset controller: %s", err) - return self.async_abort(reason="reset_failed") - - # Update the unique id of the config entry - # to the new home id, which requires waiting for the driver - # to be ready before getting the new home id. - # If the backup restore, done later in the flow, fails, - # the config entry unique id should be the new home id - # after the controller reset. - try: - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - except TimeoutError: - pass - finally: - unsubscribe() - config_entry = self._reconfigure_config_entry assert config_entry is not None - try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded, if the backup restore - # also fails. - _LOGGER.debug( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) - # Unload the config entry before asking the user to unplug the controller. await self.hass.config_entries.async_unload(config_entry.entry_id) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d9a3b82a47c..f61d871cfb9 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,13 +108,9 @@ "intent_reconfigure": "Re-configure the current controller" } }, - "intent_migrate": { - "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", - "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" - }, "instruct_unplug": { "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2e41a176a9c..e99cedbdcba 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -867,8 +867,6 @@ async def test_usb_discovery_migration( get_server_version: AsyncMock, ) -> None: """Test usb discovery migration.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 @@ -893,13 +891,6 @@ async def test_usb_discovery_migration( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -927,10 +918,6 @@ async def test_usb_discovery_migration( ) assert mock_usb_serial_by_id.call_count == 2 - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -947,7 +934,6 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" - assert entry.unique_id == "4321" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -962,6 +948,7 @@ async def test_usb_discovery_migration( assert restart_addon.call_args == call("core_zwave_js") + version_info = get_server_version.return_value version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1024,13 +1011,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -1055,10 +1035,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( ) assert mock_usb_serial_by_id.call_count == 2 - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3401,21 +3377,12 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "reset_server_version_side_effect", - "reset_unique_id", "restore_server_version_side_effect", "final_unique_id", ), [ - (None, "4321", None, "3245146787"), - (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), - (None, "4321", aiohttp.ClientError("Boom"), "5678"), - ( - aiohttp.ClientError("Boom"), - "3245146787", - aiohttp.ClientError("Boom"), - "5678", - ), + (None, "3245146787"), + (aiohttp.ClientError("Boom"), "5678"), ], ) async def test_reconfigure_migrate_with_addon( @@ -3428,15 +3395,11 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, - reset_server_version_side_effect: Exception | None, - reset_unique_id: str, restore_server_version_side_effect: Exception | None, final_unique_id: str, ) -> None: """Test migration flow with add-on.""" - get_server_version.side_effect = reset_server_version_side_effect version_info = get_server_version.return_value - version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 @@ -3494,13 +3457,6 @@ async def test_reconfigure_migrate_with_addon( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3531,11 +3487,6 @@ async def test_reconfigure_migrate_with_addon( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3552,7 +3503,6 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == reset_unique_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -3565,8 +3515,6 @@ async def test_reconfigure_migrate_with_addon( with pytest.raises(InInvalid): data_schema.schema[CONF_USB_PATH](addon_options["device"]) - # Reset side effect before starting the add-on. - get_server_version.side_effect = None version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( @@ -3646,156 +3594,6 @@ async def test_reconfigure_migrate_with_addon( assert client.driver.controller.home_id == 3245146787 -@pytest.mark.usefixtures("supervisor", "addon_running") -async def test_reconfigure_migrate_reset_driver_ready_timeout( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, - restart_addon: AsyncMock, - set_addon_options: AsyncMock, - get_server_version: AsyncMock, -) -> None: - """Test migration flow with driver ready timeout after controller reset.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 - entry = integration - assert client.connect.call_count == 1 - hass.config_entries.async_update_entry( - entry, - unique_id="1234", - data={ - "url": "ws://localhost:3000", - "use_addon": True, - "usb_path": "/dev/ttyUSB0", - }, - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - - async def mock_reset_controller(): - await asyncio.sleep(0) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): - client.driver.controller.emit( - "nvm convert progress", - {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, - ) - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm restore progress", - {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, - ) - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) - - events = async_capture_events( - hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - with ( - patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), - new=0, - ), - patch("pathlib.Path.write_bytes") as mock_file, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - assert len(events) == 1 - assert events[0].data["progress"] == 0.5 - events.clear() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "instruct_unplug" - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == "4321" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, - ) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) - ) - - await hass.async_block_till_done() - - assert restart_addon.call_args == call("core_zwave_js") - - version_info.home_id = 5678 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 - - await hass.async_block_till_done() - assert client.connect.call_count == 4 - assert entry.state is config_entries.ConfigEntryState.LOADED - assert client.driver.controller.async_restore_nvm.call_count == 1 - assert len(events) == 2 - assert events[0].data["progress"] == 0.25 - assert events[1].data["progress"] == 0.75 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "migration_successful" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" - assert entry.data["use_addon"] is True - assert entry.unique_id == "5678" - assert "keep_old_devices" not in entry.data - - @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, @@ -3828,13 +3626,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3861,11 +3652,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3960,11 +3746,6 @@ async def test_reconfigure_migrate_backup_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" assert "keep_old_devices" not in entry.data @@ -3998,11 +3779,6 @@ async def test_reconfigure_migrate_backup_file_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4040,13 +3816,6 @@ async def test_reconfigure_migrate_start_addon_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4056,11 +3825,6 @@ async def test_reconfigure_migrate_start_addon_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4124,12 +3888,6 @@ async def test_reconfigure_migrate_restore_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) client.driver.controller.async_restore_nvm = AsyncMock( side_effect=FailedCommand("test_error", "unknown_error") ) @@ -4143,11 +3901,6 @@ async def test_reconfigure_migrate_restore_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4242,106 +3995,6 @@ async def test_get_driver_failure_intent_migrate( assert "keep_old_devices" not in entry.data -async def test_get_driver_failure_instruct_unplug( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test get driver failure in instruct unplug step.""" - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "config_entry_not_loaded" - - -async def test_hard_reset_failure( - hass: HomeAssistant, - integration: MockConfigEntry, - client: MagicMock, -) -> None: - """Test hard reset failure.""" - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - client.driver.async_hard_reset = AsyncMock( - side_effect=FailedCommand("test_error", "unknown_error") - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reset_failed" - - async def test_choose_serial_port_usb_ports_failure( hass: HomeAssistant, integration: MockConfigEntry, @@ -4361,13 +4014,6 @@ async def test_choose_serial_port_usb_ports_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4377,11 +4023,6 @@ async def test_choose_serial_port_usb_ports_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" From e5e6ed601b6d088b657d832918cbfae1b175a3aa Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 27 Jun 2025 16:03:03 +0800 Subject: [PATCH 037/127] Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) --- .../components/telegram_bot/config_flow.py | 27 ++++++++++------- .../telegram_bot/test_config_flow.py | 29 ++++++++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 1a77a5b9a81..7b441889b8c 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -412,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for webhook Telegram bot.""" if not user_input: + default_trusted_networks = ",".join( + [str(network) for network in DEFAULT_TRUSTED_NETWORKS] + ) + if self.source == SOURCE_RECONFIGURE: + suggested_values = dict(self._get_reconfigure_entry().data) + if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data: + suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks + return self.async_show_form( step_id="webhooks", data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, - self._get_reconfigure_entry().data, + suggested_values, ), ) @@ -426,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, { - CONF_TRUSTED_NETWORKS: ",".join( - [str(network) for network in DEFAULT_TRUSTED_NETWORKS] - ), + CONF_TRUSTED_NETWORKS: default_trusted_networks, }, ), ) @@ -479,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str], ) -> None: # validate URL - if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"): - errors["base"] = "invalid_url" - description_placeholders[ERROR_FIELD] = "URL" - description_placeholders[ERROR_MESSAGE] = "URL must start with https" - return - if CONF_URL not in user_input: + url: str | None = user_input.get(CONF_URL) + if url is None: try: get_url(self.hass, require_ssl=True, allow_internal=False) except NoURLAvailableError: @@ -494,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): "URL is required since you have not configured an external URL in Home Assistant" ) return + elif not url.startswith("https"): + errors["base"] = "invalid_url" + description_placeholders[ERROR_FIELD] = "URL" + description_placeholders[ERROR_MESSAGE] = "URL must start with https" + return # validate trusted networks csv_trusted_networks: list[str] = [] diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index e13fab8f28b..2af90b9f7ef 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -121,13 +121,13 @@ async def test_reconfigure_flow_broadcast( async def test_reconfigure_flow_webhooks( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_broadcast_config_entry: MockConfigEntry, mock_external_calls: None, ) -> None: """Test reconfigure flow for webhook.""" - mock_webhooks_config_entry.add_to_hass(hass) + mock_broadcast_config_entry.add_to_hass(hass) - result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) + result = await mock_broadcast_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -198,8 +198,8 @@ async def test_reconfigure_flow_webhooks( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_webhooks_config_entry.data[CONF_URL] == "https://reconfigure" - assert mock_webhooks_config_entry.data[CONF_TRUSTED_NETWORKS] == [ + assert mock_broadcast_config_entry.data[CONF_URL] == "https://reconfigure" + assert mock_broadcast_config_entry.data[CONF_TRUSTED_NETWORKS] == [ "149.154.160.0/20" ] @@ -499,9 +499,22 @@ async def test_import_multiple( CONF_BOT_COUNT: 2, } - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", + return_value=ChatFullInfo( + id=987654321, + title="mock title", + first_name="mock first_name", + type="PRIVATE", + max_reaction_count=100, + accent_color_id=AccentColor.COLOR_000, + ), + ), ): # test: import first entry success From 263823c92cc85bd8201fc6f61d86a5986b7d707f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:51:28 +0200 Subject: [PATCH 038/127] Fix config schema to make credentials optional in NUT flows (#147593) --- homeassistant/components/nut/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 69281e852a8..8a498b99680 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -39,10 +39,12 @@ def _base_schema( base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, - vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED) + ): str, vol.Optional( CONF_PASSWORD, - default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED, ): str, } From efb29d024e0d90aaed48ce5ab1f0037a845df3f6 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Thu, 26 Jun 2025 14:52:07 -0400 Subject: [PATCH 039/127] Add Diagnostics to PlayStation Network (#147607) * Add Diagnostics support to PlayStation_Network * Remove unused constant * minor cleanup * Redact additional data * Redact additional data --- .../playstation_network/diagnostics.py | 55 +++++++++++++ .../playstation_network/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ .../playstation_network/test_diagnostics.py | 28 +++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/diagnostics.py create mode 100644 tests/components/playstation_network/snapshots/test_diagnostics.ambr create mode 100644 tests/components/playstation_network/test_diagnostics.py diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py new file mode 100644 index 00000000000..8332572177d --- /dev/null +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for PlayStation Network.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator + +TO_REDACT = { + "account_id", + "firstName", + "lastName", + "middleName", + "onlineId", + "url", + "username", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: PlaystationNetworkCoordinator = entry.runtime_data + + return { + "data": async_redact_data( + _serialize_platform_types(asdict(coordinator.data)), TO_REDACT + ), + } + + +def _serialize_platform_types(data: Any) -> Any: + """Recursively convert PlatformType enums to strings in dicts and sets.""" + if isinstance(data, dict): + return { + ( + platform.value if isinstance(platform, PlatformType) else platform + ): _serialize_platform_types(record) + for platform, record in data.items() + } + if isinstance(data, set): + return [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + if isinstance(data, PlatformType): + return data.value + return data diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index e173c4a710c..a98c30a7667 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -44,7 +44,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..405cee04559 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'account_id': '**REDACTED**', + 'active_sessions': dict({ + 'PS5': dict({ + 'format': 'PS5', + 'media_image_url': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'platform': 'PS5', + 'status': 'online', + 'title_id': 'PPSA07784_00', + 'title_name': 'STAR WARS Jedi: Survivor™', + }), + }), + 'available': True, + 'presence': dict({ + 'basicPresence': dict({ + 'availability': 'availableToPlay', + 'gameTitleInfoList': list([ + dict({ + 'conceptIconUrl': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'format': 'PS5', + 'launchPlatform': 'PS5', + 'npTitleId': 'PPSA07784_00', + 'titleName': 'STAR WARS Jedi: Survivor™', + }), + ]), + 'primaryPlatformInfo': dict({ + 'onlineStatus': 'online', + 'platform': 'PS5', + }), + }), + }), + 'profile': dict({ + 'aboutMe': 'Never Gonna Give You Up', + 'avatars': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + 'isMe': True, + 'isOfficiallyVerified': False, + 'isPlus': True, + 'languages': list([ + 'de-DE', + ]), + 'onlineId': '**REDACTED**', + 'personalDetail': dict({ + 'firstName': '**REDACTED**', + 'lastName': '**REDACTED**', + 'profilePictures': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + }), + }), + 'registered_platforms': list([ + 'PS5', + ]), + 'trophy_summary': dict({ + 'account_id': '**REDACTED**', + 'earned_trophies': dict({ + 'bronze': 14450, + 'gold': 11754, + 'platinum': 1398, + 'silver': 8722, + }), + 'progress': 19, + 'tier': 10, + 'trophy_level': 1079, + }), + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/playstation_network/test_diagnostics.py b/tests/components/playstation_network/test_diagnostics.py new file mode 100644 index 00000000000..b803a213207 --- /dev/null +++ b/tests/components/playstation_network/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for PlayStation Network diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 3fc154e1d76625cf83a792ccb274521057fb6359 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:36 +0200 Subject: [PATCH 040/127] Make sure Google Generative AI integration migration is clean (#147625) --- .../__init__.py | 6 ++++++ .../test_init.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 7890af59f88..5e4ad114adf 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -284,6 +284,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 85d6c70b658..08a94dd151c 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -512,6 +512,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -531,6 +535,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -626,6 +634,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == { + entry.entry_id: {list(entry.subentries.values())[0].subentry_id} + } async def test_migration_from_v1_to_v2_with_same_keys( @@ -743,6 +755,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -762,6 +778,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_devices( From c2c388e0cca31356a466ddfd75af6ed333df6cd9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:11 +0200 Subject: [PATCH 041/127] Make sure OpenAI integration migration is clean (#147627) --- .../components/openai_conversation/__init__.py | 6 ++++++ tests/components/openai_conversation/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index e14a8aabc1b..7cac3bb7003 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -346,6 +346,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b7f2a5434eb..274d09a9779 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -618,6 +618,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -709,6 +713,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -808,6 +814,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) From bc607dd013e39665c98ecf4b602e9e36dfdf0609 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:59 +0200 Subject: [PATCH 042/127] Make sure Anthropic integration migration is clean (#147629) --- homeassistant/components/anthropic/__init__.py | 6 ++++++ tests/components/anthropic/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c537a000c14..68a46f19031 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -123,6 +123,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 6295bac67cb..16240ef8120 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -141,6 +141,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -231,6 +235,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -329,3 +335,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 85343a9f53cada3e645a9e086df6eaa9c623a326 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:35 +0200 Subject: [PATCH 043/127] Make sure Ollama integration migration is clean (#147630) --- homeassistant/components/ollama/__init__.py | 6 ++++++ tests/components/ollama/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f174c709b65..8890c498e9f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -133,6 +133,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index e11eb98451a..0747578c110 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -109,6 +109,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_urls( @@ -193,6 +197,8 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_urls( @@ -285,3 +291,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 6bd6fa65d2c4469b5577706bdf177f87f86e3676 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:31:35 +0200 Subject: [PATCH 044/127] Bump pynecil to v4.1.1 (#147648) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 58cbdaa3bc6..be2309ab340 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.0"] + "requirements": ["pynecil==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bc728320a7..da31a7fad53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2166,7 +2166,7 @@ pymsteams==0.1.12 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a5f97014e2..a131e2b9e68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,7 +1799,7 @@ pymonoprice==0.4 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 From 9782637ec86ab11c6413b7aaedf4776c7f33134b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 10:01:44 +0200 Subject: [PATCH 045/127] Clarify descriptions of `subaru.unlock_specific_door` action (#147655) --- homeassistant/components/subaru/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 6aef0041874..e2399344544 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -102,11 +102,11 @@ "services": { "unlock_specific_door": { "name": "Unlock specific door", - "description": "Unlocks specific door(s).", + "description": "Unlocks the driver door, all doors, or the tailgate.", "fields": { "door": { "name": "Door", - "description": "Which door(s) to open." + "description": "The specific door(s) to unlock." } } } From 41b9a7a9a3ca65d55c2fcccea3d1216a7c77d306 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Jun 2025 08:08:02 +0000 Subject: [PATCH 046/127] Bump version to 2025.7.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 cf48d8b2427..5ded5fc83bb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index dfb0fc741fa..870c22f2a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b1" +version = "2025.7.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 1829acd0e1a7531f343285ea1b7ac1f26af1bdc4 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 18:27:43 +0300 Subject: [PATCH 047/127] Z-WaveJS config flow: Change keys question (#147518) Co-authored-by: Norbert Rittel --- .../components/zwave_js/config_flow.py | 109 +++++++--- .../components/zwave_js/strings.json | 44 ++-- tests/components/zwave_js/test_config_flow.py | 192 ++++++++++++++++-- 3 files changed, 289 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a109719965c..7e95e274713 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -40,7 +40,6 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.helpers.typing import VolDictType from .addon import get_addon_manager from .const import ( @@ -90,6 +89,9 @@ ADDON_USER_INPUT_MAP = { ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") +NETWORK_TYPE_NEW = "new" +NETWORK_TYPE_EXISTING = "existing" + def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the manual step.""" @@ -632,6 +634,81 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + return await self.async_step_network_type() + + if self._usb_discovery: + return await self.async_step_network_type() + + usb_path = self.usb_path or "" + + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + } + ) + + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) + + async def async_step_network_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for network type (new or existing).""" + # For recommended installation, automatically set network type to "new" + if self._recommended_install: + user_input = {"network_type": NETWORK_TYPE_NEW} + + if user_input is not None: + if user_input["network_type"] == NETWORK_TYPE_NEW: + # Set all keys to empty strings for new network + self.s0_legacy_key = "" + self.s2_access_control_key = "" + self.s2_authenticated_key = "" + self.s2_unauthenticated_key = "" + self.lr_s2_access_control_key = "" + self.lr_s2_authenticated_key = "" + + addon_config_updates = { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + + await self._async_set_addon_config(addon_config_updates) + return await self.async_step_start_addon() + + # Network already exists, go to security keys step + return await self.async_step_configure_security_keys() + + return self.async_show_form( + step_id="network_type", + data_schema=vol.Schema( + { + vol.Required("network_type", default=""): vol.In( + [NETWORK_TYPE_NEW, NETWORK_TYPE_EXISTING] + ) + } + ), + ) + + async def async_step_configure_security_keys( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for security keys for existing Z-Wave network.""" addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -654,10 +731,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - if self._recommended_install and self._usb_discovery: - # Recommended installation with USB discovery, skip asking for keys - user_input = {} - if user_input is not None: self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) self.s2_access_control_key = user_input.get( @@ -675,8 +748,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = user_input.get( CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key ) - if not self._usb_discovery: - self.usb_path = user_input[CONF_USB_PATH] addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, @@ -689,14 +760,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } await self._async_set_addon_config(addon_config_updates) - return await self.async_step_start_addon() - usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - schema: VolDictType = ( - {} - if self._recommended_install - else { + data_schema = vol.Schema( + { vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key @@ -716,22 +783,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } ) - if not self._usb_discovery: - try: - ports = await async_get_usb_ports(self.hass) - except OSError as err: - _LOGGER.error("Failed to get USB ports: %s", err) - return self.async_abort(reason="usb_ports_failed") - - schema = { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), - **schema, - } - - data_schema = vol.Schema(schema) - return self.async_show_form( - step_id="configure_addon_user", data_schema=data_schema + step_id="configure_security_keys", data_schema=data_schema ) async def async_step_finish_addon_setup_user( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index f61d871cfb9..b7f9b180624 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -39,25 +39,37 @@ "step": { "configure_addon_user": { "data": { - "lr_s2_access_control_key": "Long Range S2 Access Control Key", - "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_access_control_key": "S2 Access Control Key", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, + "network_type": { + "data": { + "network_type": "Is your network new or does it already exist?" + }, + "title": "Z-Wave network" + }, + "configure_security_keys": { + "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key" + }, + "description": "Enter the security keys for your existing Z-Wave network", + "title": "Security keys" + }, "configure_addon_reconfigure": { "data": { - "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", - "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", @@ -622,5 +634,13 @@ }, "name": "Set a value (advanced)" } + }, + "selector": { + "network_type": { + "options": { + "new": "It's new", + "existing": "It already exists" + } + } } } diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e99cedbdcba..a1642746d03 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,12 +29,6 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, - CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_LR_S2_AUTHENTICATED_KEY, - CONF_S0_LEGACY_KEY, - CONF_S2_ACCESS_CONTROL_KEY, - CONF_S2_AUTHENTICATED_KEY, - CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, DOMAIN, ) @@ -687,7 +681,17 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -778,9 +782,18 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" - # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] assert data_schema is not None assert data_schema({}) == { @@ -1126,6 +1139,25 @@ async def test_discovery_addon_not_running( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1226,6 +1258,25 @@ async def test_discovery_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1728,6 +1779,25 @@ async def test_addon_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1822,6 +1892,25 @@ async def test_addon_installed_start_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1911,6 +2000,25 @@ async def test_addon_installed_failures( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1981,6 +2089,25 @@ async def test_addon_installed_set_options_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2091,6 +2218,25 @@ async def test_addon_installed_already_configured( result["flow_id"], { "usb_path": "/new", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2178,6 +2324,25 @@ async def test_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -4229,13 +4394,8 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] is not None - assert data_schema.schema.get(CONF_S0_LEGACY_KEY) is None - assert data_schema.schema.get(CONF_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_S2_AUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_S2_UNAUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_AUTHENTICATED_KEY) is None + assert len(data_schema.schema) == 1 + assert data_schema.schema.get(CONF_USB_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], From b9e2c5d34c7bddce636034946a20d4b1e77463dd Mon Sep 17 00:00:00 2001 From: Bernardus Jansen Date: Fri, 27 Jun 2025 19:45:36 +0200 Subject: [PATCH 048/127] Add previously missing state classes to dsmr sensors (#147633) --- homeassistant/components/dsmr/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 918d4e33971..03e89b971fc 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="SHORT_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="LONG_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( From 62f7cbb51ea575e41f934bd27327e92dfa544f59 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:23:42 +0200 Subject: [PATCH 049/127] Remove dweet.io integration (#147645) --- homeassistant/components/dweet/__init__.py | 79 ------------ homeassistant/components/dweet/manifest.json | 10 -- homeassistant/components/dweet/sensor.py | 124 ------------------- homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 5 files changed, 222 deletions(-) delete mode 100644 homeassistant/components/dweet/__init__.py delete mode 100644 homeassistant/components/dweet/manifest.json delete mode 100644 homeassistant/components/dweet/sensor.py diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py deleted file mode 100644 index b43ce3db8c1..00000000000 --- a/homeassistant/components/dweet/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for sending data to Dweet.io.""" - -from datetime import timedelta -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_NAME, - CONF_WHITELIST, - EVENT_STATE_CHANGED, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "dweet" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_WHITELIST, default=[]): vol.All( - cv.ensure_list, [cv.entity_id] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Dweet.io component.""" - conf = config[DOMAIN] - name = conf.get(CONF_NAME) - whitelist = conf.get(CONF_WHITELIST) - json_body = {} - - def dweet_event_listener(event): - """Listen for new messages on the bus and sends them to Dweet.io.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "") - or state.entity_id not in whitelist - ): - return - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state - - send_data(name, json_body) - - hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener) - - return True - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -def send_data(name, msg): - """Send the collected data to Dweet.io.""" - try: - dweepy.dweet_for(name, msg) - except dweepy.DweepyError: - _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json deleted file mode 100644 index b4efd0744fb..00000000000 --- a/homeassistant/components/dweet/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "dweet", - "name": "dweet.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dweet", - "iot_class": "cloud_polling", - "loggers": ["dweepy"], - "quality_scale": "legacy", - "requirements": ["dweepy==0.3.0"] -} diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py deleted file mode 100644 index 6110f17f826..00000000000 --- a/homeassistant/components/dweet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for showing values from Dweet.io.""" - -from __future__ import annotations - -from datetime import timedelta -import json -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - CONF_DEVICE, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - 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.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Dweet.io Sensor" - -SCAN_INTERVAL = timedelta(minutes=1) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dweet sensor.""" - name = config.get(CONF_NAME) - device = config.get(CONF_DEVICE) - value_template = config.get(CONF_VALUE_TEMPLATE) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - - try: - content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"]) - except dweepy.DweepyError: - _LOGGER.error("Device/thing %s could not be found", device) - return - - if value_template and value_template.render_with_possible_json_value(content) == "": - _LOGGER.error("%s was not found", value_template) - return - - dweet = DweetData(device) - - add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True) - - -class DweetSensor(SensorEntity): - """Representation of a Dweet sensor.""" - - def __init__(self, hass, dweet, name, value_template, unit_of_measurement): - """Initialize the sensor.""" - self.hass = hass - self.dweet = dweet - self._name = name - self._value_template = value_template - self._state = None - self._unit_of_measurement = unit_of_measurement - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state.""" - return self._state - - def update(self) -> None: - """Get the latest data from REST API.""" - self.dweet.update() - - if self.dweet.data is None: - self._state = None - else: - values = json.dumps(self.dweet.data[0]["content"]) - self._state = self._value_template.render_with_possible_json_value( - values, None - ) - - -class DweetData: - """The class for handling the data retrieval.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self.data = None - - def update(self): - """Get the latest data from Dweet.io.""" - try: - self.data = dweepy.get_latest_dweet_for(self._device) - except dweepy.DweepyError: - _LOGGER.warning("Device %s doesn't contain any data", self._device) - self.data = None diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd88338c4b9..98670484450 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1483,12 +1483,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "dweet": { - "name": "dweet.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "eafm": { "name": "Environment Agency Flood Gauges", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index da31a7fad53..d2d0503a59f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,9 +820,6 @@ dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 -# homeassistant.components.dweet -dweepy==0.3.0 - # homeassistant.components.dynalite dynalite-devices==0.1.47 From 47f3bf29dd65d2093989574845c25eff5a6229c8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 28 Jun 2025 03:43:03 +1000 Subject: [PATCH 050/127] Fix energy history in Teslemetry (#147646) --- .../components/teslemetry/coordinator.py | 12 ++++---- tests/components/teslemetry/const.py | 1 + .../fixtures/energy_history_empty.json | 8 +++++ tests/components/teslemetry/test_sensor.py | 30 +++++++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/components/teslemetry/fixtures/energy_history_empty.json diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index c31bdc2a34e..e6b453402e9 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): except TeslaFleetError as e: raise UpdateFailed(e.message) from e + if not data or not isinstance(data.get("time_series"), list): + raise UpdateFailed("Received invalid data") + # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + for period in data["time_series"]: for key in ENERGY_HISTORY_FIELDS: if key in period: - if output[key] is None: - output[key] = period[key] - else: - output[key] += period[key] + output[key] += period[key] return output diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index b658c1e2271..3bfa452e38d 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -20,6 +20,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +ENERGY_HISTORY_EMPTY = load_json_object_fixture("energy_history_empty.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/energy_history_empty.json b/tests/components/teslemetry/fixtures/energy_history_empty.json new file mode 100644 index 00000000000..cc54000115a --- /dev/null +++ b/tests/components/teslemetry/fixtures/energy_history_empty.json @@ -0,0 +1,8 @@ +{ + "response": { + "serial_number": "xxxxxx", + "period": "day", + "installation_time_zone": "Australia/Brisbane", + "time_series": null + } +} diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f50dc93bde4..d2d6d88b3e3 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -8,12 +8,13 @@ from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import Platform +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 assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT +from .const import ENERGY_HISTORY_EMPTY, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -101,3 +102,28 @@ async def test_sensors_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") + + +async def test_energy_history_no_time_series( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_energy_history: AsyncMock, +) -> None: + """Test energy history coordinator when time_series is not a list.""" + # Mock energy history to return data without time_series as a list + + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "sensor.energy_site_battery_discharged" + state = hass.states.get(entity_id) + assert state.state == "0.036" + + mock_energy_history.return_value = ENERGY_HISTORY_EMPTY + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE From 0b5d2ab8e4c9210fec82292e1f50b232a86bb6f2 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 19:38:42 +0200 Subject: [PATCH 051/127] Respect availability of parent class in Husqvarna Automower (#147649) --- homeassistant/components/husqvarna_automower/button.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 1f7ed7127e0..281669aad04 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): @property def available(self) -> bool: """Return the available attribute of the entity.""" - return self.entity_description.available_fn(self.mower_attributes) + return super().available and self.entity_description.available_fn( + self.mower_attributes + ) @handle_sending_exception() async def async_press(self) -> None: From 5c0f2d37f0ffff038f0676b1a8a1f38b323e5598 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:31:13 +0200 Subject: [PATCH 052/127] Make jellyfin not single config entry (#147656) --- .../components/jellyfin/manifest.json | 3 +- homeassistant/generated/integrations.json | 3 +- .../jellyfin/fixtures/get-user-settings.json | 2 +- tests/components/jellyfin/test_config_flow.py | 37 +++++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index d6b2261acaa..a1bf3268721 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"], - "single_config_entry": true + "requirements": ["jellyfin-apiclient-python==1.10.0"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 98670484450..6bf63b260de 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3159,8 +3159,7 @@ "name": "Jellyfin", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true + "iot_class": "local_polling" }, "jewish_calendar": { "name": "Jewish Calendar", diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json index 5e28f87d8f2..5ed59661a60 100644 --- a/tests/components/jellyfin/fixtures/get-user-settings.json +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -1,5 +1,5 @@ { - "Id": "string", + "Id": "USER-UUID", "ViewType": "string", "SortBy": "string", "IndexBy": "string", diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index a8ffbcbf46c..fd9d3b1d773 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -23,17 +23,6 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - 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" - - async def test_form( hass: HomeAssistant, mock_jellyfin: MagicMock, @@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error( } +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the case where the user tries to configure an already configured 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() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 4977ee49982ba33e27e12c8834da7032aedbbf30 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:07:14 +0200 Subject: [PATCH 053/127] Bump jellyfin-apiclient-python to 1.11.0 (#147658) --- homeassistant/components/jellyfin/client_wrapper.py | 3 +-- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 91fe0885e4c..4855231184e 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -66,8 +66,7 @@ def _connect_to_address( ) -> dict[str, Any]: """Connect to the Jellyfin server.""" result: dict[str, Any] = connection_manager.connect_to_address(url) - - if result["State"] != CONNECTION_STATE["ServerSignIn"]: + if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn: raise CannotConnect return result diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index a1bf3268721..839d9e685fc 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"] + "requirements": ["jellyfin-apiclient-python==1.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d2d0503a59f..bcca765b043 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1276,7 +1276,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a131e2b9e68..1706bf61430 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest From 77ccfbd3a94f813f11ddffaadcb3272e8a289780 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:00:23 +0200 Subject: [PATCH 054/127] Fix: Unhandled NoneType sessions in jellyfin (#147659) --- homeassistant/components/jellyfin/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index cd22ad4ab39..30149453ba3 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An self.api_client.jellyfin.sessions ) + if sessions is None: + return {} + sessions_by_id: dict[str, dict[str, Any]] = { session["Id"]: session for session in sessions From 8cdc7523a4ad7f1e98fb3f32ce584024bf7bbc15 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 27 Jun 2025 18:50:35 +0300 Subject: [PATCH 055/127] Fix Shelly entity removal (#147665) --- homeassistant/components/shelly/entity.py | 8 ++- tests/components/shelly/test_switch.py | 60 +++++++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5a420a4543b..587eb00b979 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities( if description.removal_condition and description.removal_condition( coordinator.device.config, coordinator.device.status, key ): - domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{key}-{sensor_id}" + entity_class = get_entity_class(sensor_class, description) + domain = entity_class.__module__.split(".")[-1] + unique_id = entity_class( + coordinator, key, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) elif description.use_polling_coordinator: if not sleep_period: diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 54923b538f6..3234e3eb0b9 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,15 +1,18 @@ """Tests for Shelly switch platform.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.shelly.const import ( DOMAIN, + ENTRY_RELOAD_COOLDOWN, MODEL_WALL_DISPLAY, MOTION_MODELS, ) @@ -28,9 +31,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import ( + init_integration, + inject_rpc_device_event, + register_device, + register_entity, +) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -374,15 +382,57 @@ async def test_rpc_device_unique_ids( async def test_rpc_device_switch_type_lights_mode( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device with switch in consumption type lights mode.""" + switch_entity_id = "switch.test_name_test_switch_0" + light_entity_id = "light.test_name_test_switch_0" + + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + await init_integration(hass, 2) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await init_integration(hass, 2) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "config_changed", + "id": 1, + "ts": 1668522399.2, + }, + { + "data": [], + "id": 2, + "ts": 1668522399.2, + }, + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is None + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) @pytest.mark.parametrize( From 54510637143b1d973180dcea76e7810be46b256f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Jun 2025 17:28:14 +0200 Subject: [PATCH 056/127] Update frontend to 20250627.0 (#147668) --- 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 8e4ea47da5b..cf83ce90237 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==20250626.0"] + "requirements": ["home-assistant-frontend==20250627.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5839a3ae014..80fccb1bf78 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bcca765b043..b0727d8dfc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1706bf61430..611186f391e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 8230557aef77b5d1c8313912e94270a69b48636d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 15:53:18 +0200 Subject: [PATCH 057/127] Fix sentence-casing and spacing of button in `thermopro` (#147671) --- homeassistant/components/thermopro/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 5789de410b2..77722b6e986 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -21,7 +21,7 @@ "entity": { "button": { "set_datetime": { - "name": "Set Date&Time" + "name": "Set date & time" } } } From 013a35176acc2f89a8e0ff5bd3076d030c88f55c Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:32:25 -0400 Subject: [PATCH 058/127] Bump aiosomecomfort to 0.0.33 (#147673) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 7fa102c6599..d2cd5a3c6a4 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.32"] + "requirements": ["AIOSomecomfort==0.0.33"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0727d8dfc8..62867d3aeb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 611186f391e..cbf715ac377 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 From e4d820799fecc5b6b031eb018b3440c1e6013308 Mon Sep 17 00:00:00 2001 From: hanwg Date: Sat, 28 Jun 2025 00:18:01 +0800 Subject: [PATCH 059/127] Add codeowner for Telegram bot (#147680) --- CODEOWNERS | 2 ++ homeassistant/components/telegram_bot/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4e224f8802b..28deb93492c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor /tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj +/homeassistant/components/telegram_bot/ @hanwg +/tests/components/telegram_bot/ @hanwg /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @Petro31 @home-assistant/core diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 27c10602350..7a01f43c528 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -1,7 +1,7 @@ { "domain": "telegram_bot", "name": "Telegram bot", - "codeowners": [], + "codeowners": ["@hanwg"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/telegram_bot", From 18834849c2e5a1cbe6c80dcb0f641c349455f651 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Jun 2025 19:44:01 +0300 Subject: [PATCH 060/127] Bump aioamazondevices to 3.1.22 (#147681) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index e82cd471ac7..cdf942e836d 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.1.19"] + "requirements": ["aioamazondevices==3.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62867d3aeb8..e9e273572f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbf715ac377..f7bf7d6ccdd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 16c6bd08f82ffbfd7e940fed57d7d2873824a114 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Jun 2025 17:55:31 +0000 Subject: [PATCH 061/127] Bump version to 2025.7.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 5ded5fc83bb..ed82ed41594 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 870c22f2a12..735915581d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b2" +version = "2025.7.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From b537850f522f71791e1532c46e7b0dc9d055c10a Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:11:59 +0200 Subject: [PATCH 062/127] Bump vulcan-api to 2.4.2 (#146857) --- homeassistant/components/vulcan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vulcan/fixtures/fake_student_1.json | 8 +++++++- tests/components/vulcan/fixtures/fake_student_2.json | 8 +++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 554a82e9c2c..f9385262f05 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", - "requirements": ["vulcan-api==2.3.2"] + "requirements": ["vulcan-api==2.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9e273572f3..fdf67f91abb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3062,7 +3062,7 @@ vsure==2.6.7 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7bf7d6ccdd..a5d59456959 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2524,7 +2524,7 @@ volvooncall==0.10.3 vsure==2.6.7 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json index 0e6c79e4b03..fef69684550 100644 --- a/tests/components/vulcan/fixtures/fake_student_1.json +++ b/tests/components/vulcan/fixtures/fake_student_1.json @@ -25,5 +25,11 @@ "Surname": "Kowalski", "Sex": true }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json index 0176b72d4fc..e5200c12e17 100644 --- a/tests/components/vulcan/fixtures/fake_student_2.json +++ b/tests/components/vulcan/fixtures/fake_student_2.json @@ -25,5 +25,11 @@ "Surname": "Kowalska", "Sex": false }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } From a65eb5753901852b13139d32b18a0fde25675fdc Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 28 Jun 2025 02:16:21 +0800 Subject: [PATCH 063/127] Add lock models to switchbot cloud (#147569) --- homeassistant/components/switchbot_cloud/__init__.py | 7 ++++++- .../components/switchbot_cloud/binary_sensor.py | 9 ++++++++- homeassistant/components/switchbot_cloud/sensor.py | 5 +++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 7b7f60589f0..b87a569abda 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -153,7 +153,12 @@ async def make_device_data( ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + if isinstance(device, Device) and device.device_type in [ + "Smart Lock", + "Smart Lock Lite", + "Smart Lock Pro", + "Smart Lock Ultra", + ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 752c428fa6c..cd0e6e8968c 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -48,10 +48,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Lite": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Pro": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Ultra": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), } @@ -69,7 +77,6 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] - if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9920717a8d7..5a424ea7892 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -134,8 +134,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), - "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock": (BATTERY_DESCRIPTION,), + "Smart Lock Lite": (BATTERY_DESCRIPTION,), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": (BATTERY_DESCRIPTION,), } @@ -151,7 +153,6 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] - if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) From 862b7460b525f7f4e1f8c38fa87d8d8de607cb2b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 28 Jun 2025 11:25:59 +0200 Subject: [PATCH 064/127] Move MQTT device sw and hw version to collapsed section in subentry flow (#147685) Move MQTT device sw and hw version to collapsed section --- homeassistant/components/mqtt/config_flow.py | 41 ++++++++++++++++---- homeassistant/components/mqtt/strings.json | 15 +++++-- tests/components/mqtt/test_config_flow.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 2ef881ceaf4..b022a46cbe7 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1904,8 +1904,12 @@ ENTITY_CONFIG_VALIDATOR: dict[ MQTT_DEVICE_PLATFORM_FIELDS = { ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), - ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_CONFIGURATION_URL: PlatformField( @@ -2725,6 +2729,19 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): for field_key, value in data_schema.schema.items() } + @callback + def get_suggested_values_from_device_data( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from device data based on the data schema.""" + device_data = self._subentry_data["device"] + return { + field_key: self.get_suggested_values_from_device_data(value.schema) + if isinstance(value, section) + else device_data.get(field_key) + for field_key, value in data_schema.schema.items() + } + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -2754,15 +2771,25 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + new_device_data, errors = validate_user_input( + user_input, MQTT_DEVICE_PLATFORM_FIELDS + ) + if "mqtt_settings" in user_input: + new_device_data["mqtt_settings"] = user_input["mqtt_settings"] if not errors: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() return await self.async_step_entity() - data_schema = self.add_suggested_values_to_schema( - data_schema, device_data if user_input is None else user_input - ) + data_schema = self.add_suggested_values_to_schema( + data_schema, device_data if user_input is None else user_input + ) + elif self.source == SOURCE_RECONFIGURE: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_device_data(data_schema), + ) + return self.async_show_form( step_id=CONF_DEVICE, data_schema=data_schema, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 592ea8686e1..96b5bd15d28 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -134,20 +134,27 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "configuration_url": "Configuration URL", - "sw_version": "Software version", - "hw_version": "Hardware version", "model": "Model", "model_id": "Model ID" }, "data_description": { "name": "The name of the manually added MQTT device.", "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", - "sw_version": "The software version of the device. E.g. '2025.1.0'.", - "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." }, "sections": { + "advanced_settings": { + "name": "Advanced device settings", + "data": { + "sw_version": "Software version", + "hw_version": "Hardware version" + }, + "data_description": { + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'." + } + }, "mqtt_settings": { "name": "MQTT settings", "data": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 2177a7de8e1..12f77a95c48 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4073,7 +4073,7 @@ async def test_subentry_reconfigure_update_device_properties( result["flow_id"], user_input={ "name": "Beer notifier", - "sw_version": "1.1", + "advanced_settings": {"sw_version": "1.1"}, "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", From d3c5684cd05538e335c20ba9afa1372be0f6308c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 28 Jun 2025 22:40:58 +0300 Subject: [PATCH 065/127] Fix Shelly Block entity removal (#147694) --- homeassistant/components/shelly/entity.py | 5 ++- tests/components/shelly/test_switch.py | 45 +++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 587eb00b979..b80ac877a84 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -86,7 +86,10 @@ def async_setup_block_attribute_entities( coordinator.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" + unique_id = sensor_class( + coordinator, block, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) else: entities.append( diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 3234e3eb0b9..f1866d83e2a 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -40,6 +40,8 @@ from . import ( from tests.common import async_fire_time_changed, mock_restore_cache +DEVICE_BLOCK_ID = 4 +LIGHT_BLOCK_ID = 2 RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 MOTION_BLOCK_ID = 3 @@ -326,14 +328,51 @@ async def test_block_device_mode_roller( async def test_block_device_app_type_light( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device in app type set to light mode.""" + switch_entity_id = "switch.test_name_channel_1" + light_entity_id = "light.test_name_channel_1" + + # Remove light blocks to prevent light entity creation + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "sensor") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + + await init_integration(hass, 1) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) + mock_block_device.mock_update() + monkeypatch.setitem( mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1") is None + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 2) + mock_block_device.mock_update() + await hass.async_block_till_done() + + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) async def test_rpc_device_services( From 81e712ea496a75ae8a11860e2cd8f59dd5e2906a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Jun 2025 10:11:17 +0200 Subject: [PATCH 066/127] Bump pytibber to 0.31.6 (#147703) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 43cbd79afef..db08f422500 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.31.2"] + "requirements": ["pyTibber==0.31.6"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 26b8f5400a0..327812cdf99 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -280,7 +280,7 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err - except aiohttp.ClientError as err: + except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err diff --git a/requirements_all.txt b/requirements_all.txt index fdf67f91abb..54d8180fc0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1811,7 +1811,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5d59456959..28fbbe0e97d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1522,7 +1522,7 @@ pyHomee==1.2.10 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 From 33e1c6de68386ea25c072fd3cee19aa85638d0d2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 28 Jun 2025 22:20:47 +0200 Subject: [PATCH 067/127] Reduce idle timeout of HLS stream to conserve camera battery life (#147728) * Reduce IDLE timeout of HLS stream to conserve camera battery life * adjust tests --- homeassistant/components/stream/__init__.py | 12 ++++++++---- homeassistant/components/stream/const.py | 3 ++- homeassistant/components/stream/core.py | 4 +++- tests/components/stream/test_hls.py | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 9426b5b04de..a31ce433c06 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -55,6 +55,7 @@ from .const import ( MAX_SEGMENTS, OUTPUT_FORMATS, OUTPUT_IDLE_TIMEOUT, + OUTPUT_STARTUP_TIMEOUT, RECORDER_PROVIDER, RTSP_TRANSPORTS, SEGMENT_DURATION_ADJUSTER, @@ -363,11 +364,14 @@ class Stream: # without concern about self._outputs being modified from another thread. return MappingProxyType(self._outputs.copy()) - def add_provider( - self, fmt: str, timeout: int = OUTPUT_IDLE_TIMEOUT - ) -> StreamOutput: + def add_provider(self, fmt: str, timeout: int | None = None) -> StreamOutput: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): + startup_timeout = OUTPUT_STARTUP_TIMEOUT + if timeout is None: + timeout = OUTPUT_IDLE_TIMEOUT + else: + startup_timeout = timeout async def idle_callback() -> None: if ( @@ -379,7 +383,7 @@ class Stream: provider = PROVIDERS[fmt]( self.hass, - IdleTimer(self.hass, timeout, idle_callback), + IdleTimer(self.hass, timeout, idle_callback, startup_timeout), self._stream_settings, self.dynamic_stream_settings, ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index c81d2f6cb18..df50ecefd62 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -22,7 +22,8 @@ AUDIO_CODECS = {"aac", "mp3"} FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"} -OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity +OUTPUT_STARTUP_TIMEOUT = 60 # timeout due to no startup +OUTPUT_IDLE_TIMEOUT = 30 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 44dfe2c323d..7dc6bab16b9 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -234,10 +234,12 @@ class IdleTimer: hass: HomeAssistant, timeout: int, idle_callback: Callable[[], Coroutine[Any, Any, None]], + startup_timeout: int | None = None, ) -> None: """Initialize IdleTimer.""" self._hass = hass self._timeout = timeout + self._startup_timeout = startup_timeout or timeout self._callback = idle_callback self._unsub: CALLBACK_TYPE | None = None self.idle = False @@ -246,7 +248,7 @@ class IdleTimer: """Start the idle timer if not already started.""" self.idle = False if self._unsub is None: - self._unsub = async_call_later(self._hass, self._timeout, self.fire) + self._unsub = async_call_later(self._hass, self._startup_timeout, self.fire) def awake(self) -> None: """Keep the idle time alive by resetting the timeout.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index c96b7d9427f..eb554f2cf19 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -230,8 +230,8 @@ async def test_stream_timeout( playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == HTTPStatus.OK - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) + # Wait 40 seconds + future = dt_util.utcnow() + timedelta(seconds=40) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -241,8 +241,8 @@ async def test_stream_timeout( stream_worker_sync.resume() - # Wait 5 minutes - future = dt_util.utcnow() + timedelta(minutes=5) + # Wait 2 minutes + future = dt_util.utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() From 4b3449fe0c6b73c1329270d71b6d9a435942f5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sat, 28 Jun 2025 21:57:26 +0200 Subject: [PATCH 068/127] Fix error if cover position is not available or unknown (#147732) --- homeassistant/components/wmspro/cover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 0d9ccb8547d..77dd928bc95 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -53,6 +53,8 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return current position of cover.""" action = self._dest.action(self._drive_action_desc) + if action is None or action["percentage"] is None: + return None return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: From 2f69ed4a8ae56274532aeb596e9e7c80c711d259 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sat, 28 Jun 2025 22:13:51 +0200 Subject: [PATCH 069/127] bump pypaperless to 4.1.1 (#147735) --- homeassistant/components/paperless_ngx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json index 0be3562c76f..43c61185f3a 100644 --- a/homeassistant/components/paperless_ngx/manifest.json +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pypaperless"], "quality_scale": "silver", - "requirements": ["pypaperless==4.1.0"] + "requirements": ["pypaperless==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 54d8180fc0c..48b8605b346 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28fbbe0e97d..2ba5c97e364 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.lcn pypck==0.8.9 From c32b44b77490f12150bec242175a6a62d4fff8d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 15:18:46 -0500 Subject: [PATCH 070/127] Improve rest error logging (#147736) * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * top level --- homeassistant/components/rest/data.py | 41 ++- tests/components/rest/test_data.py | 444 ++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 tests/components/rest/test_data.py diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3c02f62f852..f20b811a887 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -6,6 +6,7 @@ import logging from typing import Any import aiohttp +from aiohttp import hdrs from multidict import CIMultiDictProxy import xmltodict @@ -77,6 +78,12 @@ class RestData: """Set url.""" self._resource = url + def _is_expected_content_type(self, content_type: str) -> bool: + """Check if the content type is one we expect (JSON or XML).""" + return content_type.startswith( + ("application/json", "text/json", *XML_MIME_TYPES) + ) + def data_without_xml(self) -> str | None: """If the data is an XML string, convert it to a JSON string.""" _LOGGER.debug("Data fetched from resource: %s", self.data) @@ -84,7 +91,7 @@ class RestData: (value := self.data) is not None # If the http request failed, headers will be None and (headers := self.headers) is not None - and (content_type := headers.get("content-type")) + and (content_type := headers.get(hdrs.CONTENT_TYPE)) and content_type.startswith(XML_MIME_TYPES) ): value = json_dumps(xmltodict.parse(value)) @@ -120,6 +127,7 @@ class RestData: # Handle data/content if self._request_data: request_kwargs["data"] = self._request_data + response = None try: # Make the request async with self._session.request( @@ -143,3 +151,34 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + + # Log response details outside the try block so we always get logging + if response is None: + return + + # Log response details for debugging + content_type = response.headers.get(hdrs.CONTENT_TYPE) + _LOGGER.debug( + "REST response from %s: status=%s, content-type=%s, length=%s", + self._resource, + response.status, + content_type or "not set", + len(self.data) if self.data else 0, + ) + + # If we got an error response with non-JSON/XML content, log a sample + # This helps debug issues like servers blocking with HTML error pages + if ( + response.status >= 400 + and content_type + and not self._is_expected_content_type(content_type) + ): + sample = self.data[:500] if self.data else "" + _LOGGER.warning( + "REST request to %s returned status %s with %s response: %s%s", + self._resource, + response.status, + content_type, + sample, + "..." if self.data and len(self.data) > 500 else "", + ) diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py new file mode 100644 index 00000000000..3add886a451 --- /dev/null +++ b/tests/components/rest/test_data.py @@ -0,0 +1,444 @@ +"""Test REST data module logging improvements.""" + +import logging + +import pytest + +from homeassistant.components.rest import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_rest_data_log_warning_on_error_status( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status codes.""" + # Mock a 403 response with HTML content + aioclient_mock.get( + "http://example.com/api", + status=403, + text="Access Denied", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged + assert ( + "REST request to http://example.com/api returned status 403 " + "with text/html response" in caplog.text + ) + assert "Access Denied" in caplog.text + + +async def test_rest_data_no_warning_on_200_with_wrong_content_type( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for 200 status with wrong content.""" + # Mock a 200 response with HTML - users might still want to parse this + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

This is HTML, not JSON!

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for 200 status, even with HTML content type + assert ( + "REST request to http://example.com/api returned status 200" not in caplog.text + ) + + +async def test_rest_data_no_warning_on_success_json( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful JSON responses.""" + # Mock a successful JSON response + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok", "value": 42}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_no_warning_on_success_xml( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful XML responses.""" + # Mock a successful XML response + aioclient_mock.get( + "http://example.com/api", + status=200, + text='42', + headers={"Content-Type": "application/xml"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.root.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_warning_truncates_long_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning truncates very long response bodies.""" + # Create a very long error message + long_message = "Error: " + "x" * 1000 + + aioclient_mock.get( + "http://example.com/api", + status=500, + text=long_message, + headers={"Content-Type": "text/plain"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with truncation + # Set the logger filter to only check our specific logger + caplog.set_level(logging.WARNING, logger="homeassistant.components.rest.data") + + # Verify the truncated warning appears + assert ( + "REST request to http://example.com/api returned status 500 " + "with text/plain response: Error: " + "x" * 493 + "..." in caplog.text + ) + + +async def test_rest_data_debug_logging_shows_response_details( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that debug logging shows response details.""" + caplog.set_level(logging.DEBUG) + + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"test": "data"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log + assert ( + "REST response from http://example.com/api: status=200, " + "content-type=application/json, length=" in caplog.text + ) + + +async def test_rest_data_no_content_type_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of responses without Content-Type header.""" + caplog.set_level(logging.DEBUG) + + # Mock response without Content-Type header + aioclient_mock.get( + "http://example.com/api", + status=200, + text="plain text response", + headers={}, # No Content-Type + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log shows "not set" + assert "content-type=not set" in caplog.text + # No warning for 200 with missing content-type + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_real_world_bom_blocking_scenario( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test real-world scenario where BOM blocks with HTML response.""" + # Mock BOM blocking response + bom_block_html = "

Your access is blocked due to automated access

" + + aioclient_mock.get( + "http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json", + status=403, + text=bom_block_html, + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": ("http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json"), + "method": "GET", + "sensor": [ + { + "name": "bom_temperature", + "value_template": ( + "{{ value_json.observations.data[0].air_temp }}" + ), + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with clear indication of the issue + assert ( + "REST request to http://www.bom.gov.au/fwo/IDN60901/" + "IDN60901.94767.json returned status 403 with text/html response" + ) in caplog.text + assert "Your access is blocked" in caplog.text + + +async def test_rest_data_warning_on_html_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status with HTML content.""" + # Mock a 404 response with HTML error page + aioclient_mock.get( + "http://example.com/api", + status=404, + text="

404 Not Found

", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should warn for error status with HTML + assert ( + "REST request to http://example.com/api returned status 404 " + "with text/html response" in caplog.text + ) + assert "

404 Not Found

" in caplog.text + + +async def test_rest_data_no_warning_on_json_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test POST request that returns JSON error - no warning expected.""" + aioclient_mock.post( + "http://example.com/api", + status=400, + text='{"error": "Invalid request payload"}', + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "POST", + "payload": '{"data": "test"}', + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.error }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for JSON error responses - users can parse these + assert ( + "REST request to http://example.com/api returned status 400" not in caplog.text + ) + + +async def test_rest_data_timeout_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test timeout error logging.""" + aioclient_mock.get( + "http://example.com/api", + exc=TimeoutError(), + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "timeout": 10, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check timeout error is logged or platform reports not ready + assert ( + "Timeout while fetching data: http://example.com/api" in caplog.text + or "Platform rest not ready yet" in caplog.text + ) From cf2e69ed7454a2439455b64ae060d500841a54f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Jun 2025 20:27:42 +0000 Subject: [PATCH 071/127] Bump version to 2025.7.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 ed82ed41594..e3b9424292e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 735915581d1..eb02751785e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b3" +version = "2025.7.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 5a0a1bbbf43af98a7b6e1b35070c5d533a077880 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:35:19 -0400 Subject: [PATCH 072/127] Person ble_trackers for non-home zones not processed correctly (#138475) Co-authored-by: Erik Montnemery Co-authored-by: Joost Lekkerkerker --- homeassistant/components/person/__init__.py | 3 +- tests/components/person/test_init.py | 75 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 856e07bb2ee..0dd8646b17e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -27,7 +27,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_HOME, - STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -526,7 +525,7 @@ class Person( latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: latest_non_gps_home = _get_latest(latest_non_gps_home, state) - elif state.state == STATE_NOT_HOME: + else: latest_not_home = _get_latest(latest_not_home, state) if latest_non_gps_home: diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1d6c398c444..c001da86adb 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -244,6 +244,81 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER +async def test_setup_router_ble_trackers( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test router and BLE trackers.""" + # BLE trackers are considered stationary trackers; however unlike a router based tracker + # whose states are home and not_home, a BLE tracker may have the value of any zone that the + # beacon is configured for. + hass.set_state(CoreState.not_running) + user_id = hass_admin_user.id + config = { + DOMAIN: { + "id": "1234", + "name": "tracked person", + "user_id": user_id, + "device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2], + } + } + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get("person.tracked_person") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.states.async_set( + DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_GPS_ACCURACY) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + # Set the BLE tracker to the "office" zone. + hass.states.async_set( + DEVICE_TRACKER_2, + "office", + { + ATTR_LATITUDE: 12.123456, + ATTR_LONGITUDE: 13.123456, + ATTR_GPS_ACCURACY: 12, + ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + }, + ) + await hass.async_block_till_done() + + # The person should be in the office. + state = hass.states.get("person.tracked_person") + assert state.state == "office" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) == 12.123456 + assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 + assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: From a8b5d1511d9624d925c1089a80c9a98e328e68bd Mon Sep 17 00:00:00 2001 From: mvn23 Date: Mon, 30 Jun 2025 20:24:13 +0200 Subject: [PATCH 073/127] Populate hvac_modes list in opentherm_gw (#142074) --- homeassistant/components/opentherm_gw/climate.py | 13 ++++++++++++- homeassistant/components/opentherm_gw/strings.json | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 68463e764f2..c7e107b1637 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -30,6 +31,7 @@ from .const import ( CONF_SET_PRECISION, DATA_GATEWAYS, DATA_OPENTHERM_GW, + DOMAIN, THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) @@ -75,7 +77,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_modes = [] + _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 @@ -129,9 +131,11 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): if ch_active and flame_on: self._attr_hvac_action = HVACAction.HEATING self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] elif cooling_active: self._attr_hvac_action = HVACAction.COOLING self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] else: self._attr_hvac_action = HVACAction.IDLE @@ -182,6 +186,13 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): return PRESET_AWAY return PRESET_NONE + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="change_hvac_mode_not_supported", + ) + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 8959e0facf9..f3938c81e7e 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -355,6 +355,9 @@ } }, "exceptions": { + "change_hvac_mode_not_supported": { + "message": "Changing HVAC mode is not supported." + }, "invalid_gateway_id": { "message": "Gateway {gw_id} not found or not loaded!" } From 578b43cf61aa448b1b9f82a9e50c0efe64f71c8c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 29 Jun 2025 21:41:50 +0300 Subject: [PATCH 074/127] Bump aioshelly to 13.7.1 (#146221) * Bump aioshelly to 13.8.0 * Change version to 13.7.1 --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index c6a255b1bbb..1db8dbf55c6 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.7.0"], + "requirements": ["aioshelly==13.7.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 48b8605b346..057dc628f74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ba5c97e364..a9be0b2281d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 From adbace95c31fb56612ca86a9c487590f3d5efbe1 Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:08:50 -0700 Subject: [PATCH 075/127] Fixed pushbullet handling of fields longer than 255 characters (#146993) --- homeassistant/components/pushbullet/sensor.py | 9 +- tests/components/pushbullet/test_sensor.py | 168 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/components/pushbullet/test_sensor.py diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 2dbaa8fc713..ea9a8f198ef 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -116,7 +116,12 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._attr_native_value = self.pb_provider.data[self.entity_description.key] + value = self.pb_provider.data[self.entity_description.key] + # Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes + if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE: + self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..." + else: + self._attr_native_value = value self._attr_extra_state_attributes = self.pb_provider.data except (KeyError, TypeError): pass diff --git a/tests/components/pushbullet/test_sensor.py b/tests/components/pushbullet/test_sensor.py new file mode 100644 index 00000000000..b6ae8c3a211 --- /dev/null +++ b/tests/components/pushbullet/test_sensor.py @@ -0,0 +1,168 @@ +"""Test pushbullet sensor platform.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.components.pushbullet.sensor import ( + SENSOR_TYPES, + PushBulletNotificationSensor, +) +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +def _create_mock_provider() -> Mock: + """Create a mock pushbullet provider for testing.""" + mock_provider = Mock() + mock_provider.pushbullet.user_info = {"iden": "test_user_123"} + return mock_provider + + +def _get_sensor_description(key: str): + """Get sensor description by key.""" + for desc in SENSOR_TYPES: + if desc.key == key: + return desc + raise ValueError(f"Sensor description not found for key: {key}") + + +def _create_test_sensor( + provider: Mock, sensor_key: str +) -> PushBulletNotificationSensor: + """Create a test sensor instance with mocked dependencies.""" + description = _get_sensor_description(sensor_key) + sensor = PushBulletNotificationSensor( + name="Test Pushbullet", pb_provider=provider, description=description + ) + # Mock async_write_ha_state to avoid requiring full HA setup + sensor.async_write_ha_state = Mock() + return sensor + + +@pytest.fixture +async def mock_pushbullet_entry(hass: HomeAssistant, requests_mock_fixture): + """Set up pushbullet integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def test_sensor_truncation_logic() -> None: + """Test sensor truncation logic for body sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test long body truncation + long_body = "a" * (MAX_LENGTH_STATE_STATE + 50) + provider.data = { + "body": long_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("a") + assert sensor._attr_extra_state_attributes["body"] == long_body + + # Test normal length body + normal_body = "This is a normal body" + provider.data = { + "body": normal_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation + assert sensor._attr_native_value == normal_body + assert len(sensor._attr_native_value) < MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == normal_body + + # Test exactly max length + exact_body = "a" * MAX_LENGTH_STATE_STATE + provider.data = { + "body": exact_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation at the limit + assert sensor._attr_native_value == exact_body + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == exact_body + + +def test_sensor_truncation_title_sensor() -> None: + """Test sensor truncation logic on title sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "title") + + # Test long title truncation + long_title = "Title " + "x" * (MAX_LENGTH_STATE_STATE) + provider.data = { + "body": "Test body", + "title": long_title, + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("Title") + assert sensor._attr_extra_state_attributes["title"] == long_title + + +def test_sensor_truncation_non_string_handling() -> None: + """Test that non-string values are handled correctly.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test with None value + provider.data = { + "body": None, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value is None + + # Test with integer value (would be converted to string by Home Assistant) + provider.data = { + "body": 12345, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value == 12345 # Not truncated since it's not a string + + # Test with missing key + provider.data = { + "title": "Test Title", + "type": "note", + } + + # This should not raise an exception + sensor.async_update_callback() From 1543726095a19d70275406e6ab3ac14e2a743a40 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 30 Jun 2025 20:15:48 +0200 Subject: [PATCH 076/127] Wallbox Integration, Reduce API impact by limiting the amount of API calls made (#147618) --- homeassistant/components/wallbox/const.py | 4 + .../components/wallbox/coordinator.py | 67 ++++++++++----- homeassistant/components/wallbox/lock.py | 13 +-- homeassistant/components/wallbox/number.py | 13 +-- tests/components/wallbox/test_lock.py | 82 ++++++++++++------- tests/components/wallbox/test_number.py | 48 +++++++---- tests/components/wallbox/test_select.py | 19 +++++ tests/components/wallbox/test_switch.py | 12 ++- 8 files changed, 170 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 34d17e52275..1059a41db53 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,6 +22,8 @@ CHARGER_CURRENT_MODE_KEY = "current_mode" CHARGER_CURRENT_VERSION_KEY = "currentVersion" CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" +CHARGER_DATA_POST_L1_KEY = "data" +CHARGER_DATA_POST_L2_KEY = "chargerData" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" CHARGER_FEATURES_KEY = "features" @@ -32,7 +34,9 @@ CHARGER_POWER_BOOST_KEY = "POWER_BOOST" CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" +CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 598bfa7429a..69bf3a3af1c 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,11 +14,13 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, CHARGER_ECO_SMART_KEY, CHARGER_ECO_SMART_MODE_KEY, CHARGER_ECO_SMART_STATUS_KEY, @@ -26,6 +28,7 @@ from .const import ( CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PLAN_KEY, CHARGER_POWER_BOOST_KEY, @@ -192,10 +195,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="too_many_requests" ) from wallbox_connection_error - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -204,10 +207,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return await self.hass.async_add_executor_job(self._get_data) @_require_authentication - def _set_charging_current(self, charging_current: float) -> None: + def _set_charging_current( + self, charging_current: float + ) -> dict[str, dict[str, dict[str, Any]]]: """Set maximum charging current for Wallbox.""" try: - self._wallbox.setMaxChargingCurrent(self._station, charging_current) + result = self._wallbox.setMaxChargingCurrent( + self._station, charging_current + ) + data = self.data + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_MAX_CHARGING_CURRENT_POST_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -221,16 +233,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) - await self.async_request_refresh() + self.async_set_updated_data(data) @_require_authentication - def _set_icp_current(self, icp_current: float) -> None: + def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: - self._wallbox.setIcpMaxCurrent(self._station, icp_current) + result = self._wallbox.setIcpMaxCurrent(self._station, icp_current) + data = self.data + data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -244,14 +259,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" - await self.hass.async_add_executor_job(self._set_icp_current, icp_current) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_icp_current, icp_current + ) + self.async_set_updated_data(data) @_require_authentication - def _set_energy_cost(self, energy_cost: float) -> None: + def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: - self._wallbox.setEnergyCost(self._station, energy_cost) + result = self._wallbox.setEnergyCost(self._station, energy_cost) + data = self.data + data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -263,17 +283,24 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_energy_cost, energy_cost + ) + self.async_set_updated_data(data) @_require_authentication - def _set_lock_unlock(self, lock: bool) -> None: + def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: if lock: - self._wallbox.lockCharger(self._station) + result = self._wallbox.lockCharger(self._station) else: - self._wallbox.unlockCharger(self._station) + result = self._wallbox.unlockCharger(self._station) + data = self.data + data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_LOCKED_UNLOCKED_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -287,8 +314,8 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + self.async_set_updated_data(data) @_require_authentication def _pause_charger(self, pause: bool) -> None: diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7acc56f67f2..7b5c99340f8 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -16,7 +15,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -34,16 +33,6 @@ async def async_setup_entry( ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to lock, if so, add lock component - try: - await coordinator.async_set_lock_unlock( - coordinator.data[CHARGER_LOCKED_UNLOCKED_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 80773478582..e1b044bbdb2 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,6 @@ from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -26,7 +25,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -86,16 +85,6 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user has sufficient rights to change values, if so, add number component: - try: - await coordinator.async_set_charging_current( - coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 5842d708f11..7d95aed7a5d 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -12,10 +12,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, http_429_error, setup_integration, - setup_integration_platform_not_ready, - setup_integration_read_only, ) from .const import MOCK_LOCK_ENTITY_ID @@ -38,11 +38,15 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - ), patch( "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}} + ), ), patch( "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}} + ), ), ): await hass.services.async_call( @@ -129,6 +133,52 @@ async def test_wallbox_lock_class_connection_error( new=Mock(side_effect=http_429_error), ), pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -138,27 +188,3 @@ async def test_wallbox_lock_class_connection_error( }, blocking=True, ) - - -async def test_wallbox_lock_class_authentication_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_read_only(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None - - -async def test_wallbox_lock_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c603ae24106..8067917977d 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -23,7 +23,6 @@ from . import ( http_429_error, setup_integration, setup_integration_bidir, - setup_integration_platform_not_ready, ) from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, @@ -56,7 +55,9 @@ async def test_wallbox_number_class( ), patch( "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + new=Mock( + return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}} + ), ), ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) @@ -259,18 +260,6 @@ async def test_wallbox_number_class_energy_price_auth_error( ) -async def test_wallbox_number_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - - assert state is None - - async def test_wallbox_number_class_icp_energy( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -285,7 +274,7 @@ async def test_wallbox_number_class_icp_energy( ), patch( "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + new=Mock(return_value={"icp_max_current": 20}), ), ): await hass.services.async_call( @@ -328,6 +317,35 @@ async def test_wallbox_number_class_icp_energy_auth_error( ) +async def test_wallbox_number_class_energy_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + async def test_wallbox_number_class_icp_energy_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f59a8367b41..e46347bfa5a 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -50,6 +50,13 @@ async def test_wallbox_select_solar_charging_class( ) -> None: """Test wallbox select class.""" + if mode == EcoSmartMode.OFF: + response = test_response + elif mode == EcoSmartMode.ECO_MODE: + response = test_response_eco_mode + elif mode == EcoSmartMode.FULL_SOLAR: + response = test_response_full_solar + with ( patch( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", @@ -59,6 +66,10 @@ async def test_wallbox_select_solar_charging_class( "homeassistant.components.wallbox.Wallbox.disableEcoSmart", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=response), + ), ): await setup_integration_select(hass, entry, response) @@ -110,6 +121,10 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -144,6 +159,10 @@ async def test_wallbox_select_too_many_requests_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=http_429_error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index eb983ca44ce..98b87828f74 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,7 +10,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, http_404_error, http_429_error, setup_integration +from . import ( + authorisation_response, + http_404_error, + http_429_error, + setup_integration, + test_response, +) from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -40,6 +46,10 @@ async def test_wallbox_switch_class( "homeassistant.components.wallbox.Wallbox.resumeChargingSession", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), ): await hass.services.async_call( "switch", From ae48e3716e80e7a4adc02077ef56c18128cbb90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sun, 29 Jun 2025 07:33:44 +0200 Subject: [PATCH 077/127] Update pywmspro to 0.3.0 to wait for short-lived actions (#147679) Replace action delays with detailed action responses. --- homeassistant/components/wmspro/cover.py | 9 ++------ homeassistant/components/wmspro/light.py | 21 +++++++++++-------- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 77dd928bc95..b6f100280ad 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -2,13 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any from wmspro.const import ( WMS_WebControl_pro_API_actionDescription, WMS_WebControl_pro_API_actionType, + WMS_WebControl_pro_API_responseType, ) from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 @@ -61,7 +60,6 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Move the cover to a specific position.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) - await asyncio.sleep(ACTION_DELAY) @property def is_closed(self) -> bool | None: @@ -72,13 +70,11 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Open the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=0) - await asyncio.sleep(ACTION_DELAY) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100) - await asyncio.sleep(ACTION_DELAY) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" @@ -86,8 +82,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionDescription.ManualCommand, WMS_WebControl_pro_API_actionType.Stop, ) - await action() - await asyncio.sleep(ACTION_DELAY) + await action(responseType=WMS_WebControl_pro_API_responseType.Detailed) class WebControlProAwning(WebControlProCover): diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index d828c8a26e8..52d092ed9f0 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -2,11 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any -from wmspro.const import WMS_WebControl_pro_API_actionDescription +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_responseType, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant @@ -17,7 +19,6 @@ from . import WebControlProConfigEntry from .const import BRIGHTNESS_SCALE from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=15) PARALLEL_UPDATES = 1 @@ -56,14 +57,16 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=True) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=True, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=False) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=False, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) class WebControlProDimmer(WebControlProLight): @@ -90,6 +93,6 @@ class WebControlProDimmer(WebControlProLight): WMS_WebControl_pro_API_actionDescription.LightDimming ) await action( - percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]), + responseType=WMS_WebControl_pro_API_responseType.Detailed, ) - await asyncio.sleep(ACTION_DELAY) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index d4eda3a90a6..9185768165a 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.2.2"] + "requirements": ["pywmspro==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 057dc628f74..58df2b903e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2596,7 +2596,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9be0b2281d..afa539b570c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2154,7 +2154,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 From 6dc464ad73e3a922ea3bb5eeebb35c5041a01d53 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 02:16:45 +0800 Subject: [PATCH 078/127] Fix Telegram bot proxy URL not initialized when creating a new bot (#147707) --- homeassistant/components/telegram_bot/config_flow.py | 7 ++++++- tests/components/telegram_bot/test_config_flow.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 7b441889b8c..b6480b84f64 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -328,6 +328,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) bot_name = await self._validate_bot( user_input, errors, description_placeholders ) @@ -350,7 +353,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), }, options={ # this value may come from yaml import diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2af90b9f7ef..659effdda7b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -117,6 +117,7 @@ async def test_reconfigure_flow_broadcast( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_webhooks_config_entry.data[CONF_PLATFORM] == PLATFORM_BROADCAST + assert mock_webhooks_config_entry.data[CONF_PROXY_URL] == "https://test" async def test_reconfigure_flow_webhooks( From c771f5fe1ecb466bf092c68b73369a66e6677e88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 17:24:09 -0500 Subject: [PATCH 079/127] Preserve httpx boolean behavior in REST integration after aiohttp conversion (#147738) --- homeassistant/components/rest/data.py | 6 ++++ tests/components/rest/test_data.py | 49 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index f20b811a887..731d1ffe9c3 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -110,6 +110,12 @@ class RestData: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) + # Convert boolean values to lowercase strings for compatibility with aiohttp/yarl + if rendered_params: + for key, value in rendered_params.items(): + if isinstance(value, bool): + rendered_params[key] = str(value).lower() + _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs request_kwargs: dict[str, Any] = { diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 3add886a451..4d6bc000fac 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -442,3 +442,52 @@ async def test_rest_data_timeout_error( "Timeout while fetching data: http://example.com/api" in caplog.text or "Platform rest not ready yet" in caplog.text ) + + +async def test_rest_data_boolean_params_converted_to_strings( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that boolean parameters are converted to lowercase strings.""" + # Mock the request and capture the actual URL + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "params": { + "boolTrue": True, + "boolFalse": False, + "stringParam": "test", + "intParam": 123, + }, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.status }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that the request was made with boolean values converted to strings + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + + # Check that the URL query parameters have boolean values converted to strings + assert url.query["boolTrue"] == "true" + assert url.query["boolFalse"] == "false" + assert url.query["stringParam"] == "test" + assert url.query["intParam"] == "123" From 3c7c9176d249662588c28423082c395b56a17077 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:11:46 +0200 Subject: [PATCH 080/127] Fix sensor displaying unknown when getting readings from heat meters in ista EcoTrend (#147741) --- .../components/ista_ecotrend/util.py | 28 +++++++++---------- tests/components/ista_ecotrend/conftest.py | 14 ++++++++++ .../snapshots/test_diagnostics.ambr | 18 ++++++++++++ .../ista_ecotrend/snapshots/test_util.ambr | 13 +++++++++ tests/components/ista_ecotrend/test_util.py | 5 ++++ 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index db64dbf85db..5d790a3cf1c 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -108,22 +108,22 @@ def get_statistics( if monthly_consumptions := get_consumptions(data, value_type): return [ { - "value": as_number( - get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get( - "additionalValue" - if value_type == IstaValueType.ENERGY - else "value" - ) - ), + "value": as_number(value), "date": consumptions["date"], } for consumptions in monthly_consumptions - if get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + if ( + value := ( + consumption := get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ) + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + and consumption.get("additionalValue") is not None + else "value" + ) + ) ] return None diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 58977c99b59..7be1302aa4f 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -96,12 +96,16 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", @@ -115,16 +119,21 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "104", + "unit": "Einheiten", "additionalValue": "113,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,1", + "unit": "m³", "additionalValue": "61,1", + "additionalUnit": "kWh", }, { "type": "water", "value": "6,8", + "unit": "m³", }, ], }, @@ -200,16 +209,21 @@ def extend_statistics(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "9000", + "unit": "Einheiten", "additionalValue": "9000,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "9999,0", + "unit": "m³", "additionalValue": "90000,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "9000,0", + "unit": "m³", }, ], }, diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr index c9f5e72ae1f..7395e2f6dc6 100644 --- a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -12,13 +12,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -34,17 +38,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), @@ -103,13 +112,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -125,17 +138,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9536c5336db..6da1a63d7ce 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -85,6 +85,14 @@ # --- # name: test_get_statistics.7 list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), ]) # --- # name: test_get_statistics.8 @@ -101,21 +109,26 @@ # --- # name: test_get_values_by_type dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }) # --- # name: test_get_values_by_type.1 dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }) # --- # name: test_get_values_by_type.2 dict({ 'type': 'water', + 'unit': 'm³', 'value': '5,0', }) # --- diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index 616abdea8d6..19ec6c886cb 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -41,16 +41,21 @@ def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "5,0", + "unit": "m³", }, ], } From b50e599517e2ebbb2ded80db313b8c71929af43d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 28 Jun 2025 22:56:37 -0700 Subject: [PATCH 081/127] Move the async_reload on updates in async_setup_entry in Google Generative AI (#147748) Move the async_reload on updates in async_setup_entry --- .../google_generative_ai_conversation/__init__.py | 9 +++++++++ .../google_generative_ai_conversation/conversation.py | 10 ---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 5e4ad114adf..e3278eb3cb5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -207,6 +207,8 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -220,6 +222,13 @@ async def async_unload_entry( return True +async def async_update_options( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d8eae3f6d0d..0b24e8bbc38 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -61,9 +61,6 @@ class GoogleGenerativeAIConversationEntity( self.hass, "conversation", self.entry.entry_id, self.entity_id ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -103,10 +100,3 @@ class GoogleGenerativeAIConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 62a1c8af1170b1e1d1425baca6b28a04dc558119 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sat, 28 Jun 2025 23:22:04 -0600 Subject: [PATCH 082/127] Fix Vesync set_percentage error (#147751) --- homeassistant/components/vesync/fan.py | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index d9336552744..5b0197606ae 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -165,28 +165,36 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return attr def set_percentage(self, percentage: int) -> None: - """Set the speed of the device.""" + """Set the speed of the device. + + If percentage is 0, turn off the fan. Otherwise, ensure the fan is on, + set manual mode if needed, and set the speed. + """ + device_type = SKU_TO_BASE_DEVICE[self.device.device_type] + speed_range = SPEED_RANGE[device_type] + if percentage == 0: - success = self.device.turn_off() - if not success: + # Turning off is a special case: do not set speed or mode + if not self.device.turn_off(): raise HomeAssistantError("An error occurred while turning off.") - elif not self.device.is_on: - success = self.device.turn_on() - if not success: + self.schedule_update_ha_state() + return + + # If the fan is off, turn it on first + if not self.device.is_on: + if not self.device.turn_on(): raise HomeAssistantError("An error occurred while turning on.") - success = self.device.manual_mode() - if not success: - raise HomeAssistantError("An error occurred while manual mode.") - success = self.device.change_fan_speed( - math.ceil( - percentage_to_ranged_value( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage - ) - ) - ) - if not success: + # Switch to manual mode if not already set + if self.device.mode != VS_FAN_MODE_MANUAL: + if not self.device.manual_mode(): + raise HomeAssistantError("An error occurred while setting manual mode.") + + # Calculate the speed level and set it + speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage)) + if not self.device.change_fan_speed(speed_level): raise HomeAssistantError("An error occurred while changing fan speed.") + self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: From 328e838351d05bfe47a84c8b0432d82013da6507 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 20:12:03 +0200 Subject: [PATCH 083/127] Use media selector for Assist Satellite actions (#147767) Co-authored-by: Michael Hansen --- .../components/assist_satellite/__init__.py | 30 +++++-- .../components/assist_satellite/services.yaml | 24 ++++-- .../assist_satellite/test_entity.py | 86 +++++++++++++++++++ 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6bfbdfb33a8..26ce9e75428 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Optional("message"): str, - vol.Optional("media_id"): str, + vol.Optional("media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, } ), cv.has_at_least_one_key("message", "media_id"), @@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( "start_conversation", vol.All( cv.make_entity_service_schema( { vol.Optional("start_message"): str, - vol.Optional("start_media_id"): str, + vol.Optional("start_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } ), @@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, - vol.Optional("question_media_id"): str, + vol.Optional("question_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { vol.Required("id"): str, @@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]: raise vol.Invalid("sentences cannot be empty") return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = vol.Any( + cv.string, # Plain string format + vol.All( + vol.Schema( + { + vol.Required("media_content_id"): cv.string, + vol.Required("media_content_type"): cv.string, + vol.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 6beb0991861..8433eb6102d 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,7 +14,9 @@ announce: media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -23,7 +25,9 @@ announce: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* start_conversation: target: entity: @@ -40,7 +44,9 @@ start_conversation: start_media_id: required: false selector: - text: + media: + accept: + - audio/* extra_system_prompt: required: false selector: @@ -53,7 +59,9 @@ start_conversation: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* ask_question: fields: entity_id: @@ -72,7 +80,9 @@ ask_question: question_media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -81,7 +91,9 @@ ask_question: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* answers: required: false selector: diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3473b23bedd..9f14be6c50f 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -235,6 +235,43 @@ async def test_new_pipeline_cancels_pipeline( preannounce_media_id="http://example.com/preannounce.mp3", ), ), + ( + { + "message": "Hello", + "media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + tts_token=None, + media_id_source="media_id", + ), + ), + ( + { + "media_id": { + "media_content_id": "http://example.com/bla.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -610,6 +647,51 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_message": "Hello", + "start_media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + { + "start_media_id": { + "media_content_id": "http://example.com/given.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -731,6 +813,10 @@ async def test_start_conversation_default_preannounce( ), ( { + "question_media_id": { + "media_content_id": "media-source://tts/cloud?message=What+kind+of+music+would+you+like+to+listen+to%3F&language=en-US&gender=female", + "media_content_type": "provider", + }, "answers": [ { "id": "genre", From 1f6d28dcbf9b29b5da43594feddd4247fdcc37f0 Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Sun, 29 Jun 2025 15:22:59 -0400 Subject: [PATCH 084/127] Honeywell: Don't use shared session (#147772) --- .../components/honeywell/__init__.py | 22 ++++++------------- .../components/honeywell/config_flow.py | 8 +++++-- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index eb89ba2a681..6c4c7091840 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import ( - async_create_clientsession, - async_get_clientsession, -) +from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import ( - _LOGGER, - CONF_COOL_AWAY_TEMPERATURE, - CONF_HEAT_AWAY_TEMPERATURE, - DOMAIN, -) +from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE UPDATE_LOOP_SLEEP_TIME = 5 PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] @@ -56,11 +48,11 @@ async def async_setup_entry( username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - if len(hass.config_entries.async_entries(DOMAIN)) > 1: - session = async_create_clientsession(hass) - else: - session = async_get_clientsession(hass) - + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. + session = async_create_clientsession(hass) client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c7cda500692..15199cdda24 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( CONF_COOL_AWAY_TEMPERATURE, @@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): async def is_valid(self, **kwargs) -> bool: """Check if login credentials are valid.""" + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. client = aiosomecomfort.AIOSomeComfort( kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + session=async_create_clientsession(self.hass), ) await client.login() From 66cf9c4ed5737a0f2d73ab97aac215f384ad1a08 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 30 Jun 2025 09:39:02 +0200 Subject: [PATCH 085/127] Bump reolink_aio to 0.14.2 (#147797) --- 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 04996689bf7..c422af292b9 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -19,5 +19,5 @@ "iot_class": "local_push", "loggers": ["reolink_aio"], "quality_scale": "platinum", - "requirements": ["reolink-aio==0.14.1"] + "requirements": ["reolink-aio==0.14.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58df2b903e8..dfa6c4ada1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2656,7 +2656,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afa539b570c..6360dc34cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2202,7 +2202,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.rflink rflink==0.0.67 From e8204e5f8e5e1d119e918223489e41b54903e763 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:18:22 -0400 Subject: [PATCH 086/127] Await firmware installation task when flashing ZBT-1/Yellow firmware (#147824) --- .../firmware_config_flow.py | 66 ++++++++++++++----- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow_failures.py | 2 +- 5 files changed, 57 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a5e35749e1b..3263b091ad5 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -67,6 +68,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task | None = None + self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -152,8 +154,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - # We 100% need to install new firmware only if the wrong firmware is - # currently installed + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type @@ -167,7 +173,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): fw_manifest = next( fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + except (StopIteration, TimeoutError, ClientError, ManifestMissing): _LOGGER.warning( "Failed to fetch firmware update manifest", exc_info=True ) @@ -179,13 +185,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) return self.async_show_progress_done(next_step_id=next_step_id) - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) if not firmware_install_required: assert self._probed_firmware_info is not None @@ -205,7 +207,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): try: fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError) as err: + except (TimeoutError, ClientError, ValueError): _LOGGER.warning("Failed to fetch firmware update", exc_info=True) # If we cannot download new firmware, we shouldn't block setup @@ -216,13 +218,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): return self.async_show_progress_done(next_step_id=next_step_id) # Otherwise, fail - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( @@ -249,8 +247,40 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): progress_task=self.firmware_install_task, ) + try: + await self.firmware_install_task + except HomeAssistantError: + _LOGGER.exception("Failed to flash firmware") + return self.async_show_progress_done(next_step_id="firmware_install_failed") + return self.async_show_progress_done(next_step_id=next_step_id) + async def async_step_firmware_download_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware download failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + + async def async_step_firmware_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware install failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_install_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index d9c086cb040..da2374de57b 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -37,7 +37,8 @@ "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", - "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.", + "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index f87a45febe4..13775d1f1eb 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -93,7 +93,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -147,7 +148,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b43f890b4e3..d0c5e969d11 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -118,7 +118,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 442cf8aea50..0494de1432c 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -339,7 +339,7 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non ) assert pick_thread_progress_result["type"] is FlowResultType.ABORT - assert pick_thread_progress_result["reason"] == "unsupported_firmware" + assert pick_thread_progress_result["reason"] == "fw_install_failed" @pytest.mark.parametrize( From db04c77e62de7206d8f950c944fb0d69a32d6db0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Jun 2025 19:39:34 +0000 Subject: [PATCH 087/127] Bump version to 2025.7.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 e3b9424292e..55406d605c3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index eb02751785e..519d7789f03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b4" +version = "2025.7.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From e4bcde7d20d2510fb4ca2d0925e8fbd9180d68eb Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:53:55 +0200 Subject: [PATCH 088/127] Fix wrong state in Husqvarna Automower (#146075) --- .../components/husqvarna_automower/const.py | 12 +++++++++++ .../husqvarna_automower/lawn_mower.py | 20 ++++++++++++++----- .../components/husqvarna_automower/sensor.py | 18 ++--------------- .../husqvarna_automower/test_lawn_mower.py | 5 +++++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index 1ea0511d721..d91fea29698 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -1,7 +1,19 @@ """The constants for the Husqvarna Automower integration.""" +from aioautomower.model import MowerStates + DOMAIN = "husqvarna_automower" EXECUTION_TIME_DELAY = 5 NAME = "Husqvarna Automower" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" + +ERROR_STATES = [ + MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, + MowerStates.FATAL_ERROR, + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 5a728265651..daeb4a113b5 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry -from .const import DOMAIN +from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerAvailableEntity, handle_sending_exception @@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): def activity(self) -> LawnMowerActivity: """Return the state of the mower.""" mower_attributes = self.mower_attributes + if mower_attributes.mower.state in ERROR_STATES: + return LawnMowerActivity.ERROR if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if (mower_attributes.mower.state == "RESTRICTED") or ( - mower_attributes.mower.activity in DOCKED_ACTIVITIES + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING + if ( + mower_attributes.mower.state is MowerStates.RESTRICTED + or mower_attributes.mower.activity in DOCKED_ACTIVITIES ): return LawnMowerActivity.DOCKED if mower_attributes.mower.state in MowerStates.IN_OPERATION: - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING return LawnMowerActivity.MOWING return LawnMowerActivity.ERROR + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return ( + super().available and self.mower_attributes.mower.state != MowerStates.OFF + ) + @property def work_areas(self) -> dict[int, WorkArea] | None: """Return the work areas of the mower.""" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 5ad8ad91b48..0a059fdd706 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,13 +7,7 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import ( - MowerAttributes, - MowerModes, - MowerStates, - RestrictedReasons, - WorkArea, -) +from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry +from .const import ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -166,15 +161,6 @@ ERROR_KEYS = [ "zone_generator_problem", ] -ERROR_STATES = [ - MowerStates.ERROR_AT_POWER_UP, - MowerStates.ERROR, - MowerStates.FATAL_ERROR, - MowerStates.OFF, - MowerStates.STOPPED, - MowerStates.WAIT_POWER_UP, - MowerStates.WAIT_UPDATING, -] ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index c62cf6653c4..bf888779baa 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -42,6 +42,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed MowerStates.IN_OPERATION, LawnMowerActivity.DOCKED, ), + ( + MowerActivities.GOING_HOME, + MowerStates.RESTRICTED, + LawnMowerActivity.RETURNING, + ), ], ) async def test_lawn_mower_states( From 08985d783f9d7b5fa1c6c6ffaaebc6477bc94e15 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:43:34 +0200 Subject: [PATCH 089/127] Fix Meteo france Ciel clair condition mapping (#146965) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/meteo_france/weather.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e2df35f21f3..9b3472e3312 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -6,6 +6,8 @@ import time from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -49,9 +51,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def format_condition(condition: str): +def format_condition(condition: str, force_day: bool = False) -> str: """Return condition from dict CONDITION_MAP.""" - return CONDITION_MAP.get(condition, condition) + mapped_condition = CONDITION_MAP.get(condition, condition) + if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT: + # Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny + return ATTR_CONDITION_SUNNY + return mapped_condition async def async_setup_entry( @@ -212,7 +218,7 @@ class MeteoFranceWeather( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( - forecast["weather12H"]["desc"] + forecast["weather12H"]["desc"], force_day=True ), ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], From 414318f3fbd80f141cac298a23eb928c1693bf25 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 1 Jul 2025 10:15:45 +0200 Subject: [PATCH 090/127] Catch access denied errors in webdav and display proper message (#147093) --- .../components/webdav/config_flow.py | 8 +++- homeassistant/components/webdav/strings.json | 4 +- tests/components/webdav/test_config_flow.py | 7 ++- tests/components/webdav/test_init.py | 46 ++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index e3e46d2575a..95b20761d09 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import voluptuous as vol import yarl @@ -65,6 +69,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except AccessDeniedError: + errors["base"] = "access_denied" except MethodNotSupportedError: errors["base"] = "invalid_method" except Exception: diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index ac6418f1239..689b27bbf66 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "access_denied": "The access to the backup path has been denied. Please check the permissions of the backup path.", "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -35,9 +36,6 @@ "cannot_connect": { "message": "Cannot connect to WebDAV server" }, - "cannot_access_or_create_backup_path": { - "message": "Cannot access or create backup path. Please check the path and permissions." - }, "failed_to_migrate_folder": { "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index 9204e6eadab..3ee5c8ae9ad 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import pytest from homeassistant import config_entries @@ -86,6 +90,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (AccessDeniedError("https://webdav.demo"), "access_denied"), (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index 124a644fa93..89f0e703b22 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import WebDavError +from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError, WebDavError import pytest from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN @@ -110,3 +110,47 @@ async def test_migrate_error( 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' in caplog.text ) + + +@pytest.mark.parametrize( + ("error", "expected_message", "expected_state"), + [ + ( + UnauthorizedError("Unauthorized"), + "Invalid username or password", + ConfigEntryState.SETUP_ERROR, + ), + ( + AccessDeniedError("/access_denied"), + "Access denied to /access_denied", + ConfigEntryState.SETUP_ERROR, + ), + ], + ids=["UnauthorizedError", "AccessDeniedError"], +) +async def test_error_during_setup( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected_message: str, + expected_state: ConfigEntryState, +) -> None: + """Test handling of various errors during setup.""" + webdav_client.check.side_effect = error + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert expected_message in caplog.text + assert config_entry.state is expected_state From c61935fc4157c84bd90bcd5cc3b0d5f9e02ea873 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 21:42:32 +0800 Subject: [PATCH 091/127] Include chat ID in Telegram bot subentry title (#147643) --- homeassistant/components/telegram_bot/config_flow.py | 11 ++++------- tests/components/telegram_bot/test_config_flow.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index b6480b84f64..41f26ccd48d 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -237,12 +237,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): subentries: list[ConfigSubentryData] = [] allowed_chat_ids: list[int] = import_data[CONF_ALLOWED_CHAT_IDS] + assert self._bot is not None, "Bot should be initialized during import" for chat_id in allowed_chat_ids: chat_name: str = await _async_get_chat_name(self._bot, chat_id) subentry: ConfigSubentryData = ConfigSubentryData( data={CONF_CHAT_ID: chat_id}, subentry_type=CONF_ALLOWED_CHAT_IDS, - title=chat_name, + title=f"{chat_name} ({chat_id})", unique_id=str(chat_id), ) subentries.append(subentry) @@ -380,7 +381,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Shutdown the bot if it exists.""" if self._bot: await self._bot.shutdown() - self._bot = None async def _validate_bot( self, @@ -649,7 +649,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): chat_name = await _async_get_chat_name(bot, chat_id) if chat_name: return self.async_create_entry( - title=chat_name, + title=f"{chat_name} ({chat_id})", data={CONF_CHAT_ID: chat_id}, unique_id=str(chat_id), ) @@ -663,10 +663,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): ) -async def _async_get_chat_name(bot: Bot | None, chat_id: int) -> str: - if not bot: - return str(chat_id) - +async def _async_get_chat_name(bot: Bot, chat_id: int) -> str: try: chat_info: ChatFullInfo = await bot.get_chat(chat_id) return chat_info.effective_name or str(chat_id) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 659effdda7b..2586761b584 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -383,7 +383,7 @@ async def test_subentry_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert subentry.subentry_type == SUBENTRY_TYPE_ALLOWED_CHAT_IDS - assert subentry.title == "mock title" + assert subentry.title == "mock title (987654321)" assert subentry.unique_id == "987654321" assert subentry.data == {CONF_CHAT_ID: 987654321} From 47b232db49ca6b5bff895d2ccd0819012a9cf8e1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:12:00 +0200 Subject: [PATCH 092/127] Add more mac address prefixes for discovery to PlayStation Network (#147739) --- .../playstation_network/manifest.json | 15 ++++++++++++++ homeassistant/generated/dhcp.py | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bb7fc7c27ff..590bd73fbf7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -60,6 +60,21 @@ }, { "macaddress": "D44B5E*" + }, + { + "macaddress": "F8D0AC*" + }, + { + "macaddress": "E86E3A*" + }, + { + "macaddress": "FC0FE6*" + }, + { + "macaddress": "9C37CB*" + }, + { + "macaddress": "84E657*" } ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 47072d4c05d..3c1d929b1d8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -539,6 +539,26 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "playstation_network", "macaddress": "D44B5E*", }, + { + "domain": "playstation_network", + "macaddress": "F8D0AC*", + }, + { + "domain": "playstation_network", + "macaddress": "E86E3A*", + }, + { + "domain": "playstation_network", + "macaddress": "FC0FE6*", + }, + { + "domain": "playstation_network", + "macaddress": "9C37CB*", + }, + { + "domain": "playstation_network", + "macaddress": "84E657*", + }, { "domain": "powerwall", "hostname": "1118431-*", From 748cc6386de450ae48dad3319c8ed3bd2c6bcabc Mon Sep 17 00:00:00 2001 From: Bob Laz Date: Tue, 1 Jul 2025 05:28:13 -0500 Subject: [PATCH 093/127] fix state_class for water used today sensor (#147787) --- homeassistant/components/drop_connect/sensor.py | 2 +- tests/components/drop_connect/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index c69e2e12ea0..cc3356cb8e9 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [ native_unit_of_measurement=UnitOfVolume.GALLONS, suggested_display_precision=1, value_fn=lambda device: device.drop_api.water_used_today(), - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), DROPSensorEntityDescription( key=AVERAGE_WATER_USED, diff --git a/tests/components/drop_connect/snapshots/test_sensor.ambr b/tests/components/drop_connect/snapshots/test_sensor.ambr index a5c91dbe3e4..8389f92d8f9 100644 --- a/tests/components/drop_connect/snapshots/test_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_sensor.ambr @@ -356,7 +356,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -372,7 +372,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From f85fc7173f99456df5db018a0dbf085f9862ed51 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 1 Jul 2025 15:39:29 +0200 Subject: [PATCH 094/127] Bump Nettigo Air Monitor backend library to version 5.0.0 (#147812) --- homeassistant/components/nam/__init__.py | 9 - homeassistant/components/nam/config_flow.py | 65 +++---- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/__init__.py | 5 +- tests/components/nam/test_config_flow.py | 199 +++++++------------- tests/components/nam/test_init.py | 23 +-- 8 files changed, 94 insertions(+), 213 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d297443c059..03ad5118352 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -44,15 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: translation_key="device_communication_error", translation_placeholders={"device": entry.title}, ) from err - - try: - await nam.async_check_credentials() - except (ApiError, ClientError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="device_communication_error", - translation_placeholders={"device": entry.title}, - ) from err except AuthFailedError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index fa94971e2ef..b90426b66e5 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass import logging from typing import Any @@ -26,15 +25,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN - -@dataclass -class NamConfig: - """NAM device configuration class.""" - - mac_address: str - auth_enabled: bool - - _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -42,29 +32,14 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: - """Get device MAC address and auth_enabled property.""" - websession = async_get_clientsession(hass) - - options = ConnectionOptions(host) - nam = await NettigoAirMonitor.create(websession, options) - - mac = await nam.async_get_mac_address() - - return NamConfig(mac, nam.auth_enabled) - - -async def async_check_credentials( +async def async_get_nam( hass: HomeAssistant, host: str, data: dict[str, Any] -) -> None: - """Check if credentials are valid.""" +) -> NettigoAirMonitor: + """Get NAM client.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - nam = await NettigoAirMonitor.create(websession, options) - - await nam.async_check_credentials() + return await NettigoAirMonitor.create(websession, options) class NAMFlowHandler(ConfigFlow, domain=DOMAIN): @@ -72,8 +47,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _config: NamConfig host: str + auth_enabled: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -85,21 +60,20 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + return await self.async_step_credentials() except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - if config.auth_enabled is True: - return await self.async_step_credentials() - return self.async_create_entry( title=self.host, data=user_input, @@ -119,7 +93,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + nam = await async_get_nam(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, TimeoutError): @@ -128,6 +102,9 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(format_mac(nam.mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + return self.async_create_entry( title=self.host, data={**user_input, CONF_HOST: self.host}, @@ -148,14 +125,16 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - self._config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + self.auth_enabled = True + return await self.async_step_confirm_discovery() - await self.async_set_unique_id(format_mac(self._config.mac_address)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + await self.async_set_unique_id(format_mac(nam.mac)) return await self.async_step_confirm_discovery() @@ -171,7 +150,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) - if self._config.auth_enabled is True: + if self.auth_enabled is True: return await self.async_step_credentials() self._set_confirm_only() @@ -198,7 +177,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + await async_get_nam(self.hass, self.host, user_input) except ( ApiError, AuthFailedError, @@ -228,11 +207,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - config = await async_get_config(self.hass, user_input[CONF_HOST]) + nam = await async_get_nam(self.hass, user_input[CONF_HOST], {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_mismatch(reason="another_device") return self.async_update_reload_and_abort( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 1c3b9db7a86..4799f657dda 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.1.0"], + "requirements": ["nettigo-air-monitor==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index dfa6c4ada1e..048c0f161f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1497,7 +1497,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6360dc34cd2..ab83d9d3586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1283,7 +1283,7 @@ nessclient==1.2.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.nexia nexia==2.10.0 diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index c531d193359..e1063c108e4 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -33,7 +33,10 @@ async def init_integration( update_response = Mock(json=AsyncMock(return_value=nam_data)) with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( "homeassistant.components.nam.NettigoAirMonitor._async_http_request", return_value=update_response, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 80c6e86f420..e3c2397de77 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Nettigo Air Monitor config flow.""" +from collections.abc import Generator from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest @@ -26,11 +27,21 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -DEVICE_CONFIG = {"www_basicauth_enabled": False} -DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} -async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form_create_entry_without_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -39,18 +50,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -64,7 +66,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: +async def test_form_create_entry_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step with auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -73,18 +77,9 @@ async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=[AuthFailedError("Authorization has failed"), "aa:bb:cc:dd:ee:ff"], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -121,23 +116,17 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: @@ -154,7 +143,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -162,8 +151,8 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" @pytest.mark.parametrize( @@ -178,15 +167,9 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: """Test we handle errors when auth is required.""" exc, base_error = error - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Authorization has failed"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -198,7 +181,7 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: assert result["step_id"] == "credentials" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=exc, ): result = await hass.config_entries.flow.async_configure( @@ -236,10 +219,6 @@ async def test_form_errors(hass: HomeAssistant, error) -> None: async def test_form_abort(hass: HomeAssistant) -> None: """Test we handle abort after error.""" with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMacError("Cannot get MAC address from device"), @@ -266,15 +245,9 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,17 +261,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -316,15 +283,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True - with patch( - "homeassistant.components.nam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() + 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"] == "10.10.2.3" @@ -332,17 +292,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: +async def test_zeroconf_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the zeroconf step with auth works.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Auth Error"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -360,18 +316,9 @@ async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -447,15 +394,9 @@ async def test_reconfigure_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -491,7 +432,7 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -503,15 +444,9 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -546,15 +481,9 @@ async def test_reconfigure_not_the_same_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 13bde1432b3..ea61739c008 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -44,27 +44,6 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_config_not_ready_while_checking_credentials(hass: HomeAssistant) -> None: - """Test for setup failure if the connection fails while checking credentials.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="10.10.2.3", - unique_id="aa:bb:cc:dd:ee:ff", - data={"host": "10.10.2.3"}, - ) - entry.add_to_hass(hass) - - with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=ApiError("API Error"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_config_auth_failed(hass: HomeAssistant) -> None: """Test for setup failure if the auth fails.""" entry = MockConfigEntry( @@ -76,7 +55,7 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=AuthFailedError("Authorization has failed"), ): await hass.config_entries.async_setup(entry.entry_id) From ff25948e37ca3575d8e389ff9f4200ff18892e67 Mon Sep 17 00:00:00 2001 From: micha91 Date: Tue, 1 Jul 2025 15:50:04 +0200 Subject: [PATCH 095/127] fix: Create new aiohttp session with DummyCookieJar (#147827) --- homeassistant/components/yamaha_musiccast/__init__.py | 5 +++-- homeassistant/components/yamaha_musiccast/config_flow.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3e890c8b943..edc124890c5 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging +from aiohttp import DummyCookieJar from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -52,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = MusicCastDevice( entry.data[CONF_HOST], - async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=DummyCookieJar()), entry.data[CONF_UPNP_DESC], ) coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index c43e547a71e..b48b5f6e67b 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -6,13 +6,13 @@ import logging from typing import Any from urllib.parse import urlparse -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, DummyCookieJar from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -50,7 +50,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): try: info = await MusicCastDevice.get_device_info( - host, async_get_clientsession(self.hass) + host, async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()) ) except (MusicCastConnectionException, ClientConnectorError): errors["base"] = "cannot_connect" @@ -89,7 +89,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, + async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()), ): return self.async_abort(reason="yxc_control_url_missing") From b25acfe823de2be86d72e8c6d705ef3abe04d002 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Jul 2025 08:46:58 +0200 Subject: [PATCH 096/127] Fix invalid configuration of MQTT device QoS option in subentry flow (#147837) --- homeassistant/components/mqtt/config_flow.py | 9 ++++----- tests/components/mqtt/test_config_flow.py | 6 +++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b022a46cbe7..ee451b5f81d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2771,11 +2771,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - new_device_data, errors = validate_user_input( - user_input, MQTT_DEVICE_PLATFORM_FIELDS - ) - if "mqtt_settings" in user_input: - new_device_data["mqtt_settings"] = user_input["mqtt_settings"] + new_device_data: dict[str, Any] = user_input.copy() + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if "advanced_settings" in new_device_data: + new_device_data |= new_device_data.pop("advanced_settings") if not errors: self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 12f77a95c48..9386f1da32c 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4077,6 +4077,7 @@ async def test_subentry_reconfigure_update_device_properties( "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, ) assert result["type"] is FlowResultType.MENU @@ -4090,12 +4091,15 @@ async def test_subentry_reconfigure_update_device_properties( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - # Check our device was updated + # Check our device and mqtt data was updated correctly device = deepcopy(dict(subentry.data))["device"] assert device["name"] == "Beer notifier" assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + assert device["sw_version"] == "1.1" + assert device["mqtt_settings"]["qos"] == 1 + assert "qos" not in device @pytest.mark.parametrize( From 5554e38171d317124bcd85b244ca27e8d086fc45 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:16:23 +1200 Subject: [PATCH 097/127] Implement suggested_display_precision for ESPHome (#147849) --- homeassistant/components/esphome/sensor.py | 5 +- tests/components/esphome/test_entity.py | 4 +- tests/components/esphome/test_sensor.py | 70 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 5baa092613b..de0f07b94c9 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): # if the string is empty if unit_of_measurement := static_info.unit_of_measurement: self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_suggested_display_precision = static_info.accuracy_decimals self._attr_device_class = try_parse_enum( SensorDeviceClass, static_info.device_class ) @@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property - def native_value(self) -> datetime | str | None: + def native_value(self) -> datetime | int | float | None: """Return the state of the entity.""" if not self._has_state or (state := self._state).missing_state: return None @@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self.device_class is SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state_float) - return f"{state_float:.{self._static_info.accuracy_decimals}f}" + return state_float class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index c97965a1ba3..ba6a82bbd23 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -375,7 +375,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(False) await hass.async_block_till_done() @@ -394,7 +394,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(True) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 55e228b72be..e520b6ca259 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -13,18 +13,28 @@ from aioesphomeapi import ( TextSensorInfo, TextSensorState, ) +import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, + async_rounded_state, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -441,3 +451,63 @@ async def test_generic_numeric_sensor_empty_string_uom( assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement", "state_value", "expected_precision"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 23.456, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 0.1, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, -25.789, 1), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1234.56, 0), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1.23456, 3), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 0.123, 3), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 1234.5, 0), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 12.3456, 2), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 230.45, 1), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 3.3, 1), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 15.678, 2), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 0.015, 3), + (SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.HPA, 1013.25, 1), + (SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, 1.01325, 3), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 45.67, 1), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 4567.0, 0), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 87.654, 1), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 45.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 95.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 100.0, 1), + ], +) +async def test_suggested_display_precision_by_device_class( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + device_class: SensorDeviceClass, + unit_of_measurement: str, + state_value: float, + expected_precision: int, +) -> None: + """Test suggested display precision for different device classes.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + accuracy_decimals=expected_precision, + device_class=device_class.value, + unit_of_measurement=unit_of_measurement, + ) + ] + states = [SensorState(key=1, state=state_value)] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert float( + async_rounded_state(hass, "sensor.test_my_sensor", state) + ) == pytest.approx(round(state_value, expected_precision)) From c42fc818bf5283f6c12950d3ff97af8e581edc9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:46:13 +0200 Subject: [PATCH 098/127] Correct Google generative AI config entry migration (#147856) --- .../__init__.py | 46 ++++ .../config_flow.py | 1 + .../test_init.py | 217 +++++++++++++++++- 3 files changed, 263 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e3278eb3cb5..346d5322b02 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -308,4 +308,50 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_TITLE, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Add TTS subentry which was missing in 2025.7.0b0 + if not any( + subentry.subentry_type == "tts" for subentry in entry.subentries.values() + ): + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) + + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ad90cbcf553..1b1444e81b1 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -92,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_api( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 08a94dd151c..9702aae4c9e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( DOMAIN, RECOMMENDED_TTS_OPTIONS, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -473,6 +473,7 @@ async def test_migration_from_v1_to_v2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -618,6 +619,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 @@ -716,6 +718,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -784,6 +787,218 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +@pytest.mark.parametrize( + ("device_changes", "extra_subentries", "expected_device_subentries"), + [ + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b0: + # Wrong device registry, no TTS subentry + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: + # Wrong device registry, TTS subentry created + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 + # or later: Correct device registry, TTS subentry created + ( + {}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {"mock_id_1"}}, + ), + ], +) +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_changes: dict[str, str], + extra_subentries: list[ConfigSubentryData], + expected_device_subentries: dict[str, set[str | None]], +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Google Generative AI", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Google Generative AI 2", + unique_id=None, + ), + *extra_subentries, + ], + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Google Generative AI", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device(device_1.id, **device_changes) + assert device_1.config_entries_subentries == expected_device_subentries + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Google Generative AI 2", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 725269ecda9bda1c068bf731cb383a3a23664045 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:47:06 +0200 Subject: [PATCH 099/127] Correct anthropic config entry migration (#147857) --- .../components/anthropic/__init__.py | 30 ++++ .../components/anthropic/config_flow.py | 1 + tests/components/anthropic/test_init.py | 161 ++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 68a46f19031..b25d30fe90e 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -138,4 +138,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 6a18cb693cd..099eae73d31 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 16240ef8120..be4f41ad4cd 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -12,6 +12,7 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -113,6 +114,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -224,6 +226,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -317,6 +320,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -339,3 +343,160 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Claude", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Claude 2", + unique_id=None, + ), + ], + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Claude", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Claude 2", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Claude" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.claude") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.claude_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 8b2f4f0f86f1c618d452103a44d9bbf3216db8a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:45:17 +0200 Subject: [PATCH 100/127] Correct ollama config entry migration (#147858) --- homeassistant/components/ollama/__init__.py | 30 ++++ .../components/ollama/config_flow.py | 1 + tests/components/ollama/test_init.py | 155 ++++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 8890c498e9f..eaddf936e81 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -148,4 +148,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 58b557549e1..03e2b038bab 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -73,6 +73,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 0747578c110..a6cfe4c2de0 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -81,6 +82,7 @@ async def test_migration_from_v1_to_v2( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == TEST_USER_DATA assert mock_config_entry.options == {} @@ -186,6 +188,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -273,6 +276,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -295,3 +299,154 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_DATA, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Ollama", + unique_id=None, + ), + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Ollama 2", + unique_id=None, + ), + ], + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Ollama", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Ollama 2", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + assert "Ollama" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.ollama") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.ollama_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From d5d1b620d08a6c7b805db7fde11903b125268b45 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:49:07 +0200 Subject: [PATCH 101/127] Correct openai conversation config entry migration (#147859) --- .../openai_conversation/__init__.py | 30 ++++ .../openai_conversation/config_flow.py | 1 + .../openai_conversation/test_init.py | 163 +++++++++++++++++- 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 7cac3bb7003..48ca21e05cd 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -361,4 +361,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index a9a444cf3dd..63ebc351ee3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -99,6 +99,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 274d09a9779..d7e8b29cab2 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -18,6 +18,7 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -578,7 +579,7 @@ async def test_migration_from_v1_to_v2( mock_config_entry.entry_id, config_entry=mock_config_entry, device_id=device.id, - suggested_object_id="google_generative_ai_conversation", + suggested_object_id="chatgpt", ) # Run migration @@ -590,6 +591,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -702,6 +704,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -796,6 +799,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -820,6 +824,163 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="ChatGPT 2", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="ChatGPT", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="ChatGPT 2", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.chatgpt") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.chatgpt_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) async def test_devices( hass: HomeAssistant, From e272ab18859031ace45db53a996826e3abbaeaa9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 14:33:33 +0200 Subject: [PATCH 102/127] Initialize EsphomeEntity._has_state (#147877) --- homeassistant/components/esphome/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 74f73508d83..b9f0125094a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT - _has_state: bool + _has_state: bool = False unique_id: str def __init__( From 3548ab70fd882d8cf686c7f91b8975d4cc5ca892 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:38:04 +0200 Subject: [PATCH 103/127] Update frontend to 20250701.0 (#147879) --- 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 cf83ce90237..d9b9527c358 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==20250627.0"] + "requirements": ["home-assistant-frontend==20250701.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 80fccb1bf78..bf8eb55c506 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 048c0f161f5..ac88cd8f05c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab83d9d3586..2de41577052 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From c707bf6264425fab94f057de80233ebaadb85bcb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Jul 2025 14:26:59 +0000 Subject: [PATCH 104/127] Bump version to 2025.7.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 55406d605c3..a1187d0994e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 519d7789f03..0842ffb0c1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b5" +version = "2025.7.0b6" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 60a930554a6716a7f6dc5e8f9463e2d66aa90bf3 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:18:13 +0100 Subject: [PATCH 105/127] Fix station name sensor for metoffice (#145500) --- homeassistant/components/metoffice/sensor.py | 15 ++++++++------- tests/components/metoffice/const.py | 12 ++++++++++++ tests/components/metoffice/test_sensor.py | 2 ++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index c6b9f96514b..fc3972eac2a 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -9,6 +9,7 @@ from datapoint.Forecast import Forecast from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, + EntityCategory, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -59,6 +60,7 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( native_attr_name="name", name="Station name", icon="mdi:label-outline", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( @@ -235,14 +237,13 @@ class MetOfficeCurrentSensor( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = get_attribute( - self.coordinator.data.now(), self.entity_description.native_attr_name - ) + native_attr = self.entity_description.native_attr_name - if ( - self.entity_description.native_attr_name == "significantWeatherCode" - and value is not None - ): + if native_attr == "name": + return str(self.coordinator.data.name) + + value = get_attribute(self.coordinator.data.now(), native_attr) + if native_attr == "significantWeatherCode" and value is not None: value = CONDITION_MAP.get(value) return value diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 59061f12ddc..436bc636899 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -40,6 +40,12 @@ KINGSLYNN_SENSOR_RESULTS = { "probability_of_precipitation": "67", "pressure": "998.20", "wind_speed": "22.21", + "wind_direction": "180", + "wind_gust": "40.26", + "feels_like_temperature": "3.4", + "visibility_distance": "7478.00", + "humidity": "97.5", + "station_name": "King's Lynn", } WAVERTREE_SENSOR_RESULTS = { @@ -49,6 +55,12 @@ WAVERTREE_SENSOR_RESULTS = { "probability_of_precipitation": "61", "pressure": "987.50", "wind_speed": "17.60", + "wind_direction": "176", + "wind_gust": "34.52", + "feels_like_temperature": "5.8", + "visibility_distance": "5106.00", + "humidity": "95.13", + "station_name": "Wavertree", } DEVICE_KEY_KINGSLYNN = {(DOMAIN, TEST_COORDINATES_KINGSLYNN)} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index bd139873073..5ce069a3d09 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -28,6 +28,7 @@ from tests.common import MockConfigEntry, async_load_fixture, get_sensor_display @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_one_sensor_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -78,6 +79,7 @@ async def test_one_sensor_site_running( @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_two_sensor_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 01e7efc7b4a52a3ce1e96fc43cbd5d08322fb703 Mon Sep 17 00:00:00 2001 From: Jamin Date: Tue, 1 Jul 2025 09:09:51 -0500 Subject: [PATCH 106/127] Bump VoIP utils to 0.3.3 (#147880) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 59e54bfefea..0b533795a2c 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.2"] + "requirements": ["voip-utils==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac88cd8f05c..6de083de86a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3047,7 +3047,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2de41577052..c896aad0e08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2515,7 +2515,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volvooncall volvooncall==0.10.3 From 3ed440a3af12d3ac7e9288a48c4f63a7abd6c1f1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 1 Jul 2025 20:26:26 +0200 Subject: [PATCH 107/127] Bump Music Assistant Client to 1.2.3 (#147885) --- .../components/music_assistant/entity.py | 2 +- .../components/music_assistant/manifest.json | 2 +- .../music_assistant/media_browser.py | 8 +------ .../music_assistant/media_player.py | 12 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/music_assistant/conftest.py | 1 + .../music_assistant/fixtures/players.json | 21 ++++++++----------- .../components/music_assistant/test_button.py | 2 +- 9 files changed, 22 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index f5b6d92b0cf..21fc072a639 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity): 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, + name=self.player.name, configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}", ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 28e8587e90c..e29491e2b21 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.0"], + "requirements": ["music-assistant-client==1.2.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 11cbbd3f655..e4724be650a 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -6,11 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from music_assistant_models.enums import MediaType as MASSMediaType -from music_assistant_models.media_items import ( - BrowseFolder, - MediaItemType, - SearchResults, -) +from music_assistant_models.media_items import MediaItemType, SearchResults from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -549,8 +545,6 @@ def _process_search_results( # Add available items to results for item in items: - if TYPE_CHECKING: - assert not isinstance(item, BrowseFolder) if not item.available: continue diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8d4e69bf082..b748aad241c 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -250,8 +250,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): # 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) + if player.powered and player.playback_state is not None: + self._attr_state = MediaPlayerState(player.playback_state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) # active source and source list (translate to HA source names) @@ -270,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_source = active_source_name group_members: list[str] = [] - if player.group_childs: - group_members = player.group_childs + if player.group_members: + group_members = player.group_members elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): - group_members = parent.group_childs + group_members = parent.group_members - # translate MA group_childs to HA group_members as entity id's + # translate MA group_members to HA group_members as entity id's entity_registry = er.async_get(self.hass) group_members_entity_ids: list[str] = [ entity_id diff --git a/requirements_all.txt b/requirements_all.txt index 6de083de86a..dc72f39aa08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c896aad0e08..63a979fab9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1259,7 +1259,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2b397891d6f..5eefccbcda9 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -53,6 +53,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.connect = AsyncMock(side_effect=connect) client.start_listening = AsyncMock(side_effect=listen) + client.send_command = AsyncMock(return_value=None) client.server_info = ServerInfoMessage( server_id=MOCK_SERVER_ID, server_version="0.0.0", diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 58ce20da824..5116c97a6ae 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -4,7 +4,6 @@ "player_id": "00:00:00:00:00:01", "provider": "test", "type": "player", - "name": "Test Player 1", "available": true, "powered": false, "device_info": { @@ -23,10 +22,10 @@ ], "elapsed_time": null, "elapsed_time_last_updated": 0, - "state": "idle", + "playback_state": "idle", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "00:00:00:00:00:01", "active_group": null, "current_media": null, @@ -37,7 +36,7 @@ "enabled": true, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "Test Player 1", + "name": "Test Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -75,7 +74,6 @@ "player_id": "00:00:00:00:00:02", "provider": "test", "type": "player", - "name": "Test Player 2", "available": true, "powered": true, "device_info": { @@ -93,10 +91,10 @@ ], "elapsed_time": 0, "elapsed_time_last_updated": 0, - "state": "playing", + "playback_state": "playing", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "spotify", "active_group": null, "current_media": { @@ -117,7 +115,7 @@ "hidden": false, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "My Super Test Player 2", + "name": "My Super Test Player 2", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -139,7 +137,6 @@ "player_id": "test_group_player_1", "provider": "player_group", "type": "group", - "name": "Test Group Player 1", "available": true, "powered": true, "device_info": { @@ -157,10 +154,10 @@ ], "elapsed_time": 0.0, "elapsed_time_last_updated": 1730315437.9904983, - "state": "idle", + "playback_state": "idle", "volume_level": 6, "volume_muted": false, - "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "group_members": ["00:00:00:00:00:01", "00:00:00:00:00:02"], "active_source": "test_group_player_1", "active_group": null, "current_media": { @@ -180,7 +177,7 @@ "enabled": true, "icon": "mdi-speaker-multiple", "group_volume": 6, - "display_name": "Test Group Player 1", + "name": "Test Group Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 5a326b1d8ea..432430b4223 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -75,7 +75,7 @@ async def test_button_press_action( await trigger_subscription_callback( hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id ) - with pytest.raises(HomeAssistantError, match="Player has no active source"): + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From b2c393db7239cb8254378ac59165375b7acef208 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Jul 2025 20:11:01 +0000 Subject: [PATCH 108/127] Bump version to 2025.7.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 a1187d0994e..397617088c8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 0842ffb0c1e..c212e2cdb5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b6" +version = "2025.7.0b7" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 04ae9665447afa3ce327220c4d8d684de69899dc Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 08:36:41 +0300 Subject: [PATCH 109/127] Bump aioamazondevices to 3.2.1 (#147912) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index cdf942e836d..2e74561b755 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.1.22"] + "requirements": ["aioamazondevices==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc72f39aa08..f1ab0674e13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63a979fab9c..545f93b47fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From f838e85a790019cb4b8d60677ed98aab1a556870 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 09:02:53 +0300 Subject: [PATCH 110/127] Manager wrong country selection in Alexa Devices (#147914) Co-authored-by: Franck Nijhof --- homeassistant/components/alexa_devices/config_flow.py | 4 +++- homeassistant/components/alexa_devices/strings.json | 1 + tests/components/alexa_devices/test_config_flow.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 5add7ceb711..20f0888d4ec 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -36,6 +36,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except WrongCountry: + errors["base"] = "wrong_country" else: await self.async_set_unique_id(data["customer_info"]["user_id"]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index d092cfaa2ae..8e11e79418b 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -33,6 +33,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9bf174c5955..e140bb4ad32 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +57,7 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (WrongCountry, "wrong_country"), ], ) async def test_flow_errors( From d4dec6c7a9b09dd7693f33508b220300d638adc9 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 2 Jul 2025 04:12:58 +0000 Subject: [PATCH 111/127] Swap the Models label for the model name not it's display name, (#147918) Swap display name for name. --- .../google_generative_ai_conversation/config_flow.py | 9 +++++---- .../test_config_flow.py | 4 ---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 1b1444e81b1..ade326cf71b 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -330,13 +330,14 @@ async def google_generative_ai_config_option_schema( api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( - label=api_model.display_name, + label=api_model.name.lstrip("models/"), value=api_model.name, ) - for api_model in sorted(api_models, key=lambda x: x.display_name or "") + for api_model in sorted( + api_models, key=lambda x: x.name.lstrip("models/") or "" + ) if ( - api_model.display_name - and api_model.name + api_model.name and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index b43c8a42275..a3fa487e1d3 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -43,25 +43,21 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" model_25_flash = Mock( - display_name="Gemini 2.5 Flash", supported_actions=["generateContent"], ) model_25_flash.name = "models/gemini-2.5-flash" model_20_flash = Mock( - display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], ) model_20_flash.name = "models/gemini-2.0-flash" model_15_flash = Mock( - display_name="Gemini 1.5 Flash", supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( - display_name="Gemini 1.5 Pro", supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" From fdba791f18a2ee219e43d98ef476ec5ed0109dcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Jul 2025 00:34:40 -0500 Subject: [PATCH 112/127] Bump bluetooth-data-tools to 1.28.2 (#147920) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f212f4bdc17..33914f3457f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.5.2", - "bluetooth-data-tools==1.28.1", + "bluetooth-data-tools==1.28.2", "dbus-fast==2.43.0", "habluetooth==3.49.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index ba5ca3bdba4..1efe4e05682 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 49daafeca25..3a73c28cdf6 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index f1e1839b735..439e44faad1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1"] + "requirements": ["bluetooth-data-tools==1.28.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf8eb55c506..d81403c2715 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.5.2 -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index f1ab0674e13..dfaaae375e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 545f93b47fe..e300f2a4df4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -583,7 +583,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 From 0e6bbb30c1775be3262aac164b502c74c21bc7c6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 06:04:14 +0000 Subject: [PATCH 113/127] Bump version to 2025.7.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 397617088c8..db378d77902 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c212e2cdb5b..dfc393f69bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b7" +version = "2025.7.0b8" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From b677ce6c9069e8ee38d2c807b5e736ecb84692fc Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 2 Jul 2025 14:48:21 +0200 Subject: [PATCH 114/127] SMA add DHCP strictness (#145753) * Add DHCP strictness (needs beta check) * Update to check on CONF_MAC * Update to check on CONF_HOST * Update hostname * Polish it a bit * Update to CONF_HOST, again * Add split * Add CONF_MAC add upon detection * epenet feedback * epenet round II --- homeassistant/components/sma/config_flow.py | 31 ++++++++++++++++++- tests/components/sma/test_config_flow.py | 33 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index f43c851d04a..e08b9ade9fc 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -184,7 +184,36 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): self._data[CONF_HOST] = discovery_info.ip self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) - await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + _LOGGER.debug( + "DHCP discovery detected SMA device: %s, IP: %s, MAC: %s", + self._discovery_data[CONF_NAME], + self._discovery_data[CONF_HOST], + self._discovery_data[CONF_MAC], + ) + + existing_entries_with_host = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_HOST) == self._data[CONF_HOST] + and not entry.data.get(CONF_MAC) + ] + + # If we have an existing entry with the same host but no MAC address, + # we update the entry with the MAC address and reload it. + if existing_entries_with_host: + entry = existing_entries_with_host[0] + self.async_update_reload_and_abort( + entry, data_updates={CONF_MAC: self._data[CONF_MAC]} + ) + + # Finally, check if the hostname (which represents the SMA serial number) is unique + serial_number = discovery_info.hostname.lower() + # Example hostname: sma12345678-01 + # Remove 'sma' prefix and strip everything after the dash (including the dash) + if serial_number.startswith("sma"): + serial_number = serial_number.removeprefix("sma") + serial_number = serial_number.split("-", 1)[0] + await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured() return await self.async_step_discovery_confirm() diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index c8939ef2d64..dd7ff73574d 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -11,8 +11,10 @@ import pytest from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( @@ -37,6 +39,12 @@ DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( macaddress="0015BB00abcd", ) +DHCP_DISCOVERY_DUPLICATE_001 = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789-001", + macaddress="0015bb00abcd", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock @@ -154,6 +162,31 @@ async def test_dhcp_already_configured( assert result["reason"] == "already_configured" +async def test_dhcp_already_configured_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by DHCP when already configured and MAC is added.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert CONF_MAC not in mock_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY_DUPLICATE_001, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert mock_config_entry.data.get(CONF_MAC) == format_mac( + DHCP_DISCOVERY_DUPLICATE_001.macaddress + ) + + @pytest.mark.parametrize( ("exception", "error"), [ From b8c19f23f31be9f38af651e2665f95fd79299b84 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:57:46 +0200 Subject: [PATCH 115/127] UnifiProtect Change log level from debug to error for connection exceptions in ProtectFlowHandler (#147730) --- homeassistant/components/unifiprotect/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 22af2fb135d..8e7dcaede5b 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -274,7 +274,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" except ClientError as ex: - _LOGGER.debug(ex) + _LOGGER.error(ex) errors["base"] = "cannot_connect" else: if nvr_data.version < MIN_REQUIRED_PROTECT_V: From fa1bed184948381007f763bae1803c17e692916b Mon Sep 17 00:00:00 2001 From: Space Date: Wed, 2 Jul 2025 11:45:45 +0200 Subject: [PATCH 116/127] Skip processing request body for HTTP HEAD requests (#147899) * Skip processing request body for HTTP HEAD requests * Use aiohttp's must_be_empty_body() to check whether ingress requests should be streamed * Only call must_be_empty_body() once per request * Fix incorrect use of walrus operator --- homeassistant/components/hassio/ingress.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index e673c3a70e9..ca6764cfa34 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -11,6 +11,7 @@ from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web +from aiohttp.helpers import must_be_empty_body from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView): content_type = "application/octet-stream" # Simple request - if result.status in (204, 304) or ( + if (empty_body := must_be_empty_body(result.method, result.status)) or ( content_length is not UNDEFINED and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): # Return Response - body = await result.read() + if empty_body: + body = None + else: + body = await result.read() simple_response = web.Response( headers=headers, status=result.status, From 2f27d55495f8292108f8e09dd09abc6f4bb09a5d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 2 Jul 2025 13:06:27 +0200 Subject: [PATCH 117/127] Open repair issue when outbound WebSocket is enabled for Shelly non-sleeping RPC device (#147901) --- homeassistant/components/shelly/__init__.py | 9 +- homeassistant/components/shelly/const.py | 3 + homeassistant/components/shelly/repairs.py | 91 +++++++++++++++++++- homeassistant/components/shelly/strings.json | 14 +++ tests/components/shelly/test_repairs.py | 82 ++++++++++++++++++ 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 75fedf9b16d..0467b93a7c8 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,7 +56,10 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) -from .repairs import async_manage_ble_scanner_firmware_unsupported_issue +from .repairs import ( + async_manage_ble_scanner_firmware_unsupported_issue, + async_manage_outbound_websocket_incorrectly_enabled_issue, +) from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -327,6 +330,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + async_manage_outbound_websocket_incorrectly_enabled_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7462766e2d4..60fc5b03d13 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,9 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" +OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( + "outbound_websocket_incorrectly_enabled_{unique}" +) GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index c39f619fc6c..e1b15f04417 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -20,9 +20,11 @@ from .const import ( BLE_SCANNER_MIN_FIRMWARE, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry +from .utils import get_rpc_ws_url @callback @@ -65,7 +67,46 @@ def async_manage_ble_scanner_firmware_unsupported_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) -class BleScannerFirmwareUpdateFlow(RepairsFlow): +@callback +def async_manage_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the Outbound WebSocket incorrectly enabled issue.""" + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format( + unique=entry.unique_id + ) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if ( + (ws_config := device.config.get("ws")) + and ws_config["enable"] + and ws_config["server"] == get_rpc_ws_url(hass) + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="outbound_websocket_incorrectly_enabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class ShellyRpcRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" def __init__(self, device: RpcDevice) -> None: @@ -83,7 +124,7 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - return await self.async_step_update_firmware() + return await self._async_step_confirm() issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -96,6 +137,18 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): description_placeholders=description_placeholders, ) + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + raise NotImplementedError + + +class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow): + """Handler for BLE Scanner Firmware Update flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_update_firmware() + async def async_step_update_firmware( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: @@ -110,6 +163,29 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): return self.async_create_entry(title="", data={}) +class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow): + """Handler for Disable Outbound WebSocket flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_disable_outbound_websocket() + + async def async_step_disable_outbound_websocket( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + try: + result = await self._device.ws_setconfig( + False, self._device.config["ws"]["server"] + ) + if result["restart_required"]: + await self._device.trigger_reboot() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -124,4 +200,11 @@ async def async_create_fix_flow( assert entry is not None device = entry.runtime_data.rpc.device - return BleScannerFirmwareUpdateFlow(device) + + if "ble_scanner_firmware_unsupported" in issue_id: + return BleScannerFirmwareUpdateFlow(device) + + if "outbound_websocket_incorrectly_enabled" in issue_id: + return DisableOutboundWebSocketFlow(device) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 28f3a993462..c1d520a59f1 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -288,6 +288,20 @@ "unsupported_firmware": { "title": "Unsupported firmware for device {device_name}", "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." + }, + "outbound_websocket_incorrectly_enabled": { + "title": "Outbound WebSocket is enabled for {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Outbound WebSocket is enabled for {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is a non-sleeping device and Outbound WebSocket should be disabled in its configuration.\n\nSelect **Submit** button to disable Outbound WebSocket." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } } } } diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index f68d2f82f1b..8dfd59c49ba 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -9,6 +9,7 @@ from homeassistant.components.shelly.const import ( BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -129,3 +130,84 @@ async def test_unsupported_firmware_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issues handling for the outbound WebSocket incorrectly enabled.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.ws_setconfig.call_count == 1 + assert mock_rpc_device.ws_setconfig.call_args[0] == (False, ws_url) + assert mock_rpc_device.trigger_reboot.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_outbound_websocket_incorrectly_enabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issues handling when ws_setconfig ends with an exception.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.ws_setconfig.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.ws_setconfig.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 From eb351e65053343126d5370418166eef1fcb7fd53 Mon Sep 17 00:00:00 2001 From: John Hess Date: Wed, 2 Jul 2025 01:11:49 -0700 Subject: [PATCH 118/127] Bump thermopro-ble to 0.13.1 (#147924) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 29dadfd3d63..6749a53b7b6 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.13.0"] + "requirements": ["thermopro-ble==0.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dfaaae375e2..4cfc51b222d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2925,7 +2925,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e300f2a4df4..82ae255b791 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2411,7 +2411,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.lg_thinq thinqconnect==1.0.7 From b816f1a408b2e91434a62229cb18722f6d7f9d90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 2 Jul 2025 13:26:47 +0200 Subject: [PATCH 119/127] Handle additional errors in Nord Pool (#147937) --- .../components/nordpool/coordinator.py | 3 ++ tests/components/nordpool/test_coordinator.py | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index a6cfd40c323..d2edb81b9e6 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING +import aiohttp from pynordpool import ( Currency, DeliveryPeriodData, @@ -91,6 +92,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): except ( NordPoolResponseError, NordPoolError, + TimeoutError, + aiohttp.ClientError, ) as error: LOGGER.debug("Connection error: %s", error) self.async_set_update_error(error) diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 71c4644ea95..c2d18c4702a 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import patch +import aiohttp from freezegun.api import FrozenDateTimeFactory from pynordpool import ( NordPoolAuthenticationError, @@ -90,6 +91,36 @@ async def test_coordinator( assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=aiohttp.ClientError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=TimeoutError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -109,4 +140,4 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81645" + assert state.state == "1.81983" From 1fdf15229231b656b710139cf302e7944500366b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 2 Jul 2025 12:00:48 +0200 Subject: [PATCH 120/127] Bump deebot-client to 13.5.0 (#147938) --- 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 97739f698d9..ceb7a1da9de 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.11", "deebot-client==13.4.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4cfc51b222d..c3d981a9e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82ae255b791..7e8db3086d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -671,7 +671,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 116c74587287b221a7e7946e3bb1ee1edf4dddbc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 21:31:54 +0200 Subject: [PATCH 121/127] Split Ollama entity (#147769) --- .../components/ollama/conversation.py | 251 +---------------- homeassistant/components/ollama/entity.py | 258 ++++++++++++++++++ tests/components/ollama/test_conversation.py | 4 +- 3 files changed, 268 insertions(+), 245 deletions(-) create mode 100644 homeassistant/components/ollama/entity.py diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index beedb61f942..ae4de7d48a1 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,41 +2,18 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Callable -import json -import logging -from typing import Any, Literal - -import ollama -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry -from .const import ( - CONF_KEEP_ALIVE, - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_NUM_CTX, - CONF_PROMPT, - CONF_THINK, - DEFAULT_KEEP_ALIVE, - DEFAULT_MAX_HISTORY, - DEFAULT_NUM_CTX, - DOMAIN, -) -from .models import MessageHistory, MessageRole - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_PROMPT, DOMAIN +from .entity import OllamaBaseLLMEntity async def async_setup_entry( @@ -55,129 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: - """Format tool specification.""" - tool_spec = { - "name": tool.name, - "parameters": convert(tool.parameters, custom_serializer=custom_serializer), - } - if tool.description: - tool_spec["description"] = tool.description - return {"type": "function", "function": tool_spec} - - -def _fix_invalid_arguments(value: Any) -> Any: - """Attempt to repair incorrectly formatted json function arguments. - - Small models (for example llama3.1 8B) may produce invalid argument values - which we attempt to repair here. - """ - if not isinstance(value, str): - return value - if (value.startswith("[") and value.endswith("]")) or ( - value.startswith("{") and value.endswith("}") - ): - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - pass - return value - - -def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: - """Rewrite ollama tool arguments. - - This function improves tool use quality by fixing common mistakes made by - small local tool use models. This will repair invalid json arguments and - omit unnecessary arguments with empty values that will fail intent parsing. - """ - return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} - - -def _convert_content( - chat_content: ( - conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent - ), -) -> ollama.Message: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(chat_content.tool_result), - ) - if isinstance(chat_content, conversation.AssistantContent): - return ollama.Message( - role=MessageRole.ASSISTANT.value, - content=chat_content.content, - tool_calls=[ - ollama.Message.ToolCall( - function=ollama.Message.ToolCall.Function( - name=tool_call.tool_name, - arguments=tool_call.tool_args, - ) - ) - for tool_call in chat_content.tool_calls or () - ], - ) - if isinstance(chat_content, conversation.UserContent): - return ollama.Message( - role=MessageRole.USER.value, - content=chat_content.content, - ) - if isinstance(chat_content, conversation.SystemContent): - return ollama.Message( - role=MessageRole.SYSTEM.value, - content=chat_content.content, - ) - raise TypeError(f"Unexpected content type: {type(chat_content)}") - - -async def _transform_stream( - result: AsyncIterator[ollama.ChatResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - An Ollama streaming response may come in chunks like this: - - response: message=Message(role="assistant", content="Paris") - response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - - This generator conforms to the chatlog delta stream expectations in that it - yields deltas, then the role only once the response is done. - """ - - new_msg = True - async for response in result: - _LOGGER.debug("Received response: %s", response) - response_message = response["message"] - chunk: conversation.AssistantContentDeltaDict = {} - if new_msg: - new_msg = False - chunk["role"] = "assistant" - if (tool_calls := response_message.get("tool_calls")) is not None: - chunk["tool_calls"] = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls - ] - if (content := response_message.get("content")) is not None: - chunk["content"] = content - if response_message.get("done"): - new_msg = True - yield chunk - - class OllamaConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OllamaBaseLLMEntity, ): """Ollama conversation agent.""" @@ -185,17 +43,7 @@ class OllamaConversationEntity( def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self._attr_name = subentry.title - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - manufacturer="Ollama", - model=entry.data[CONF_MODEL], - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -255,89 +103,6 @@ class OllamaConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.subentry.data} - - client = self.entry.runtime_data - model = settings[CONF_MODEL] - - tools: list[dict[str, Any]] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - message_history: MessageHistory = MessageHistory( - [_convert_content(content) for content in chat_log.content] - ) - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Get response - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - response_generator = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - tools=tools, - stream=True, - # keep_alive requires specifying unit. In this case, seconds - keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", - options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - think=settings.get(CONF_THINK), - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - raise HomeAssistantError( - f"Sorry, I had a problem talking to the Ollama server: {err}" - ) from err - - message_history.messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(response_generator) - ) - ] - ) - - if not chat_log.unresponded_tool_results: - break - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history. - - This sets the max history to allow a configurable size history may take - up in the context window. - - Note that some messages in the history may not be from ollama only, and - may come from other anents, so the assumptions here may not strictly hold, - but generally should be effective. - """ - if max_messages < 1: - # Keep all messages - return - - # Ignore the in progress user message - num_previous_rounds = message_history.num_user_messages - 1 - if num_previous_rounds >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. Also keep the last in progress user message - num_keep = 2 * max_messages + 1 - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0], - *message_history.messages[drop_index:], - ] - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py new file mode 100644 index 00000000000..a577bf77573 --- /dev/null +++ b/homeassistant/components/ollama/entity.py @@ -0,0 +1,258 @@ +"""Base entity for the Ollama integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Callable +import json +import logging +from typing import Any + +import ollama +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OllamaConfigEntry +from .const import ( + CONF_KEEP_ALIVE, + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_NUM_CTX, + CONF_THINK, + DEFAULT_KEEP_ALIVE, + DEFAULT_MAX_HISTORY, + DEFAULT_NUM_CTX, + DOMAIN, +) +from .models import MessageHistory, MessageRole + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> dict[str, Any]: + """Format tool specification.""" + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return {"type": "function", "function": tool_spec} + + +def _fix_invalid_arguments(value: Any) -> Any: + """Attempt to repair incorrectly formatted json function arguments. + + Small models (for example llama3.1 8B) may produce invalid argument values + which we attempt to repair here. + """ + if not isinstance(value, str): + return value + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + try: + return json.loads(value) + except json.decoder.JSONDecodeError: + pass + return value + + +def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: + """Rewrite ollama tool arguments. + + This function improves tool use quality by fixing common mistakes made by + small local tool use models. This will repair invalid json arguments and + omit unnecessary arguments with empty values that will fail intent parsing. + """ + return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} + + +def _convert_content( + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncIterator[ollama.ChatResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + +class OllamaBaseLLMEntity(Entity): + """Ollama base LLM entity.""" + + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Ollama", + model=entry.data[CONF_MODEL], + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.subentry.data} + + client = self.entry.runtime_data + model = settings[CONF_MODEL] + + tools: list[dict[str, Any]] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + # Get response + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + response_generator = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + tools=tools, + stream=True, + # keep_alive requires specifying unit. In this case, seconds + keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", + options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, + think=settings.get(CONF_THINK), + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err + + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(response_generator) + ) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ + if max_messages < 1: + # Keep all messages + return + + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0], + *message_history.messages[drop_index:], + ] diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index cebb185bd08..d33fffe7152 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -206,7 +206,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -293,7 +293,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, From ec5e543c09f5fdda90e2f11e866aba48b94b7260 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 2 Jul 2025 15:20:42 +0200 Subject: [PATCH 122/127] Ollama: Migrate pick model to subentry (#147944) --- homeassistant/components/ollama/__init__.py | 34 +- .../components/ollama/config_flow.py | 299 ++++++------ homeassistant/components/ollama/entity.py | 5 +- homeassistant/components/ollama/strings.json | 22 +- tests/components/ollama/__init__.py | 2 +- tests/components/ollama/conftest.py | 13 +- tests/components/ollama/test_config_flow.py | 448 ++++++++++++------ tests/components/ollama/test_conversation.py | 38 +- tests/components/ollama/test_init.py | 127 +++-- 9 files changed, 655 insertions(+), 333 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index eaddf936e81..0f187da1476 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from types import MappingProxyType import httpx import ollama @@ -92,8 +93,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: for entry in entries: use_existing = False + # Create subentry with model from entry.data and options from entry.options + subentry_data = entry.options.copy() + subentry_data[CONF_MODEL] = entry.data[CONF_MODEL] + subentry = ConfigSubentry( - data=entry.options, + data=MappingProxyType(subentry_data), subentry_type="conversation", title=entry.title, unique_id=None, @@ -146,9 +151,11 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, + # Update parent entry to only keep URL, remove model + data={CONF_URL: entry.data[CONF_URL]}, options={}, - version=2, - minor_version=2, + version=3, + minor_version=1, ) @@ -156,7 +163,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: + if entry.version > 3: # This means the user has downgraded from a future version return False @@ -174,6 +181,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Update subentries to include the model + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + updated_data = dict(subentry.data) + updated_data[CONF_MODEL] = entry.data[CONF_MODEL] + + hass.config_entries.async_update_subentry( + entry, subentry, data=MappingProxyType(updated_data) + ) + + # Update main entry to remove model and bump version + hass.config_entries.async_update_entry( + entry, + data={CONF_URL: entry.data[CONF_URL]}, + version=3, + minor_version=1, + ) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 03e2b038bab..49eb12a5c23 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import llm +from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( BooleanSelector, NumberSelector, @@ -38,6 +38,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util.ssl import get_default_context +from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, @@ -72,43 +73,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" - VERSION = 2 - MINOR_VERSION = 2 + VERSION = 3 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize config flow.""" self.url: str | None = None - self.model: str | None = None - self.client: ollama.AsyncClient | None = None - self.download_task: asyncio.Task | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - user_input = user_input or {} - self.url = user_input.get(CONF_URL, self.url) - self.model = user_input.get(CONF_MODEL, self.model) - - if self.url is None: + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) errors = {} + url = user_input[CONF_URL] - self._async_abort_entries_match({CONF_URL: self.url}) + self._async_abort_entries_match({CONF_URL: url}) try: - self.client = ollama.AsyncClient( - host=self.url, verify=get_default_context() + url = cv.url(url) + except vol.Invalid: + errors["base"] = "invalid_url" + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - async with asyncio.timeout(DEFAULT_TIMEOUT): - response = await self.client.list() - downloaded_models: set[str] = { - model_info["model"] for model_info in response.get("models", []) - } + try: + client = ollama.AsyncClient(host=url, verify=get_default_context()) + async with asyncio.timeout(DEFAULT_TIMEOUT): + await client.list() except (TimeoutError, httpx.ConnectError): errors["base"] = "cannot_connect" except Exception: @@ -117,10 +118,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - if self.model is None: + return self.async_create_entry( + title=url, + data={CONF_URL: url}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self._name: str | None = None + self._model: str | None = None + self.download_task: asyncio.Task | None = None + self._config_data: dict[str, Any] | None = None + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + @property + def _client(self) -> ollama.AsyncClient: + """Return the Ollama client.""" + entry: OllamaConfigEntry = self._get_entry() + return entry.runtime_data + + async def async_step_set_options( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle model selection and configuration step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is None: + # Get available models from Ollama server + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + except (TimeoutError, httpx.ConnectError, httpx.HTTPError): + _LOGGER.exception("Failed to get models from Ollama server") + return self.async_abort(reason="cannot_connect") + # Show models that have been downloaded first, followed by all known # models (only latest tags). models_to_list = [ @@ -131,52 +191,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): for m in sorted(MODEL_NAMES) if m not in downloaded_models ] - model_step_schema = vol.Schema( - { - vol.Required( - CONF_MODEL, description={"suggested_value": DEFAULT_MODEL} - ): SelectSelector( - SelectSelectorConfig(options=models_to_list, custom_value=True) - ), - } - ) + + if self._is_new: + options = {} + else: + options = self._get_reconfigure_subentry().data.copy() return self.async_show_form( - step_id="user", - data_schema=model_step_schema, + step_id="set_options", + data_schema=vol.Schema( + ollama_config_option_schema( + self.hass, self._is_new, options, models_to_list + ) + ), ) - if self.model not in downloaded_models: - # Ollama server needs to download model first - return await self.async_step_download() + self._model = user_input[CONF_MODEL] + if self._is_new: + self._name = user_input.pop(CONF_NAME) - return self.async_create_entry( - title=self.url, - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], + # Check if model needs to be downloaded + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + currently_downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + + if self._model not in currently_downloaded_models: + # Store the user input to use after download + self._config_data = user_input + # Ollama server needs to download model first + return await self.async_step_download() + except Exception: + _LOGGER.exception("Failed to check model availability") + return self.async_abort(reason="cannot_connect") + + # Model is already downloaded, create/update the entry + if self._is_new: + return self.async_create_entry( + title=self._name, + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, ) async def async_step_download( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step to wait for Ollama server to download a model.""" - assert self.model is not None - assert self.client is not None + assert self._model is not None if self.download_task is None: # Tell Ollama server to pull the model. # The task will block until the model and metadata are fully # downloaded. self.download_task = self.hass.async_create_background_task( - self.client.pull(self.model), - f"Downloading {self.model}", + self._client.pull(self._model), + f"Downloading {self._model}", ) if self.download_task.done(): @@ -192,80 +269,28 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): progress_task=self.download_task, ) - async def async_step_finish( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step after model downloading has succeeded.""" - assert self.url is not None - assert self.model is not None - - return self.async_create_entry( - title=_get_title(self.model), - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], - ) - async def async_step_failed( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step after model downloading has failed.""" return self.async_abort(reason="download_failed") - @classmethod - @callback - def async_get_supported_subentry_types( - cls, config_entry: ConfigEntry - ) -> dict[str, type[ConfigSubentryFlow]]: - """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} - - -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" - - @property - def _is_new(self) -> bool: - """Return if this is a new subentry.""" - return self.source == "user" - - async def async_step_set_options( + async def async_step_finish( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Set conversation options.""" - # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: - return self.async_abort(reason="entry_not_loaded") + """Step after model downloading has succeeded.""" + assert self._config_data is not None - errors: dict[str, str] = {} - - if user_input is None: - if self._is_new: - options = {} - else: - options = self._get_reconfigure_subentry().data.copy() - - elif self._is_new: + # Model download completed, create/update the entry with stored config + if self._is_new: return self.async_create_entry( - title=user_input.pop(CONF_NAME), - data=user_input, + title=self._name, + data=self._config_data, ) - else: - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=user_input, - ) - - schema = ollama_config_option_schema(self.hass, self._is_new, options) - return self.async_show_form( - step_id="set_options", data_schema=vol.Schema(schema), errors=errors + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=self._config_data, ) async_step_user = async_step_set_options @@ -273,19 +298,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): def ollama_config_option_schema( - hass: HomeAssistant, is_new: bool, options: Mapping[str, Any] + hass: HomeAssistant, + is_new: bool, + options: Mapping[str, Any], + models_to_list: list[SelectOptionDict], ) -> dict: """Ollama options schema.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, - ) - for api in llm.async_get_apis(hass) - ] - if is_new: - schema: dict[vol.Required | vol.Optional, Any] = { + schema: dict = { vol.Required(CONF_NAME, default="Ollama Conversation"): str, } else: @@ -293,6 +313,12 @@ def ollama_config_option_schema( schema.update( { + vol.Required( + CONF_MODEL, + description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)}, + ): SelectSelector( + SelectSelectorConfig(options=models_to_list, custom_value=True) + ), vol.Optional( CONF_PROMPT, description={ @@ -304,7 +330,18 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ], + multiple=True, + ) + ), vol.Optional( CONF_NUM_CTX, description={ @@ -350,11 +387,3 @@ def ollama_config_option_schema( ) return schema - - -def _get_title(model: str) -> str: - """Get title for config entry.""" - if model.endswith(":latest"): - model = model.split(":", maxsplit=1)[0] - - return model diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index a577bf77573..7b63b1dff00 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -166,11 +166,14 @@ class OllamaBaseLLMEntity(Entity): self.subentry = subentry self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id + + model, _, version = subentry.data[CONF_MODEL].partition(":") self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Ollama", - model=entry.data[CONF_MODEL], + model=model, + sw_version=version or "latest", entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 74a5eaff454..bb08e4a4684 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -3,24 +3,17 @@ "step": { "user": { "data": { - "url": "[%key:common::config_flow::data::url%]", - "model": "Model" + "url": "[%key:common::config_flow::data::url%]" } - }, - "download": { - "title": "Downloading model" } }, "abort": { - "download_failed": "Model downloading failed", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "invalid_url": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } }, "config_subentries": { @@ -33,6 +26,7 @@ "step": { "set_options": { "data": { + "model": "Model", "name": "[%key:common::config_flow::data::name%]", "prompt": "Instructions", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", @@ -47,11 +41,19 @@ "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." } + }, + "download": { + "title": "Downloading model" } }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Failed to add agent. The configuration is disabled.", + "download_failed": "Model downloading failed", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } } } diff --git a/tests/components/ollama/__init__.py b/tests/components/ollama/__init__.py index 6ad77bb2217..92db3b13304 100644 --- a/tests/components/ollama/__init__.py +++ b/tests/components/ollama/__init__.py @@ -5,10 +5,10 @@ from homeassistant.helpers import llm TEST_USER_DATA = { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: "test model", } TEST_OPTIONS = { ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", } diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index c99f586a5d4..552e7dee20a 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -30,10 +30,11 @@ def mock_config_entry( entry = MockConfigEntry( domain=ollama.DOMAIN, data=TEST_USER_DATA, - version=2, + version=3, + minor_version=1, subentries_data=[ { - "data": mock_config_entry_options, + "data": {**TEST_OPTIONS, **mock_config_entry_options}, "subentry_type": "conversation", "title": "Ollama Conversation", "unique_id": None, @@ -49,10 +50,14 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" + subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( mock_config_entry, - next(iter(mock_config_entry.subentries.values())), - data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + subentry, + data={ + **subentry.data, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + }, ) return mock_config_entry diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 4b78df9bce2..7372a460c95 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import ollama +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,7 +18,7 @@ TEST_MODEL = "test_model:latest" async def test_form(hass: HomeAssistant) -> None: - """Test flow when the model is already downloaded.""" + """Test flow when configuring URL only.""" # Pretend we already set up a config entry. hass.config.components.add(ollama.DOMAIN) MockConfigEntry( @@ -34,7 +35,6 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # test model is already "downloaded" return_value={"models": [{"model": TEST_MODEL}]}, ), patch( @@ -42,24 +42,17 @@ async def test_form(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - # Step 1: URL result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} ) await hass.async_block_till_done() - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"] == { + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, } + # No subentries created by default + assert len(result2.get("subentries", [])) == 0 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,98 +87,6 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_need_download(hass: HomeAssistant) -> None: - """Test flow when a model needs to be downloaded.""" - # Pretend we already set up a config entry. - hass.config.components.add(ollama.DOMAIN) - MockConfigEntry( - domain=ollama.DOMAIN, - state=config_entries.ConfigEntryState.LOADED, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - pull_ready = asyncio.Event() - pull_called = asyncio.Event() - pull_model: str | None = None - - async def pull(self, model: str, *args, **kwargs) -> None: - nonlocal pull_model - - async with asyncio.timeout(1): - await pull_ready.wait() - - pull_model = model - pull_called.set() - - with ( - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # No models are downloaded - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - pull, - ), - patch( - "homeassistant.components.ollama.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - # Step 1: URL - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} - ) - await hass.async_block_till_done() - - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - # Step 3: download - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - ) - await hass.async_block_till_done() - - # Run again without the task finishing. - # We should still be downloading. - assert result4["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - await hass.async_block_till_done() - assert result4["type"] is FlowResultType.SHOW_PROGRESS - - # Signal fake pull method to complete - pull_ready.set() - async with asyncio.timeout(1): - await pull_called.wait() - - assert pull_model == TEST_MODEL - - # Step 4: finish - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["data"] == { - ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_subentry_options( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: @@ -193,34 +94,84 @@ async def test_subentry_options( subentry = next(iter(mock_config_entry.subentries.values())) # Test reconfiguration - options_flow = await mock_config_entry.start_subentry_reconfigure_flow( - hass, subentry.subentry_id - ) + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) - assert options_flow["type"] is FlowResultType.FORM - assert options_flow["step_id"] == "set_options" + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["step_id"] == "set_options" - options = await hass.config_entries.subentries.async_configure( - options_flow["flow_id"], - { - ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, - ollama.CONF_THINK: True, - }, - ) + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 100, + ollama.CONF_NUM_CTX: 32768, + ollama.CONF_THINK: True, + }, + ) await hass.async_block_till_done() assert options["type"] is FlowResultType.ABORT assert options["reason"] == "reconfigure_successful" assert subentry.data == { + ollama.CONF_MODEL: TEST_MODEL, ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, + ollama.CONF_MAX_HISTORY: 100.0, + ollama.CONF_NUM_CTX: 32768.0, ollama.CONF_THINK: True, } +async def test_creating_new_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a new conversation subentry includes name field.""" + # Start a new subentry flow + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with name field + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component, @@ -237,6 +188,125 @@ async def test_creating_conversation_subentry_not_loaded( assert result["reason"] == "entry_not_loaded" +async def test_subentry_need_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model needs to be downloaded.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop 1 iteration + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM, new_flow + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", # not cached + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + +async def test_subentry_download_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model download fails.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + await asyncio.sleep(0) # yield + + raise RuntimeError("Download failed") + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model that needs downloading but will fail + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + # Should show progress flow result for download + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + # Wait for download task to complete (with error) + await hass.async_block_till_done() + + # Submit the progress flow - should get failure + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "download_failed" + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -262,40 +332,132 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -async def test_download_error(hass: HomeAssistant) -> None: - """Test we handle errors while downloading a model.""" +async def test_form_invalid_url(hass: HomeAssistant) -> None: + """Test we handle invalid URL.""" result = await hass.config_entries.flow.async_init( ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - async def _delayed_runtime_error(*args, **kwargs): - await asyncio.sleep(0) - raise RuntimeError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {ollama.CONF_URL: "not-a-valid-url"} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_url"} + + +async def test_subentry_connection_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when connection to Ollama server fails.""" + with patch( + "ollama.AsyncClient.list", + side_effect=ConnectError("Connection failed"), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.ABORT + assert new_flow["reason"] == "cannot_connect" + + +async def test_subentry_model_check_exception( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when checking model availability throws exception.""" + with patch( + "ollama.AsyncClient.list", + side_effect=[ + {"models": [{"model": TEST_MODEL}]}, # First call succeeds + RuntimeError("Failed to check models"), # Second call fails + ], + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model, should fail when checking availability + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "new_model:latest", + CONF_NAME: "Test Conversation", + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_subentry_reconfigure_with_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry when model needs to be downloaded.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop with ( patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - _delayed_runtime_error, + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, ), + patch("ollama.AsyncClient.pull", delayed_pull), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} + reconfigure_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "set_options" + + # Reconfigure with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75, + ollama.CONF_NUM_CTX: 8192, + ollama.CONF_THINK: True, + }, ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - await hass.async_block_till_done() + # Finish download + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], {} + ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "download_failed" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75.0, + ollama.CONF_NUM_CTX: 8192.0, + ollama.CONF_THINK: True, + } diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index d33fffe7152..f7e50d61e2c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -15,7 +15,12 @@ from homeassistant.components.conversation import trace from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent, + llm, +) from tests.common import MockConfigEntry @@ -68,7 +73,7 @@ async def test_chat( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -128,7 +133,7 @@ async def test_chat_stream( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -158,6 +163,7 @@ async def test_template_variables( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." ), + ollama.CONF_MODEL: "test_model:latest", }, ) with ( @@ -524,7 +530,9 @@ async def test_message_history_unlimited( ): subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( - mock_config_entry, subentry, data={ollama.CONF_MAX_HISTORY: 0} + mock_config_entry, + subentry, + data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) for i in range(100): result = await conversation.async_converse( @@ -573,6 +581,7 @@ async def test_template_error( mock_config_entry, subentry, data={ + **subentry.data, "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -593,6 +602,8 @@ async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test OllamaConversationEntity.""" agent = conversation.get_agent_manager(hass).async_get_agent( @@ -604,6 +615,24 @@ async def test_conversation_agent( assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + entity_entry = entity_registry.async_get("conversation.ollama_conversation") + assert entity_entry + subentry = mock_config_entry.subentries.get(entity_entry.unique_id) + assert subentry + assert entity_entry.original_name == subentry.title + + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry + + assert device_entry.identifiers == {(ollama.DOMAIN, subentry.subentry_id)} + assert device_entry.name == subentry.title + assert device_entry.manufacturer == "Ollama" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + + model, _, version = subentry.data[ollama.CONF_MODEL].partition(":") + assert device_entry.model == model + assert device_entry.sw_version == version + async def test_conversation_agent_with_assist( hass: HomeAssistant, @@ -679,6 +708,7 @@ async def test_reasoning_filter( mock_config_entry, subentry, data={ + **subentry.data, ollama.CONF_THINK: think, }, ) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index a6cfe4c2de0..c7cd78fca9a 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -9,13 +9,26 @@ from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er, llm from homeassistant.setup import async_setup_component -from . import TEST_OPTIONS, TEST_USER_DATA +from . import TEST_OPTIONS from tests.common import MockConfigEntry +V1_TEST_USER_DATA = { + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", +} + +V1_TEST_OPTIONS = { + ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + ollama.CONF_MAX_HISTORY: 2, +} + +V21_TEST_USER_DATA = V1_TEST_USER_DATA +V21_TEST_OPTIONS = V1_TEST_OPTIONS + @pytest.mark.parametrize( ("side_effect", "error"), @@ -41,17 +54,17 @@ async def test_init_error( assert error in caplog.text -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2.""" + """Test migration from version 1.""" # Create a v1 config entry with conversation options and an entity mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, - options=TEST_OPTIONS, + data=V1_TEST_USER_DATA, + options=V1_TEST_OPTIONS, version=1, title="llama-3.2-8b", ) @@ -81,9 +94,10 @@ async def test_migration_from_v1_to_v2( ): await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 - assert mock_config_entry.data == TEST_USER_DATA + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + # After migration, parent entry should only have URL + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} assert len(mock_config_entry.subentries) == 1 @@ -92,7 +106,9 @@ async def test_migration_from_v1_to_v2( assert subentry.unique_id is None assert subentry.title == "llama-3.2-8b" assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should now include the model from the original options + expected_subentry_data = TEST_OPTIONS.copy() + assert subentry.data == expected_subentry_data migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -117,17 +133,17 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_urls( +async def test_migration_from_v1_with_multiple_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different URLs.""" + """Test migration from version 1 with different URLs.""" # Create two v1 config entries with different URLs mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 1", ) @@ -135,7 +151,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11435", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -187,13 +203,16 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert len(entries) == 2 for idx, entry in enumerate(entries): - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data assert subentry.title == f"Ollama {idx + 1}" dev = device_registry.async_get_device( @@ -204,17 +223,17 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_urls( +async def test_migration_from_v1_with_same_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same URLs consolidates entries.""" + """Test migration from version 1 with same URLs consolidates entries.""" # Create two v1 config entries with the same URL mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama", ) @@ -222,7 +241,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, # Same URL - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -275,8 +294,8 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -288,7 +307,10 @@ async def test_migration_from_v1_to_v2_with_same_urls( for subentry in subentries: assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data # Check devices were migrated correctly dev = device_registry.async_get_device( @@ -301,12 +323,12 @@ async def test_migration_from_v1_to_v2_with_same_urls( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -315,20 +337,20 @@ async def test_migration_from_v2_1_to_v2_2( # Create a v2.1 config entry with 2 subentries, devices and entities mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, + data=V21_TEST_USER_DATA, entry_id="mock_entry_id", version=2, minor_version=1, subentries_data=[ ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_1", subentry_type="conversation", title="Ollama", unique_id=None, ), ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_2", subentry_type="conversation", title="Ollama 2", @@ -392,8 +414,8 @@ async def test_migration_from_v2_1_to_v2_2( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 2 @@ -405,6 +427,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(conversation_subentries) == 2 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" + # Since TEST_USER_DATA no longer has a model, subentry data should be TEST_OPTIONS assert subentry.data == TEST_OPTIONS assert "Ollama" in subentry.title @@ -450,3 +473,45 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + subentry_data = ConfigSubentryData( + data=V21_TEST_USER_DATA, + subentry_type="conversation", + title="Test Conversation", + unique_id=None, + ) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", # Model still in main data + }, + version=2, + minor_version=2, + subentries_data=[subentry_data], + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Check migration to v3.1 + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + + # Check that model was moved from main data to subentry + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.data == { + **V21_TEST_USER_DATA, + ollama.CONF_MODEL: "test_model:latest", + } From 12e8b81ec7265d92a8bdb659e66ab313ec0fe0a5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Jul 2025 14:26:19 +0200 Subject: [PATCH 123/127] Update frontend to 20250702.0 (#147952) --- 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 d9b9527c358..bfd868a5334 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==20250701.0"] + "requirements": ["home-assistant-frontend==20250702.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d81403c2715..f0fa428fbb3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c3d981a9e51..e815f8898ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e8db3086d2..c6f0ba0cdf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 9472ff5d36ed00b814c793dcd1d6a729eb04f284 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 15:27:51 +0300 Subject: [PATCH 124/127] Bump aioamazondevices to 3.2.2 (#147953) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 2e74561b755..7c23edd92ce 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.2.1"] + "requirements": ["aioamazondevices==3.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e815f8898ac..04b3f0eaa2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6f0ba0cdf2..ea925412def 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 4eb688b5609942f7c4dfdce07bc936c0203eb379 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:23:08 +0200 Subject: [PATCH 125/127] Z-Wave JS: rename controller to adapter according to term decision (#147955) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/zwave_js/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index b7f9b180624..7445182e5f6 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,12 +15,12 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "reset_failed": "Failed to reset controller.", + "reset_failed": "Failed to reset adapter.", "usb_ports_failed": "Failed to get USB devices." }, "error": { @@ -114,19 +114,19 @@ }, "reconfigure": { "title": "Migrate or re-configure", - "description": "Are you migrating to a new controller or re-configuring the current controller?", + "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_options": { - "intent_migrate": "Migrate to a new controller", - "intent_reconfigure": "Re-configure the current controller" + "intent_migrate": "Migrate to a new adapter", + "intent_reconfigure": "Re-configure the current adapter" } }, "instruct_unplug": { - "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." + "title": "Unplug your old adapter", + "description": "Backup saved to \"{file_path}\"\n\nYour old adapter has not been reset. You should now unplug it to prevent it from interfering with the new adapter.\n\nPlease make sure your new adapter is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", - "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", + "description": "Your Z-Wave network could not be restored to the new adapter. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", "submit": "Try again" }, "choose_serial_port": { @@ -289,12 +289,12 @@ "fix_flow": { "step": { "confirm": { - "description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.", - "title": "An unknown controller was detected" + "description": "A Z-Wave adapter of model {controller_model} was connected to the {config_entry_title} configuration entry. This adapter has a different ID ({new_unique_id}) than the previously connected adapter ({old_unique_id}).\n\nReasons for a different adapter ID could be:\n\n1. The adapter was factory reset using a 3rd party application.\n2. A backup of the adapter's non-volatile memory was restored to the adapter using a 3rd party application.\n3. A different adapter was connected to this configuration entry.\n\nIf a different adapter was connected, you should instead set up a new configuration entry for the new adapter.\n\nIf you are sure that the current adapter is the correct adapter, confirm by pressing Submit. The configuration entry will remember the new adapter ID.", + "title": "An unknown adapter was detected" } } }, - "title": "An unknown controller was detected" + "title": "An unknown adapter was detected" } }, "services": { From 8fc3fa51a85061047b4164dc044176f962c58bb5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 13:30:51 +0000 Subject: [PATCH 126/127] Bump version to 2025.7.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 db378d77902..9c78cddc505 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index dfc393f69bc..242de41247d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b8" +version = "2025.7.0b9" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 8334a0398c17065594424e66b6e1c4518cfd3ed7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 15:12:16 +0000 Subject: [PATCH 127/127] Bump version to 2025.7.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 9c78cddc505..f1a9f7f79c2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -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, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 242de41247d..c34a268e347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b9" +version = "2025.7.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."