diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 649bf7b918ddf..be8403ef4411a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -736,7 +736,7 @@ jobs: apt-cache-version: ${{ env.APT_CACHE_VERSION }} - name: Restore pytest test counts cache id: cache-pytest-counts - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: pytest_test_counts.json # Primary key is a sentinel; restore-keys pick the most recent @@ -769,7 +769,7 @@ jobs: steps.cache-pytest-counts.outputs.cache-matched-key, steps.cache-pytest-counts-hash.outputs.hash ) - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: pytest_test_counts.json key: >- diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index cf8e3ac596d8b..d27706a9c1792 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3 with: languages: python diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 70bf103437851..60a97ce4d7724 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.10.0", "home-assistant-intents==2026.6.24"] + "requirements": ["hassil==3.10.0", "home-assistant-intents==2026.7.30"] } diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 9e64dc63c9afd..2b8e8f7c78ddc 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -3,14 +3,15 @@ from sucks import VacBot from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_DEVICE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import CONF_OVERRIDE_REST_URL, DOMAIN from .controller import EcovacsController from .services import async_setup_services +from .util import get_client_device_id PLATFORMS = [ Platform.BINARY_SENSOR, @@ -61,3 +62,18 @@ async def _async_wait_connect(device: VacBot) -> None: async def async_unload_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool: + """Migrate an old entry.""" + if entry.version == 1 and entry.minor_version < 2: + # Persist the client device ID, which was generated on every start before + rest_url = entry.data.get(CONF_OVERRIDE_REST_URL) + device_id = get_client_device_id(hass, rest_url is not None, entry.data) + hass.config_entries.async_update_entry( + entry, + data=entry.data | {CONF_DEVICE_ID: device_id}, + minor_version=2, + ) + + return True diff --git a/homeassistant/components/ecovacs/config_flow.py b/homeassistant/components/ecovacs/config_flow.py index 66e42b36f7833..1790492e33d29 100644 --- a/homeassistant/components/ecovacs/config_flow.py +++ b/homeassistant/components/ecovacs/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Ecovacs mqtt integration.""" +from collections.abc import Mapping from functools import partial import logging import ssl @@ -9,14 +10,25 @@ from aiohttp import ClientError from deebot_client.authentication import Authenticator, create_rest_config from deebot_client.const import UNDEFINED, UndefinedType -from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.exceptions import ( + DeviceVerificationRequiredError, + InvalidAuthenticationError, + InvalidVerificationCodeError, + MqttError, +) from deebot_client.mqtt_client import MqttClient, create_mqtt_config from deebot_client.util import md5 import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_COUNTRY, CONF_MODE, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_MODE, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, selector from homeassistant.helpers.typing import VolDictType from homeassistant.util.ssl import get_default_no_verify_context @@ -24,6 +36,7 @@ from .const import ( CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL, + CONF_VERIFICATION_CODE, CONF_VERIFY_MQTT_CERTIFICATE, DOMAIN, InstanceMode, @@ -49,7 +62,10 @@ def _validate_url( async def _validate_input( - hass: HomeAssistant, user_input: dict[str, Any] + hass: HomeAssistant, + user_input: dict[str, Any], + device_id: str, + authenticator: Authenticator, ) -> dict[str, str]: """Validate user input.""" errors: dict[str, str] = {} @@ -66,23 +82,11 @@ async def _validate_input( if errors: return errors - device_id = get_client_device_id(hass, rest_url is not None) - country = user_input[CONF_COUNTRY] - rest_config = create_rest_config( - aiohttp_client.async_get_clientsession(hass), - device_id=device_id, - alpha_2_country=country, - override_rest_url=rest_url, - ) - - authenticator = Authenticator( - rest_config, - user_input[CONF_USERNAME], - md5(user_input[CONF_PASSWORD]), - ) - try: await authenticator.authenticate() + except DeviceVerificationRequiredError: + # Handled by the caller, which starts the device verification step + raise except ClientError: _LOGGER.debug("Cannot connect", exc_info=True) errors["base"] = "cannot_connect" @@ -95,6 +99,19 @@ async def _validate_input( if errors: return errors + return await _validate_mqtt(hass, user_input, device_id, authenticator) + + +async def _validate_mqtt( + hass: HomeAssistant, + user_input: dict[str, Any], + device_id: str, + authenticator: Authenticator, +) -> dict[str, str]: + """Validate the MQTT connection.""" + errors: dict[str, str] = {} + country = user_input[CONF_COUNTRY] + mqtt_url = user_input.get(CONF_OVERRIDE_MQTT_URL) ssl_context: UndefinedType | ssl.SSLContext = UNDEFINED if not user_input.get(CONF_VERIFY_MQTT_CERTIFICATE, True) and mqtt_url: ssl_context = get_default_no_verify_context() @@ -130,14 +147,80 @@ class EcovacsConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ecovacs.""" VERSION = 1 + MINOR_VERSION = 2 _mode: InstanceMode = InstanceMode.CLOUD + _input: dict[str, Any] + _authenticator: Authenticator | None = None + _device_id: str + + async def _async_set_input(self, user_input: dict[str, Any]) -> Authenticator: + """Set the input and create its authenticator.""" + await self._async_teardown_authenticator() + self._input = user_input + self_hosted = CONF_OVERRIDE_REST_URL in user_input + self._device_id = get_client_device_id(self.hass, self_hosted, user_input) + self._authenticator = Authenticator( + create_rest_config( + aiohttp_client.async_get_clientsession(self.hass), + device_id=self._device_id, + alpha_2_country=user_input[CONF_COUNTRY], + override_rest_url=user_input.get(CONF_OVERRIDE_REST_URL), + ), + user_input[CONF_USERNAME], + md5(user_input[CONF_PASSWORD]), + ) + return self._authenticator + + async def _async_teardown_authenticator(self) -> None: + """Tear down the authenticator to cancel its token refresh timer.""" + if self._authenticator is not None: + await self._authenticator.teardown() + self._authenticator = None + + @callback + @override + def async_remove(self) -> None: + """Handle flow removal - tear down the authenticator.""" + super().async_remove() + if self._authenticator is not None: + self.hass.async_create_background_task( + self._async_teardown_authenticator(), + name="ecovacs_config_flow_authenticator_teardown", + ) + + async def _async_request_device_verification_code( + self, authenticator: Authenticator + ) -> dict[str, str]: + """Request a device verification code.""" + try: + await authenticator.request_device_verification_code() + except ClientError: + _LOGGER.debug("Cannot request Ecovacs verification code", exc_info=True) + return {"base": "cannot_connect"} + except Exception: + _LOGGER.exception("Unexpected exception requesting verification code") + return {"base": "unknown"} + return {} + + def _finish_flow(self) -> ConfigFlowResult: + """Create or update the config entry.""" + self._input[CONF_DEVICE_ID] = self._device_id + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=self._input + ) + return self.async_create_entry( + title=self._input[CONF_USERNAME], + data=self._input, + ) @override async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + self._input = {} if user_input: self._mode = user_input[CONF_MODE] return await self.async_step_auth() @@ -160,22 +243,12 @@ async def async_step_user( last_step=False, ) - async def async_step_auth( - self, user_input: dict[str, Any] | None = None + def _show_auth_form( + self, + user_input: dict[str, Any] | None, + errors: dict[str, str], ) -> ConfigFlowResult: - """Handle the auth step.""" - errors = {} - - if user_input: - self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) - - errors = await _validate_input(self.hass, user_input) - - if not errors: - return self.async_create_entry( - title=user_input[CONF_USERNAME], data=user_input - ) - + """Show the authentication form.""" schema: VolDictType = { vol.Required(CONF_USERNAME): selector.TextSelector( selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) @@ -212,3 +285,138 @@ async def async_step_auth( errors=errors, last_step=True, ) + + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the auth step.""" + errors: dict[str, str] = {} + + if user_input: + self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) + if CONF_DEVICE_ID in self._input and CONF_DEVICE_ID not in user_input: + user_input[CONF_DEVICE_ID] = self._input[CONF_DEVICE_ID] + authenticator = await self._async_set_input(user_input) + try: + errors = await _validate_input( + self.hass, + self._input, + self._device_id, + authenticator, + ) + except DeviceVerificationRequiredError: + errors = await self._async_request_device_verification_code( + authenticator + ) + if not errors: + return await self.async_step_device_verification() + if not errors: + return self._finish_flow() + + return self._show_auth_form(user_input, errors) + + async def async_step_device_verification( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Verify the stable Ecovacs client device ID.""" + errors: dict[str, str] = {} + # The authenticator is created by the step asking for the credentials + if user_input and (authenticator := self._authenticator): + try: + await authenticator.verify_device(user_input[CONF_VERIFICATION_CODE]) + except InvalidVerificationCodeError: + errors["base"] = "invalid_verification_code" + except ClientError: + _LOGGER.debug("Cannot verify Ecovacs device", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception verifying Ecovacs device") + errors["base"] = "unknown" + else: + # Keep the verified device ID, so a retry needs no new code + self._input[CONF_DEVICE_ID] = self._device_id + errors = await _validate_mqtt( + self.hass, + self._input, + self._device_id, + authenticator, + ) + if not errors: + return self._finish_flow() + if self.source == SOURCE_REAUTH: + return self._show_reauth_form(user_input=None, errors=errors) + return self._show_auth_form(self._input, errors) + + return self.async_show_form( + step_id="device_verification", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_VERIFICATION_CODE): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT + ) + ) + } + ), + suggested_values=user_input, + ), + description_placeholders={CONF_USERNAME: self._input[CONF_USERNAME]}, + errors=errors, + ) + + def _show_reauth_form( + self, + user_input: dict[str, Any] | None, + errors: dict[str, str], + ) -> ConfigFlowResult: + """Show the reauthentication form.""" + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD + ) + ) + } + ), + suggested_values=user_input, + ), + description_placeholders={CONF_USERNAME: self._input[CONF_USERNAME]}, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + self._input = dict(entry_data) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm credentials and verify a new device ID if required.""" + errors: dict[str, str] = {} + if user_input: + authenticator = await self._async_set_input(self._input | user_input) + try: + errors = await _validate_input( + self.hass, + self._input, + self._device_id, + authenticator, + ) + except DeviceVerificationRequiredError: + errors = await self._async_request_device_verification_code( + authenticator + ) + if not errors: + return await self.async_step_device_verification() + if not errors: + return self._finish_flow() + + return self._show_reauth_form(user_input, errors) diff --git a/homeassistant/components/ecovacs/const.py b/homeassistant/components/ecovacs/const.py index cc5c8276cbcca..685ff9533c643 100644 --- a/homeassistant/components/ecovacs/const.py +++ b/homeassistant/components/ecovacs/const.py @@ -10,6 +10,7 @@ CONF_CONTINENT = "continent" CONF_OVERRIDE_REST_URL = "override_rest_url" CONF_OVERRIDE_MQTT_URL = "override_mqtt_url" +CONF_VERIFICATION_CODE = "verification_code" CONF_VERIFY_MQTT_CERTIFICATE = "verify_mqtt_certificate" SUPPORTED_LIFESPANS = ( diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index cb94505e4a77d..2bd4389c50810 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -11,15 +11,24 @@ from deebot_client.authentication import Authenticator, create_rest_config from deebot_client.const import UNDEFINED, UndefinedType from deebot_client.device import Device -from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +from deebot_client.exceptions import ( + DeebotError, + DeviceVerificationRequiredError, + InvalidAuthenticationError, +) from deebot_client.mqtt_client import MqttClient, create_mqtt_config from deebot_client.util import md5 from deebot_client.util.continents import get_continent from sucks import EcoVacsAPI, VacBot -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.util.ssl import get_default_no_verify_context @@ -28,7 +37,6 @@ CONF_OVERRIDE_REST_URL, CONF_VERIFY_MQTT_CERTIFICATE, ) -from .util import get_client_device_id _LOGGER = logging.getLogger(__name__) @@ -42,7 +50,7 @@ def __init__(self, hass: HomeAssistant, config: Mapping[str, Any]) -> None: self._devices: list[Device] = [] self._legacy_devices: list[VacBot] = [] rest_url = config.get(CONF_OVERRIDE_REST_URL) - self._device_id = get_client_device_id(hass, rest_url is not None) + self._device_id = config[CONF_DEVICE_ID] country = config[CONF_COUNTRY] self._continent = get_continent(country) @@ -116,8 +124,10 @@ async def _init(device: Device) -> None: device_config, ) + except DeviceVerificationRequiredError as ex: + raise ConfigEntryAuthFailed("Device verification required") from ex except InvalidAuthenticationError as ex: - raise ConfigEntryError("Invalid credentials") from ex + raise ConfigEntryAuthFailed("Invalid credentials") from ex except DeebotError as ex: raise ConfigEntryNotReady("Error during setup") from ex diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 74587a044c896..e38a53e35d0c8 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==18.5.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==18.5.1"] } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 5714cbebe9aba..8c02b59ca3129 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -9,6 +10,7 @@ "invalid_url": "Invalid URL", "invalid_url_schema_override_mqtt_url": "Invalid MQTT URL scheme.\nThe URL should start with `mqtt://` or `mqtts://`.", "invalid_url_schema_override_rest_url": "Invalid REST URL scheme.\nThe URL should start with `http://` or `https://`.", + "invalid_verification_code": "The verification code is invalid or has expired.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { @@ -26,6 +28,18 @@ "override_rest_url": "Enter the REST URL of your self-hosted instance including the scheme (http/https)." } }, + "device_verification": { + "data": { + "verification_code": "Verification code" + }, + "description": "Enter the verification code Ecovacs sent to {username} to confirm Home Assistant as a trusted device." + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Enter the Ecovacs password for {username} to reconnect the integration." + }, "user": { "data": { "mode": "[%key:common::config_flow::data::mode%]" diff --git a/homeassistant/components/ecovacs/util.py b/homeassistant/components/ecovacs/util.py index b5c6cb844615b..5ffad4ab2f39c 100644 --- a/homeassistant/components/ecovacs/util.py +++ b/homeassistant/components/ecovacs/util.py @@ -1,10 +1,12 @@ """Ecovacs util functions.""" +from collections.abc import Mapping from enum import Enum import random import string -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify @@ -18,8 +20,12 @@ from .controller import EcovacsController -def get_client_device_id(hass: HomeAssistant, self_hosted: bool) -> str: +def get_client_device_id( + hass: HomeAssistant, self_hosted: bool, config: Mapping[str, Any] +) -> str: """Get client device id.""" + if device_id := config.get(CONF_DEVICE_ID): + return cast(str, device_id) if self_hosted: return f"HA-{slugify(hass.config.location_name)}" diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py index 12d2fe496885b..ab8269d4f4bd6 100644 --- a/homeassistant/components/music_assistant/helpers.py +++ b/homeassistant/components/music_assistant/helpers.py @@ -4,12 +4,15 @@ import functools from typing import TYPE_CHECKING, Any +from music_assistant_models.auth import UserRole from music_assistant_models.errors import MusicAssistantError from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from .const import DOMAIN + if TYPE_CHECKING: from music_assistant_client import MusicAssistantClient @@ -46,10 +49,19 @@ def get_music_assistant_client( return entry.runtime_data.mass +async def _async_get_available_mass_usernames(mass: MusicAssistantClient) -> list[str]: + """Get available Music Assistant usernames which can be used in Home Assistant.""" + users = await mass.auth.list_users() + return [ + user.username for user in users if user.enabled and user.role != UserRole.GUEST + ] + + async def async_resolve_mass_username( - hass: HomeAssistant, user_id: str, available_usernames: list[str] + hass: HomeAssistant, mass: MusicAssistantClient, user_id: str ) -> str | None: """Resolve the Music Assistant username for the Home Assistant user.""" + available_usernames = await _async_get_available_mass_usernames(mass) if (user := await hass.auth.async_get_user(user_id)) is None: return None for cred in user.credentials: @@ -62,3 +74,19 @@ async def async_resolve_mass_username( if username in available_usernames: return username return None + + +async def async_verify_mass_username_availability( + mass: MusicAssistantClient, username: str +) -> None: + """Verify Music Assistant username availability for service calls.""" + available_usernames = await _async_get_available_mass_usernames(mass) + if username not in available_usernames: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_username", + translation_placeholders={ + "username": username, + "available_usernames": ", ".join(available_usernames), + }, + ) diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 74d1db426191a..83aa62bc1b218 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -6,7 +6,6 @@ import os from typing import TYPE_CHECKING, Any, override -from music_assistant_models.auth import UserRole from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( EventType, @@ -61,7 +60,11 @@ DOMAIN, ) from .entity import MusicAssistantEntity -from .helpers import async_resolve_mass_username, catch_musicassistant_error +from .helpers import ( + async_resolve_mass_username, + async_verify_mass_username_availability, + catch_musicassistant_error, +) from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -463,26 +466,12 @@ async def _async_handle_play_media( # An explicit username is validated strictly; when omitted we default to # the Home Assistant user that made the call (best-effort, never raises). user_id = self._context.user_id if self._context is not None else None - if username is not None or user_id is not None: - available_usernames = [ - user.username - for user in await self.mass.auth.list_users() - if user.enabled and user.role != UserRole.GUEST - ] - if username is not None: - if username not in available_usernames: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_username", - translation_placeholders={ - "username": username, - "available_usernames": ", ".join(available_usernames), - }, - ) - elif user_id is not None: - username = await async_resolve_mass_username( - self.hass, user_id, available_usernames - ) + if username is not None: + await async_verify_mass_username_availability( + mass=self.mass, username=username + ) + elif user_id is not None: + username = await async_resolve_mass_username(self.hass, self.mass, user_id) media_uris: list[str] = [] item: MediaItemType | ItemMapping | None = None diff --git a/homeassistant/components/music_assistant/services.py b/homeassistant/components/music_assistant/services.py index 8154a4eeae475..64c94bda46551 100644 --- a/homeassistant/components/music_assistant/services.py +++ b/homeassistant/components/music_assistant/services.py @@ -54,7 +54,7 @@ ATTR_USERNAME, DOMAIN, ) -from .helpers import get_music_assistant_client +from .helpers import async_verify_mass_username_availability, get_music_assistant_client from .schemas import ( LIBRARY_RESULTS_SCHEMA, SEARCH_RESULT_SCHEMA, @@ -102,6 +102,7 @@ def register_actions(hass: HomeAssistant) -> None: vol.Optional(ATTR_SEARCH_ALBUM): cv.string, vol.Optional(ATTR_LIMIT, default=5): vol.Coerce(int), vol.Optional(ATTR_LIBRARY_ONLY, default=False): cv.boolean, + vol.Optional(ATTR_USERNAME): cv.string, } ), supports_response=SupportsResponse.ONLY, @@ -184,6 +185,11 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: search_name = call.data[ATTR_SEARCH_NAME] search_artist = call.data.get(ATTR_SEARCH_ARTIST) search_album = call.data.get(ATTR_SEARCH_ALBUM) + search_username = call.data.get(ATTR_USERNAME) + if search_username is not None: + await async_verify_mass_username_availability( + mass=mass, username=search_username + ) if search_album and search_artist: search_name = f"{search_artist} - {search_album} - {search_name}" elif search_album: @@ -195,6 +201,7 @@ async def handle_search(call: ServiceCall) -> ServiceResponse: media_types=call.data.get(ATTR_MEDIA_TYPE, MediaType.ALL), limit=call.data[ATTR_LIMIT], library_only=call.data[ATTR_LIBRARY_ONLY], + user=search_username, ) response: ServiceResponse = SEARCH_RESULT_SCHEMA( { diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml index d5852d76100b8..55a581fafe4be 100644 --- a/homeassistant/components/music_assistant/services.yaml +++ b/homeassistant/components/music_assistant/services.yaml @@ -156,6 +156,10 @@ search: default: false selector: boolean: + username: + example: "john" + selector: + text: get_library: fields: diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index 722e272741d30..456492b45d033 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -462,6 +462,10 @@ "name": { "description": "The name/title to search for.", "name": "Search name" + }, + "username": { + "description": "Music Assistant username used for searching. Searches respect the user's configured provider filters.", + "name": "Username" } }, "name": "Search Music Assistant", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9630c6b6dc1c2..e9a1162cabc50 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==2.2.0 hassil==3.10.0 home-assistant-bluetooth==2.0.0 home-assistant-frontend==20260729.1 -home-assistant-intents==2026.6.24 +home-assistant-intents==2026.7.30 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements.txt b/requirements.txt index d12f5cef378d2..247e7ea7e510c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2 hass-nabucasa==2.2.0 hassil==3.10.0 home-assistant-bluetooth==2.0.0 -home-assistant-intents==2026.6.24 +home-assistant-intents==2026.7.30 httpx==0.28.1 ifaddr==0.2.0 infrared-protocols==8.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index c5dce4d8cfd38..c21c95f4cc3cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -812,7 +812,7 @@ debugpy==1.8.21 decora-wifi==1.4 # homeassistant.components.ecovacs -deebot-client==18.5.0 +deebot-client==18.5.1 # homeassistant.components.ihc # homeassistant.components.ohmconnect @@ -1281,7 +1281,7 @@ holidays==0.101 home-assistant-frontend==20260729.1 # homeassistant.components.conversation -home-assistant-intents==2026.6.24 +home-assistant-intents==2026.7.30 # homeassistant.components.homekit homekit-audio-proxy==1.2.1 diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 7178dc0d5d03a..948c440e353b4 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -6,6 +6,7 @@ 'id': 'conversation.home_assistant', 'name': 'Home Assistant', 'supported_languages': list([ + 'af', 'ar', 'bg', 'bn', @@ -24,6 +25,7 @@ 'fi', 'fr', 'gl', + 'gu', 'he', 'hi', 'hr', @@ -34,6 +36,7 @@ 'it', 'ja', 'ka', + 'kn', 'ko', 'kw', 'lb', diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 8bb1ab70c8b0f..011aefe83223d 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components import ( + climate, conversation, cover, light, @@ -25,6 +26,8 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, STATE_CLOSED, + STATE_OFF, + STATE_ON, STATE_PAUSED, STATE_PLAYING, ) @@ -463,6 +466,49 @@ async def test_todo_add_item_fr( assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine" +async def test_climate_turn_on_off( + hass: HomeAssistant, + init_components, +) -> None: + """Test turning a climate device on and off.""" + entity_id = f"{climate.DOMAIN}.thermostat" + attributes = { + ATTR_SUPPORTED_FEATURES: climate.ClimateEntityFeature.TURN_ON + | climate.ClimateEntityFeature.TURN_OFF + } + + hass.states.async_set(entity_id, STATE_OFF, attributes=attributes) + async_expose_entity(hass, conversation.DOMAIN, entity_id, True) + + # turn on + on_calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_TURN_ON) + result = await conversation.async_converse( + hass, "turn on the thermostat", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(on_calls) == 1 + call = on_calls[0] + assert call.data == {"entity_id": [entity_id]} + + hass.states.async_set(entity_id, STATE_ON, attributes=attributes) + + # turn off + off_calls = async_mock_service(hass, climate.DOMAIN, climate.SERVICE_TURN_OFF) + result = await conversation.async_converse( + hass, "turn off the thermostat", None, Context(), None + ) + await hass.async_block_till_done() + + response = result.response + assert response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(off_calls) == 1 + call = off_calls[0] + assert call.data == {"entity_id": [entity_id]} + + @pytest.mark.freeze_time( datetime( year=2013, diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 6820f3db255ab..ed80fd78f28d3 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -19,11 +19,21 @@ from homeassistant.const import CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from .const import VALID_ENTRY_DATA_CLOUD +from .const import CLOUD_DEVICE_ID, STORED_ENTRY_DATA_CLOUD from tests.common import MockConfigEntry, load_json_object_fixture +@pytest.fixture +def mock_device_id() -> Generator[None]: + """Return a deterministic cloud device ID.""" + with patch( + "homeassistant.components.ecovacs.util.random.choice", + return_value=CLOUD_DEVICE_ID[0], + ): + yield + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -36,7 +46,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_config_entry_data() -> dict[str, Any]: """Return the default mocked config entry data.""" - return VALID_ENTRY_DATA_CLOUD + return STORED_ENTRY_DATA_CLOUD @pytest.fixture @@ -46,6 +56,7 @@ def mock_config_entry(mock_config_entry_data: dict[str, Any]) -> MockConfigEntry title=mock_config_entry_data[CONF_USERNAME], domain=DOMAIN, data=mock_config_entry_data, + minor_version=2, ) diff --git a/tests/components/ecovacs/const.py b/tests/components/ecovacs/const.py index 89d2e2a816659..51bfd6bdb5d3f 100644 --- a/tests/components/ecovacs/const.py +++ b/tests/components/ecovacs/const.py @@ -6,7 +6,12 @@ CONF_OVERRIDE_REST_URL, CONF_VERIFY_MQTT_CERTIFICATE, ) -from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_USERNAME, +) VALID_ENTRY_DATA_CLOUD = { CONF_USERNAME: "username@cloud", @@ -25,3 +30,13 @@ } IMPORT_DATA = VALID_ENTRY_DATA_CLOUD | {CONF_CONTINENT: "EU"} + +# Cloud device IDs are random, self-hosted ones are derived from the instance name +CLOUD_DEVICE_ID = "AAAAAAAA" +SELF_HOSTED_DEVICE_ID = "HA-test_home" + +# The client device ID is generated during the config flow and stored afterwards +STORED_ENTRY_DATA_CLOUD = VALID_ENTRY_DATA_CLOUD | {CONF_DEVICE_ID: CLOUD_DEVICE_ID} +STORED_ENTRY_DATA_SELF_HOSTED = VALID_ENTRY_DATA_SELF_HOSTED | { + CONF_DEVICE_ID: SELF_HOSTED_DEVICE_ID +} diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index f9540e06038ce..2736ea80fce70 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -4,6 +4,7 @@ 'config': dict({ 'data': dict({ 'country': 'IT', + 'device_id': 'AAAAAAAA', 'password': '**REDACTED**', 'username': '**REDACTED**', }), @@ -11,7 +12,7 @@ 'discovery_keys': dict({ }), 'domain': 'ecovacs', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -57,6 +58,7 @@ 'config': dict({ 'data': dict({ 'country': 'IT', + 'device_id': 'HA-test_home', 'override_mqtt_url': '**REDACTED**', 'override_rest_url': '**REDACTED**', 'password': '**REDACTED**', @@ -66,7 +68,7 @@ 'discovery_keys': dict({ }), 'domain': 'ecovacs', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/ecovacs/test_config_flow.py b/tests/components/ecovacs/test_config_flow.py index bdfdebd7e6beb..f465f9cedb351 100644 --- a/tests/components/ecovacs/test_config_flow.py +++ b/tests/components/ecovacs/test_config_flow.py @@ -7,23 +7,34 @@ from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError -from deebot_client.exceptions import InvalidAuthenticationError, MqttError +from deebot_client.authentication import create_rest_config +from deebot_client.exceptions import ( + DeviceVerificationRequiredError, + InvalidAuthenticationError, + InvalidVerificationCodeError, + MqttError, +) from deebot_client.mqtt_client import create_mqtt_config import pytest from homeassistant.components.ecovacs.const import ( CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL, + CONF_VERIFICATION_CODE, CONF_VERIFY_MQTT_CERTIFICATE, DOMAIN, InstanceMode, ) -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_MODE, CONF_USERNAME +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.const import CONF_DEVICE_ID, CONF_MODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .const import ( + CLOUD_DEVICE_ID, + SELF_HOSTED_DEVICE_ID, + STORED_ENTRY_DATA_CLOUD, + STORED_ENTRY_DATA_SELF_HOSTED, VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED, VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, @@ -43,7 +54,7 @@ class _TestFnUserInput: async def _test_user_flow( hass: HomeAssistant, user_input: _TestFnUserInput, -) -> dict[str, Any]: +) -> ConfigFlowResult: """Test config flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -69,20 +80,36 @@ async def _test_user_flow( ) +async def _test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> ConfigFlowResult: + """Start a reauth flow and return the shown reauth confirmation form.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + return result + + @pytest.mark.parametrize( ("test_fn_user_input", "entry_data"), [ ( _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - VALID_ENTRY_DATA_CLOUD, + STORED_ENTRY_DATA_CLOUD, ), ( _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), - VALID_ENTRY_DATA_SELF_HOSTED, + STORED_ENTRY_DATA_SELF_HOSTED, ), ], ids=["cloud", "self_hosted"], ) +@pytest.mark.usefixtures("mock_device_id") async def test_user_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -101,7 +128,7 @@ async def test_user_flow( mock_mqtt_client.verify_config.assert_called() -def _cannot_connect_error(user_input: dict[str, Any]) -> str: +def _cannot_connect_error(user_input: dict[str, Any]) -> dict[str, str]: field = "base" if CONF_OVERRIDE_MQTT_URL in user_input: field = CONF_OVERRIDE_MQTT_URL @@ -132,15 +159,17 @@ def _cannot_connect_error(user_input: dict[str, Any]) -> str: [ ( _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), - VALID_ENTRY_DATA_CLOUD, + STORED_ENTRY_DATA_CLOUD, ), ( _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), - VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT, + VALID_ENTRY_DATA_SELF_HOSTED_WITH_VALIDATE_CERT + | {CONF_DEVICE_ID: SELF_HOSTED_DEVICE_ID}, ), ], ids=["cloud", "self_hosted"], ) +@pytest.mark.usefixtures("mock_device_id") async def test_user_flow_raise_error( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -149,7 +178,7 @@ async def test_user_flow_raise_error( side_effect_rest: Exception, reason_rest: str, side_effect_mqtt: Exception, - errors_mqtt: Callable[[dict[str, Any]], str], + errors_mqtt: Callable[[dict[str, Any]], dict[str, str]], test_fn_user_input: _TestFnUserInput, entry_data: dict[str, Any], ) -> None: @@ -227,6 +256,7 @@ async def test_user_flow_self_hosted_error( mock_setup_entry.assert_not_called() # Check that the schema includes select box to disable ssl verification of mqtt + assert result["data_schema"] is not None assert CONF_VERIFY_MQTT_CERTIFICATE in result["data_schema"].schema data = VALID_ENTRY_DATA_SELF_HOSTED | {CONF_VERIFY_MQTT_CERTIFICATE: False} @@ -246,7 +276,7 @@ async def test_user_flow_self_hosted_error( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == data[CONF_USERNAME] - assert result["data"] == data + assert result["data"] == data | {CONF_DEVICE_ID: SELF_HOSTED_DEVICE_ID} mock_setup_entry.assert_called() mock_authenticator_authenticate.assert_called() mock_mqtt_client.verify_config.assert_called() @@ -275,3 +305,287 @@ async def test_already_exists( assert result assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("test_fn_user_input", "entry_data"), + [ + ( + _TestFnUserInput(VALID_ENTRY_DATA_CLOUD), + STORED_ENTRY_DATA_CLOUD, + ), + ( + _TestFnUserInput(VALID_ENTRY_DATA_SELF_HOSTED, _USER_STEP_SELF_HOSTED), + STORED_ENTRY_DATA_SELF_HOSTED, + ), + ], + ids=["cloud", "self_hosted"], +) +@pytest.mark.usefixtures("mock_device_id") +async def test_device_verification( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator: Mock, + mock_mqtt_client: Mock, + test_fn_user_input: _TestFnUserInput, + entry_data: dict[str, Any], +) -> None: + """Test verifying the Ecovacs client device ID during the user flow.""" + mock_authenticator.authenticate.side_effect = DeviceVerificationRequiredError + + result = await _test_user_flow(hass, test_fn_user_input) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device_verification" + mock_authenticator.request_device_verification_code.assert_awaited_once() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_VERIFICATION_CODE: "123456"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == entry_data + mock_authenticator.verify_device.assert_awaited_once_with("123456") + mock_authenticator.teardown.assert_awaited_once() + mock_mqtt_client.verify_config.assert_called_once() + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "errors"), + [ + pytest.param(ClientError, {"base": "cannot_connect"}, id="cannot_connect"), + pytest.param(Exception, {"base": "unknown"}, id="unknown"), + ], +) +@pytest.mark.usefixtures("mock_device_id") +async def test_request_device_verification_code_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator: Mock, + mock_mqtt_client: Mock, + side_effect: type[Exception], + errors: dict[str, str], +) -> None: + """Test handling errors while requesting a device verification code.""" + mock_authenticator.authenticate.side_effect = DeviceVerificationRequiredError + mock_authenticator.request_device_verification_code.side_effect = side_effect + + result = await _test_user_flow(hass, _TestFnUserInput(VALID_ENTRY_DATA_CLOUD)) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == errors + mock_authenticator.verify_device.assert_not_called() + mock_mqtt_client.verify_config.assert_not_called() + mock_setup_entry.assert_not_called() + + +@pytest.mark.parametrize( + ("side_effect", "errors"), + [ + pytest.param( + InvalidVerificationCodeError, + {"base": "invalid_verification_code"}, + id="invalid_verification_code", + ), + pytest.param(ClientError, {"base": "cannot_connect"}, id="cannot_connect"), + pytest.param(Exception, {"base": "unknown"}, id="unknown"), + ], +) +@pytest.mark.usefixtures("mock_device_id") +async def test_verify_device_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator: Mock, + mock_mqtt_client: Mock, + side_effect: type[Exception], + errors: dict[str, str], +) -> None: + """Test handling errors while verifying the Ecovacs client device ID.""" + mock_authenticator.authenticate.side_effect = DeviceVerificationRequiredError + + result = await _test_user_flow(hass, _TestFnUserInput(VALID_ENTRY_DATA_CLOUD)) + mock_authenticator.verify_device.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_VERIFICATION_CODE: "expired"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device_verification" + assert result["errors"] == errors + mock_mqtt_client.verify_config.assert_not_called() + mock_setup_entry.assert_not_called() + + +async def test_mqtt_retry_after_device_verification( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_authenticator: Mock, + mock_mqtt_client: Mock, +) -> None: + """Test retrying connection validation without reusing the email code.""" + mock_authenticator.authenticate.side_effect = [ + DeviceVerificationRequiredError, + None, + ] + mock_mqtt_client.verify_config.side_effect = [MqttError, None] + + with patch( + "homeassistant.components.ecovacs.config_flow.create_rest_config", + wraps=create_rest_config, + ) as mock_create_rest_config: + result = await _test_user_flow(hass, _TestFnUserInput(VALID_ENTRY_DATA_CLOUD)) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_VERIFICATION_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "auth" + assert result["errors"] == {"base": "cannot_connect"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=VALID_ENTRY_DATA_CLOUD + ) + await hass.async_block_till_done() + + # The verified device ID is reused, so no new verification code is required + assert mock_create_rest_config.call_count == 2 + device_id = mock_create_rest_config.call_args_list[0].kwargs["device_id"] + assert mock_create_rest_config.call_args_list[1].kwargs["device_id"] == device_id + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == VALID_ENTRY_DATA_CLOUD | {CONF_DEVICE_ID: device_id} + mock_authenticator.request_device_verification_code.assert_awaited_once() + mock_authenticator.verify_device.assert_awaited_once_with("123456") + # The superseded authenticator and the one of the created entry are torn down + assert mock_authenticator.teardown.await_count == 2 + assert mock_mqtt_client.verify_config.call_count == 2 + mock_setup_entry.assert_called_once() + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_reauth( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, +) -> None: + """Test reauthentication without a required device verification.""" + result = await _test_reauth_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "new-password"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + # The already verified device ID is kept, so no new verification is required + assert mock_config_entry.data == STORED_ENTRY_DATA_CLOUD | { + CONF_PASSWORD: "new-password" + } + mock_authenticator.request_device_verification_code.assert_not_called() + mock_authenticator.verify_device.assert_not_called() + mock_setup_entry.assert_called_once() + + +@pytest.mark.usefixtures("mock_mqtt_client") +async def test_reauth_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, +) -> None: + """Test handling invalid credentials during reauthentication.""" + mock_authenticator.authenticate.side_effect = InvalidAuthenticationError + + result = await _test_reauth_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PASSWORD: "wrong-password"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + assert mock_config_entry.data == STORED_ENTRY_DATA_CLOUD + mock_setup_entry.assert_not_called() + + +async def test_reauth_device_verification( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, + mock_mqtt_client: Mock, +) -> None: + """Test device verification for an existing config entry.""" + mock_authenticator.authenticate.side_effect = DeviceVerificationRequiredError + + result = await _test_reauth_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: VALID_ENTRY_DATA_CLOUD[CONF_PASSWORD]}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device_verification" + mock_authenticator.request_device_verification_code.assert_awaited_once() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_VERIFICATION_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_DEVICE_ID] == CLOUD_DEVICE_ID + mock_authenticator.verify_device.assert_awaited_once_with("123456") + mock_mqtt_client.verify_config.assert_called_once() + mock_setup_entry.assert_called_once() + + +async def test_reauth_mqtt_retry_after_device_verification( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_authenticator: Mock, + mock_mqtt_client: Mock, +) -> None: + """Test retrying a reauthentication without reusing the email code.""" + mock_authenticator.authenticate.side_effect = [ + DeviceVerificationRequiredError, + None, + ] + mock_mqtt_client.verify_config.side_effect = [MqttError, None] + + result = await _test_reauth_flow(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: VALID_ENTRY_DATA_CLOUD[CONF_PASSWORD]}, + ) + assert result["step_id"] == "device_verification" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_VERIFICATION_CODE: "123456"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: VALID_ENTRY_DATA_CLOUD[CONF_PASSWORD]}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_DEVICE_ID] == CLOUD_DEVICE_ID + # The verified device ID is reused, so no new verification code is required + mock_authenticator.request_device_verification_code.assert_awaited_once() + mock_authenticator.verify_device.assert_awaited_once_with("123456") + assert mock_mqtt_client.verify_config.call_count == 2 + mock_setup_entry.assert_called_once() diff --git a/tests/components/ecovacs/test_diagnostics.py b/tests/components/ecovacs/test_diagnostics.py index 6e4dcd5f677bb..beac891d9086d 100644 --- a/tests/components/ecovacs/test_diagnostics.py +++ b/tests/components/ecovacs/test_diagnostics.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED +from .const import STORED_ENTRY_DATA_CLOUD, STORED_ENTRY_DATA_SELF_HOSTED from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -16,7 +16,7 @@ @pytest.mark.parametrize( "mock_config_entry_data", - [VALID_ENTRY_DATA_CLOUD, VALID_ENTRY_DATA_SELF_HOSTED], + [STORED_ENTRY_DATA_CLOUD, STORED_ENTRY_DATA_SELF_HOSTED], ids=lambda data: data[CONF_USERNAME], ) async def test_diagnostics( diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3f3af62f22b03..e5320c21e8f56 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -2,16 +2,28 @@ from unittest.mock import Mock, patch -from deebot_client.exceptions import DeebotError, InvalidAuthenticationError +from deebot_client.exceptions import ( + DeebotError, + DeviceVerificationRequiredError, + InvalidAuthenticationError, +) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.ecovacs.const import DOMAIN from homeassistant.components.ecovacs.controller import EcovacsController -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from .const import ( + CLOUD_DEVICE_ID, + SELF_HOSTED_DEVICE_ID, + VALID_ENTRY_DATA_CLOUD, + VALID_ENTRY_DATA_SELF_HOSTED, +) + from tests.common import MockConfigEntry @@ -70,18 +82,74 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_invalid_auth( +@pytest.mark.parametrize( + "side_effect", + [ + InvalidAuthenticationError, + DeviceVerificationRequiredError, + ], + ids=["invalid_auth", "device_verification_required"], +) +async def test_auth_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api_client: Mock, + side_effect: type[Exception], ) -> None: - """Test auth error during setup.""" - mock_api_client.get_devices.side_effect = InvalidAuthenticationError + """Test an auth error during setup triggers reauthentication.""" + mock_api_client.get_devices.side_effect = side_effect 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 mock_config_entry.state is ConfigEntryState.SETUP_ERROR + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("entry_data", "device_id"), + [ + (VALID_ENTRY_DATA_CLOUD, CLOUD_DEVICE_ID), + (VALID_ENTRY_DATA_SELF_HOSTED, SELF_HOSTED_DEVICE_ID), + ], + ids=["cloud", "self_hosted"], +) +@pytest.mark.usefixtures("mock_authenticator", "mock_mqtt_client", "mock_device_id") +async def test_migrate_entry( + hass: HomeAssistant, + entry_data: dict[str, str], + device_id: str, +) -> None: + """Test the client device ID is added to an entry created before it was stored.""" + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, minor_version=1) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data == entry_data | {CONF_DEVICE_ID: device_id} + + +@pytest.mark.usefixtures("mock_authenticator", "mock_mqtt_client") +async def test_migrate_entry_keeps_device_id( + hass: HomeAssistant, +) -> None: + """Test an already stored client device ID is not replaced.""" + entry_data = VALID_ENTRY_DATA_CLOUD | {CONF_DEVICE_ID: "STOREDID"} + entry = MockConfigEntry(domain=DOMAIN, data=entry_data, minor_version=1) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.minor_version == 2 + assert entry.data == entry_data async def test_devices_in_dr( diff --git a/tests/components/music_assistant/test_services.py b/tests/components/music_assistant/test_services.py index 5a6949764cafd..8655b2f19a4a7 100644 --- a/tests/components/music_assistant/test_services.py +++ b/tests/components/music_assistant/test_services.py @@ -1,7 +1,8 @@ """Test Music Assistant actions.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call +from music_assistant_models.enums import MediaType from music_assistant_models.media_items import SearchResults import pytest from syrupy.assertion import SnapshotAssertion @@ -10,6 +11,7 @@ ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, + ATTR_USERNAME, DOMAIN, ) from homeassistant.components.music_assistant.services import ( @@ -18,6 +20,7 @@ ) from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .common import create_library_albums_from_fixture, setup_integration_from_fixtures @@ -48,6 +51,59 @@ async def test_search_action( assert response == snapshot +async def test_search_action_with_username( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test music assistant search action.""" + entry = await setup_integration_from_fixtures(hass, music_assistant_client) + + # tests for servers supporting the username + music_assistant_client.server_info.schema_version = 35 + music_assistant_client.music.client.send_command = AsyncMock( + return_value={"albums": []} + ) + + # valid user ok and forwarded + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: "user_user", + }, + blocking=True, + return_response=True, + ) + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "music/search", + search_query="test", + media_types=MediaType.ALL, + limit=5, + library_only=False, + user="user_user", + require_schema=35, + ) + + # not valid because of name, disabled or guest + for username in ("non_existing_user", "party_guest", "user_disabled"): + with pytest.raises(ServiceValidationError) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SEARCH, + { + ATTR_CONFIG_ENTRY_ID: entry.entry_id, + ATTR_SEARCH_NAME: "test", + ATTR_USERNAME: username, + }, + blocking=True, + return_response=True, + ) + assert exc.value.translation_key == "invalid_username" + + @pytest.mark.parametrize( "media_type", [