diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index aa8bd42b1a..8be86442a7 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -1,15 +1,17 @@ """ Yandex Smart Home Plugin Provider for Music Assistant. -Exposes Music Assistant players to Yandex Alice via the Yandex Smart Home API. -Allows voice control of MA players through Alice commands like -"Алиса, включи музыку на [имя плеера]". - -Architecture: - Alice voice command → Yandex Cloud → Smart Home API callback → this plugin → MA Player - -The plugin registers MA players as media_device in Yandex Smart Home, -mapping capabilities (on_off, volume, pause) to MA player controls. +Exposes Music Assistant players as Yandex Smart Home devices so Alice can +control playback (play / pause / volume / mute / source) via natural-language +commands. The voice-skill (custom dialog) functionality lives in the sister +provider `ma-provider-yandex-alice`. + +Connection modes: +- ``cloud`` — public yaha-cloud.ru relay (zero setup, but the public skill can + only be linked to one MA / Home Assistant instance per Yandex account). +- ``cloud_plus`` — private skill via the yaha-cloud relay (multiple instances + per account, registered manually in the dev console). +- ``direct`` — Yandex calls the MA webserver directly (requires public HTTPS). Reference: https://github.com/dext0r/yandex_smart_home """ @@ -26,23 +28,24 @@ import aiohttp from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption from music_assistant_models.enums import ConfigEntryType, ProviderFeature - -from ._compat import SecretStr -from .auto_skill import ( - auto_create_skill, - load_default_logo_bytes, -) -from .auto_skill_state import ( +from ya_dialogs_api import ( + SMART_HOME_CHANNEL, + SecretStr, + SkillCreationArtifacts, SkillCreationState, + auto_create_skill, dump_artifacts, load_artifacts, + load_default_logo_bytes, ) -from .auto_skill_ui import build_cloud_plus_entries, build_direct_entries + +from ._smarthome_auto_create import derive_smart_home_urls, resolve_base_url from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( CONF_ACTION_AUTO_CREATE, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_AUTH_X_TOKEN, CONF_AUTO_CREATE_ARTIFACTS, CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, @@ -52,13 +55,19 @@ CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, + CONF_EXPOSED_PLAYLISTS, + CONF_EXTERNAL_BASE_URL, CONF_INSTANCE_NAME, CONF_SKILL_ID, CONF_SKILL_TOKEN, CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + MAX_INPUT_SOURCES, + YANDEX_OAUTH_URL, ) +from .ma_authenticator import make_authenticator +from .playlists import fetch_playlist_options from .plugin import YandexSmartHomePlugin if TYPE_CHECKING: @@ -68,13 +77,14 @@ from music_assistant.mass import MusicAssistant from music_assistant.models import ProviderInstanceType + _LOGGER = logging.getLogger(__name__) SUPPORTED_FEATURES: set[ProviderFeature] = set() def _build_status_label(otp_code: str | None, is_cloud_plus: bool, is_registered: bool) -> str: - """Build the status label text based on registration state.""" + """Status banner text for cloud / cloud_plus modes.""" if otp_code and is_cloud_plus: return ( "✅ Cloud instance registered! " @@ -107,18 +117,37 @@ async def setup( return YandexSmartHomePlugin(mass, manifest, config, SUPPORTED_FEATURES) +def _is_skill_token_set( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> bool: + """Return True if a non-empty Skill OAuth token is persisted or in-flight. + + The token is a SECURE_STRING — the frontend doesn't echo it back into + ``values`` on re-open. Prefer the persisted provider-config value; fall + back to ``values`` for the very first save round-trip when nothing is + persisted yet. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_SKILL_TOKEN) + if saved: + return True + return bool(values.get(CONF_SKILL_TOKEN)) + + def _resolve_direct_client_secret( mass: MusicAssistant, instance_id: str | None, values: dict[str, ConfigValueType], ) -> str: - """Return the direct-mode OAuth client secret for the current install. + """Return the direct-mode per-install OAuth client secret. - `CONF_DIRECT_CLIENT_SECRET` is a SECURE_STRING: MA's frontend does - not echo saved secrets back into ``values`` on re-open, so reading - from ``values`` alone returns an empty string for existing instances. - Prefer the persisted value from saved config and fall back to - ``values`` only for first-time setup before any save. + The frontend does not echo SECURE_STRING fields back into ``values`` on + re-open, so prefer the saved provider config; fall back to the in-flight + form value (e.g. on a fresh-install round-trip before the first save). """ if instance_id: prov = mass.get_provider(instance_id) @@ -129,6 +158,134 @@ def _resolve_direct_client_secret( return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") +def _resolve_cached_x_token( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the cached Yandex Passport x_token, or empty string if absent. + + Like the other secret resolvers, prefers the persisted SECURE_STRING + from saved config since the frontend does not echo secrets back into + ``values`` on re-open. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_AUTH_X_TOKEN) + if saved: + return str(saved) + return str(values.get(CONF_AUTH_X_TOKEN) or "") + + +async def _run_auto_create_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + connection_type: str, + instance_id: str | None, +) -> None: + """Run the smart-home auto-create skill pipeline. + + Never re-raises: failures are persisted in artifacts.last_error so the + UI can render the message on the next form open. + """ + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + # MA's frontend supplies values["session_id"] on every action invocation — + # AuthenticationHelper listens on that exact id to open and later close + # the popup. Generating our own UUID would tie the popup we open via + # auth_helper.send_url(...) to a channel nothing is listening on, leaving + # the user with a popup that doesn't appear or doesn't close. Fail loudly. + session_id_raw = values.get("session_id") + if not session_id_raw or not str(session_id_raw).strip(): + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=( + "Missing session_id from the config-flow frontend. " + "Auto-create needs a session id to open the Device Code " + "popup; the action must be invoked through the MA UI, not " + "programmatically." + ), + ) + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + _LOGGER.warning("auto-create invoked without frontend session_id") + return + session_id = str(session_id_raw).strip() + values[CONF_AUTO_CREATE_SESSION_ID] = session_id + + base_url = resolve_base_url(mass, str(values.get(CONF_EXTERNAL_BASE_URL) or "") or None) + + try: + urls = derive_smart_home_urls( + connection_type=connection_type, + base_url=base_url, + cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), + direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), + ) + except ValueError as exc: + new_artifacts = dataclasses.replace( + artifacts, state=SkillCreationState.FAILED, last_error=str(exc) + ) + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + _LOGGER.warning("auto-create precondition failed: %s", exc) + return + + def _cache_x_token(token: str) -> None: + values[CONF_AUTH_X_TOKEN] = token + + async def _persist_artifacts(a: SkillCreationArtifacts) -> None: + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(a) + + cached = _resolve_cached_x_token(mass, instance_id, values) or None + authenticator = make_authenticator( + mass=mass, + session_id=session_id, + cached_x_token=cached, + on_token_obtained=_cache_x_token, + ) + + try: + new_artifacts = await auto_create_skill( + authenticator=authenticator, + skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), + artifacts=artifacts, + backend_uri=urls.backend_uri, + oauth_authorize_url=urls.oauth_authorize_url, + oauth_token_url=urls.oauth_token_url, + oauth_client_id=urls.oauth_client_id, + oauth_client_secret=urls.oauth_client_secret, + logo_bytes=load_default_logo_bytes(), + channel=SMART_HOME_CHANNEL, + progress_cb=_persist_artifacts, + ) + except asyncio.CancelledError: + raise + except Exception as exc: # defensive — never crash the config form + # Use type-name + str(exc) instead of repr(exc): repr() of e.g. + # aiohttp.ClientResponseError includes request_info (URL, headers) + # which can leak into the UI-visible artifacts.last_error. The full + # traceback is captured via _LOGGER.exception below. + msg = str(exc).strip() or type(exc).__name__ + # Cap the surfaced message so a runaway exception body can't bloat + # the round-tripped artifacts blob. + if len(msg) > 500: + msg = msg[:497] + "..." + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=f"{type(exc).__name__}: {msg}", + ) + _LOGGER.exception("auto-create hit unexpected error") + + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: + # Only set CONF_SKILL_ID on full success so the runtime doesn't + # try to use a half-built skill mid-pipeline. + values[CONF_SKILL_ID] = new_artifacts.skill_id + + async def _handle_config_actions( mass: MusicAssistant, action: str | None, @@ -137,7 +294,7 @@ async def _handle_config_actions( is_cloud_plus: bool, connection_type: str, ) -> str | None: - """Execute config-flow actions and return OTP code if obtained.""" + """Execute config-flow actions; return OTP code if obtained, else None.""" saved_config = None if instance_id: prov = mass.get_provider(instance_id) @@ -171,76 +328,24 @@ async def _handle_config_actions( except Exception: _LOGGER.exception("Failed to get OTP code") - # NOTE: the old flow used to auto-fetch OTP right after Register so - # the user saw the code immediately. In the 3-step cloud_plus flow - # (Register → Create skill → Get OTP), that leaks the OTP into Step 1. - # OTP is now fetched only when the user explicitly presses Get OTP - # in Step 3. - if action == CONF_ACTION_AUTO_CREATE: await _run_auto_create_action(mass, values, connection_type, instance_id) return otp_code -async def _run_auto_create_action( - mass: MusicAssistant, - values: dict[str, ConfigValueType], - connection_type: str, - instance_id: str | None, -) -> None: - """Execute the experimental auto-create-skill action. - - Never re-raises: all errors are persisted into the artifacts blob so - the UI can show a FAILED state on the next render rather than - crashing the config form. - """ - # MA's frontend supplies ``values["session_id"]`` when it triggers an - # action — AuthenticationHelper listens on that exact id to open - # and later close the popup. If we roll our own id nothing listens - # and the popup never appears. Fall back to a local uuid only if the - # frontend happened not to pass one (shouldn't happen in practice). - session_id = str(values.get("session_id") or uuid.uuid4().hex) - values[CONF_AUTO_CREATE_SESSION_ID] = session_id - artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) - artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) - +async def _list_player_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """Build the player-picker options list.""" + options: list[ConfigValueOption] = [] try: - new_artifacts = await auto_create_skill( - mass=mass, - connection_type=connection_type, - skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), - artifacts=artifacts, - cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), - direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), - logo_bytes=load_default_logo_bytes(), - session_id=session_id, - ) - except asyncio.CancelledError: - # Preserve cooperative cancellation so config-flow shutdown - # doesn't get converted into a FAILED artifact. - raise - except ValueError as exc: - # Precondition failures come back here — surface as FAILED. - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=str(exc), - ) - _LOGGER.warning("auto-create precondition failed: %s", exc) - except Exception as exc: # defensive — never crash the config form - new_artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=repr(exc), - ) - _LOGGER.exception("auto-create hit unexpected error") - - values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) - if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: - # Only set CONF_SKILL_ID on full success so the runtime doesn't - # try to use a half-built skill mid-pipeline. - values[CONF_SKILL_ID] = new_artifacts.skill_id + for player in mass.players.all_players(): + state = player.state + options.append( + ConfigValueOption(title=state.name or state.player_id, value=state.player_id) + ) + except Exception: + _LOGGER.debug("could not enumerate players") + return options async def get_config_entries( @@ -249,7 +354,7 @@ async def get_config_entries( action: str | None = None, values: dict[str, ConfigValueType] | None = None, ) -> tuple[ConfigEntry, ...]: - """Return Config entries to setup this provider.""" + """Build the provider config-form entries.""" if values is None: values = {} @@ -262,52 +367,38 @@ async def get_config_entries( mass, action, values, instance_id, is_cloud_plus, connection_type ) - # Auto-create-skill state — loaded once and threaded through the - # per-mode builders below. - artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) - artifacts_str = str(artifacts_raw) if artifacts_raw else None - artifacts = load_artifacts(artifacts_str) - session_id_val = values.get(CONF_AUTO_CREATE_SESSION_ID) - session_id_str = str(session_id_val) if session_id_val else None - ma_base_url_for_ui = "" - with contextlib.suppress(Exception): - ma_base_url_for_ui = str(mass.webserver.base_url) - is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) ) - cloud_instance_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) - label_text = _build_status_label(otp_code, is_cloud_plus, is_registered) - # Build player options for exposed players filter - player_options: list[ConfigValueOption] = [] + # Load current auto-create artifacts for state-aware button labels. + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts_str = str(artifacts_raw) if artifacts_raw else None + artifacts = load_artifacts(artifacts_str) + + player_options = await _list_player_options(mass) + playlist_options: list[ConfigValueOption] = [] try: - for player in mass.players.all_players(): - state = player.state - player_options.append( - ConfigValueOption(title=state.name or state.player_id, value=state.player_id) - ) - except Exception: # noqa: S110 - pass + playlist_options = await fetch_playlist_options(mass) + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.debug("could not enumerate playlists") entries: list[ConfigEntry] = [ - # Instance name ConfigEntry( key=CONF_INSTANCE_NAME, type=ConfigEntryType.STRING, label="Instance Name", description=( - "Name of this MA instance as it will appear in Yandex Smart Home. " - "Alice will use this name for voice commands, e.g. " - '"Алиса, включи музыку на [имя]".' + "Display name for this MA instance in Yandex Smart Home. " + "Alice will use this name for voice commands like " + '"Алиса, поставь паузу на [имя]".' ), required=False, default_value="Music Assistant", ), - # Save-and-reopen notice — the form doesn't re-render on - # dropdown change, so the user has to Save + reopen to see - # the next mode's fields. ConfigEntry( key="label_connection_type_notice", type=ConfigEntryType.LABEL, @@ -316,7 +407,6 @@ async def get_config_entries( "reopen this settings page to see the fields for the new mode." ), ), - # Connection type selector ConfigEntry( key=CONF_CONNECTION_TYPE, type=ConfigEntryType.STRING, @@ -333,62 +423,132 @@ async def get_config_entries( ConfigValueOption(title="Cloud Plus (private skill)", value="cloud_plus"), ConfigValueOption(title="Direct (no relay, requires public URL)", value="direct"), ], - # NOTE: immediate_apply produced glitchy mixed-mode renders - # (entries from old mode stayed on screen next to new ones), - # so users need Save + reopen after changing Connection - # Type. Kept here to stop someone re-adding it. ), ] - # -- Per-mode sections (each builder returns only the fields for its mode) + skill_token_set = _is_skill_token_set(mass, instance_id, values) + if is_cloud: entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) elif is_cloud_plus: entries.extend( - build_cloud_plus_entries( - otp_code=otp_code, - is_registered=is_registered, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - session_id=session_id_str, - user_code=None, # popup URL carries the code - verification_url=None, - existing_artifacts_raw=artifacts_str, - base_url=ma_base_url_for_ui, - skill_id=str(values.get(CONF_SKILL_ID) or ""), - skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + _cloud_plus_mode_entries( + label_text, otp_code, is_registered, values, artifacts, skill_token_set ) ) elif is_direct: - # Pre-generate the per-install direct client secret once so it - # survives round-trips (auto-skill pipeline reads it later). - # SECURE_STRING is not echoed back into ``values`` on re-open, - # so prefer the persisted value from saved config first and - # only mint a fresh UUID on true first-time setup. - direct_secret = _resolve_direct_client_secret(mass, instance_id, values) - if not direct_secret: - direct_secret = uuid.uuid4().hex - values[CONF_DIRECT_CLIENT_SECRET] = direct_secret - entries.extend( - build_direct_entries( - artifacts=artifacts, - session_id=session_id_str, - user_code=None, - verification_url=None, - existing_artifacts_raw=artifacts_str, - base_url=ma_base_url_for_ui, - direct_client_secret=direct_secret, - skill_id=str(values.get(CONF_SKILL_ID) or ""), - skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + entries.extend(_direct_mode_entries(mass, instance_id, values, artifacts, skill_token_set)) + + entries.extend(_common_tail_entries(player_options, playlist_options, values)) + return tuple(entries) + + +def _build_auto_create_status( + artifacts: SkillCreationArtifacts, + skill_id_already_set: bool, + *, + skill_token_set: bool = True, +) -> tuple[str, str]: + """Return (status_label, action_button_label) based on artifacts state. + + The action button label flips based on what makes sense to do next: + fresh attempt → 'Create…', resumable failure → 'Retry…', + in-progress → 'Continue…', success → 'Re-create…'. + + ``skill_token_set`` lets the success-state label nudge the user to paste + the OAuth token next — the auto-create pipeline only fills Skill ID; the + token comes from a separate OAuth flow (oauth.yandex.ru/authorize) that + the user does themselves via the help-link icon on the Skill OAuth Token + field. + """ + state = artifacts.state + if state == SkillCreationState.DONE and (artifacts.skill_id or skill_id_already_set): + skill_id = artifacts.skill_id or "" + if not skill_token_set: + return ( + f"✅ Smart Home skill registered (skill_id={skill_id}). " + "**Next step:** click the link icon next to *Skill OAuth " + "Token* below to open the Yandex OAuth page, approve access, " + "and paste the access_token value from the resulting URL " + "fragment into the field. State callbacks won't work until " + "this is done.", + "Re-create skill", ) + return ( + f"✅ Smart Home skill registered (skill_id={skill_id}). " + "Click 'Re-create' below to provision a fresh skill in your " + "Yandex account.", + "Re-create skill", + ) + if state == SkillCreationState.FAILED: + err = (artifacts.last_error or "").strip() + if err: + return ( + f"❌ Last attempt failed: {err}\n\n" + "Click 'Retry' to resume from the last completed step.", + "Retry", + ) + return ( + "Click 'Create Smart Home skill' to register a private skill in " + "your Yandex account programmatically (Device Flow OAuth login, " + "then automated skill provisioning).", + "Create Smart Home skill", + ) + if state in ( + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + ): + return ( + f"🔄 Pipeline in progress (state: {state.value}). " + "Click 'Continue' to resume from this step.", + "Continue", ) - # NB: CONF_DIRECT_CLIENT_SECRET is now emitted by the manual - # fallback block (advanced/hidden per state), so we don't add a - # duplicate hidden round-trip entry here. + # NONE or any other unexpected state: fresh start. + return ( + "Click 'Create Smart Home skill' to register a private skill in " + "your Yandex account programmatically (Device Flow OAuth login, " + "then automated skill provisioning).", + "Create Smart Home skill", + ) - # -- Tail: player filter + hidden round-trip fields (all modes) -- - entries.extend(_common_tail_entries(player_options, values)) - return tuple(entries) + +def _auto_create_entries( + artifacts: SkillCreationArtifacts, + *, + skill_id_already_set: bool, + skill_token_set: bool, + depends_on_value: str, +) -> list[ConfigEntry]: + """Auto-create button + state-aware status label, gated by connection_type.""" + status_text, button_label = _build_auto_create_status( + artifacts, skill_id_already_set, skill_token_set=skill_token_set + ) + return [ + ConfigEntry( + key=f"label_auto_create_status_{depends_on_value}", + type=ConfigEntryType.LABEL, + label=status_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=depends_on_value, + ), + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label="Auto-create Smart Home skill", + description=( + "Sign in via Yandex Passport (Device Flow) and provision the " + "skill at dialogs.yandex.ru programmatically. The skill_id " + "field below populates on success. After creation you still " + "need to paste the skill OAuth token from the dev console " + "(see https://yandex.ru/dev/dialogs/smart-home/doc/en/concepts/oauth)." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=button_label, + ), + ] def _cloud_mode_entries( @@ -396,11 +556,6 @@ def _cloud_mode_entries( ) -> list[ConfigEntry]: """Public-cloud mode: simple register + get-OTP flow.""" return [ - # Advisory — the public Yaha Cloud skill can only be linked to one - # instance per Yandex account, so users who already set up Yaha - # Cloud in Home Assistant (or another MA install) need Cloud Plus. - # There's no pre-flight API to detect this, so the warning is - # static — cheaper than a failed OTP attempt. ConfigEntry( key="label_cloud_conflict_warning", type=ConfigEntryType.LABEL, @@ -439,9 +594,6 @@ def _cloud_mode_entries( action=CONF_ACTION_REGISTER, action_label="Register with cloud", hidden=is_registered, - # No depends_on — MA disables actions with an unsaved - # dependency value until the user clicks Save, which breaks - # the flow right after picking a connection type. ), ConfigEntry( key=CONF_ACTION_GET_OTP, @@ -455,8 +607,218 @@ def _cloud_mode_entries( ] +def _cloud_plus_mode_entries( + label_text: str, + otp_code: str | None, + is_registered: bool, + values: dict[str, ConfigValueType], + artifacts: SkillCreationArtifacts, + skill_token_set: bool, +) -> list[ConfigEntry]: + """Cloud Plus: register + auto-create or manual skill_id/skill_token.""" + skill_id_set = bool(values.get(CONF_SKILL_ID)) + return [ + ConfigEntry( + key="label_status_cp", + type=ConfigEntryType.LABEL, + label=label_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key="label_cloud_plus_help", + type=ConfigEntryType.LABEL, + label=( + "Cloud Plus uses a private skill in your Yandex developer " + "account. Steps: 1) Click 'Register with cloud' below to " + "provision a yaha-cloud relay slot. 2) Click 'Create Smart " + "Home skill' to provision the skill automatically (Device " + "Flow login, then automated skill creation), OR create one " + "manually at https://dialogs.yandex.ru/developer and paste " + "the skill ID + OAuth token below. 3) Click 'Get OTP code' " + "and enter it in the Yandex app to finish linking." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key="otp_code_cp", + type=ConfigEntryType.STRING, + label="OTP Code", + description="Copy this code and enter it in the Yandex app.", + required=False, + value=otp_code, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_ACTION_REGISTER, + type=ConfigEntryType.ACTION, + label="Register cloud instance", + description="Provision a yaha-cloud relay slot for the private skill.", + action=CONF_ACTION_REGISTER, + action_label="Register with cloud", + hidden=is_registered, + ), + *_auto_create_entries( + artifacts, + skill_id_already_set=skill_id_set, + skill_token_set=skill_token_set, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID from the dev console URL " + "(https://dialogs.yandex.ru/developer/skills/). " + "Populated automatically after 'Create Smart Home skill' " + "succeeds; set manually if you created the skill yourself." + ), + required=False, + value=cast("str", values.get(CONF_SKILL_ID)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token", + description=( + "Click the link icon next to this field to open the Yandex " + "OAuth page; approve access for 'Yandex.Dialogs', then copy " + "the access_token value from the resulting URL fragment " + "(after #access_token=) and paste it here. The token is " + "used to push state callbacks back to Yandex when MA " + "players change state." + ), + help_link=YANDEX_OAUTH_URL, + required=False, + value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + ), + ConfigEntry( + key=CONF_ACTION_GET_OTP, + type=ConfigEntryType.ACTION, + label="Get OTP code", + description="Get a fresh one-time password to link with the Yandex Smart Home app.", + action=CONF_ACTION_GET_OTP, + action_label="Get OTP code", + hidden=not is_registered, + ), + ] + + +def _direct_mode_entries( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], + artifacts: SkillCreationArtifacts, + skill_token_set: bool, +) -> list[ConfigEntry]: + """Direct mode: HTTPS callback URL + auto-create or manual skill_id/skill_token.""" + direct_secret = _resolve_direct_client_secret(mass, instance_id, values) + if not direct_secret: + direct_secret = uuid.uuid4().hex + values[CONF_DIRECT_CLIENT_SECRET] = direct_secret + + ma_global_base_url = "" + with contextlib.suppress(Exception): + ma_global_base_url = str(mass.webserver.base_url) + external_url_description = ( + "Public HTTPS URL of this MA instance (e.g. https://ma.example.com), " + "used for Yandex callbacks and webhooks. Set this if you don't want " + "to change MA's global Base URL — e.g. you reach MA via Home " + "Assistant Ingress and exposing a public URL globally would break " + f"local access. Leave empty to use MA's Base URL ({ma_global_base_url or ''})." + ) + skill_id_set = bool(values.get(CONF_SKILL_ID)) + + return [ + ConfigEntry( + key="label_direct_help", + type=ConfigEntryType.LABEL, + label=( + "Direct mode points Yandex straight at this MA instance. " + "Steps: 1) Set the External Base URL below to a public HTTPS " + "URL (Yandex requires HTTPS). 2) Click 'Create Smart Home " + "skill' to provision the skill automatically (Device Flow " + "login, then automated skill creation), OR create one " + "manually at https://dialogs.yandex.ru/developer with the " + "Backend URL shown after first save. 3) Paste the OAuth " + "token from the dev console below." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External Base URL (HTTPS, optional override)", + description=external_url_description, + required=False, + default_value="", + value=str(values.get(CONF_EXTERNAL_BASE_URL) or ""), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + *_auto_create_entries( + artifacts, + skill_id_already_set=skill_id_set, + skill_token_set=skill_token_set, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID from your skill's dev console URL. Populated " + "automatically after 'Create Smart Home skill' succeeds; " + "set manually if you created the skill yourself." + ), + required=False, + value=cast("str", values.get(CONF_SKILL_ID)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token", + description=( + "Click the link icon next to this field to open the Yandex " + "OAuth page; approve access for 'Yandex.Dialogs', then copy " + "the access_token value from the resulting URL fragment " + "(after #access_token=) and paste it here. The token is " + "used to push state callbacks back to Yandex when MA " + "players change state." + ), + help_link=YANDEX_OAUTH_URL, + required=False, + value=cast("str", values.get(CONF_SKILL_TOKEN)) if values else None, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_DIRECT, + ), + # Per-install OAuth client secret (kept hidden, round-tripped). + ConfigEntry( + key=CONF_DIRECT_CLIENT_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Direct client secret (internal)", + hidden=True, + required=False, + value=direct_secret, + ), + ] + + def _common_tail_entries( - player_options: list[ConfigValueOption], values: dict[str, ConfigValueType] + player_options: list[ConfigValueOption], + playlist_options: list[ConfigValueOption], + values: dict[str, ConfigValueType], ) -> list[ConfigEntry]: """Player filter + hidden round-trip fields shared by every mode.""" return [ @@ -473,6 +835,22 @@ def _common_tail_entries( default_value=[], options=list(player_options) if player_options else [], ), + ConfigEntry( + key=CONF_EXPOSED_PLAYLISTS, + type=ConfigEntryType.STRING, + label=f"Exposed Playlists (max {MAX_INPUT_SOURCES})", + description=( + f"Pick up to {MAX_INPUT_SOURCES} playlists from your MA library — " + "they fill the input_source mode slots after each player's native " + 'sources. Alice triggers slots by ordinal only ("switch source to five"); ' + "remember the order you picked. If the list is empty, save the form " + "and reopen it once your music providers have finished loading their library." + ), + required=False, + multi_value=True, + default_value=[], + options=list(playlist_options) if playlist_options else [], + ), ConfigEntry( key=CONF_CLOUD_INSTANCE_ID, type=ConfigEntryType.STRING, @@ -505,4 +883,34 @@ def _common_tail_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), + # Auto-create-skill state — JSON-serialised SkillCreationArtifacts. + # Round-tripped through every config-flow render so the state machine + # survives popup cycles and partial failures. + ConfigEntry( + key=CONF_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (internal)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTO_CREATE_ARTIFACTS)) if values else None), + ), + ConfigEntry( + key=CONF_AUTO_CREATE_SESSION_ID, + type=ConfigEntryType.STRING, + label="Auto-create session id (internal)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTO_CREATE_SESSION_ID)) if values else None), + ), + # Cached Yandex Passport x_token — populated after the first + # successful auto-create Device Flow and reused on subsequent + # auto-create runs to skip the device-code prompt. + ConfigEntry( + key=CONF_AUTH_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Passport x_token (cached)", + hidden=True, + required=False, + value=(cast("str", values.get(CONF_AUTH_X_TOKEN)) if values else None), + ), ] diff --git a/music_assistant/providers/yandex_smarthome/_compat.py b/music_assistant/providers/yandex_smarthome/_compat.py deleted file mode 100644 index 6b747cbd01..0000000000 --- a/music_assistant/providers/yandex_smarthome/_compat.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Compatibility shim for ya-passport-auth. - -Provides a single source of `SecretStr` for the provider. When `ya-passport-auth` -is installed (the normal runtime case — declared in manifest.json), we re-export -the real implementation. When it's missing (bare test envs, pre-install linting) -we expose a minimal drop-in so importing the provider package doesn't crash. - -Centralized here to avoid duplicating the fallback across modules. -""" - -from __future__ import annotations - -try: - from ya_passport_auth import SecretStr -except ImportError: - - class SecretStr: # type: ignore[no-redef] - """Minimal fallback when ya-passport-auth is not yet installed.""" - - def __init__(self, value: str) -> None: - """Initialize with a secret value.""" - if not value: - raise ValueError("SecretStr value must not be empty") - self._value = value - - def get_secret(self) -> str: - """Return the secret value.""" - return self._value - - -__all__ = ["SecretStr"] diff --git a/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py new file mode 100644 index 0000000000..73e2693d99 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/_smarthome_auto_create.py @@ -0,0 +1,134 @@ +"""Smart-home auto-create-skill helpers — URL derivation + preconditions. + +Equivalent of the deleted ``derive_backend_uri`` / ``derive_auth_urls`` / +``derive_client_id`` / ``_resolve_base_url`` / ``check_preconditions`` +that lived inside the old ``provider/auto_skill.py``. Narrower scope: +smart-home only — drops the ``skill_type="dialog"`` branch (that's alice's +concern, lives in trudenboy/ma-provider-yandex-alice). + +These derivations live in the provider, not in the lib, because +``connection_type`` and the cloud-relay/direct-mode protocols are +Music-Assistant-specific concepts. ``ya-dialogs-api`` accepts the +already-computed URLs as plain string parameters. +""" + +from __future__ import annotations + +import contextlib +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_AUTH_BASE_PATH, + DIRECT_BACKEND_URI_PATH, + DIRECT_OAUTH_CLIENT_ID, +) + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +@dataclass(frozen=True, slots=True) +class SmartHomeUrls: + """The five pre-computed values ya_dialogs_api.auto_create_skill needs. + + Pass these straight into ``auto_create_skill`` — they correspond to its + ``backend_uri`` / ``oauth_authorize_url`` / ``oauth_token_url`` / + ``oauth_client_id`` / ``oauth_client_secret`` keyword arguments. + """ + + backend_uri: str + oauth_authorize_url: str + oauth_token_url: str + oauth_client_id: str + oauth_client_secret: str + + +def resolve_base_url(mass: MusicAssistant, override: str | None) -> str: + """Pick override over ``mass.webserver.base_url``; strip trailing slashes. + + ``override`` is the user-set ``CONF_EXTERNAL_BASE_URL``. Lets users keep + MA's global Base URL pointing at the local address (so HA Ingress / local + UI keep working) while exposing a public HTTPS URL only to Yandex via a + reverse proxy. + """ + if override and override.strip(): + return override.strip().rstrip("/") + fallback = "" + with contextlib.suppress(Exception): + fallback = str(mass.webserver.base_url) + return fallback.strip().rstrip("/") + + +def derive_smart_home_urls( + *, + connection_type: str, + base_url: str, + cloud_instance_id: str, + direct_client_secret: str, +) -> SmartHomeUrls: + """Compute the five pre-computed values for ``auto_create_skill``. + + cloud_plus + Backend uses :data:`CLOUD_SKILL_WEBHOOK_TEMPLATE` (the yaha-cloud + relay). OAuth uses yaha-cloud's own authorize/token endpoints. + ``client_id`` is ``yandex_smart_home:{instance_id}``; + ``client_secret`` is the literal ``"secret"`` required by the + relay protocol. + + direct + Backend = ``base_url + DIRECT_BACKEND_URI_PATH``. OAuth uses MA's + own ``/api/yandex_smarthome/auth/{authorize,token}`` endpoints + served by ``provider/direct.py``. ``client_id`` is the social + redirect base (Yandex's existing OAuth client expects this exact + value); ``client_secret`` is the per-install random UUID minted on + first save. + + :raises ValueError: ``cloud_plus`` without a registered cloud + instance id; ``direct`` without a client secret or with a + non-HTTPS base URL; any other ``connection_type``. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = ( + "Cloud Plus requires a registered yaha-cloud instance first. " + "Use the 'Register with cloud' action." + ) + raise ValueError(msg) + return SmartHomeUrls( + backend_uri=CLOUD_SKILL_WEBHOOK_TEMPLATE, + oauth_authorize_url=CLOUD_OAUTH_AUTHORIZE_URL, + oauth_token_url=CLOUD_OAUTH_TOKEN_URL, + oauth_client_id=CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id), + oauth_client_secret=CLOUD_SKILL_CLIENT_SECRET, + ) + if connection_type == CONNECTION_TYPE_DIRECT: + if not direct_client_secret: + msg = "Direct mode requires a generated Client Secret" + raise ValueError(msg) + if not base_url.startswith("https://"): + msg = ( + "Direct mode requires MA to be reachable over HTTPS from " + f"the public internet (got base_url={base_url!r}). Yandex " + "rejects skills with non-HTTPS backends." + ) + raise ValueError(msg) + return SmartHomeUrls( + backend_uri=f"{base_url}{DIRECT_BACKEND_URI_PATH}", + oauth_authorize_url=f"{base_url}{DIRECT_AUTH_BASE_PATH}/authorize", + oauth_token_url=f"{base_url}{DIRECT_AUTH_BASE_PATH}/token", + oauth_client_id=DIRECT_OAUTH_CLIENT_ID, + oauth_client_secret=direct_client_secret, + ) + msg = ( + f"auto-create is not supported for connection_type={connection_type!r}; " + "use cloud_plus or direct." + ) + raise ValueError(msg) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py deleted file mode 100644 index bdca544abe..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill.py +++ /dev/null @@ -1,1279 +0,0 @@ -"""Low-level client for the undocumented dialogs.yandex.ru developer API. - -Implements the 8-step sequence captured from Chrome DevTools HAR for -creating a Smart Home skill with account-linking: - - 1. GET /developer → extract CSRF (secretkey) - 2. GET /developer/app-store-api/snapshot → existing skills list (optional) - 3. POST /developer/app-store-api/apps → skill_id - 4. POST /developer/app-store-api/apps/{id}/draft/upload-logo → logo_id - 5. PATCH /developer/app-store-api/apps/{id}/draft/update → settings - 6. POST /developer/app-store-api/oauth/apps → oauth_app_id - 7. POST /developer/app-store-api/apps/{id}/oauthApp → bind oauth - 8. POST /developer/app-store-api/apps/{id}/draft/request-deploy → publish - -This is an UNDOCUMENTED, PRIVATE API. It may break at any time. The -caller is responsible for surfacing that risk to the user (see -``provider.auto_skill_ui``). - -Authentication: passport session cookies (``Session_id`` / ``sessionid2``) -must already be present in the supplied ``aiohttp.ClientSession``'s -cookie jar. Obtain them via ``ya_passport_auth.PassportClient``: - - creds = await client.login_device_code(...) - await client.refresh_passport_cookies(creds.x_token) - creator = DialogsSkillCreator(client._session) - -The CSRF token (returned by ``fetch_csrf``) must be passed as the -``x-csrf-token`` header on every mutating request. -""" - -from __future__ import annotations - -import asyncio -import dataclasses -import json -import logging -import re -from contextlib import asynccontextmanager -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import aiohttp - -from .auto_skill_state import SkillCreationArtifacts, SkillCreationState -from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, - DIRECT_API_BASE_PATH, - DIRECT_AUTH_BASE_PATH, - DIRECT_OAUTH_CLIENT_ID, -) - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Awaitable, Callable, Mapping - - from music_assistant.mass import MusicAssistant - -__all__ = [ - "DEVICE_FLOW_TIMEOUT_SECONDS", - "DIALOGS_API_BASE", - "DIALOGS_CSRF_REGEX", - "DIALOGS_DEV_BASE", - "DIALOGS_DEV_HTML_URL", - "DialogsApiError", - "DialogsCsrfError", - "DialogsDuplicateSkillError", - "DialogsSkillCreator", - "auto_create_skill", - "build_draft_payload", - "build_oauth_app_payload", - "check_preconditions", - "derive_auth_urls", - "derive_backend_uri", - "derive_client_id", - "load_default_logo_bytes", -] - -_LOGGER = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Endpoints / patterns -# --------------------------------------------------------------------------- -DIALOGS_DEV_BASE = "https://dialogs.yandex.ru" -DIALOGS_DEV_HTML_URL = f"{DIALOGS_DEV_BASE}/developer" -DIALOGS_API_BASE = f"{DIALOGS_DEV_BASE}/developer/app-store-api" - -# The developer console embeds a CSRF token in its HTML as: -# ..."secretkey":"u9c94f1aca53bf156be4..."... -# Captured from HAR 2026-04-24. If Yandex re-renders differently, this -# regex will miss and ``fetch_csrf`` raises ``DialogsCsrfError`` so the -# user falls back to manual setup. -DIALOGS_CSRF_REGEX = re.compile(r'"secretkey":"([^"]+)"') - -SMART_HOME_CHANNEL = "smartHome" -_MAX_HTML_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB - -# --------------------------------------------------------------------------- -# Errors -# --------------------------------------------------------------------------- - - -class DialogsApiError(Exception): - """Base error for dialogs.yandex.ru API failures.""" - - def __init__( - self, - message: str, - *, - step: str, - http_status: int | None = None, - yandex_error: str | None = None, - ) -> None: - """Initialise with the pipeline step that failed for clearer messages.""" - super().__init__(message) - self.step = step - self.http_status = http_status - self.yandex_error = yandex_error - - -class DialogsCsrfError(DialogsApiError): - """Raised when the CSRF token cannot be extracted from the developer page.""" - - -class DialogsDuplicateSkillError(DialogsApiError): - """Raised when create_app rejects because a skill with the same name exists.""" - - -# --------------------------------------------------------------------------- -# Client -# --------------------------------------------------------------------------- - - -class DialogsSkillCreator: - """Thin async wrapper over dialogs.yandex.ru developer-console API. - - Every method is idempotent on the transport layer: a single call - either succeeds or raises. Retry / state-machine logic lives in the - orchestrator (see :func:`auto_create_skill`). - """ - - __slots__ = ("_logger", "_session") - - def __init__( - self, - session: aiohttp.ClientSession, - logger: logging.Logger | None = None, - ) -> None: - """Take a session that already carries Passport auth cookies.""" - self._session = session - self._logger = logger or _LOGGER - - # ----------------------------------------------------------------------- - # Step 1: CSRF token extraction - # ----------------------------------------------------------------------- - - async def fetch_csrf(self) -> str: - """Fetch the developer page HTML and extract the CSRF ``secretkey``. - - Caller uses the returned value as the ``x-csrf-token`` header on - all mutating requests. Returns a fresh token on every call; the - orchestrator caches it for the duration of a single attempt. - """ - async with self._session.get(DIALOGS_DEV_HTML_URL) as resp: - if resp.status == 401: - raise DialogsApiError( - "not authenticated — passport session cookies missing or expired", - step="fetch_csrf", - http_status=401, - ) - if resp.status != 200: - raise DialogsApiError( - f"dialogs.yandex.ru/developer returned HTTP {resp.status}", - step="fetch_csrf", - http_status=resp.status, - ) - # Enforce the size cap while reading so an oversized - # response can't buffer fully in memory (T5 pattern from - # ya-passport-auth). - body = bytearray() - async for chunk in resp.content.iter_chunked(8192): - body.extend(chunk) - if len(body) > _MAX_HTML_RESPONSE_BYTES: - raise DialogsApiError( - "developer page response exceeded size cap", - step="fetch_csrf", - ) - html = body.decode(resp.get_encoding() or "utf-8", errors="replace") - - match = DIALOGS_CSRF_REGEX.search(html) - if not match: - raise DialogsCsrfError( - "could not locate CSRF token in developer page HTML — " - "Yandex may have changed the rendering format", - step="fetch_csrf", - ) - token = match.group(1).strip() - if not token: - raise DialogsCsrfError( - "CSRF token matched but is empty", - step="fetch_csrf", - ) - self._logger.debug("dialogs CSRF token fetched (len=%d)", len(token)) - return token - - # ----------------------------------------------------------------------- - # Step 2: list existing skills (for duplicate-name detection) - # ----------------------------------------------------------------------- - - async def list_existing_skills(self, csrf: str) -> list[dict[str, Any]]: - """Return the user's existing skills from the snapshot endpoint. - - The dashboard uses this to populate its skill list; we use it to - warn the user before they hit a duplicate-name 4xx on create_app. - """ - url = f"{DIALOGS_API_BASE}/snapshot" - data = await self._get_json(url, csrf=csrf, step="list_existing_skills") - result = data.get("result") - if not isinstance(result, dict): - return [] - skills = result.get("skills") - if not isinstance(skills, list): - return [] - return [s for s in skills if isinstance(s, dict)] - - # ----------------------------------------------------------------------- - # Step 3: create the skill app - # ----------------------------------------------------------------------- - - async def create_app(self, csrf: str, name: str) -> str: - """Create a Smart Home skill with the given name. - - Returns the newly-minted ``skill_id`` (UUID). Raises - :class:`DialogsDuplicateSkillError` if the name is already taken - by another skill on this account. - """ - url = f"{DIALOGS_API_BASE}/apps" - payload = { - "channel": SMART_HOME_CHANNEL, - "language": "ru", - "isYangoConsole": False, - "appName": name, - } - data = await self._post_json(url, payload, csrf=csrf, step="create_app") - result = data.get("result") - if not isinstance(result, dict): - raise DialogsApiError( - "create_app response missing 'result' object", - step="create_app", - ) - skill_id = result.get("id") or result.get("skill_id") - if not isinstance(skill_id, str) or not skill_id: - raise DialogsApiError( - "create_app response missing skill id", - step="create_app", - ) - self._logger.info("dialogs skill created: id=%s name=%r", skill_id, name) - return skill_id - - # ----------------------------------------------------------------------- - # Step 4: upload logo - # ----------------------------------------------------------------------- - - async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: - """Upload a PNG logo for the skill. - - Returns a ``logo_id`` that must be referenced in ``update_draft``. - The logo file is sent as multipart with the field name ``file`` - and filename ``icon.png`` (matching the HAR capture). - """ - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={SMART_HOME_CHANNEL}" - form = aiohttp.FormData() - form.add_field( - "file", - png, - filename="icon.png", - content_type="image/png", - ) - headers = {"x-csrf-token": csrf} - async with self._session.post(url, data=form, headers=headers) as resp: - body = await resp.text() - if resp.status != 200: - raise DialogsApiError( - f"upload_logo HTTP {resp.status}: {body[:200]}", - step="upload_logo", - http_status=resp.status, - ) - data = _try_json(body) - result = data.get("result") if isinstance(data, dict) else None - if not isinstance(result, dict): - raise DialogsApiError( - "upload_logo response missing 'result'", - step="upload_logo", - ) - logo_id = result.get("id") - if not isinstance(logo_id, str) or not logo_id: - raise DialogsApiError( - "upload_logo response missing logo id", - step="upload_logo", - ) - return logo_id - - # ----------------------------------------------------------------------- - # Step 5: update draft settings - # ----------------------------------------------------------------------- - - async def update_draft(self, csrf: str, skill_id: str, payload: Mapping[str, Any]) -> None: - """PATCH the skill draft with backend URL / publishing metadata.""" - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/update" - await self._patch_json(url, dict(payload), csrf=csrf, step="update_draft") - - # ----------------------------------------------------------------------- - # Step 6: create OAuth app (account-linking) - # ----------------------------------------------------------------------- - - async def create_oauth_app( - self, - csrf: str, - *, - name: str, - client_id: str, - client_secret: str, - authorize_url: str, - token_url: str, - refresh_url: str, - ) -> str: - """Create the OAuth app that powers account-linking in the skill. - - Returns the OAuth-app UUID which is then bound to the skill via - ``attach_oauth``. - """ - url = f"{DIALOGS_API_BASE}/oauth/apps" - payload = { - "name": name, - "clientId": client_id, - "clientSecret": client_secret, - "authorizationUrl": authorize_url, - "tokenUrl": token_url, - "refreshTokenUrl": refresh_url, - "scope": "", - "yandexClientId": "", - } - data = await self._post_json(url, payload, csrf=csrf, step="create_oauth_app") - result = data.get("result") - if not isinstance(result, dict): - raise DialogsApiError( - "create_oauth_app response missing 'result'", - step="create_oauth_app", - ) - oauth_app_id = result.get("id") - if not isinstance(oauth_app_id, str) or not oauth_app_id: - raise DialogsApiError( - "create_oauth_app response missing oauth app id", - step="create_oauth_app", - ) - return oauth_app_id - - # ----------------------------------------------------------------------- - # Step 7: bind OAuth app to the skill - # ----------------------------------------------------------------------- - - async def attach_oauth(self, csrf: str, skill_id: str, oauth_app_id: str) -> None: - """Attach an existing OAuth app to the skill's account-linking slot.""" - url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={SMART_HOME_CHANNEL}" - payload = {"oauthAppId": oauth_app_id} - await self._post_json(url, payload, csrf=csrf, step="attach_oauth") - - # ----------------------------------------------------------------------- - # Step 8: publish (send for moderation) - # ----------------------------------------------------------------------- - - async def request_deploy(self, csrf: str, skill_id: str) -> None: - """Send the draft to moderation / publish. - - Body is empty; all params are in the query string. Returns on - 2xx; otherwise raises. - """ - url = ( - f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={SMART_HOME_CHANNEL}" - ) - headers = {"x-csrf-token": csrf} - async with self._session.post(url, headers=headers) as resp: - body = await resp.text() - if resp.status not in (200, 201, 202, 204): - raise DialogsApiError( - f"request_deploy HTTP {resp.status}: {body[:200]}", - step="request_deploy", - http_status=resp.status, - ) - - # ----------------------------------------------------------------------- - # Internal helpers - # ----------------------------------------------------------------------- - - async def _get_json(self, url: str, *, csrf: str, step: str) -> dict[str, Any]: - headers = {"x-csrf-token": csrf} - async with self._session.get(url, headers=headers) as resp: - body = await resp.text() - if resp.status != 200: - raise DialogsApiError( - f"GET {url} HTTP {resp.status}: {body[:200]}", - step=step, - http_status=resp.status, - ) - data = _try_json(body) - if not isinstance(data, dict): - raise DialogsApiError( - f"GET {url} returned non-object JSON", - step=step, - ) - return data - - async def _post_json( - self, url: str, payload: dict[str, Any], *, csrf: str, step: str - ) -> dict[str, Any]: - return await self._send_json("POST", url, payload, csrf=csrf, step=step) - - async def _patch_json( - self, url: str, payload: dict[str, Any], *, csrf: str, step: str - ) -> dict[str, Any]: - return await self._send_json("PATCH", url, payload, csrf=csrf, step=step) - - async def _send_json( - self, - method: str, - url: str, - payload: dict[str, Any], - *, - csrf: str, - step: str, - ) -> dict[str, Any]: - headers = { - "x-csrf-token": csrf, - "content-type": "application/json", - } - async with self._session.request(method, url, json=payload, headers=headers) as resp: - body = await resp.text() - # Only ``create_app`` can fail with duplicate-name errors — - # other endpoints use 409 for unrelated conflicts and would - # be misclassified as duplicates if the mapping were global. - duplicate_candidate = step == "create_app" and ( - resp.status == 409 or (resp.status in (400, 422) and _looks_like_duplicate(body)) - ) - if duplicate_candidate: - raise DialogsDuplicateSkillError( - f"{step}: skill with this name already exists", - step=step, - http_status=resp.status, - yandex_error=_extract_error_code(body), - ) - if resp.status not in (200, 201, 202): - raise DialogsApiError( - f"{method} {url} HTTP {resp.status}: {body[:200]}", - step=step, - http_status=resp.status, - yandex_error=_extract_error_code(body), - ) - data = _try_json(body) - if not isinstance(data, dict): - raise DialogsApiError( - f"{method} {url} returned non-object JSON", - step=step, - ) - return data - - -# --------------------------------------------------------------------------- -# Module-private helpers -# --------------------------------------------------------------------------- - - -def _try_json(body: str) -> Any: - """Parse JSON defensively — return None on any error.""" - if not body: - return None - try: - return json.loads(body) - except (ValueError, TypeError): - return None - - -def _looks_like_duplicate(body: str) -> bool: - """Heuristic for whether a 4xx body indicates a duplicate-name error.""" - if not body: - return False - lowered = body.lower() - return any( - kw in lowered for kw in ("already exists", "duplicate", "exists with name", "not_unique") - ) - - -def _extract_error_code(body: str) -> str | None: - """Pull Yandex error code/message out of a 4xx response body (best-effort).""" - data = _try_json(body) - if not isinstance(data, dict): - return None - for key in ("error", "errorCode", "message", "code"): - value = data.get(key) - if isinstance(value, str) and value: - return value - return None - - -# --------------------------------------------------------------------------- -# Pure helpers: backend/oauth URLs, payload builders, preconditions -# -# All of these are side-effect-free and separately unit-testable; the -# orchestrator in a later commit wires them together. -# --------------------------------------------------------------------------- - - -def derive_backend_uri(mass: MusicAssistant, connection_type: str) -> str: - """Return the Backend URL the skill should point at for *connection_type*. - - cloud_plus → yaha-cloud.ru relay (fixed URL). - direct → ``{mass.webserver.base_url}`` + our API path (requires - HTTPS base URL; see :func:`check_preconditions`). - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - return CLOUD_SKILL_WEBHOOK_TEMPLATE - if connection_type == CONNECTION_TYPE_DIRECT: - base = str(mass.webserver.base_url).rstrip("/") - return f"{base}{DIRECT_API_BASE_PATH}" - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def derive_auth_urls(mass: MusicAssistant, connection_type: str) -> tuple[str, str]: - """Return (authorize_url, token_url) for the OAuth app. - - cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct - uses the MA webserver's own authorize/token endpoints (served by - provider/direct.py). - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - return CLOUD_OAUTH_AUTHORIZE_URL, CLOUD_OAUTH_TOKEN_URL - if connection_type == CONNECTION_TYPE_DIRECT: - base = str(mass.webserver.base_url).rstrip("/") - return ( - f"{base}{DIRECT_AUTH_BASE_PATH}/authorize", - f"{base}{DIRECT_AUTH_BASE_PATH}/token", - ) - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def derive_client_id(connection_type: str, cloud_instance_id: str) -> str: - """Return the OAuth client_id to register in the skill's account linking. - - cloud_plus uses ``yandex_smart_home:{instance_id}`` (yaha-cloud - protocol); direct uses the fixed Yandex social redirect base URL - (its existing Yandex OAuth client expects this exact ID). - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - if not cloud_instance_id: - msg = "cloud_plus requires a registered cloud_instance_id" - raise ValueError(msg) - return CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - if connection_type == CONNECTION_TYPE_DIRECT: - return DIRECT_OAUTH_CLIENT_ID - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - -def build_draft_payload( - *, - connection_type: str, - skill_name: str, - backend_uri: str, - logo_id: str | None, - developer_name: str = "Music Assistant user", -) -> dict[str, Any]: - """Compose the PATCH /draft/update body for a Smart Home skill. - - Matches the HAR sample field-for-field; every key that the - dashboard UI sends on save is reproduced so Yandex's validator - sees a complete draft and allows ``request-deploy`` afterwards. - """ - if connection_type not in (CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT): - msg = f"auto-create is not supported for connection_type={connection_type!r}" - raise ValueError(msg) - - return { - "logo2": None, - "name": skill_name, - "voice": "shitova.us", - "logoId": logo_id, - "skillAccess": "private", - "hideInStore": False, - "noteForModerator": "", - "backendSettings": { - "uri": backend_uri, - "functionId": "", - "backendType": "webhook", - }, - "publishingSettings": { - "brandVerificationWebsite": "", - "category": "smart_home", - "developerName": developer_name, - "secondaryTitle": "", - "email": "", # server pulls from the authenticated session - "smartHome": { - "deepLinks": { - "android": {"url": ""}, - "ios": {"url": "", "fallbackUrl": ""}, - }, - }, - "multilingualSettings": { - "ru": { - "name": skill_name, - "secondaryTitle": "", - "externalSettingsDescription": skill_name, - "supportedUnitsDescription": skill_name, - }, - }, - }, - "oauthAppId": None, - "isTrustedSmartHomeSkill": False, - "enableAllAvailableRegions": True, - "selectedRegions": [], - "channel": SMART_HOME_CHANNEL, - } - - -def build_oauth_app_payload( - *, - skill_name: str, - client_id: str, - client_secret: str, - authorize_url: str, - token_url: str, -) -> dict[str, Any]: - """Compose the POST /oauth/apps body for account-linking. - - Values come from :func:`derive_client_id`, :func:`derive_auth_urls`, - and :func:`derive_backend_uri`'s caller context. ``refreshTokenUrl`` - always equals ``token_url`` — Yandex's flow uses the same endpoint - for both grant types. - """ - return { - "name": skill_name, - "clientId": client_id, - "clientSecret": client_secret, - "authorizationUrl": authorize_url, - "tokenUrl": token_url, - "refreshTokenUrl": token_url, - "scope": "", - "yandexClientId": "", - } - - -def check_preconditions( - *, - connection_type: str, - mass: MusicAssistant, - cloud_instance_id: str, - direct_client_secret: str, -) -> None: - """Validate that auto-create can run for the given connection type. - - Raises :class:`ValueError` with a human-readable message on failure. - Called before any network I/O so the UI can surface the error - without a half-created skill on Yandex's side. - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - if not cloud_instance_id: - msg = ( - "Cloud Plus requires a registered yaha-cloud instance first. " - "Use the 'Register with cloud' action." - ) - raise ValueError(msg) - return - - if connection_type == CONNECTION_TYPE_DIRECT: - if not direct_client_secret: - msg = "Direct mode requires a generated Client Secret" - raise ValueError(msg) - try: - base = str(mass.webserver.base_url) - except Exception as exc: - msg = f"MA webserver base URL is not available: {exc}" - raise ValueError(msg) from exc - if not base.startswith("https://"): - msg = ( - "Direct mode requires MA to be reachable over HTTPS from the " - f"public internet (got base_url={base!r}). Yandex will reject " - "a skill with a non-HTTPS backend." - ) - raise ValueError(msg) - return - - msg = ( - f"auto-create is not supported for connection_type={connection_type!r}; " - "use cloud_plus or direct." - ) - raise ValueError(msg) - - -# --------------------------------------------------------------------------- -# Orchestrator: device flow + resumable pipeline -# --------------------------------------------------------------------------- - -# Hard cap on how long we'll wait for the user to enter the code. -DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 - -_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" -# Keep the intermediate HTML page alive long enough for one more poll -# after state flips to done/failed — ~1s is plenty, the page polls -# every 2s so we're just covering the in-flight window. -_POST_AUTH_GRACE_SECONDS = 1 -# Server-suggested interval from Yandex is 5s (RFC 8628) but after the -# user has confirmed the code we want to detect it promptly; 2s is the -# RFC-recommended minimum. If Yandex ever returns SLOW_DOWN, the library -# bumps the interval automatically. -_DEVICE_FLOW_POLL_INTERVAL = 2.0 -_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") - - -def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: - """Render the HTML page shown during Device Flow login. - - Yandex's ya.ru/device page does not pre-fill from query params and - strips them on redirect-to-login, so the only reliable way to show - the code is to host our own page in MA's webserver that displays - the code prominently and opens ya.ru/device in a new tab. - - Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. - """ - import html # noqa: PLC0415 - - safe_code = html.escape(user_code) - safe_url = html.escape(verification_url, quote=True) - safe_status_url = json.dumps(status_url).replace(" - - - - Yandex Smart Home — Device Code - - - - -
-

Authorise Music Assistant for skill creation

-

Open the link below, log in to your Yandex account, and enter this code.

-
{safe_code}
-
- -
- Continue to Yandex -
- - -""" - - -async def _default_authenticator( - *, - mass: MusicAssistant, - session_id: str, - timeout: float, -) -> AsyncIterator[aiohttp.ClientSession]: - """Real-world authentication path — runs Device Flow and yields a session. - - Serves an intermediate HTML page through MA's webserver so the user - sees the short ``user_code`` (Yandex's ya.ru/device does not pre-fill - from query params). The popup is opened via - :class:`AuthenticationHelper` using the frontend-provided - ``session_id`` — that's how MA's UI knows which popup session to - render and later close. - - Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. - """ - from aiohttp import web # noqa: PLC0415 - from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 - from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 - - from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 - - if not _SAFE_SESSION_ID_RE.match(session_id): - msg = "invalid session_id for device authentication" - raise ValueError(msg) - - allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) - config = ClientConfig(allowed_hosts=allowed) - - async with PassportClient.create(config=config) as client: - device_session = await client.start_device_login() - # Don't log user_code — it's a time-limited credential (grants - # Yandex sign-in for the device-flow window) and writing it to - # shared log backends would leak access. - _LOGGER.info( - "device flow started — verification_url=%s", - device_session.verification_url, - ) - - page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" - status_path = f"{page_path}/status" - # MA frontend requires an absolute URL in signal_event(AUTH_SESSION, - # ...). The URL comes from mass.webserver.base_url which the user - # configures in Settings → Core → Webserver → Base URL. If they - # haven't touched it and MA is behind Docker/reverse-proxy, it may - # point at an unreachable internal address — the warning log below - # gives them the path so they can open it manually if the popup - # fails to load. - base_url = str(mass.webserver.base_url).rstrip("/") - status_url = f"{base_url}{status_path}" - page_url = f"{base_url}{page_path}" - state = {"value": "pending"} - - page_html = _build_device_code_page( - device_session.user_code, - device_session.verification_url, - status_url, - ) - - async def _serve_page(_request: web.Request) -> web.Response: - return web.Response( - text=page_html, - content_type="text/html", - charset="utf-8", - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - "Expires": "0", - }, - ) - - async def _serve_status(_request: web.Request) -> web.Response: - return web.json_response( - {"state": state["value"]}, - headers={"Cache-Control": "no-store"}, - ) - - mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") - mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") - _LOGGER.warning( - "auto-skill: device-code popup URL %s (path=%s) " - "— if the popup does not open or points at an unreachable " - "address, open the path directly in your browser (the page " - "displays the user_code) or fix Settings → Core → Webserver " - "→ Base URL", - page_url, - page_path, - ) - try: - async with AuthenticationHelper(mass, session_id) as auth_helper: - auth_helper.send_url(page_url) - try: - creds = await client.poll_device_until_confirmed( - device_session, - total_timeout=timeout, - poll_interval=_DEVICE_FLOW_POLL_INTERVAL, - ) - except asyncio.CancelledError: - raise - except Exception: - state["value"] = "failed" - await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) - raise - state["value"] = "done" - await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) - finally: - mass.webserver.unregister_dynamic_route(page_path, "GET") - mass.webserver.unregister_dynamic_route(status_path, "GET") - - await client.refresh_passport_cookies(creds.x_token) - yield client._session - - -def _build_authenticator_cm( - authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]], - *, - mass: MusicAssistant, - session_id: str, - timeout: float, -) -> Any: - """Wrap *authenticator* so it supports ``async with`` uniformly. - - The default implementation is a plain async generator, but callers - may inject an already-decorated ``@asynccontextmanager``. Re-wrapping - a CM factory with ``asynccontextmanager`` is *not* idempotent — the - outer wrapper would call ``__anext__`` on the inner CM object and - crash — so detect the CM result and pass it through unchanged. - """ - result = authenticator(mass=mass, session_id=session_id, timeout=timeout) - if hasattr(result, "__aenter__") and hasattr(result, "__aexit__"): - return result - - # Adapt the async iterator returned above into a proper context - # manager. We have to drive the existing iterator (not call the - # authenticator again) to avoid leaving a half-created generator - # unawaited and to preserve any work it already did (e.g. starting - # a Device Flow session). - @asynccontextmanager - async def _cm() -> AsyncIterator[aiohttp.ClientSession]: - session = await result.__anext__() - try: - yield session - finally: - try: - await result.__anext__() - except StopAsyncIteration: - pass - else: - msg = "authenticator yielded more than one session" - raise RuntimeError(msg) - - return _cm() - - -async def auto_create_skill( # noqa: PLR0913 - *, - mass: MusicAssistant, - connection_type: str, - skill_name: str, - artifacts: SkillCreationArtifacts, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - session_id: str, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, - authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, - creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, - timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, - developer_name: str = "Music Assistant user", -) -> SkillCreationArtifacts: - """End-to-end flow: Device Flow → passport cookies → skill pipeline. - - Resumes from ``artifacts.state`` — steps that already completed - (skill_id present, etc.) are skipped. On any exception, returns - artifacts with ``state=FAILED`` and a human-readable ``last_error`` - instead of re-raising, so the config-flow UI can render the message - without crashing. - - ``progress_cb`` is invoked after each successful step with the - updated artifacts; the caller uses it to persist state to MA config - so a subsequent retry resumes from the latest completed step. - - ``authenticator`` and ``creator_factory`` are injection points for - tests; production callers leave them as ``None`` to use the real - Device Flow and a real :class:`DialogsSkillCreator`. - """ - # Precondition failures surface unmodified (caller decides message). - check_preconditions( - connection_type=connection_type, - mass=mass, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - ) - - auth_fn = authenticator or _default_authenticator - creator_fn = creator_factory or DialogsSkillCreator - - try: - async with _build_authenticator_cm( - auth_fn, mass=mass, session_id=session_id, timeout=timeout - ) as session: - creator = creator_fn(session) - return await _run_pipeline_with_recovery( - creator=creator, - artifacts=artifacts, - connection_type=connection_type, - skill_name=skill_name, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=logo_bytes, - mass=mass, - developer_name=developer_name, - progress_cb=progress_cb, - ) - except asyncio.CancelledError: - # Preserve cooperative cancellation — do not absorb into FAILED. - raise - except ValueError: - raise - except Exception as exc: - _LOGGER.exception("auto-create hit unexpected error") - return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) - - -async def _run_pipeline_with_recovery( - *, - creator: DialogsSkillCreator, - artifacts: SkillCreationArtifacts, - connection_type: str, - skill_name: str, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - mass: MusicAssistant, - developer_name: str, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, -) -> SkillCreationArtifacts: - """Fetch CSRF and run the pipeline, preserving partial state on failure. - - Holds a ``current`` reference that ``_execute_pipeline`` updates via - ``progress_cb``, so a mid-pipeline raise lets us surface whatever - progress was captured (skill_id / logo_id / oauth_app_id) as a - FAILED artifact instead of losing it. - """ - current = artifacts - - async def _track(a: SkillCreationArtifacts) -> None: - nonlocal current - current = a - if progress_cb is not None: - await progress_cb(a) - - try: - _LOGGER.info("auto-skill: fetching CSRF from dialogs.yandex.ru") - csrf = await creator.fetch_csrf() - _LOGGER.info("auto-skill: CSRF acquired, starting skill pipeline") - return await _execute_pipeline( - creator=creator, - csrf=csrf, - artifacts=artifacts, - connection_type=connection_type, - skill_name=skill_name, - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=logo_bytes, - mass=mass, - developer_name=developer_name, - progress_cb=_track, - ) - except DialogsApiError as exc: - _LOGGER.warning("auto-create failed at %s: %s", exc.step, exc, exc_info=True) - return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) - - -async def _execute_pipeline( # noqa: PLR0913, PLR0915 - *, - creator: DialogsSkillCreator, - csrf: str, - artifacts: SkillCreationArtifacts, - connection_type: str, - skill_name: str, - cloud_instance_id: str, - direct_client_secret: str, - logo_bytes: bytes, - mass: MusicAssistant, - developer_name: str, - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, -) -> SkillCreationArtifacts: - """Advance through states sequentially, skipping completed steps.""" - state = artifacts.state - - # -- Step 3: create app -- - if state in (SkillCreationState.NONE, SkillCreationState.FAILED): - _LOGGER.info("auto-skill: [1/5] creating skill app") - new_skill_id = await creator.create_app(csrf, skill_name) - artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.APP_CREATED, - skill_id=new_skill_id, - last_error=None, - ) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if artifacts.skill_id is None: - msg = "internal error: skill_id missing after create_app" - raise RuntimeError(msg) - skill_id: str = artifacts.skill_id - - # -- Step 4+5: upload logo and update draft (merged step) -- - if state == SkillCreationState.APP_CREATED: - logo_id = artifacts.logo_id - if logo_id is None: - _LOGGER.info("auto-skill: [2/5] uploading logo") - logo_id = await creator.upload_logo(csrf, skill_id, logo_bytes) - artifacts = dataclasses.replace(artifacts, logo_id=logo_id) - - backend_uri = derive_backend_uri(mass, connection_type) - draft = build_draft_payload( - connection_type=connection_type, - skill_name=skill_name, - backend_uri=backend_uri, - logo_id=logo_id, - developer_name=developer_name, - ) - _LOGGER.info("auto-skill: [3/5] updating draft with settings") - await creator.update_draft(csrf, skill_id, draft) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DRAFT_UPDATED) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - # -- Step 6: create OAuth app -- - if state == SkillCreationState.DRAFT_UPDATED: - client_id = derive_client_id(connection_type, cloud_instance_id) - client_secret = ( - CLOUD_SKILL_CLIENT_SECRET - if connection_type == CONNECTION_TYPE_CLOUD_PLUS - else direct_client_secret - ) - authorize_url, token_url = derive_auth_urls(mass, connection_type) - _LOGGER.info("auto-skill: [4/5] creating OAuth app + attaching") - oauth_app_id = await creator.create_oauth_app( - csrf, - name=skill_name, - client_id=client_id, - client_secret=client_secret, - authorize_url=authorize_url, - token_url=token_url, - refresh_url=token_url, - ) - artifacts = dataclasses.replace( - artifacts, - state=SkillCreationState.OAUTH_CREATED, - oauth_app_id=oauth_app_id, - ) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if artifacts.oauth_app_id is None: - msg = "internal error: oauth_app_id missing after create_oauth_app" - raise RuntimeError(msg) - oauth_app_id_str: str = artifacts.oauth_app_id - - # -- Step 7: attach OAuth app to skill -- - if state == SkillCreationState.OAUTH_CREATED: - await creator.attach_oauth(csrf, skill_id, oauth_app_id_str) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.OAUTH_ATTACHED) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - # -- Step 8: publish -- - # Checkpoint state=DEPLOY_REQUESTED *before* calling request_deploy - # so a crash after the call reached Yandex but before we returned - # can skip straight to DONE on retry (Yandex accepts the idempotent - # re-deploy but we'd rather not re-drive the flow end-to-end). - if state == SkillCreationState.OAUTH_ATTACHED: - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DEPLOY_REQUESTED) - await _maybe_save(progress_cb, artifacts) - state = artifacts.state - - if state == SkillCreationState.DEPLOY_REQUESTED: - _LOGGER.info("auto-skill: [5/5] publishing skill") - await creator.request_deploy(csrf, skill_id) - artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) - await _maybe_save(progress_cb, artifacts) - - return artifacts - - -# Minimal 1x1 transparent PNG — used when the packaged logo is missing -# (e.g. during unit tests before the asset commit lands). Real installs -# pick up provider/auto_skill_logo.png instead. -_FALLBACK_LOGO_PNG = bytes.fromhex( - "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" - "0000000d49444154789c6300010000050001d0a0c9a30000000049454e44ae426082" -) - - -def load_default_logo_bytes() -> bytes: - """Return PNG bytes for the skill logo. - - Reads ``provider/auto_skill_logo.png`` if it exists; otherwise - returns a 1x1 transparent PNG so tests can run without the asset. - """ - path = Path(__file__).parent / "auto_skill_logo.png" - if path.is_file(): - return path.read_bytes() - return _FALLBACK_LOGO_PNG - - -async def _maybe_save( - progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, - artifacts: SkillCreationArtifacts, -) -> None: - """Call ``progress_cb`` if provided, swallowing any save errors.""" - if progress_cb is None: - return - try: - await progress_cb(artifacts) - except Exception: - _LOGGER.exception("progress_cb raised; continuing pipeline anyway") diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_logo.png b/music_assistant/providers/yandex_smarthome/auto_skill_logo.png deleted file mode 100644 index 86904a3613..0000000000 Binary files a/music_assistant/providers/yandex_smarthome/auto_skill_logo.png and /dev/null differ diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py deleted file mode 100644 index 6f2b8e8b8e..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill_state.py +++ /dev/null @@ -1,119 +0,0 @@ -"""State model for experimental auto-create-skill feature. - -Tracks progress of the multi-step skill creation flow against -dialogs.yandex.ru so partial failures can be retried from the -last successful step rather than starting over. -""" - -from __future__ import annotations - -import dataclasses -import json -import logging -from dataclasses import dataclass -from enum import StrEnum - -_LOGGER = logging.getLogger(__name__) - -__all__ = [ - "SkillCreationArtifacts", - "SkillCreationState", - "dump_artifacts", - "load_artifacts", -] - - -class SkillCreationState(StrEnum): - """Progress marker for the skill-creation pipeline. - - Linear states advance through the 6 HAR-captured API calls. - ``FAILED`` replaces the stored linear state in the artifact; - failure details are kept separately in ``last_error``, while - captured artifact IDs (``skill_id`` / ``logo_id`` / ``oauth_app_id``) - stay intact so a retry can resume from the partial results. - """ - - NONE = "none" - APP_CREATED = "app_created" - DRAFT_UPDATED = "draft_updated" - OAUTH_CREATED = "oauth_created" - OAUTH_ATTACHED = "oauth_attached" - DEPLOY_REQUESTED = "deploy_requested" - DONE = "done" - FAILED = "failed" - - -@dataclass(frozen=True, slots=True) -class SkillCreationArtifacts: - """Persistent state for a skill-creation attempt. - - Stored as a JSON blob in the ``CONF_AUTO_CREATE_ARTIFACTS`` - config entry and round-tripped through every call to - ``get_config_entries``. - """ - - state: SkillCreationState = SkillCreationState.NONE - skill_id: str | None = None - logo_id: str | None = None - oauth_app_id: str | None = None - last_error: str | None = None - - -def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: - """Serialise artifacts to a JSON string for config storage.""" - return json.dumps( - { - "state": artifacts.state.value, - "skill_id": artifacts.skill_id, - "logo_id": artifacts.logo_id, - "oauth_app_id": artifacts.oauth_app_id, - "last_error": artifacts.last_error, - }, - ensure_ascii=False, - ) - - -def load_artifacts(raw: str | None) -> SkillCreationArtifacts: - """Deserialise artifacts from a config-stored JSON string. - - Returns a fresh ``SkillCreationArtifacts`` on any parse error - or missing input — the feature is optional so config stays - usable even if the blob is corrupted. - """ - if not raw: - return SkillCreationArtifacts() - try: - data = json.loads(raw) - except (ValueError, TypeError): - _LOGGER.warning("auto-skill artifacts corrupt, resetting") - return SkillCreationArtifacts() - if not isinstance(data, dict): - return SkillCreationArtifacts() - - try: - state = SkillCreationState(str(data.get("state", SkillCreationState.NONE.value))) - except ValueError: - state = SkillCreationState.NONE - - def _opt_str(key: str) -> str | None: - value = data.get(key) - if value is None: - return None - return str(value) if value else None - - return SkillCreationArtifacts( - state=state, - skill_id=_opt_str("skill_id"), - logo_id=_opt_str("logo_id"), - oauth_app_id=_opt_str("oauth_app_id"), - last_error=_opt_str("last_error"), - ) - - -def mark_failed(artifacts: SkillCreationArtifacts, error: str) -> SkillCreationArtifacts: - """Return a copy of *artifacts* flipped to ``FAILED`` with an error.""" - return dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=error, - ) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py deleted file mode 100644 index 735b504128..0000000000 --- a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py +++ /dev/null @@ -1,838 +0,0 @@ -"""ConfigEntry builders for the Yandex Smart Home provider. - -Builds the numbered-step config form per connection type: - -* ``cloud_plus`` → 3 steps: Register cloud → Create skill → Link via OTP. -* ``direct`` → 1 step: Create skill (skill is linked by Yandex Dialogs - account-linking UI, no OTP). -* ``cloud`` → 2 steps: Register → Link via OTP (unchanged). - -Each step hides until the previous completes, so the user always sees -the single next action they need to take. - -Kept separate from ``__init__.py`` so the long field list doesn't bloat -``get_config_entries`` and so it can be unit-tested in isolation from -the network-facing parts of the feature. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ConfigEntryType - -from .auto_skill_state import SkillCreationArtifacts, SkillCreationState -from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, - CONF_ACTION_AUTO_CREATE, - CONF_ACTION_GET_OTP, - CONF_ACTION_REGISTER, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_AUTO_CREATE_SESSION_ID, - CONF_CONNECTION_TYPE, - CONF_DIRECT_CLIENT_SECRET, - CONF_SKILL_ID, - CONF_SKILL_TOKEN, - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, - DIRECT_API_BASE_PATH, - DIRECT_AUTH_BASE_PATH, - DIRECT_OAUTH_CLIENT_ID, - YANDEX_DIALOGS_DEVELOPER_URL, - YANDEX_OAUTH_URL, -) - -if TYPE_CHECKING: - from collections.abc import Sequence - -__all__ = [ - "AUTO_CREATE_CATEGORY", - "auto_create_entries", - "build_cloud_plus_entries", - "build_direct_entries", - "should_show_button", -] - -AUTO_CREATE_CATEGORY = "Auto-create skill" - -# Category names are used as visual group headers in the MA UI. Numbered -# so they render in order and users see the flow as a sequence. -_CAT_STEP_1_REGISTER = "Step 1 — Register cloud instance" -_CAT_STEP_2_CREATE = "Step 2 — Create Smart Home skill" -_CAT_STEP_3_LINK = "Step 3 — Link skill to Yandex" -# Direct mode has only one step — no cloud registration, no OTP linking. -_CAT_STEP_DIRECT_CREATE = "Create Smart Home skill" - - -def _status_label(state: SkillCreationState, last_error: str | None) -> str: - """Human-readable status line shown in the UI.""" - if state == SkillCreationState.DONE: - return ( - "✅ Skill created and published. Now get the OAuth token " - "(link below) and paste it into 'Skill OAuth Token'." - ) - if state == SkillCreationState.FAILED: - err = last_error or "unknown error" - return ( - f"❌ Creation failed: {err}\n" - f"Press '{_action_label(state)}' to try again, or fill in " - "Skill ID / Skill OAuth Token manually below." - ) - if state == SkillCreationState.NONE: - return "Ready to create skill. Press the button below to start." - # Any partial state — resume is possible. - return ( - f"Partial progress saved ({state.value}). " - f"Press '{_action_label(state)}' to finish, or fill Skill ID manually." - ) - - -def _action_label(state: SkillCreationState) -> str: - if state == SkillCreationState.NONE: - return "Create skill automatically" - if state == SkillCreationState.FAILED: - return "Retry" - return "Retry from last step" - - -def should_show_button( - *, - connection_type: str, - state: SkillCreationState, - cloud_instance_id: str, - base_url: str, -) -> bool: - """Return True iff the auto-create action button is actionable now. - - Hides the button when: - - Mode is plain ``cloud`` (no custom skill exists there). - - Skill creation already reached DONE. - - cloud_plus is selected but no cloud instance has been registered. - - direct is selected but MA base_url is not HTTPS. - """ - if connection_type == CONNECTION_TYPE_CLOUD: - return False - if state == SkillCreationState.DONE: - return False - if connection_type == CONNECTION_TYPE_CLOUD_PLUS and not cloud_instance_id: - return False - return not (connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith("https://")) - - -def auto_create_entries( - *, - connection_type: str, - artifacts: SkillCreationArtifacts, - cloud_instance_id: str, - base_url: str, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, -) -> Sequence[ConfigEntry]: - """Build the auto-create section of the config form. - - Empty list for ``cloud`` mode — the feature is meaningless without - a custom skill. - """ - if connection_type == CONNECTION_TYPE_CLOUD: - return () - - entries: list[ConfigEntry] = [] - - # Device-flow user code — shown when the flow obtained one this round. - if user_code: - entries.append( - ConfigEntry( - key="auto_create_user_code", - type=ConfigEntryType.STRING, - label="Device code for ya.ru/device", - description=( - "Open the URL below in your browser, log in to your " - "Yandex account, and enter this code." - ), - value=user_code, - required=False, - help_link=verification_url or "https://ya.ru/device", - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - # Status label — dynamic based on current artifact state. - entries.append( - ConfigEntry( - key="label_auto_create_status", - type=ConfigEntryType.LABEL, - label=_status_label(artifacts.state, artifacts.last_error), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - # The action button — hidden in states where it can't run. - show_button = should_show_button( - connection_type=connection_type, - state=artifacts.state, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - ) - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE, - type=ConfigEntryType.ACTION, - label=_action_label(artifacts.state), - description=( - "Runs the Yandex Device Flow login, then creates and " - "publishes the private Smart Home skill. Takes ~30 seconds " - "after you enter the code." - ), - action=CONF_ACTION_AUTO_CREATE, - action_label=_action_label(artifacts.state), - hidden=not show_button, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category=AUTO_CREATE_CATEGORY, - ) - ) - - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - return entries - - -def _hidden_state_entries( - existing_artifacts_raw: str | None, session_id: str | None -) -> list[ConfigEntry]: - """Round-trip the artifact blob and session id through the config form.""" - return [ - ConfigEntry( - key=CONF_AUTO_CREATE_ARTIFACTS, - type=ConfigEntryType.STRING, - label="Auto-create artifacts (internal)", - hidden=True, - required=False, - value=existing_artifacts_raw, - ), - ConfigEntry( - key=CONF_AUTO_CREATE_SESSION_ID, - type=ConfigEntryType.STRING, - label="Auto-create session id (internal)", - hidden=True, - required=False, - value=session_id, - ), - ] - - -# --------------------------------------------------------------------------- -# Cloud Plus step-flow -# --------------------------------------------------------------------------- - - -def build_cloud_plus_entries( # noqa: PLR0913 - *, - otp_code: str | None, - is_registered: bool, - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, - base_url: str, - skill_id: str = "", - skill_token_set: bool = False, -) -> list[ConfigEntry]: - """Return the cloud_plus-mode config entries as three visible steps. - - Step 1 (Register) — always visible. - Step 2 (Create skill) — visible once cloud instance is registered. - Step 3 (Link via OTP) — visible once the skill (id + token) is set. - """ - skill_id_set = bool(skill_id) - fully_configured = skill_id_set and skill_token_set - - entries: list[ConfigEntry] = [] - entries.extend(_step1_register_entries(is_registered, cloud_instance_id)) - entries.extend( - _step2_create_skill_entries( - is_registered=is_registered, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - skill_id=skill_id, - fully_configured=fully_configured, - ) - ) - entries.extend( - _step3_link_entries( - is_registered=is_registered, - skill_id_set=skill_id_set, - otp_code=otp_code, - ) - ) - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - return entries - - -def _step1_register_entries(is_registered: bool, cloud_instance_id: str) -> list[ConfigEntry]: - """Step 1 — yaha-cloud.ru instance registration.""" - status_text = ( - f"✅ Cloud instance registered (id: {cloud_instance_id})." - if is_registered - else ( - "Click 'Register with cloud' to create a yaha-cloud.ru relay " - "instance. This is free and takes a second." - ) - ) - return [ - ConfigEntry( - key="label_step1_status", - type=ConfigEntryType.LABEL, - label=status_text, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_1_REGISTER, - ), - ConfigEntry( - key=CONF_ACTION_REGISTER, - type=ConfigEntryType.ACTION, - label="Register cloud instance", - description="Registers a new instance on yaha-cloud.ru relay.", - action=CONF_ACTION_REGISTER, - action_label="Register with cloud", - hidden=is_registered, - # No depends_on — see note above action_auto_create. - category=_CAT_STEP_1_REGISTER, - ), - ] - - -def _create_skill_step_entries( - *, - connection_type: str, - category: str, - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - user_code: str | None, - verification_url: str | None, - base_url: str, - direct_client_secret: str = "", - skill_id: str = "", - fully_configured: bool = False, -) -> list[ConfigEntry]: - """Shared builder for the Create-Skill step. - - Used by both cloud_plus Step 2 and direct single-step mode. - - Skill ID / Skill OAuth Token are shown after DONE (happy path) or - FAILED (so the user can finish manually). FAILED additionally shows - manual copy-paste fields (Backend URL / Client ID / Secret / Auth - URLs / Dialogs console link) so the user can create the skill by - hand in Yandex.Dialogs without leaving the form. - """ - entries: list[ConfigEntry] = [] - - if user_code: - entries.append( - ConfigEntry( - key="auto_create_user_code", - type=ConfigEntryType.STRING, - label="Device code for ya.ru/device", - description=( - "Open the URL below, log in to your Yandex account, and enter this code." - ), - value=user_code, - required=False, - help_link=verification_url or "https://ya.ru/device", - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - entries.append( - ConfigEntry( - key="label_create_skill_status", - type=ConfigEntryType.LABEL, - label=_status_label(artifacts.state, artifacts.last_error), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - # direct mode prerequisite: Yandex Dialogs only accepts HTTPS - # backends. If MA's Base URL is not HTTPS, show the user what's - # wrong (and what URL we'd have used) so they can fix it in - # Settings → Core → Webserver → Base URL. - direct_https_missing = connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith( - "https://" - ) - if direct_https_missing: - entries.append( - ConfigEntry( - key="label_direct_https_warning", - type=ConfigEntryType.LABEL, - label=( - f"⚠️ MA's Base URL is {base_url or ''}. " - "Direct mode requires a **publicly reachable HTTPS URL** — " - "Yandex refuses to talk to a non-HTTPS backend. " - "Set a reverse proxy with a real certificate and " - "update Settings → Core → Webserver → Base URL, then " - "reopen these settings." - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - entries.append( - ConfigEntry( - key="current_ma_base_url", - type=ConfigEntryType.STRING, - label="Current MA Base URL (read-only)", - description=( - "For reference. Change it in Settings → Core → Webserver " - "→ Base URL; provider doesn't own this setting." - ), - required=False, - default_value=base_url or "", - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - show_button = should_show_button( - connection_type=connection_type, - state=artifacts.state, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - ) - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE, - type=ConfigEntryType.ACTION, - label=_action_label(artifacts.state), - description=( - "Runs the Yandex Device Flow login, then creates and " - "publishes the private Smart Home skill." - ), - action=CONF_ACTION_AUTO_CREATE, - action_label=_action_label(artifacts.state), - hidden=not show_button, - # No depends_on — MA disables actions with unsaved-dependency - # fields until the user clicks Save, which breaks the flow. - # Visibility is already correctly gated via `hidden`. - category=category, - ) - ) - - # Manual-fallback copy-paste fields — always emitted so advanced - # users can edit them, but on any state other than FAILED they're - # marked ``advanced=True`` so default view stays clean. On FAILED - # they show up unconditionally (auto-fallback UX). - entries.extend( - _manual_fallback_entries( - connection_type=connection_type, - category=category, - cloud_instance_id=cloud_instance_id, - base_url=base_url, - direct_client_secret=direct_client_secret, - advanced=artifacts.state != SkillCreationState.FAILED, - ) - ) - - # Skill ID / Skill OAuth Token / OAuth URL — the actual fields the - # provider needs at runtime. Auto-shown once auto-create reached - # DONE or FAILED; hidden under Advanced once the user has fully - # configured them (so a clean default view stays clean after setup). - token_fields_advanced = fully_configured or artifacts.state not in ( - SkillCreationState.DONE, - SkillCreationState.FAILED, - ) - entries.extend( - [ - ConfigEntry( - key="oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get token)", - description=( - "Open this URL in your browser, approve, and copy the " - "access_token from the resulting URL into the field below." - ), - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key=CONF_SKILL_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Skill OAuth Token", - description="Paste the OAuth token obtained from the URL above.", - required=False, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key=CONF_SKILL_ID, - type=ConfigEntryType.STRING, - label="Skill ID", - description=( - "UUID of your private Smart Home skill. Set automatically " - "when auto-create succeeds; you can paste it manually if " - "you created the skill by hand." - ), - required=False, - advanced=token_fields_advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ] - ) - - # Once setup is complete, replace the cluster of edit fields with - # a single non-editable link to the skill in Yandex.Dialogs so the - # user can quickly open it (the fields are still available under - # Advanced if they need to re-edit). - if fully_configured and skill_id: - skill_url = f"https://dialogs.yandex.ru/developer/skills/{skill_id}/" - entries.append( - ConfigEntry( - key="skill_dialogs_link", - type=ConfigEntryType.STRING, - label="Open skill in Yandex.Dialogs", - description="Click the link to open the skill's page.", - required=False, - default_value=skill_url, - help_link=skill_url, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - return entries - - -def _manual_fallback_entries( - *, - connection_type: str, - category: str, - cloud_instance_id: str, - base_url: str, - direct_client_secret: str, - advanced: bool, -) -> list[ConfigEntry]: - """Copy-paste fields for creating the skill by hand. - - ``advanced=True`` → hidden from the default view, shown when the - user toggles Advanced. Used when auto-create is expected to work - but power users still want to see/edit everything. - - ``advanced=False`` → visible unconditionally. Used on FAILED state - so the user has everything they need to finish manually without - needing to click Advanced. - """ - if connection_type == CONNECTION_TYPE_CLOUD_PLUS: - # The cloud_plus Client ID embeds the yaha-cloud instance UUID, - # which doesn't exist until the user registers. Suppress the - # manual block entirely before registration rather than render - # an invalid ``yandex_smart_home:`` Client ID that would lead - # someone to create a broken skill. - if not cloud_instance_id: - return [] - backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE - client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - client_secret = CLOUD_SKILL_CLIENT_SECRET - auth_url = CLOUD_OAUTH_AUTHORIZE_URL - token_url = CLOUD_OAUTH_TOKEN_URL - elif connection_type == CONNECTION_TYPE_DIRECT: - base = base_url.rstrip("/") or "https://" - backend_uri = f"{base}{DIRECT_API_BASE_PATH}" - client_id = DIRECT_OAUTH_CLIENT_ID - client_secret = direct_client_secret or "(auto-generated on save)" - auth_url = f"{base}{DIRECT_AUTH_BASE_PATH}/authorize" - token_url = f"{base}{DIRECT_AUTH_BASE_PATH}/token" - else: - return [] - - label_text = ( - "Auto-create failed — you can create the skill by hand instead. " - "Open Yandex.Dialogs (link below), create a private Smart Home " - "skill, paste the values below into the skill's Basic info and " - "Account linking tabs, then put the skill UUID in the Skill ID " - "field below." - if not advanced - else ( - "Manual setup values — copy these into Yandex.Dialogs if you " - "prefer to create the skill by hand, or want to verify what " - "auto-create used." - ) - ) - - # For direct mode, also surface the hidden generated secret as an - # editable field so user can copy it. - extra: list[ConfigEntry] = [] - if connection_type == CONNECTION_TYPE_DIRECT and direct_client_secret: - extra.append( - ConfigEntry( - key=CONF_DIRECT_CLIENT_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Client Secret (→ Account linking)", - description="Copy to 'Account linking' → 'Client secret' field.", - required=False, - default_value=direct_client_secret, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ) - - return [ - ConfigEntry( - key="manual_fallback_label", - type=ConfigEntryType.LABEL, - label=label_text, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_backend_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy to 'Basic info' → 'Backend URL' in your skill.", - required=False, - # Reference-only fields use default_value so MA UI renders - # the text without storing it as a mutable config value - # (``value=`` was returning empty on first render before the - # user clicked Save). - default_value=backend_uri, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - default_value=client_id, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - *extra, - # manual_client_secret is a plain STRING so it's readable for - # copy-paste in cloud_plus (where the value is the literal - # "secret"). For direct mode we skip it: the real per-install - # UUID is surfaced via the SECURE_STRING CONF_DIRECT_CLIENT_SECRET - # entry in ``extra`` above, and showing the same value in a - # second unmasked STRING would leak it. - *( - [ - ConfigEntry( - key="manual_client_secret", - type=ConfigEntryType.STRING, - label="Client Secret value (for reference)", - description=("Copy this string into 'Account linking' → 'Client secret'."), - required=False, - default_value=client_secret, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ) - ] - if connection_type != CONNECTION_TYPE_DIRECT - else [] - ), - ConfigEntry( - key="manual_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - required=False, - default_value=auth_url, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ConfigEntry( - key="manual_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description="Paste into BOTH 'Token endpoint' and 'Refresh token URL'.", - required=False, - default_value=token_url, - advanced=advanced, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=connection_type, - category=category, - ), - ] - - -def _step2_create_skill_entries( - *, - is_registered: bool, # noqa: ARG001 — kept for call-site symmetry - cloud_instance_id: str, - artifacts: SkillCreationArtifacts, - user_code: str | None, - verification_url: str | None, - base_url: str, - skill_id: str = "", - fully_configured: bool = False, -) -> list[ConfigEntry]: - """cloud_plus Step 2 — always emitted. - - The Create-Skill action button self-hides via ``should_show_button`` - when no cloud instance exists yet, but the section's other fields - (status label + advanced manual-setup references) stay visible so - power users can see everything under Advanced without first going - through the register step. - """ - return _create_skill_step_entries( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_2_CREATE, - cloud_instance_id=cloud_instance_id, - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - skill_id=skill_id, - fully_configured=fully_configured, - ) - - -def _step3_link_entries( - *, is_registered: bool, skill_id_set: bool, otp_code: str | None -) -> list[ConfigEntry]: - """Step 3 — get an OTP from the cloud and enter it in the Yandex app. - - Hidden until both Step 1 (cloud registration) and Step 2 (skill - created — ``skill_id_set``) are done: OTP linking only makes sense - once the private skill exists in Yandex.Dialogs for the user to - link against in the Yandex app. - """ - if not is_registered or not skill_id_set: - return [] - - # Banner priority: fresh OTP > linked (skill configured). - if otp_code: - banner_text = f"Enter this OTP in the Yandex app: {otp_code}" - else: - banner_text = ( - "✅ Skill configured. Press 'Get OTP code' to link it with your " - "Yandex account (or re-link if you ever unlinked it)." - ) - - entries: list[ConfigEntry] = [ - ConfigEntry( - key="label_step3_status", - type=ConfigEntryType.LABEL, - label=banner_text, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_3_LINK, - ), - ConfigEntry( - key="otp_code", - type=ConfigEntryType.STRING, - label="OTP Code", - description="Copy this code and enter it in the Yandex app.", - required=False, - value=otp_code, - hidden=not otp_code, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - category=_CAT_STEP_3_LINK, - ), - ConfigEntry( - key=CONF_ACTION_GET_OTP, - type=ConfigEntryType.ACTION, - label="Get OTP code", - description="Get a fresh one-time password to link with Yandex.", - action=CONF_ACTION_GET_OTP, - action_label="Get OTP code", - # No depends_on — see note above action_auto_create. - category=_CAT_STEP_3_LINK, - ), - ] - return entries - - -# --------------------------------------------------------------------------- -# Direct-mode flow (auto-create only; no cloud registration, no OTP) -# --------------------------------------------------------------------------- - - -def build_direct_entries( - *, - artifacts: SkillCreationArtifacts, - session_id: str | None, - user_code: str | None, - verification_url: str | None, - existing_artifacts_raw: str | None, - base_url: str, - direct_client_secret: str = "", - skill_id: str = "", - skill_token_set: bool = False, -) -> list[ConfigEntry]: - """Return the direct-mode config entries as a single Create-Skill step. - - direct mode has no yaha-cloud registration (Step 1) and no OTP - linking (Step 3) — Yandex Dialogs' account-linking UI handles that - once the skill exists. - """ - fully_configured = bool(skill_id) and skill_token_set - entries = _create_skill_step_entries( - connection_type=CONNECTION_TYPE_DIRECT, - category=_CAT_STEP_DIRECT_CREATE, - cloud_instance_id="", - artifacts=artifacts, - user_code=user_code, - verification_url=verification_url, - base_url=base_url, - direct_client_secret=direct_client_secret, - skill_id=skill_id, - fully_configured=fully_configured, - ) - entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) - return entries diff --git a/music_assistant/providers/yandex_smarthome/cloud.py b/music_assistant/providers/yandex_smarthome/cloud.py index 35483504e4..4e9130dada 100644 --- a/music_assistant/providers/yandex_smarthome/cloud.py +++ b/music_assistant/providers/yandex_smarthome/cloud.py @@ -18,7 +18,7 @@ import aiohttp if TYPE_CHECKING: - from ._compat import SecretStr + from ya_dialogs_api import SecretStr from .constants import ( CLOUD_BASE_URL, diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 9df04d3ea8..4ec1b1f688 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -7,16 +7,27 @@ # --------------------------------------------------------------------------- CONF_INSTANCE_NAME = "instance_name" CONF_CONNECTION_TYPE = "connection_type" +# Override for MA's webserver Base URL — used when generating callback / +# webhook URLs for Yandex. Lets users keep MA's global Base URL unset (so +# HA Ingress / local access keep working) while still exposing a public +# HTTPS URL only to Yandex via a reverse proxy. +CONF_EXTERNAL_BASE_URL = "external_base_url" CONF_CLOUD_INSTANCE_ID = "cloud_instance_id" CONF_CLOUD_INSTANCE_PASSWORD = "cloud_instance_password" CONF_CLOUD_CONNECTION_TOKEN = "cloud_connection_token" CONF_SKILL_ID = "skill_id" CONF_SKILL_TOKEN = "skill_token" CONF_EXPOSED_PLAYERS = "exposed_players" +CONF_EXPOSED_PLAYLISTS = "exposed_playlists" # Auto-create-skill feature state (round-trips through the config form) CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" +# Cached Yandex Passport x_token from the first successful Device Flow. +# Reused on subsequent auto-create runs so the user does not have to confirm +# the device code every time. Long-lived (months); automatically refreshed +# on use. Cleared if Yandex returns 401 on refresh. +CONF_AUTH_X_TOKEN = "auth_x_token" # --------------------------------------------------------------------------- # Config actions @@ -66,6 +77,10 @@ # Direct connection — HTTP endpoints on MA webserver # --------------------------------------------------------------------------- DIRECT_API_BASE_PATH = "/api/yandex_smarthome/v1.0" +# What we send to Yandex as the skill Backend URI. Yandex appends /v1.0/... +# itself when calling our endpoints, so the backend URI must NOT include +# /v1.0 — otherwise Yandex calls /v1.0/v1.0/user/devices and gets 404. +DIRECT_BACKEND_URI_PATH = "/api/yandex_smarthome" DIRECT_AUTH_BASE_PATH = "/api/yandex_smarthome/auth" DIRECT_HEALTH_RESPONSE = "Yandex Smart Home for Music Assistant" CONF_DIRECT_ACCESS_TOKEN = "direct_access_token" @@ -117,6 +132,10 @@ "ten", ) +# Combined cap for native sources + playlist sources in mode(input_source). +# Yandex allows max 10 modes per capability. +MAX_INPUT_SOURCES = len(YANDEX_MODE_VALUES) + # --------------------------------------------------------------------------- # Yandex Smart Home API — response codes # --------------------------------------------------------------------------- diff --git a/music_assistant/providers/yandex_smarthome/device.py b/music_assistant/providers/yandex_smarthome/device.py index 74340f7183..9748f971d1 100644 --- a/music_assistant/providers/yandex_smarthome/device.py +++ b/music_assistant/providers/yandex_smarthome/device.py @@ -6,6 +6,7 @@ from __future__ import annotations +import asyncio import logging import re from typing import TYPE_CHECKING, Any @@ -22,10 +23,12 @@ INSTANCE_ON, INSTANCE_PAUSE, INSTANCE_VOLUME, + MAX_INPUT_SOURCES, UNIT_PERCENT, YANDEX_DEVICE_TYPE_MEDIA, YANDEX_MODE_VALUES, ) +from .playlists import play_playlist from .schema import ( ActionResult, CapabilityAction, @@ -85,9 +88,42 @@ def _get_source_list(player: Player) -> list[PlayerSource]: return [] -def _build_source_modes(source_list: list[PlayerSource]) -> list[ModeValue]: - """Build Yandex mode values from an MA source list (max 10).""" - return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(min(len(source_list), 10))] +def _combined_size( + source_list: list[PlayerSource], playlist_uris: tuple[str, ...] | list[str] +) -> int: + """Total slot count for mode(input_source): native first, playlists fill rest.""" + return min(len(source_list) + len(playlist_uris), MAX_INPUT_SOURCES) + + +def _build_combined_modes( + source_list: list[PlayerSource], playlist_uris: tuple[str, ...] | list[str] +) -> list[ModeValue]: + """Build mode values covering both native sources and playlist slots.""" + size = _combined_size(source_list, playlist_uris) + return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(size)] + + +def _resolve_combined_slot( + index: int, + source_list: list[PlayerSource], + playlist_uris: tuple[str, ...] | list[str], +) -> tuple[str, str] | None: + """Resolve a 0-based slot index to ("native"|"playlist", value). + + Returns None if the slot is out of range. The native source slots come + first; playlist URIs fill the remainder up to MAX_INPUT_SOURCES. + """ + native_count = len(source_list) + if index < 0: + return None + if index < native_count: + return ("native", source_list[index].id) + playlist_idx = index - native_count + if playlist_idx >= len(playlist_uris): + return None + if native_count + playlist_idx >= MAX_INPUT_SOURCES: + return None + return ("playlist", playlist_uris[playlist_idx]) def _source_to_mode(active_source: str | None, source_list: list[PlayerSource]) -> str | None: @@ -105,7 +141,7 @@ def _source_to_mode(active_source: str | None, source_list: list[PlayerSource]) def _mode_to_source(mode_value: str, source_list: list[PlayerSource]) -> str | None: """Resolve a Yandex mode value to an MA source id.""" try: - idx = list(YANDEX_MODE_VALUES).index(mode_value) + idx = YANDEX_MODE_VALUES.index(mode_value) except ValueError: return None if idx >= len(source_list): @@ -147,7 +183,11 @@ def normalize_device_name(name: str) -> str: return result or name -def get_device_description(player: Player) -> DeviceDescription: +def get_device_description( + player: Player, + *, + playlist_uris: tuple[str, ...] | list[str] = (), +) -> DeviceDescription: """Build a Yandex Smart Home device description from an MA player.""" capabilities = [ CapabilityDescription(type=YandexCapabilityType.ON_OFF), @@ -178,20 +218,30 @@ def get_device_description(player: Player) -> DeviceDescription: ) ) - # mode(input_source) — only if player has sources + # mode(input_source): register when native sources or playlists exist. + # Native sources occupy first slots; playlists fill remainder up to MAX_INPUT_SOURCES. source_list = _get_source_list(player) - if source_list: - modes = _build_source_modes(source_list) - if modes: - capabilities.append( - CapabilityDescription( - type=YandexCapabilityType.MODE, - parameters=CapabilityParameters( - instance=INSTANCE_INPUT_SOURCE, - modes=modes, - ), - ) + if len(source_list) >= MAX_INPUT_SOURCES and playlist_uris: + # Debug-level: this fires on every /user/devices poll for an + # affected player, but it documents a config decision rather than + # a runtime fault — promoting it to warn would spam production logs. + _LOGGER.debug( + "Player %s has %d native sources (>= cap %d); playlist sources ignored", + player.player_id, + len(source_list), + MAX_INPUT_SOURCES, + ) + modes = _build_combined_modes(source_list, playlist_uris) + if modes: + capabilities.append( + CapabilityDescription( + type=YandexCapabilityType.MODE, + parameters=CapabilityParameters( + instance=INSTANCE_INPUT_SOURCE, + modes=modes, + ), ) + ) model = "MA Player" if hasattr(player, "device_info") and player.device_info: @@ -206,7 +256,11 @@ def get_device_description(player: Player) -> DeviceDescription: ) -def get_device_state(player: Player) -> DeviceState: +def get_device_state( + player: Player, + *, + playlist_uris: tuple[str, ...] | list[str] = (), +) -> DeviceState: """Read current MA player state and convert to Yandex capability states.""" # on = player is powered on (or available if power state unknown) powered = getattr(player, "powered", None) @@ -256,9 +310,11 @@ def get_device_state(player: Player) -> DeviceState: ) ) - # input_source state — only if player has sources + # input_source state — only reported when active MA source matches a native slot. + # Playlist slots have no reliable "active" signal, so they leave state unset + # (Yandex tolerates an unreported state on mode capabilities). source_list = _get_source_list(player) - if source_list: + if source_list or playlist_uris: active = getattr(player, "active_source", None) mode_value = _source_to_mode(active, source_list) if mode_value: @@ -273,7 +329,13 @@ def get_device_state(player: Player) -> DeviceState: async def _execute_input_source( - mass: Any, player_id: str, player: Player | None, instance: str, value: Any + mass: Any, + player_id: str, + player: Player | None, + instance: str, + value: Any, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> CapabilityActionResult | None: """Handle input_source mode action. Returns error result or None on success.""" if player is None: @@ -288,23 +350,50 @@ async def _execute_input_source( ), ), ) + + try: + slot_index = YANDEX_MODE_VALUES.index(str(value)) + except ValueError: + return CapabilityActionResult( + type=YandexCapabilityType.MODE, + state=CapabilityActionResultState( + instance=instance, + action_result=ActionResult( + status="ERROR", + error_code=ERROR_INVALID_ACTION, + error_message=f"Unknown source mode: {value}", + ), + ), + ) + p_state = player.state if hasattr(player, "state") else player source_list = _get_source_list(p_state) - source = _mode_to_source(str(value), source_list) - if source: - await mass.players.select_source(player_id, source) - return None - return CapabilityActionResult( - type=YandexCapabilityType.MODE, - state=CapabilityActionResultState( - instance=instance, - action_result=ActionResult( - status="ERROR", - error_code=ERROR_INVALID_ACTION, - error_message=f"Unknown source mode: {value}", + resolved = _resolve_combined_slot(slot_index, source_list, playlist_uris) + if resolved is None: + return CapabilityActionResult( + type=YandexCapabilityType.MODE, + state=CapabilityActionResultState( + instance=instance, + action_result=ActionResult( + status="ERROR", + error_code=ERROR_INVALID_ACTION, + error_message=f"No source configured for mode: {value}", + ), ), - ), - ) + ) + + kind, target = resolved + if kind == "native": + await mass.players.select_source(player_id, target) + return None + + # Playlist slot: power on if needed, then start playback via player_queues.play_media. + if _has_feature(p_state, "power"): + powered = getattr(p_state, "powered", None) + if powered is False: + await mass.players.cmd_power(player_id, True) + await play_playlist(mass, player_id, target) + return None def _invalid_bool_result(cap_type: str, instance: str, value: Any) -> CapabilityActionResult: @@ -346,6 +435,8 @@ async def execute_capability_action( # noqa: PLR0915 player_id: str, action: CapabilityAction, current_volume: int = 0, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> CapabilityActionResult: """Execute a Yandex capability action by calling the corresponding MA player command. @@ -430,7 +521,9 @@ async def execute_capability_action( # noqa: PLR0915 # Non-relative channel set is ignored (no concept of channel number in MA) elif action.type == YandexCapabilityType.MODE and instance == INSTANCE_INPUT_SOURCE: - result = await _execute_input_source(mass, player_id, player, instance, value) + result = await _execute_input_source( + mass, player_id, player, instance, value, playlist_uris=playlist_uris + ) if result: return result @@ -459,6 +552,11 @@ async def execute_capability_action( # noqa: PLR0915 ), ), ) + except asyncio.CancelledError: + # Cooperative cancellation must propagate untouched — without this + # the broad `except Exception` below would convert a shutdown / + # config-flow abort into an INTERNAL_ERROR action result. + raise except Exception: _LOGGER.exception("Error executing action %s/%s on %s", action.type, instance, player_id) return CapabilityActionResult( diff --git a/music_assistant/providers/yandex_smarthome/direct.py b/music_assistant/providers/yandex_smarthome/direct.py index 1d0025ec3b..196b57aa25 100644 --- a/music_assistant/providers/yandex_smarthome/direct.py +++ b/music_assistant/providers/yandex_smarthome/direct.py @@ -99,24 +99,29 @@ def __init__( exposed_ids: set[str] | None = None, logger: logging.Logger | None = None, on_token_created: Callable[[str], None] | None = None, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> None: - """Initialize the handler. - - Args: - mass: MusicAssistant instance. - user_id: User identifier for Yandex API responses. - access_token: Current Bearer access token (may be empty on first run). - client_secret: OAuth client secret for account linking validation. - exposed_ids: Set of player IDs to expose, or None for all. - logger: Optional logger instance. - on_token_created: Callback invoked with new access token when generated - via OAuth flow (to persist in config). + """ + Initialize the handler. + + :param mass: MusicAssistant instance. + :param user_id: User identifier for Yandex API responses. + :param access_token: Current Bearer access token (may be empty + on first run). + :param client_secret: OAuth client secret for account linking + validation. + :param exposed_ids: Set of player IDs to expose, or ``None`` for + all. + :param logger: Optional logger instance. + :param on_token_created: Callback invoked with new access token + when generated via OAuth flow (to persist in config). """ self._mass = mass self._user_id = user_id self._access_token = access_token self._client_secret = client_secret self._exposed_ids = exposed_ids + self._playlist_uris = tuple(playlist_uris) self._logger = logger or _LOGGER self._on_token_created = on_token_created self._unregister_callbacks: list[Callable[[], None]] = [] @@ -219,7 +224,10 @@ async def _handle_devices(self, request: web.Request) -> web.Response: try: device_list = await handle_device_list( - self._mass, self._user_id, exposed_ids=self._exposed_ids + self._mass, + self._user_id, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(device_list))) except Exception: @@ -247,7 +255,10 @@ async def _handle_query(self, request: web.Request) -> web.Response: device_id for d in devices_raw if isinstance(d, dict) and (device_id := d.get("id")) ] states = await handle_devices_query( - self._mass, device_ids, exposed_ids=self._exposed_ids + self._mass, + device_ids, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(states))) except Exception: @@ -268,7 +279,10 @@ async def _handle_action(self, request: web.Request) -> web.Response: try: action_payload = parse_action_payload(body) result = await handle_devices_action( - self._mass, action_payload, exposed_ids=self._exposed_ids + self._mass, + action_payload, + exposed_ids=self._exposed_ids, + playlist_uris=self._playlist_uris, ) return web.json_response(build_response(request_id, asdict(result))) except Exception: diff --git a/music_assistant/providers/yandex_smarthome/handlers.py b/music_assistant/providers/yandex_smarthome/handlers.py index 2607e188f2..3fb2e2c75d 100644 --- a/music_assistant/providers/yandex_smarthome/handlers.py +++ b/music_assistant/providers/yandex_smarthome/handlers.py @@ -43,6 +43,8 @@ async def handle_device_list( mass: MusicAssistant, user_id: str, exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> DeviceListPayload: """Handle /user/devices — return list of all MA players as Yandex devices.""" devices = [] @@ -50,7 +52,7 @@ async def handle_device_list( state = player.state if not is_player_exposable(state, exposed_ids=exposed_ids): continue - devices.append(get_device_description(state)) + devices.append(get_device_description(state, playlist_uris=playlist_uris)) _LOGGER.debug("Device list: %d devices exposed", len(devices)) return DeviceListPayload(user_id=user_id, devices=devices) @@ -59,6 +61,8 @@ async def handle_devices_query( mass: MusicAssistant, device_ids: list[str], exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> DeviceStatesPayload: """Handle /user/devices/query — return current states for requested devices.""" states: list[DeviceState] = [] @@ -77,7 +81,7 @@ async def handle_devices_query( states.append(make_error_device_state(device_id)) continue - states.append(get_device_state(player_state)) # type: ignore[arg-type] + states.append(get_device_state(player_state, playlist_uris=playlist_uris)) # type: ignore[arg-type] return DeviceStatesPayload(devices=states) @@ -86,6 +90,8 @@ async def handle_devices_action( mass: MusicAssistant, payload: ActionRequestPayload, exposed_ids: set[str] | None = None, + *, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> ActionResultPayload: """Handle /user/devices/action — execute capability actions on devices.""" results: list[DeviceActionResult] = [] @@ -125,7 +131,11 @@ async def handle_devices_action( cap_results = [] for cap_action in device_action.capabilities: result = await execute_capability_action( - mass, device_action.id, cap_action, current_volume + mass, + device_action.id, + cap_action, + current_volume, + playlist_uris=playlist_uris, ) cap_results.append(result) diff --git a/music_assistant/providers/yandex_smarthome/ma_authenticator.py b/music_assistant/providers/yandex_smarthome/ma_authenticator.py new file mode 100644 index 0000000000..0de22740e7 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/ma_authenticator.py @@ -0,0 +1,367 @@ +"""Music-Assistant Device-Flow authenticator for ya-dialogs-api. + +Adapter that wraps :class:`ya_passport_auth.PassportClient` Device Flow +behind the :data:`ya_dialogs_api.AuthenticatorCM` Protocol — a no-arg +async-context-manager factory yielding an authorized +``aiohttp.ClientSession``. + +UX: +- Hosts a temporary HTML activation page on ``mass.webserver`` showing the + Device Code prominently (Yandex's ya.ru/device strips query params on + the redirect-to-login, so we cannot pre-fill the code there). +- Opens the page in a popup via ``music_assistant.helpers.auth.AuthenticationHelper``. +- After the user enters the code at ya.ru/device, the page polls a status + endpoint and self-closes on success. + +Cache fast-path: +- If a valid cached ``x_token`` is provided, we skip Device Flow entirely + and call ``refresh_passport_cookies`` directly. On any failure during + refresh we fall back to a fresh Device Flow. + +Body is ported verbatim from the deleted ``provider/auto_skill.py:_default_authenticator``; +the only structural change is the wrapper — async-iterator → ``@asynccontextmanager`` +to match the lib's :data:`AuthenticatorCM` Protocol. + +Pattern originally adapted from ``ma-provider-yandex-station/provider/auth.py``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable + + import aiohttp + from ya_dialogs_api import AuthenticatorCM + + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +# Hard cap on how long we'll wait for the user to enter the code. +DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 + +_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" +# Keep the intermediate HTML page alive long enough for the browser to +# observe the done/failed state transition. The page polls every 2s +# (see _build_device_code_page → setTimeout(pollStatus, 2000)), so we +# need at least one full poll interval + RTT margin after flipping the +# server-side state, otherwise the route gets unregistered before the +# page can fetch the final state and self-close. +_POST_AUTH_GRACE_SECONDS = 3 +# Server-suggested interval from Yandex is 5s (RFC 8628) but after the +# user has confirmed the code we want to detect it promptly; 2s is the +# RFC-recommended minimum. If Yandex returns SLOW_DOWN, ya-passport-auth +# bumps the interval automatically. +_DEVICE_FLOW_POLL_INTERVAL = 2.0 +_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") + + +def make_authenticator( # noqa: PLR0915 + *, + mass: MusicAssistant, + session_id: str, + timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, + cached_x_token: str | None = None, + on_token_obtained: Callable[[str], None] | None = None, +) -> AuthenticatorCM: + """Build an :data:`AuthenticatorCM` for ``ya_dialogs_api.auto_create_skill``. + + The returned no-arg callable produces an ``aiohttp.ClientSession`` + context manager. On ``__aenter__`` it either: + + 1. Reuses ``cached_x_token`` (``refresh_passport_cookies`` fast-path), or + 2. Runs the full Device Flow: registers an HTML activation page on + ``mass.webserver``, opens it via ``AuthenticationHelper(mass, session_id)``, + polls Yandex Passport, then refreshes passport cookies. + + After a successful Device Flow, ``on_token_obtained(x_token_str)`` is + called so the caller can persist the new token for the next run. + Callback failures are logged but never break authentication. + + :param mass: MusicAssistant runtime — used for ``mass.webserver`` + route registration and ``AuthenticationHelper`` popup + management. + :param session_id: Frontend-supplied session id (matches what + ``AuthenticationHelper`` listens on for popup open/close). + Must be safe for URL paths. + :param timeout: Hard cap on Device Flow polling (seconds). Default + 5 min. + :param cached_x_token: Optional Yandex Passport ``x_token`` from a + prior Device Flow. If still valid, skips Device Flow entirely. + :param on_token_obtained: Optional callback invoked with the fresh + ``x_token`` (plain ``str``, unwrapped from ``SecretStr``) + after a successful Device Flow. Use to persist into MA config + so the next run can use the cache. + :raises ValueError: ``session_id`` doesn't match the safe + character set. + """ + if not _SAFE_SESSION_ID_RE.match(session_id): + msg = "invalid session_id for device authentication" + raise ValueError(msg) + + @asynccontextmanager + async def _cm() -> AsyncIterator[aiohttp.ClientSession]: # noqa: PLR0915 + # Imports kept inline so the module can be imported without MA in test envs. + from aiohttp import web # noqa: PLC0415 + from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 + from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 + from ya_passport_auth.credentials import SecretStr as PpSecretStr # noqa: PLC0415 + + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) + config = ClientConfig(allowed_hosts=allowed) + + async with PassportClient.create(config=config) as client: + # Cache fast-path: try cached x_token first. If the token is + # still valid Yandex returns fresh session cookies and we skip + # Device Flow. + if cached_x_token: + try: + await client.refresh_passport_cookies(PpSecretStr(cached_x_token)) + _LOGGER.info( + "auto-skill: reused cached Yandex Passport x_token (no Device Flow needed)" + ) + yield client._session + return + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.info( + "auto-skill: cached x_token rejected (%s) — " + "falling back to fresh Device Flow", + exc, + ) + + # Device Flow path + device_session = await client.start_device_login() + # Don't log user_code — it's a time-limited credential and writing + # it to shared log backends would leak access. + _LOGGER.info( + "device flow started — verification_url=%s", + device_session.verification_url, + ) + + page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" + status_path = f"{page_path}/status" + base_url = str(mass.webserver.base_url).rstrip("/") + status_url = f"{base_url}{status_path}" + page_url = f"{base_url}{page_path}" + state = {"value": "pending"} + + page_html = _build_device_code_page( + device_session.user_code, + device_session.verification_url, + status_url, + ) + + async def _serve_page(_request: web.Request) -> web.Response: + return web.Response( + text=page_html, + content_type="text/html", + charset="utf-8", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + async def _serve_status(_request: web.Request) -> web.Response: + return web.json_response( + {"state": state["value"]}, + headers={"Cache-Control": "no-store"}, + ) + + mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") + mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") + _LOGGER.warning( + "auto-skill: device-code popup URL %s (path=%s) " + "— if the popup does not open or points at an unreachable " + "address, open the path directly in your browser (the page " + "displays the user_code) or fix Settings → Core → Webserver " + "→ Base URL", + page_url, + page_path, + ) + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(page_url) + try: + creds = await client.poll_device_until_confirmed( + device_session, + total_timeout=timeout, + poll_interval=_DEVICE_FLOW_POLL_INTERVAL, + ) + except asyncio.CancelledError: + raise + except Exception: + state["value"] = "failed" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + mass.webserver.unregister_dynamic_route(page_path, "GET") + mass.webserver.unregister_dynamic_route(status_path, "GET") + + await client.refresh_passport_cookies(creds.x_token) + + # Persist the new x_token so subsequent auto-create runs can skip + # Device Flow. Best-effort: a callback failure must not break auth. + # Unwrap SecretStr → str so the callback can store it via the MA + # config plumbing (SECURE_STRING serialiser expects a plain str). + if on_token_obtained is not None: + try: + on_token_obtained(creds.x_token.get_secret()) + except Exception: + _LOGGER.exception( + "auto-skill: on_token_obtained callback failed; x_token will not be cached" + ) + + yield client._session + + return _cm + + +def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: + """Render the HTML page shown during Device Flow login. + + Yandex's ya.ru/device page does not pre-fill from query params and + strips them on redirect-to-login, so the only reliable way to show + the code is to host our own page in MA's webserver that displays + the code prominently and opens ya.ru/device in a new tab. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + import html # noqa: PLC0415 + + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + safe_status_url = json.dumps(status_url).replace(" + + + + Yandex Smart Home — Device Code + + + + +
+

Authorise Music Assistant for skill creation

+

Open the link below, log in to your Yandex account, and enter this code.

+
{safe_code}
+
+ +
+ Continue to Yandex +
+ + +""" diff --git a/music_assistant/providers/yandex_smarthome/manifest.json b/music_assistant/providers/yandex_smarthome/manifest.json index 19b0074718..8d0744824e 100644 --- a/music_assistant/providers/yandex_smarthome/manifest.json +++ b/music_assistant/providers/yandex_smarthome/manifest.json @@ -7,7 +7,10 @@ "credits": [ "[dext0r/yandex_smart_home](https://github.com/dext0r/yandex_smart_home)" ], - "requirements": ["ya-passport-auth==1.3.0"], + "requirements": [ + "ya-passport-auth==1.3.0", + "ya-dialogs-api==2.4.0" + ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-smarthome", "stage": "beta", "multi_instance": false, diff --git a/music_assistant/providers/yandex_smarthome/notifier.py b/music_assistant/providers/yandex_smarthome/notifier.py index 3a2a3aad6e..a035d0e40f 100644 --- a/music_assistant/providers/yandex_smarthome/notifier.py +++ b/music_assistant/providers/yandex_smarthome/notifier.py @@ -37,6 +37,15 @@ _LOGGER = logging.getLogger(__name__) +class _CallbackErrorAlreadyLogged(RuntimeError): + """Sentinel for state-callback errors that have already been logged. + + Raised by ``_send_state_callback`` after dedupe-aware logging so the + outer exception handler can re-queue (via ``_flush_pending``) without + emitting a second log line. + """ + + class StateNotifier: """Watches MA player events and reports state changes to Yandex.""" @@ -49,6 +58,7 @@ def __init__( auth_header: dict[str, str], logger: logging.Logger | None = None, exposed_ids: set[str] | None = None, + playlist_uris: tuple[str, ...] | list[str] = (), ) -> None: """Initialize state notifier.""" self._mass = mass @@ -58,6 +68,7 @@ def __init__( self._auth_header = auth_header self._logger = logger or _LOGGER self._exposed_ids = exposed_ids + self._playlist_uris = tuple(playlist_uris) self._dirty_player_ids: set[str] = set() self._flush_handle: asyncio.TimerHandle | None = None @@ -65,6 +76,21 @@ def __init__( self._heartbeat_task: asyncio.Task[None] | None = None self._unsub: Callable[[], None] | None = None + # Dedupe state-callback errors by fingerprint. Yandex's backend + # returns transient HTTP 5xx for ~1-2 minutes after a freshly + # created skill (CDN warmup), then 400 + UNKNOWN_USER until the + # user links the skill in the mobile app. Without dedupe each + # 1 s flush logs a full traceback — flooding the log. First + # occurrence per fingerprint logs as follows: + # - UNKNOWN_USER, HTTP 5xx → WARNING (expected first-run state, + # no traceback — see _emit_callback_error) + # - transport / unexpected errors → ERROR + traceback (real + # bugs worth diagnostic detail — see outer except) + # Repeats with the same fingerprint drop to DEBUG until a + # different error class arrives or a successful callback resets + # the fingerprint (which then logs an INFO recovery line). + self._last_error_fingerprint: str | None = None + async def start(self) -> None: """Subscribe to player events and start background tasks.""" self._unsub = self._mass.subscribe( @@ -166,24 +192,54 @@ async def _flush_pending(self) -> None: continue state = player.state if is_player_exposable(state, exposed_ids=self._exposed_ids): - devices.append(get_device_state(state)) + devices.append(get_device_state(state, playlist_uris=self._playlist_uris)) if not devices: return try: await self._send_state_callback(devices) + except asyncio.CancelledError: + raise except Exception: - # Re-queue failed player IDs + # Re-queue failed player IDs and reschedule. _send_state_callback + # already deduplicated the log entry (WARNING for known classes, + # ERROR-with-traceback for unexpected) so we swallow the exception + # here to keep MA's task scheduler from re-logging it as + # "Task exception was never retrieved" on every retry. self._dirty_player_ids |= dirty self._schedule_flush() - raise # ----------------------------------------------------------------------- # State reporting # ----------------------------------------------------------------------- + def _emit_callback_error(self, fingerprint: str, warn_message: str) -> None: + """Log a state-callback error once per fingerprint, then DEBUG. + + Different fingerprint classes (UNKNOWN_USER, HTTP 5xx, transport + failures) each emit a single WARNING the first time they occur, + then drop to DEBUG until a different error class arrives or a + successful callback resets the fingerprint. + """ + if self._last_error_fingerprint == fingerprint: + self._logger.debug("State callback still failing (%s)", fingerprint) + return + self._last_error_fingerprint = fingerprint + self._logger.warning("%s", warn_message) + async def _send_state_callback(self, devices: list[DeviceState]) -> None: - """POST state callback to Yandex.""" + """POST state callback to Yandex. + + Yandex's callback endpoint can fail three ways: HTTP 5xx while + the skill propagates through their CDN, HTTP 400 + UNKNOWN_USER + until the user links the skill in the mobile app, and + transport-level errors during network issues. All three are + deduped via ``_last_error_fingerprint`` so each class only logs + once per "episode" — UNKNOWN_USER and 5xx at WARNING (expected + first-run states), transport / unexpected errors at ERROR with + traceback (real bugs worth diagnostic detail). Repeats drop to + DEBUG until something changes. + """ payload = CallbackRequest( ts=time.time(), payload=CallbackPayload(user_id=self._user_id, devices=devices), @@ -194,14 +250,64 @@ async def _send_state_callback(self, devices: list[DeviceState]) -> None: json=_strip_none(asdict(payload)), headers=self._auth_header, ) as resp: - if resp.status not in (200, 202): - body = await resp.text() - raise RuntimeError( - f"State callback failed with HTTP {resp.status}: {body[:200]}" + if resp.status in (200, 202): + if self._last_error_fingerprint is not None: + self._logger.info( + "State callback recovered (was failing with %s)", + self._last_error_fingerprint, + ) + self._last_error_fingerprint = None + self._logger.debug("State callback sent: %d device(s)", len(devices)) + return + + body = await resp.text() + if resp.status == 400 and "UNKNOWN_USER" in body: + self._emit_callback_error( + "unknown_user", + "Yandex returned UNKNOWN_USER for state callback — the skill is " + "not yet linked to a Yandex account. Open " + "https://yandex.ru/quasar/iot or the «Дом с Алисой» app, find " # noqa: RUF001 + "the skill in Devices → +, and tap «Связать аккаунт». Further " + "callback errors will be suppressed at debug level until linking " + "succeeds.", ) - self._logger.debug("State callback sent: %d device(s)", len(devices)) - except Exception: - self._logger.exception("State callback error") + return # silent — not a real error, don't raise + + if 500 <= resp.status < 600: + # Transient Yandex backend issue — common for ~1-2 min + # after a freshly created skill while CDN propagates. + # Dedupe the WARNING but still raise so _flush_pending + # re-queues the dirty players for the next attempt. + self._emit_callback_error( + f"http_{resp.status}", + f"State callback failed with HTTP {resp.status} — Yandex backend " + "may be propagating a freshly created skill. Further callback " + "errors will be suppressed at debug level until the next " + f"successful callback. Body: {body[:200]}", + ) + raise _CallbackErrorAlreadyLogged( + f"State callback failed with HTTP {resp.status}" + ) + + raise RuntimeError(f"State callback failed with HTTP {resp.status}: {body[:200]}") + except asyncio.CancelledError: + # Cooperative cancellation must propagate untouched. + raise + except _CallbackErrorAlreadyLogged: + # Already deduped via _emit_callback_error above — just propagate + # so _flush_pending re-queues without a second log entry. + raise + except Exception as exc: + # Transport-level errors (aiohttp.ClientError, DNS resolution + # failures, connection resets, etc.) plus the catch-all RuntimeError + # for non-5xx HTTP failures. Dedupe by exception class name so a + # repeat of the same transport failure doesn't flood the log. + fingerprint = type(exc).__name__ + if self._last_error_fingerprint == fingerprint: + self._logger.debug("State callback still failing (%s)", fingerprint) + else: + self._last_error_fingerprint = fingerprint + self._logger.exception("State callback error") raise async def _report_all_states(self) -> None: @@ -210,7 +316,7 @@ async def _report_all_states(self) -> None: for player in self._mass.players.all_players(): state = player.state if is_player_exposable(state, exposed_ids=self._exposed_ids): - devices.append(get_device_state(state)) + devices.append(get_device_state(state, playlist_uris=self._playlist_uris)) if devices: self._logger.info("Reporting all states: %d device(s)", len(devices)) await self._send_state_callback(devices) diff --git a/music_assistant/providers/yandex_smarthome/playlists.py b/music_assistant/providers/yandex_smarthome/playlists.py new file mode 100644 index 0000000000..e517d1ddda --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/playlists.py @@ -0,0 +1,57 @@ +"""Helpers for exposing MA library playlists as Yandex input_source modes. + +Wraps the few MA APIs used by the playlist-source feature so the rest of +the provider stays decoupled from `mass.music`/`player_queues` internals +and so tests can stub a single seam. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigValueOption + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + + +async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """Build ConfigValueOption list of all library playlists for the config form. + + Pages through `iter_library_items` so the dropdown is not silently + truncated for users with very large libraries (the underlying + `library_items(limit=...)` defaults to 500). Used at config-render + time only. Fail-soft: returns [] if mass.music or the playlists + controller is not yet available (e.g. provider load order). + """ + options: list[ConfigValueOption] = [] + try: + async for playlist in mass.music.playlists.iter_library_items(): + if not playlist.uri: + continue + provider_label = playlist.provider or "" + title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name + options.append(ConfigValueOption(title=title, value=playlist.uri)) + except asyncio.CancelledError: + raise + except Exception as exc: + # Fail-soft: this runs on every config-form render and races with + # provider/database startup. Don't spam stack traces — debug-level + # is enough for diagnostics, normal renders stay quiet. + _LOGGER.debug("Library playlists not available yet: %s", exc) + return [] + return options + + +async def play_playlist(mass: MusicAssistant, player_id: str, uri: str) -> None: + """Start playback of a playlist URI on the given player's queue. + + `play_media` accepts a URI string directly and resolves the playlist's + tracks into the queue. queue_id == player_id for the player's own queue. + """ + await mass.player_queues.play_media(queue_id=player_id, media=uri) diff --git a/music_assistant/providers/yandex_smarthome/plugin.py b/music_assistant/providers/yandex_smarthome/plugin.py index 335e3813e0..f0c8636fdf 100644 --- a/music_assistant/providers/yandex_smarthome/plugin.py +++ b/music_assistant/providers/yandex_smarthome/plugin.py @@ -21,9 +21,10 @@ from dataclasses import asdict from typing import Any +from ya_dialogs_api import SecretStr + from music_assistant.models.plugin import PluginProvider -from ._compat import SecretStr from .cloud import CloudManager from .constants import ( CLOUD_CALLBACK_URL, @@ -34,12 +35,14 @@ CONF_DIRECT_ACCESS_TOKEN, CONF_DIRECT_CLIENT_SECRET, CONF_EXPOSED_PLAYERS, + CONF_EXPOSED_PLAYLISTS, CONF_INSTANCE_NAME, CONF_SKILL_ID, CONF_SKILL_TOKEN, CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, + MAX_INPUT_SOURCES, YANDEX_DIALOGS_CALLBACK_BASE, ) from .direct import DirectConnectionHandler @@ -102,6 +105,23 @@ async def handle_async_init(self) -> None: exposed_raw = [] self._exposed_ids: set[str] | None = set(exposed_raw) if exposed_raw else None + # Parse exposed playlists (URIs) — capped at MAX_INPUT_SOURCES. + playlists_raw = self.config.get_value(CONF_EXPOSED_PLAYLISTS) or [] + if isinstance(playlists_raw, str): + playlists_raw = [x.strip() for x in playlists_raw.split(",") if x.strip()] + elif isinstance(playlists_raw, list): + playlists_raw = [str(x) for x in playlists_raw if x] + else: + playlists_raw = [] + if len(playlists_raw) > MAX_INPUT_SOURCES: + self.logger.warning( + "Exposed playlists count (%d) exceeds cap %d; truncating", + len(playlists_raw), + MAX_INPUT_SOURCES, + ) + playlists_raw = playlists_raw[:MAX_INPUT_SOURCES] + self._exposed_playlists: tuple[str, ...] = tuple(playlists_raw) + self.logger.info( "Yandex Smart Home plugin init (mode=%s, name=%s)", self._connection_type, @@ -182,18 +202,21 @@ async def _start_cloud_mode(self) -> None: auth_header=auth_header, logger=self.logger, exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) await self._state_notifier.start() async def _start_direct_mode(self) -> None: - """Initialize direct connection mode — HTTP endpoints + state notifier.""" - if not self._skill_id or not self._skill_token or not self._skill_token.get_secret(): - self.logger.error( - "Direct mode requires skill_id and skill_token — " - "create a private skill in Yandex.Dialogs and configure the tokens" - ) - return - + """Initialize direct connection mode — HTTP endpoints + state notifier. + + Two-stage: HTTP routes are registered as soon as ``direct_client_secret`` + is available (auto-generated when the user opens the config form), so + Yandex's backend-validation step during ``request_deploy`` can reach + them before the skill is created. The state notifier (outgoing + callbacks to Yandex) only starts once ``skill_id``/``skill_token`` + are populated by a successful auto-create — there is nothing to + report state to before that point. + """ if not self._direct_client_secret: self.logger.error("Direct mode requires a client secret for OAuth account linking") return @@ -213,24 +236,48 @@ def _on_token_created(token: str) -> None: exposed_ids=self._exposed_ids, logger=self.logger, on_token_created=_on_token_created, + playlist_uris=self._exposed_playlists, ) self._direct_handler.register_routes() - # State notifier — callback to Yandex Dialogs (same as Cloud Plus) - session = self.mass.http_session - callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state" - auth_header = {"Authorization": f"OAuth {self._skill_token.get_secret()}"} - - self._state_notifier = StateNotifier( - mass=self.mass, - session=session, - user_id=self._user_id, - callback_url=callback_url, - auth_header=auth_header, - logger=self.logger, - exposed_ids=self._exposed_ids, - ) - await self._state_notifier.start() + # State notifier needs skill_id + skill_token to push state callbacks + # to Yandex — these only exist after a successful auto-create AND the + # user pasting the OAuth token. Skip silently if either is missing; + # this is the normal "first run" / "skill created but token not yet + # pasted" state. + has_skill_id = bool(self._skill_id) + skill_token = self._skill_token + has_skill_token = skill_token is not None and bool(skill_token.get_secret()) + if has_skill_id and has_skill_token and skill_token is not None: + session = self.mass.http_session + callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state" + auth_header = {"Authorization": f"OAuth {skill_token.get_secret()}"} + + self._state_notifier = StateNotifier( + mass=self.mass, + session=session, + user_id=self._user_id, + callback_url=callback_url, + auth_header=auth_header, + logger=self.logger, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, + ) + await self._state_notifier.start() + else: + missing = [] + if not has_skill_id: + missing.append("Skill ID") + if not has_skill_token: + missing.append("Skill OAuth Token") + self.logger.info( + "Direct mode: HTTP routes registered, but state notifier is " + "idle (missing: %s). Open the plugin settings: 'Auto-create " + "Smart Home skill' fills the Skill ID for you, then open the " + "OAuth-token URL shown in the form, approve access, and paste " + "the resulting access_token into 'Skill OAuth Token'.", + " + ".join(missing), + ) self.logger.info("Direct connection mode started") @@ -255,6 +302,7 @@ async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: self.mass, self._user_id, exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(device_list)) @@ -265,14 +313,20 @@ async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]: if isinstance(d, dict) and (device_id := d.get("id")) ] states = await handle_devices_query( - self.mass, device_ids, exposed_ids=self._exposed_ids + self.mass, + device_ids, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(states)) if normalized == "/user/devices/action": action_payload = parse_action_payload(message) action_result = await handle_devices_action( - self.mass, action_payload, exposed_ids=self._exposed_ids + self.mass, + action_payload, + exposed_ids=self._exposed_ids, + playlist_uris=self._exposed_playlists, ) return build_response(request_id, asdict(action_result)) diff --git a/requirements_all.txt b/requirements_all.txt index 65aec4004a..c17b434753 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,6 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 +ya-dialogs-api==2.4.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 diff --git a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr index 52f586be00..a7b507d3a8 100644 --- a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr +++ b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr @@ -52,7 +52,7 @@ 'backendSettings': dict({ 'backendType': 'webhook', 'functionId': '', - 'uri': 'https://ma.example.com/api/yandex_smarthome/v1.0', + 'uri': 'https://ma.example.com/api/yandex_smarthome', }), 'channel': 'smartHome', 'enableAllAvailableRegions': True, diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py deleted file mode 100644 index a409a0b26b..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill.py +++ /dev/null @@ -1,1004 +0,0 @@ -"""Tests for provider/auto_skill.py — DialogsSkillCreator low-level client.""" - -from __future__ import annotations - -import json -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - -import aiohttp -import pytest - -from music_assistant.providers.yandex_smarthome.auto_skill import ( - DIALOGS_API_BASE, - DIALOGS_DEV_HTML_URL, - DialogsApiError, - DialogsCsrfError, - DialogsDuplicateSkillError, - DialogsSkillCreator, - auto_create_skill, - build_draft_payload, - build_oauth_app_payload, - check_preconditions, - derive_auth_urls, - derive_backend_uri, - derive_client_id, - load_default_logo_bytes, -) -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - -# --------------------------------------------------------------------------- -# Test helpers: mock aiohttp session matching existing test_cloud.py pattern -# --------------------------------------------------------------------------- - - -def _mock_response(*, status: int = 200, body_text: str = "", body_json: Any = None) -> AsyncMock: - """Build a mock aiohttp.ClientResponse. - - If *body_json* is given, ``text()`` returns its JSON-encoded form; - otherwise ``body_text`` is used verbatim. Also wires up - ``content.iter_chunked(size)`` as a single-chunk async iterator over - ``body_text.encode()`` so ``fetch_csrf``'s streaming reader works. - """ - resp = AsyncMock() - resp.status = status - if body_json is not None: - body_text = json.dumps(body_json, ensure_ascii=False) - resp.text = AsyncMock(return_value=body_text) - resp.get_encoding = MagicMock(return_value="utf-8") - - body_bytes = body_text.encode("utf-8") - - async def _aiter(_size: int) -> Any: # pragma: no cover — trivial - if body_bytes: - yield body_bytes - - resp.content = MagicMock() - resp.content.iter_chunked = _aiter - return resp - - -def _install_ctx(session_method: MagicMock, mock_resp: AsyncMock) -> MagicMock: - """Attach an async context manager returning *mock_resp* to a session method.""" - ctx = MagicMock() - ctx.__aenter__ = AsyncMock(return_value=mock_resp) - ctx.__aexit__ = AsyncMock(return_value=False) - session_method.return_value = ctx - return ctx - - -def _make_session() -> MagicMock: - session = MagicMock(spec=aiohttp.ClientSession) - session.get = MagicMock() - session.post = MagicMock() - session.request = MagicMock() - return session - - -# --------------------------------------------------------------------------- -# fetch_csrf -# --------------------------------------------------------------------------- - - -class TestFetchCsrf: - """CSRF token extraction from developer console HTML.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_token(self) -> None: - """CSRF is extracted from the secretkey field in the developer HTML.""" - html = ( - "" - ) - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - token = await creator.fetch_csrf() - assert token == "u9c94f1aca53bf156be4abc" - # Verify we hit the expected URL - session.get.assert_called_once_with(DIALOGS_DEV_HTML_URL) - - @pytest.mark.asyncio - async def test_regex_miss_raises_csrf_error(self) -> None: - """Yandex changed the HTML format → clean typed error for fallback.""" - html = "no secretkey here" - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsCsrfError) as exc_info: - await creator.fetch_csrf() - assert exc_info.value.step == "fetch_csrf" - - @pytest.mark.asyncio - async def test_401_maps_to_auth_error(self) -> None: - """401 means passport cookies missing/expired — retryable via relogin.""" - session = _make_session() - _install_ctx(session.get, _mock_response(status=401, body_text="nope")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.fetch_csrf() - assert exc_info.value.http_status == 401 - assert exc_info.value.step == "fetch_csrf" - - @pytest.mark.asyncio - async def test_empty_token_raises(self) -> None: - """Regex matched but captured empty string — treat as miss.""" - html = '{"secretkey":""}' - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text=html)) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsCsrfError): - await creator.fetch_csrf() - - -# --------------------------------------------------------------------------- -# create_app -# --------------------------------------------------------------------------- - - -class TestCreateApp: - """POST /apps — returns skill_id on success.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_skill_id(self) -> None: - """POST /apps returns a skill UUID and sends the expected payload.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=200, - body_json={"result": {"id": "7584419b-6815-4a68-8e32-b9fb111b596d"}}, - ), - ) - creator = DialogsSkillCreator(session) - - skill_id = await creator.create_app("csrf-token", "My Skill") - assert skill_id == "7584419b-6815-4a68-8e32-b9fb111b596d" - - # Verify request shape - method, url = session.request.call_args.args[:2] - assert method == "POST" - assert url == f"{DIALOGS_API_BASE}/apps" - payload = session.request.call_args.kwargs["json"] - assert payload["channel"] == "smartHome" - assert payload["language"] == "ru" - assert payload["isYangoConsole"] is False - assert payload["appName"] == "My Skill" - headers = session.request.call_args.kwargs["headers"] - assert headers["x-csrf-token"] == "csrf-token" - - @pytest.mark.asyncio - async def test_duplicate_name_raises_typed_error(self) -> None: - """HTTP 409 with a duplicate-indicator body maps to DialogsDuplicateSkillError.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=409, - body_json={"error": "not_unique", "message": "skill already exists"}, - ), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsDuplicateSkillError) as exc_info: - await creator.create_app("csrf", "duplicate name") - assert exc_info.value.http_status == 409 - - @pytest.mark.asyncio - async def test_generic_4xx_raises_api_error(self) -> None: - """Non-duplicate 4xx surfaces as plain DialogsApiError, not the subclass.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=400, body_text="bad request"), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.create_app("csrf", "X") - # Not a duplicate — should be plain DialogsApiError, not subclass - assert not isinstance(exc_info.value, DialogsDuplicateSkillError) - assert exc_info.value.http_status == 400 - - @pytest.mark.asyncio - async def test_missing_skill_id_raises(self) -> None: - """A 200 response without an id field is treated as a protocol break.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": {}}), - ) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError): - await creator.create_app("csrf", "X") - - -# --------------------------------------------------------------------------- -# upload_logo -# --------------------------------------------------------------------------- - - -class TestUploadLogo: - """POST /apps/{id}/draft/upload-logo — multipart file upload.""" - - @pytest.mark.asyncio - async def test_happy_path_returns_logo_id(self) -> None: - """upload_logo posts multipart PNG and returns the avatar id.""" - session = _make_session() - _install_ctx( - session.post, - _mock_response( - status=200, - body_json={ - "result": { - "id": "be043706-a868-4999-83c8-f17bbd60745d", - "url": "https://avatars.mds.yandex.net/...", - } - }, - ), - ) - creator = DialogsSkillCreator(session) - - logo_id = await creator.upload_logo("csrf", "skill-1", b"\x89PNG\r\n\x1a\n...") - assert logo_id == "be043706-a868-4999-83c8-f17bbd60745d" - - call = session.post.call_args - url = call.args[0] - assert "/draft/upload-logo" in url - assert "channel=smartHome" in url - # FormData used for multipart - assert isinstance(call.kwargs["data"], aiohttp.FormData) - assert call.kwargs["headers"]["x-csrf-token"] == "csrf" - - @pytest.mark.asyncio - async def test_upload_500_raises(self) -> None: - """Server-side 500 surfaces as DialogsApiError carrying the step name.""" - session = _make_session() - _install_ctx(session.post, _mock_response(status=500, body_text="oops")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError) as exc_info: - await creator.upload_logo("csrf", "sk", b"data") - assert exc_info.value.step == "upload_logo" - assert exc_info.value.http_status == 500 - - -# --------------------------------------------------------------------------- -# update_draft -# --------------------------------------------------------------------------- - - -class TestUpdateDraft: - """PATCH /apps/{id}/draft/update — accepts arbitrary payload dict.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """PATCH goes to the right URL with the forwarded payload and CSRF header.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": {"ok": True}}), - ) - creator = DialogsSkillCreator(session) - - payload = {"name": "Music Assistant", "channel": "smartHome"} - await creator.update_draft("csrf", "skill-1", payload) - - method, url = session.request.call_args.args[:2] - assert method == "PATCH" - assert url == f"{DIALOGS_API_BASE}/apps/skill-1/draft/update" - assert session.request.call_args.kwargs["json"] == payload - - -# --------------------------------------------------------------------------- -# create_oauth_app -# --------------------------------------------------------------------------- - - -class TestCreateOAuthApp: - """POST /oauth/apps — returns oauth_app_id.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """create_oauth_app builds the correct payload and returns the new id.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response( - status=200, - body_json={"result": {"id": "oauth-uuid-123"}}, - ), - ) - creator = DialogsSkillCreator(session) - - oauth_id = await creator.create_oauth_app( - "csrf", - name="My Skill", - client_id="yandex_smart_home:inst1", - client_secret="secret", - authorize_url="https://yaha-cloud.ru/oauth/authorize", - token_url="https://yaha-cloud.ru/oauth/token", - refresh_url="https://yaha-cloud.ru/oauth/token", - ) - assert oauth_id == "oauth-uuid-123" - - payload = session.request.call_args.kwargs["json"] - assert payload["clientId"] == "yandex_smart_home:inst1" - assert payload["clientSecret"] == "secret" - assert payload["authorizationUrl"] == "https://yaha-cloud.ru/oauth/authorize" - assert payload["scope"] == "" - - -# --------------------------------------------------------------------------- -# attach_oauth -# --------------------------------------------------------------------------- - - -class TestAttachOAuth: - """POST /apps/{id}/oauthApp — links oauth_app to skill.""" - - @pytest.mark.asyncio - async def test_happy_path(self) -> None: - """attach_oauth sends POST with channel query + oauthAppId body.""" - session = _make_session() - _install_ctx( - session.request, - _mock_response(status=200, body_json={"result": "oauth-id"}), - ) - creator = DialogsSkillCreator(session) - - await creator.attach_oauth("csrf", "skill-1", "oauth-1") - - method, url = session.request.call_args.args[:2] - assert method == "POST" - assert "channel=smartHome" in url - assert session.request.call_args.kwargs["json"] == {"oauthAppId": "oauth-1"} - - -# --------------------------------------------------------------------------- -# request_deploy -# --------------------------------------------------------------------------- - - -class TestRequestDeploy: - """POST /apps/{id}/draft/request-deploy — publishes draft.""" - - @pytest.mark.asyncio - async def test_happy_path_empty_body(self) -> None: - """Request-deploy uses an empty body and relies on the URL query only.""" - session = _make_session() - _install_ctx(session.post, _mock_response(status=200, body_json={"result": {}})) - creator = DialogsSkillCreator(session) - - await creator.request_deploy("csrf", "skill-1") - - call = session.post.call_args - url = call.args[0] - assert "request-deploy" in url - assert "channel=smartHome" in url - # No body (only headers) - assert "data" not in call.kwargs - assert "json" not in call.kwargs - assert call.kwargs["headers"]["x-csrf-token"] == "csrf" - - @pytest.mark.asyncio - async def test_deploy_accepts_2xx_variants(self) -> None: - """Some publish responses are 202/204 depending on server side.""" - for status in (201, 202, 204): - session = _make_session() - _install_ctx(session.post, _mock_response(status=status, body_text="")) - creator = DialogsSkillCreator(session) - await creator.request_deploy("csrf", "skill-1") # must not raise - - -# --------------------------------------------------------------------------- -# list_existing_skills -# --------------------------------------------------------------------------- - - -class TestListExistingSkills: - """GET /snapshot — returns existing skills; raises DialogsApiError on malformed JSON.""" - - @pytest.mark.asyncio - async def test_returns_skill_dicts(self) -> None: - """Snapshot is parsed into a list of skill dicts.""" - session = _make_session() - _install_ctx( - session.get, - _mock_response( - status=200, - body_json={ - "result": { - "skills": [ - {"id": "s1", "name": "First"}, - {"id": "s2", "name": "Second"}, - ] - } - }, - ), - ) - creator = DialogsSkillCreator(session) - - skills = await creator.list_existing_skills("csrf") - assert len(skills) == 2 - assert skills[0]["name"] == "First" - - @pytest.mark.asyncio - async def test_raises_when_malformed(self) -> None: - """Non-JSON snapshot body is a protocol break — raise instead of hiding it.""" - session = _make_session() - _install_ctx(session.get, _mock_response(status=200, body_text="not json")) - creator = DialogsSkillCreator(session) - - with pytest.raises(DialogsApiError): - await creator.list_existing_skills("csrf") - - -# --------------------------------------------------------------------------- -# Payload builders + preconditions (pure functions) -# --------------------------------------------------------------------------- - - -def _mass_with_base_url(base_url: str) -> MagicMock: - mass = MagicMock() - mass.webserver.base_url = base_url - return mass - - -class TestDeriveBackendUri: - """derive_backend_uri routes per-mode.""" - - def test_cloud_plus_uses_yaha_relay_constant(self) -> None: - """cloud_plus always points at the fixed yaha-cloud webhook.""" - mass = _mass_with_base_url("https://my-ma.example.com") - assert ( - derive_backend_uri(mass, CONNECTION_TYPE_CLOUD_PLUS) - == "https://yaha-cloud.ru/api/yandex_smart_home" - ) - - def test_direct_uses_ma_base_plus_api_path(self) -> None: - """Direct concatenates MA base_url with the provider's API path.""" - mass = _mass_with_base_url("https://my-ma.example.com/") - # Trailing slash on base_url should be stripped so the full URL is clean. - assert ( - derive_backend_uri(mass, CONNECTION_TYPE_DIRECT) - == "https://my-ma.example.com/api/yandex_smarthome/v1.0" - ) - - def test_cloud_raises(self) -> None: - """Plain 'cloud' mode has no custom skill — function must reject it.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="connection_type"): - derive_backend_uri(mass, CONNECTION_TYPE_CLOUD) - - -class TestDeriveAuthUrls: - """derive_auth_urls returns (authorize_url, token_url).""" - - def test_cloud_plus_urls(self) -> None: - """cloud_plus uses yaha-cloud OAuth endpoints.""" - mass = _mass_with_base_url("https://x") - auth, token = derive_auth_urls(mass, CONNECTION_TYPE_CLOUD_PLUS) - assert auth == "https://yaha-cloud.ru/oauth/authorize" - assert token == "https://yaha-cloud.ru/oauth/token" - - def test_direct_urls_use_ma_base(self) -> None: - """Direct uses the MA webserver's own authorize/token endpoints.""" - mass = _mass_with_base_url("https://ma.example.com") - auth, token = derive_auth_urls(mass, CONNECTION_TYPE_DIRECT) - assert auth == "https://ma.example.com/api/yandex_smarthome/auth/authorize" - assert token == "https://ma.example.com/api/yandex_smarthome/auth/token" - - -class TestDeriveClientId: - """derive_client_id formats the OAuth client_id per mode.""" - - def test_cloud_plus_templated(self) -> None: - """cloud_plus wraps the instance_id in the yaha protocol prefix.""" - assert derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") == "yandex_smart_home:abc123" - - def test_cloud_plus_missing_instance_raises(self) -> None: - """Empty instance_id is a configuration bug — raise early.""" - with pytest.raises(ValueError, match="cloud_instance_id"): - derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "") - - def test_direct_fixed_value(self) -> None: - """Direct mode uses the fixed Yandex social redirect base.""" - assert derive_client_id(CONNECTION_TYPE_DIRECT, "") == "https://social.yandex.net/" - - -class TestBuildDraftPayload: - """Snapshot coverage for the 100+-field draft/update payload.""" - - def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """cloud_plus draft payload matches the captured HAR shape.""" - payload = build_draft_payload( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - skill_name="Music Assistant", - backend_uri="https://yaha-cloud.ru/api/yandex_smart_home", - logo_id="be043706-a868-4999-83c8-f17bbd60745d", - developer_name="alice", - ) - assert payload == snapshot - - def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """Direct draft payload matches the captured HAR shape.""" - payload = build_draft_payload( - connection_type=CONNECTION_TYPE_DIRECT, - skill_name="Music Assistant", - backend_uri="https://ma.example.com/api/yandex_smarthome/v1.0", - logo_id=None, - developer_name="alice", - ) - assert payload == snapshot - - def test_invalid_mode_raises(self) -> None: - """Plain 'cloud' has no auto-create path.""" - with pytest.raises(ValueError, match="connection_type"): - build_draft_payload( - connection_type=CONNECTION_TYPE_CLOUD, - skill_name="x", - backend_uri="https://x", - logo_id=None, - ) - - -class TestBuildOAuthAppPayload: - """OAuth-app payload is simpler — both modes round-trip the given fields.""" - - def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """cloud_plus payload uses literal 'secret' and the yaha-prefixed client_id.""" - payload = build_oauth_app_payload( - skill_name="Music Assistant", - client_id="yandex_smart_home:abc123", - client_secret="secret", - authorize_url="https://yaha-cloud.ru/oauth/authorize", - token_url="https://yaha-cloud.ru/oauth/token", - ) - assert payload == snapshot - - def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] - """Direct payload uses social.yandex.net client_id and a per-install secret.""" - payload = build_oauth_app_payload( - skill_name="Music Assistant", - client_id="https://social.yandex.net/", - client_secret="abc123deadbeef", - authorize_url="https://ma.example.com/api/yandex_smarthome/auth/authorize", - token_url="https://ma.example.com/api/yandex_smarthome/auth/token", - ) - assert payload == snapshot - - -class TestCheckPreconditions: - """check_preconditions rejects invalid configurations early.""" - - def test_cloud_plus_requires_instance(self) -> None: - """cloud_plus without a registered cloud instance is rejected.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="yaha-cloud instance"): - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - def test_cloud_plus_with_instance_ok(self) -> None: - """cloud_plus with a registered instance_id passes.""" - mass = _mass_with_base_url("https://x") - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - mass=mass, - cloud_instance_id="abc", - direct_client_secret="", - ) - - def test_direct_requires_https_base_url(self) -> None: - """Direct rejects non-HTTPS base URLs (Yandex won't accept).""" - mass = _mass_with_base_url("http://ma.local:8095") - with pytest.raises(ValueError, match="HTTPS"): - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="secret", - ) - - def test_direct_requires_client_secret(self) -> None: - """Direct rejects empty client_secret (would break account-linking).""" - mass = _mass_with_base_url("https://ma.example.com") - with pytest.raises(ValueError, match="Client Secret"): - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - def test_direct_happy_path(self) -> None: - """Direct with HTTPS base URL and a secret passes.""" - mass = _mass_with_base_url("https://ma.example.com") - check_preconditions( - connection_type=CONNECTION_TYPE_DIRECT, - mass=mass, - cloud_instance_id="", - direct_client_secret="my-secret", - ) - - def test_cloud_mode_rejected(self) -> None: - """Plain 'cloud' never uses a custom skill — reject.""" - mass = _mass_with_base_url("https://x") - with pytest.raises(ValueError, match="cloud_plus or direct"): - check_preconditions( - connection_type=CONNECTION_TYPE_CLOUD, - mass=mass, - cloud_instance_id="", - direct_client_secret="", - ) - - -# --------------------------------------------------------------------------- -# Orchestrator (auto_create_skill) -# --------------------------------------------------------------------------- - - -def _make_creator_mock() -> AsyncMock: - """Build an AsyncMock matching DialogsSkillCreator's interface.""" - creator = AsyncMock(spec=DialogsSkillCreator) - creator.fetch_csrf = AsyncMock(return_value="csrf-token") - creator.create_app = AsyncMock(return_value="skill-uuid") - creator.upload_logo = AsyncMock(return_value="logo-uuid") - creator.update_draft = AsyncMock(return_value=None) - creator.create_oauth_app = AsyncMock(return_value="oauth-uuid") - creator.attach_oauth = AsyncMock(return_value=None) - creator.request_deploy = AsyncMock(return_value=None) - return creator - - -def _fake_authenticator_factory( - *, - session: MagicMock | None = None, - session_id_captor: list[str] | None = None, -) -> Any: - """Build an async-generator authenticator usable as *authenticator*. - - If *session_id_captor* is given, the session_id passed in by the - orchestrator is appended to it so tests can assert it was forwarded. - """ - if session is None: - session = MagicMock(spec=aiohttp.ClientSession) - - async def _auth(mass, session_id, timeout): # type: ignore[no-untyped-def] - if session_id_captor is not None: - session_id_captor.append(session_id) - _ = (mass, timeout) - yield session - - return _auth - - -async def _run_orch( - *, - creator: AsyncMock, - connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, - artifacts: SkillCreationArtifacts | None = None, - base_url: str = "https://ma.example.com", - cloud_instance_id: str = "inst-1", - direct_client_secret: str = "", - session_id: str = "test-session-id", - progress_cb: Any = None, -) -> SkillCreationArtifacts: - """Run auto_create_skill with sensible test defaults. - - Injects *creator* via ``creator_factory`` and a fake authenticator - so no real ya-passport-auth or aiohttp traffic is attempted. - """ - mass = _mass_with_base_url(base_url) - return await auto_create_skill( - mass=mass, - connection_type=connection_type, - skill_name="Music Assistant", - artifacts=artifacts if artifacts is not None else SkillCreationArtifacts(), - cloud_instance_id=cloud_instance_id, - direct_client_secret=direct_client_secret, - logo_bytes=b"\x89PNG", - session_id=session_id, - authenticator=_fake_authenticator_factory(), - creator_factory=lambda _s: creator, - progress_cb=progress_cb, - ) - - -class TestAutoCreateSkillHappyPath: - """auto_create_skill on a fresh artifact runs all steps to DONE.""" - - @pytest.mark.asyncio - async def test_fresh_artifact_reaches_done(self) -> None: - """A NONE-state artifact runs the full pipeline to DONE.""" - creator = _make_creator_mock() - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.DONE - assert result.skill_id == "skill-uuid" - assert result.logo_id == "logo-uuid" - assert result.oauth_app_id == "oauth-uuid" - assert result.last_error is None - - for method in ( - creator.fetch_csrf, - creator.create_app, - creator.upload_logo, - creator.update_draft, - creator.create_oauth_app, - creator.attach_oauth, - creator.request_deploy, - ): - method.assert_awaited_once() - - @pytest.mark.asyncio - async def test_progress_cb_invoked_after_each_step(self) -> None: - """progress_cb receives a snapshot after every state transition.""" - creator = _make_creator_mock() - progress_calls: list[SkillCreationArtifacts] = [] - - async def _progress(a: SkillCreationArtifacts) -> None: - progress_calls.append(a) - - await _run_orch(creator=creator, progress_cb=_progress) - - observed_states = [a.state for a in progress_calls] - assert observed_states == [ - SkillCreationState.APP_CREATED, - SkillCreationState.DRAFT_UPDATED, - SkillCreationState.OAUTH_CREATED, - SkillCreationState.OAUTH_ATTACHED, - SkillCreationState.DEPLOY_REQUESTED, - SkillCreationState.DONE, - ] - - -class TestAutoCreateSkillResume: - """Non-NONE artifacts skip completed steps.""" - - @pytest.mark.asyncio - async def test_resume_from_app_created(self) -> None: - """create_app is not re-called when a skill_id is already present.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.APP_CREATED, skill_id="existing-skill" - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_awaited_once() - assert result.skill_id == "existing-skill" - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_oauth_attached(self) -> None: - """A near-done artifact only needs request_deploy.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_ATTACHED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_not_awaited() - creator.update_draft.assert_not_awaited() - creator.create_oauth_app.assert_not_awaited() - creator.attach_oauth.assert_not_awaited() - creator.request_deploy.assert_awaited_once() - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_deploy_requested(self) -> None: - """DEPLOY_REQUESTED is a real checkpoint: resume re-runs publish only.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.DEPLOY_REQUESTED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_not_awaited() - creator.upload_logo.assert_not_awaited() - creator.update_draft.assert_not_awaited() - creator.create_oauth_app.assert_not_awaited() - creator.attach_oauth.assert_not_awaited() - creator.request_deploy.assert_awaited_once() - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_resume_from_failed_restarts_pipeline(self) -> None: - """FAILED state is treated like NONE — full retry from create_app.""" - creator = _make_creator_mock() - starting = SkillCreationArtifacts( - state=SkillCreationState.FAILED, - last_error="some earlier error", - ) - result = await _run_orch(creator=creator, artifacts=starting) - - creator.create_app.assert_awaited_once() - assert result.state == SkillCreationState.DONE - assert result.last_error is None - - -class TestAutoCreateSkillFailure: - """Pipeline failures convert to FAILED state with preserved partial data.""" - - @pytest.mark.asyncio - async def test_failure_at_create_app_preserves_nothing(self) -> None: - """Duplicate-name error bubbles up as FAILED with no skill_id captured.""" - creator = _make_creator_mock() - creator.create_app.side_effect = DialogsDuplicateSkillError( - "exists", step="create_app", http_status=409 - ) - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert result.skill_id is None - assert "exists" in (result.last_error or "") - - @pytest.mark.asyncio - async def test_failure_after_app_created_preserves_skill_id(self) -> None: - """A partial failure keeps the skill_id so retry resumes from DRAFT_UPDATED.""" - creator = _make_creator_mock() - creator.upload_logo.side_effect = DialogsApiError( - "500", step="upload_logo", http_status=500 - ) - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert result.skill_id == "skill-uuid" - - @pytest.mark.asyncio - async def test_csrf_miss_becomes_failed_state(self) -> None: - """CSRF extraction failure doesn't crash — surfaces as FAILED.""" - creator = _make_creator_mock() - creator.fetch_csrf.side_effect = DialogsCsrfError("secretkey not found", step="fetch_csrf") - - result = await _run_orch(creator=creator) - - assert result.state == SkillCreationState.FAILED - assert "secretkey" in (result.last_error or "") - - @pytest.mark.asyncio - async def test_precondition_raises_unmodified(self) -> None: - """Preconditions raise ValueError so UI can show the exact message.""" - with pytest.raises(ValueError, match="HTTPS"): - await _run_orch( - creator=_make_creator_mock(), - connection_type=CONNECTION_TYPE_DIRECT, - base_url="http://not-https.example.com", - direct_client_secret="secret", - ) - - @pytest.mark.asyncio - async def test_progress_cb_exception_does_not_abort(self) -> None: - """If the config-save callback raises, the pipeline still reaches DONE.""" - - async def _boom(_a: SkillCreationArtifacts) -> None: - msg = "cannot write config" - raise RuntimeError(msg) - - result = await _run_orch(creator=_make_creator_mock(), progress_cb=_boom) - assert result.state == SkillCreationState.DONE - - @pytest.mark.asyncio - async def test_cancelled_error_propagates(self) -> None: - """CancelledError must propagate, not be converted into a FAILED artifact. - - Swallowing it would break cooperative task cancellation during - HA shutdown or config-flow abort. - """ - import asyncio # noqa: PLC0415 - - creator = _make_creator_mock() - creator.create_app.side_effect = asyncio.CancelledError - with pytest.raises(asyncio.CancelledError): - await _run_orch(creator=creator) - - -class TestAutoCreateSkillDirectMode: - """Direct mode wires the MA webserver URLs into the payloads.""" - - @pytest.mark.asyncio - async def test_direct_mode_passes_ma_base_to_draft(self) -> None: - """Backend URL and auth URLs must use the MA webserver base URL.""" - creator = _make_creator_mock() - await _run_orch( - creator=creator, - connection_type=CONNECTION_TYPE_DIRECT, - base_url="https://ma.example.com", - cloud_instance_id="", - direct_client_secret="my-secret", - ) - - draft_payload = creator.update_draft.call_args.args[2] - assert ( - draft_payload["backendSettings"]["uri"] - == "https://ma.example.com/api/yandex_smarthome/v1.0" - ) - oauth_call = creator.create_oauth_app.call_args - assert oauth_call.kwargs["client_id"] == "https://social.yandex.net/" - assert oauth_call.kwargs["client_secret"] == "my-secret" - assert ( - oauth_call.kwargs["authorize_url"] - == "https://ma.example.com/api/yandex_smarthome/auth/authorize" - ) - - -class TestAuthenticatorInjection: - """Both async-generator and already-decorated async-CM authenticators work. - - Re-wrapping a callable that already returns an async CM with - ``@asynccontextmanager`` breaks at runtime — the orchestrator must - detect that shape and pass it through. - """ - - @pytest.mark.asyncio - async def test_accepts_already_decorated_context_manager(self) -> None: - """Authenticator whose call returns an async CM must not be re-wrapped.""" - session = MagicMock(spec=aiohttp.ClientSession) - creator = _make_creator_mock() - - @asynccontextmanager - async def _cm_auth( - *, mass: Any, session_id: str, timeout: float - ) -> AsyncIterator[aiohttp.ClientSession]: - _ = (mass, session_id, timeout) - yield session - - mass = _mass_with_base_url("https://ma.example.com") - result = await auto_create_skill( - mass=mass, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - skill_name="Music Assistant", - artifacts=SkillCreationArtifacts(), - cloud_instance_id="inst-1", - direct_client_secret="", - logo_bytes=b"\x89PNG", - session_id="test-session-id", - authenticator=_cm_auth, # type: ignore[arg-type] - creator_factory=lambda _s: creator, - ) - assert result.state == SkillCreationState.DONE - - -class TestLoadDefaultLogoBytes: - """load_default_logo_bytes reads the bundled PNG from disk.""" - - def test_returns_real_png(self) -> None: - """Bundled provider/auto_skill_logo.png exists and has a PNG magic header.""" - data = load_default_logo_bytes() - # PNG magic: 89 50 4E 47 0D 0A 1A 0A - assert data[:8] == bytes.fromhex("89504e470d0a1a0a") - # Sanity: the bundled asset is non-trivial, not the 1x1 fallback. - assert len(data) > 1000, f"expected real logo asset, got {len(data)} bytes (fallback?)" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_state.py b/tests/providers/yandex_smarthome/test_auto_skill_state.py deleted file mode 100644 index f87953fb88..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill_state.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Tests for auto_skill_state: artifact serialisation and state transitions.""" - -from __future__ import annotations - -import dataclasses - -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, - dump_artifacts, - load_artifacts, - mark_failed, -) - - -def test_default_artifacts_state_is_none() -> None: - """Freshly-constructed artifacts sit at state NONE with no fields set.""" - artifacts = SkillCreationArtifacts() - assert artifacts.state == SkillCreationState.NONE - assert artifacts.skill_id is None - assert artifacts.logo_id is None - assert artifacts.oauth_app_id is None - assert artifacts.last_error is None - - -def test_dump_load_roundtrip_none() -> None: - """NONE state with no fields round-trips cleanly.""" - original = SkillCreationArtifacts() - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_dump_load_roundtrip_populated() -> None: - """Fully-populated artifacts round-trip without loss.""" - original = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_ATTACHED, - skill_id="7584419b-6815-4a68-8e32-b9fb111b596d", - logo_id="be043706-a868-4999-83c8-f17bbd60745d", - oauth_app_id="50de2b35-e593-417d-83d1-764544996fbb", - last_error=None, - ) - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_dump_load_roundtrip_failed_with_error() -> None: - """FAILED state with an error string round-trips.""" - original = SkillCreationArtifacts( - state=SkillCreationState.FAILED, - skill_id="some-skill-id", - last_error="HTTP 401: Unauthorized", - ) - restored = load_artifacts(dump_artifacts(original)) - assert restored == original - - -def test_load_empty_returns_default() -> None: - """Empty/None input yields a fresh default artifacts object.""" - assert load_artifacts(None) == SkillCreationArtifacts() - assert load_artifacts("") == SkillCreationArtifacts() - - -def test_load_invalid_json_returns_default() -> None: - """Corrupt JSON doesn't crash config — returns default.""" - assert load_artifacts("{not-json") == SkillCreationArtifacts() - assert load_artifacts("null") == SkillCreationArtifacts() - assert load_artifacts('"just-a-string"') == SkillCreationArtifacts() - - -def test_load_unknown_state_falls_back_to_none() -> None: - """Unrecognised state values don't crash — reset to NONE.""" - raw = '{"state": "totally_bogus", "skill_id": "abc"}' - result = load_artifacts(raw) - assert result.state == SkillCreationState.NONE - # Other fields still parse correctly - assert result.skill_id == "abc" - - -def test_load_ignores_extra_fields() -> None: - """Forward-compat: extra JSON keys are silently dropped.""" - raw = ( - '{"state": "done", "skill_id": "s1", "logo_id": "l1", ' - '"oauth_app_id": "o1", "future_field": "ignored"}' - ) - result = load_artifacts(raw) - assert result.state == SkillCreationState.DONE - assert result.skill_id == "s1" - - -def test_load_empty_string_fields_become_none() -> None: - """Empty-string fields normalise to None (matches default).""" - raw = '{"state": "none", "skill_id": "", "logo_id": ""}' - result = load_artifacts(raw) - assert result.skill_id is None - assert result.logo_id is None - - -def test_artifacts_frozen() -> None: - """Artifacts are immutable — must copy via dataclasses.replace.""" - artifacts = SkillCreationArtifacts() - try: - artifacts.state = SkillCreationState.DONE # type: ignore[misc] - except dataclasses.FrozenInstanceError: - pass - else: - msg = "SkillCreationArtifacts must be frozen" - raise AssertionError(msg) - - -def test_mark_failed_preserves_ids() -> None: - """mark_failed() flips state to FAILED but keeps skill/oauth IDs for retry.""" - partial = SkillCreationArtifacts( - state=SkillCreationState.OAUTH_CREATED, - skill_id="s1", - logo_id="l1", - oauth_app_id="o1", - ) - failed = mark_failed(partial, "network error") - assert failed.state == SkillCreationState.FAILED - assert failed.last_error == "network error" - assert failed.skill_id == "s1" - assert failed.logo_id == "l1" - assert failed.oauth_app_id == "o1" - - -def test_state_is_string_enum() -> None: - """State values compare equal to plain strings — useful for config storage.""" - assert SkillCreationState.DONE.value == "done" - assert SkillCreationState.FAILED.value == "failed" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py deleted file mode 100644 index d5d4a1a343..0000000000 --- a/tests/providers/yandex_smarthome/test_auto_skill_ui.py +++ /dev/null @@ -1,405 +0,0 @@ -"""Tests for auto_skill_ui — ConfigEntry visibility and action label logic.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.auto_skill_ui import ( - auto_create_entries, - build_cloud_plus_entries, - build_direct_entries, - should_show_button, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONF_ACTION_AUTO_CREATE, - CONF_ACTION_GET_OTP, - CONF_ACTION_REGISTER, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_SKILL_ID, - CONF_SKILL_TOKEN, - CONNECTION_TYPE_CLOUD, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - -if TYPE_CHECKING: - from collections.abc import Iterable, Sequence - - from music_assistant_models.config_entries import ConfigEntry - - -def _find(entries: Iterable[ConfigEntry], key: str) -> ConfigEntry | None: - for e in entries: - if e.key == key: - return e - return None - - -# --------------------------------------------------------------------------- -# should_show_button -# --------------------------------------------------------------------------- - - -class TestShouldShowButton: - """should_show_button captures the full visibility truth table.""" - - def test_hidden_in_cloud_mode(self) -> None: - """Plain cloud has no custom skill — button hidden.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD, - state=SkillCreationState.NONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_hidden_when_state_done(self) -> None: - """DONE means skill already created — don't offer re-creation.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.DONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_hidden_cloud_plus_no_instance(self) -> None: - """cloud_plus requires a registered cloud instance first.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="https://x", - ) - - def test_hidden_direct_non_https(self) -> None: - """Direct needs an HTTPS base URL or Yandex will reject the skill.""" - assert not should_show_button( - connection_type=CONNECTION_TYPE_DIRECT, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="http://localhost:8095", - ) - - def test_shown_cloud_plus_ready(self) -> None: - """All gates satisfied for cloud_plus → button visible.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.NONE, - cloud_instance_id="abc", - base_url="https://x", - ) - - def test_shown_direct_ready(self) -> None: - """All gates satisfied for direct → button visible.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_DIRECT, - state=SkillCreationState.NONE, - cloud_instance_id="", - base_url="https://ma.example.com", - ) - - def test_shown_after_failed(self) -> None: - """FAILED state keeps the button visible for retry.""" - assert should_show_button( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - state=SkillCreationState.FAILED, - cloud_instance_id="abc", - base_url="https://x", - ) - - -# --------------------------------------------------------------------------- -# auto_create_entries -# --------------------------------------------------------------------------- - - -class TestAutoCreateEntries: - """auto_create_entries renders a list of ConfigEntries matching state.""" - - def _entries( - self, - *, - connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, - state: SkillCreationState = SkillCreationState.NONE, - cloud_instance_id: str = "abc", - base_url: str = "https://ma.example.com", - ) -> Sequence[ConfigEntry]: - return auto_create_entries( - connection_type=connection_type, - artifacts=SkillCreationArtifacts(state=state), - cloud_instance_id=cloud_instance_id, - base_url=base_url, - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - ) - - def test_empty_for_cloud_mode(self) -> None: - """Plain cloud returns no entries — the section is meaningless.""" - assert self._entries(connection_type=CONNECTION_TYPE_CLOUD) == () - - def test_action_shown_and_enabled_when_ready(self) -> None: - """In the ready-to-create state, action is present and not hidden.""" - entries = list(self._entries()) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is False - - def test_action_hidden_when_state_done(self) -> None: - """state=DONE renders the action with hidden=True.""" - entries = list(self._entries(state=SkillCreationState.DONE)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is True - - def test_action_label_changes_on_failed(self) -> None: - """After a failure the button label switches to 'Retry'.""" - entries = list(self._entries(state=SkillCreationState.FAILED)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.action_label == "Retry" - - def test_action_label_says_retry_on_partial(self) -> None: - """Partial (non-FAILED) progress state uses the 'Retry from last step' label.""" - entries = list(self._entries(state=SkillCreationState.OAUTH_CREATED)) - action = _find(entries, CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.action_label is not None - assert "Retry" in action.action_label - - def test_hidden_artifacts_always_round_tripped(self) -> None: - """Artifacts blob is included even when the section is mostly empty.""" - entries = list(self._entries()) - artifacts_entry = _find(entries, CONF_AUTO_CREATE_ARTIFACTS) - assert artifacts_entry is not None - assert artifacts_entry.hidden is True - - def test_status_label_reflects_failed_error(self) -> None: - """The status LABEL carries the last_error text for user visibility.""" - entries = auto_create_entries( - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - artifacts=SkillCreationArtifacts( - state=SkillCreationState.FAILED, - last_error="HTTP 401: session expired", - ), - cloud_instance_id="abc", - base_url="https://x", - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - ) - status = _find(list(entries), "label_auto_create_status") - assert status is not None - assert "401" in status.label - - -# --------------------------------------------------------------------------- -# build_cloud_plus_entries — 3-step structure -# --------------------------------------------------------------------------- - - -class TestBuildCloudPlusEntries: - """cloud_plus entries render as numbered steps with proper gating.""" - - def _call( - self, - *, - is_registered: bool = False, - state: SkillCreationState = SkillCreationState.NONE, - otp_code: str | None = None, - skill_id: str = "", - ) -> list[ConfigEntry]: - return build_cloud_plus_entries( - otp_code=otp_code, - is_registered=is_registered, - cloud_instance_id="inst-1" if is_registered else "", - artifacts=SkillCreationArtifacts(state=state), - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - base_url="https://ma.example.com", - skill_id=skill_id, - ) - - def test_step1_register_always_visible(self) -> None: - """Step 1 (Register) is always rendered — even before first registration.""" - entries = self._call(is_registered=False) - keys = [e.key for e in entries] - assert CONF_ACTION_REGISTER in keys - - def test_step2_button_inactive_until_registered(self) -> None: - """Step 2 fields are emitted, but the Create-Skill button is hidden.""" - entries = self._call(is_registered=False) - action = _find(list(entries), CONF_ACTION_AUTO_CREATE) - # Entry exists (so advanced users can see other Step 2 fields), - # but the action button self-hides without a cloud instance. - assert action is not None - assert action.hidden is True - - def test_step2_visible_after_register(self) -> None: - """After register, Step 2 renders the auto-create action.""" - entries = self._call(is_registered=True) - keys = [e.key for e in entries] - assert CONF_ACTION_AUTO_CREATE in keys - - def test_step3_hidden_until_registered(self) -> None: - """Step 3 (Get OTP) requires Step 1 done (cloud registration).""" - entries = self._call(is_registered=False, skill_id="s1") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_step3_hidden_until_skill_created(self) -> None: - """Step 3 also requires Step 2 done — OTP linking needs an existing skill.""" - entries = self._call(is_registered=True, skill_id="") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_step3_visible_after_register_and_skill(self) -> None: - """After register AND skill created, Step 3 renders the Get-OTP action.""" - entries = self._call(is_registered=True, skill_id="s1") - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP in keys - - def test_register_action_hidden_once_registered(self) -> None: - """The Register button disappears after a cloud instance exists.""" - entries = self._call(is_registered=True) - register = _find(list(entries), CONF_ACTION_REGISTER) - assert register is not None - assert register.hidden is True - - def test_skill_token_shown_on_done(self) -> None: - """Skill OAuth Token input is surfaced (non-advanced) on DONE.""" - entries = self._call(is_registered=True, state=SkillCreationState.DONE) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is False - - def test_skill_token_shown_on_failed(self) -> None: - """Skill OAuth Token input is also surfaced on FAILED for manual entry.""" - entries = self._call(is_registered=True, state=SkillCreationState.FAILED) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is False - - def test_skill_token_advanced_on_none(self) -> None: - """Before user interacts, the token field is hidden behind Advanced.""" - entries = self._call(is_registered=True, state=SkillCreationState.NONE) - token = _find(list(entries), CONF_SKILL_TOKEN) - assert token is not None - assert getattr(token, "advanced", False) is True - - def test_manual_fallback_appears_on_failed(self) -> None: - """FAILED renders manual copy-paste fields inline (non-advanced).""" - entries = self._call(is_registered=True, state=SkillCreationState.FAILED) - keys = [e.key for e in entries] - assert "manual_backend_url" in keys - assert "manual_client_id" in keys - assert "manual_auth_url" in keys - assert "manual_token_url" in keys - # On FAILED the block is NOT advanced — auto-visible - backend = _find(list(entries), "manual_backend_url") - assert getattr(backend, "advanced", False) is False - - def test_manual_fallback_shown_under_advanced_on_done(self) -> None: - """Happy path keeps manual fields under Advanced (power-user visibility).""" - entries = self._call(is_registered=True, state=SkillCreationState.DONE) - keys = [e.key for e in entries] - # Still emitted — but advanced so the default view stays clean - assert "manual_backend_url" in keys - backend = _find(list(entries), "manual_backend_url") - assert getattr(backend, "advanced", False) is True - - def test_manual_fallback_suppressed_before_cloud_register(self) -> None: - """Don't render manual fields with an invalid `yandex_smart_home:` Client ID. - - cloud_plus Client ID embeds the yaha-cloud instance UUID; before - the user clicks Register there's no UUID, and emitting a - half-formed ``yandex_smart_home:`` value would lead power users - (Advanced view) to create a skill with broken account-linking. - """ - entries = self._call(is_registered=False, state=SkillCreationState.NONE) - keys = [e.key for e in entries] - assert "manual_backend_url" not in keys - assert "manual_client_id" not in keys - assert "manual_fallback_label" not in keys - - def test_otp_code_appears_when_present(self) -> None: - """OTP code field is visible once an OTP has been fetched.""" - entries = self._call(is_registered=True, skill_id="s1", otp_code="ABC123") - otp = _find(list(entries), "otp_code") - assert otp is not None - assert otp.hidden is False - - -# --------------------------------------------------------------------------- -# build_direct_entries — 1-step structure (no register, no OTP) -# --------------------------------------------------------------------------- - - -class TestBuildDirectEntries: - """direct mode renders a single Create-Skill step (no yaha, no OTP).""" - - def _call( - self, - *, - state: SkillCreationState = SkillCreationState.NONE, - base_url: str = "https://ma.example.com", - direct_client_secret: str = "secret-123", # noqa: S107 - ) -> list[ConfigEntry]: - return build_direct_entries( - artifacts=SkillCreationArtifacts(state=state), - session_id=None, - user_code=None, - verification_url=None, - existing_artifacts_raw=None, - base_url=base_url, - direct_client_secret=direct_client_secret, - ) - - def test_no_register_action(self) -> None: - """Direct mode has no yaha registration step.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_REGISTER not in keys - - def test_no_get_otp_action(self) -> None: - """Direct mode has no OTP linking step.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_GET_OTP not in keys - - def test_has_create_skill_action(self) -> None: - """Direct mode renders the Create-Skill action.""" - entries = self._call() - keys = [e.key for e in entries] - assert CONF_ACTION_AUTO_CREATE in keys - - def test_create_hidden_when_base_url_not_https(self) -> None: - """Non-HTTPS base URL disables the create button (Yandex rejects).""" - entries = self._call(base_url="http://ma.local:8095") - action = _find(list(entries), CONF_ACTION_AUTO_CREATE) - assert action is not None - assert action.hidden is True - - def test_manual_fallback_includes_per_install_secret(self) -> None: - """FAILED fallback for direct shows the per-install client_secret.""" - entries = self._call(state=SkillCreationState.FAILED) - # The fallback block surfaces the generated secret in a visible - # field, plus the Backend URL with the MA base URL. - backend = _find(list(entries), "manual_backend_url") - assert backend is not None - assert "ma.example.com" in str(backend.default_value) - assert "/api/yandex_smarthome/v1.0" in str(backend.default_value) - - def test_skill_id_field_shown_on_done(self) -> None: - """Skill ID input field is surfaced (non-advanced) on DONE.""" - entries = self._call(state=SkillCreationState.DONE) - skill_id = _find(list(entries), CONF_SKILL_ID) - assert skill_id is not None - assert getattr(skill_id, "advanced", False) is False diff --git a/tests/providers/yandex_smarthome/test_cloud.py b/tests/providers/yandex_smarthome/test_cloud.py index 03507b53c2..081b45837b 100644 --- a/tests/providers/yandex_smarthome/test_cloud.py +++ b/tests/providers/yandex_smarthome/test_cloud.py @@ -6,8 +6,8 @@ import aiohttp import pytest +from ya_dialogs_api import SecretStr -from music_assistant.providers.yandex_smarthome._compat import SecretStr from music_assistant.providers.yandex_smarthome.cloud import ( CloudManager, get_cloud_otp, diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py deleted file mode 100644 index b338176598..0000000000 --- a/tests/providers/yandex_smarthome/test_config_actions.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Tests for the auto-create action wired into get_config_entries. - -Exercises _run_auto_create_action via the public _handle_config_actions -entry point so the integration between config values, artifacts, and -the orchestrator is covered end-to-end (with the orchestrator itself -mocked). -""" - -from __future__ import annotations - -import json -from typing import Any -from unittest.mock import MagicMock - -import aiohttp -import pytest - -from music_assistant.providers import yandex_smarthome as provider_module -from music_assistant.providers.yandex_smarthome import _handle_config_actions -from music_assistant.providers.yandex_smarthome.auto_skill_state import ( - SkillCreationArtifacts, - SkillCreationState, -) -from music_assistant.providers.yandex_smarthome.constants import ( - CONF_ACTION_AUTO_CREATE, - CONF_AUTO_CREATE_ARTIFACTS, - CONF_CLOUD_INSTANCE_ID, - CONF_DIRECT_CLIENT_SECRET, - CONF_INSTANCE_NAME, - CONF_SKILL_ID, - CONNECTION_TYPE_CLOUD_PLUS, - CONNECTION_TYPE_DIRECT, -) - - -def _make_mass() -> MagicMock: - mass = MagicMock() - mass.get_provider.return_value = None - mass.signal_event = MagicMock() - return mass - - -@pytest.mark.asyncio -async def test_auto_create_done_populates_skill_id(monkeypatch) -> None: # type: ignore[no-untyped-def] - """On DONE, CONF_SKILL_ID is written and artifacts are persisted.""" - - async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: - return SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="new-skill-uuid", - logo_id="l1", - oauth_app_id="o1", - ) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake_auto_create, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "My Instance", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert values[CONF_SKILL_ID] == "new-skill-uuid" - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "done" - assert stored["skill_id"] == "new-skill-uuid" - - -@pytest.mark.asyncio -async def test_auto_create_failed_preserves_artifacts(monkeypatch) -> None: # type: ignore[no-untyped-def] - """On FAILED, CONF_SKILL_ID is NOT set so runtime doesn't use a half-skill.""" - - async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: - return SkillCreationArtifacts( - state=SkillCreationState.FAILED, - skill_id="partial-skill-id", - last_error="upload failed", - ) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake_auto_create, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - # CONF_SKILL_ID must remain unset — skill is incomplete - assert CONF_SKILL_ID not in values - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert stored["last_error"] == "upload failed" - assert stored["skill_id"] == "partial-skill-id" - - -@pytest.mark.asyncio -async def test_auto_create_precondition_valueerror_becomes_failed( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """ValueError from preconditions is caught and stored as a FAILED artifact.""" - - async def _raises_value_error(**_kwargs: Any) -> SkillCreationArtifacts: - msg = "direct mode requires HTTPS" - raise ValueError(msg) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _raises_value_error, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_DIRECT_CLIENT_SECRET: "secret", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert "HTTPS" in stored["last_error"] - assert CONF_SKILL_ID not in values - - -@pytest.mark.asyncio -async def test_auto_create_unexpected_error_caught(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Any other exception from orchestrator also surfaces as FAILED, not a crash.""" - - async def _raises_runtime(**_kwargs: Any) -> SkillCreationArtifacts: - msg = "network unreachable" - raise RuntimeError(msg) - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _raises_runtime, - ) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) - assert stored["state"] == "failed" - assert "network" in stored["last_error"] - - -@pytest.mark.asyncio -async def test_auto_create_non_action_is_noop(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Any action other than AUTO_CREATE must not touch auto-create values.""" - # Spy: if auto_create_skill is somehow called, this fails the test. - called = [] - - async def _fake(**_kwargs: Any) -> SkillCreationArtifacts: - called.append(True) - return SkillCreationArtifacts() - - monkeypatch.setattr( - provider_module, - "auto_create_skill", - _fake, - ) - - mass = _make_mass() - values: dict[str, Any] = {} - - # A different action — should not trigger auto-create - await _handle_config_actions( - mass, - "some_other_action", - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert not called - assert CONF_AUTO_CREATE_ARTIFACTS not in values - - -@pytest.mark.asyncio -async def test_session_id_forwarded_from_frontend(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Frontend-supplied ``values['session_id']`` is forwarded to the orchestrator. - - The session_id is what AuthenticationHelper listens on — if the - wiring drops it and generates a local uuid instead, MA's popup - never opens (silent failure the user won't see). - """ - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["session_id"] = kwargs.get("session_id") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - "session_id": "frontend-supplied-id-123", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert captured["session_id"] == "frontend-supplied-id-123" - - -@pytest.mark.asyncio -async def test_auto_create_cancelled_error_propagates(monkeypatch) -> None: # type: ignore[no-untyped-def] - """CancelledError must not be absorbed into a FAILED artifact. - - Config-flow shutdown and HA stop rely on cooperative cancellation; - converting it into state=FAILED would both leak the error into the - UI and break clean task teardown. - """ - import asyncio # noqa: PLC0415 - - async def _raises_cancelled(**_kwargs: Any) -> SkillCreationArtifacts: - raise asyncio.CancelledError - - monkeypatch.setattr(provider_module, "auto_create_skill", _raises_cancelled) - - mass = _make_mass() - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_CLOUD_INSTANCE_ID: "inst-1", - } - - with pytest.raises(asyncio.CancelledError): - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - # The FAILED artifact must NOT have been written on cancellation. - assert CONF_AUTO_CREATE_ARTIFACTS not in values - - -@pytest.mark.asyncio -async def test_auto_create_prefers_saved_direct_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] - """SECURE_STRING client secret: saved_config beats empty ``values`` on re-open. - - On re-open MA does not echo SECURE_STRING values back into ``values``, - so reading from ``values`` alone would pass an empty secret into the - orchestrator. The helper must pull it from the persisted provider - config instead. - """ - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["direct_client_secret"] = kwargs.get("direct_client_secret") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() - saved_cfg = MagicMock() - saved_cfg.get_value.side_effect = lambda key: ( - "persisted-secret" if key == CONF_DIRECT_CLIENT_SECRET else None - ) - prov = MagicMock() - prov.config = saved_cfg - mass.get_provider.return_value = prov - - # Frontend re-open: SECURE_STRING not echoed back -> values has no secret. - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id="inst-42", - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - assert captured["direct_client_secret"] == "persisted-secret" - - -@pytest.mark.asyncio -async def test_auto_create_falls_back_to_values_for_first_setup(monkeypatch) -> None: # type: ignore[no-untyped-def] - """First-time setup: no instance yet, secret is read from ``values``.""" - captured: dict[str, Any] = {} - - async def _capture(**kwargs: Any) -> SkillCreationArtifacts: - captured["direct_client_secret"] = kwargs.get("direct_client_secret") - return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") - - monkeypatch.setattr(provider_module, "auto_create_skill", _capture) - - mass = _make_mass() # get_provider returns None - values: dict[str, Any] = { - CONF_INSTANCE_NAME: "X", - CONF_DIRECT_CLIENT_SECRET: "fresh-secret", - } - - await _handle_config_actions( - mass, - CONF_ACTION_AUTO_CREATE, - values, - instance_id=None, - is_cloud_plus=False, - connection_type=CONNECTION_TYPE_DIRECT, - ) - - assert captured["direct_client_secret"] == "fresh-secret" - - -@pytest.mark.asyncio -async def test_existing_action_register_still_works(monkeypatch) -> None: # type: ignore[no-untyped-def] - """Gap-fill: pre-existing CONF_ACTION_REGISTER path is still covered.""" - - async def _fake_register( - _session: Any, - platform: str | None = None, # noqa: ARG001 - ) -> dict[str, str]: - return {"id": "inst-new", "password": "p", "connection_token": "t"} - - async def _fake_otp(_session: Any, _id: str, _tok: Any) -> str: - return "111111" - - class _NoopSession: - async def __aenter__(self): # type: ignore[no-untyped-def] - return self - - async def __aexit__(self, *_a): # type: ignore[no-untyped-def] - return False - - monkeypatch.setattr(provider_module, "register_cloud_instance", _fake_register) - monkeypatch.setattr(provider_module, "get_cloud_otp", _fake_otp) - monkeypatch.setattr(aiohttp, "ClientSession", _NoopSession) - - mass = _make_mass() - values: dict[str, Any] = {} - - otp = await _handle_config_actions( - mass, - "register_cloud", - values, - instance_id=None, - is_cloud_plus=True, - connection_type=CONNECTION_TYPE_CLOUD_PLUS, - ) - - assert values[CONF_CLOUD_INSTANCE_ID] == "inst-new" - # Register no longer auto-fetches OTP — that's a separate Step 3 action. - # The handler returns None because no OTP was requested. - assert otp is None diff --git a/tests/providers/yandex_smarthome/test_device.py b/tests/providers/yandex_smarthome/test_device.py index f373d8b28f..62dc342ff6 100644 --- a/tests/providers/yandex_smarthome/test_device.py +++ b/tests/providers/yandex_smarthome/test_device.py @@ -95,11 +95,20 @@ def get_player(self, player_id: str) -> MockPlayer | None: return self._players.get(player_id) +class MockPlayerQueues: + """Mock of mass.player_queues controller.""" + + def __init__(self) -> None: + """Initialize mock player_queues controller.""" + self.play_media = AsyncMock() + + @dataclass class MockMass: """Mock MusicAssistant for testing.""" players: MockPlayers = field(default_factory=MockPlayers) + player_queues: MockPlayerQueues = field(default_factory=MockPlayerQueues) # --------------------------------------------------------------------------- @@ -836,6 +845,168 @@ async def test_unknown_source_mode_returns_error(self) -> None: assert result.state.action_result.error_code == "INVALID_ACTION" +# --------------------------------------------------------------------------- +# Tests: input_source playlist sources (mode/input_source backed by playlists) +# --------------------------------------------------------------------------- + + +class TestPlaylistInputSources: + """Tests for playlist-backed input_source modes.""" + + def test_playlists_only_register_mode_cap(self) -> None: + """Player with no native sources but configured playlists gets mode cap.""" + player = MockPlayer(source_list=[]) + playlist_uris = ["library://playlist/1", "library://playlist/2", "library://playlist/3"] + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + modes = mode_caps[0].parameters.modes # type: ignore[union-attr] + assert modes is not None + assert [m.value for m in modes] == ["one", "two", "three"] + + def test_native_then_playlists_capped_at_10(self) -> None: + """5 native sources + 7 playlists → 10 combined modes, native first.""" + sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(5)] + playlist_uris = [f"library://playlist/{i}" for i in range(7)] + player = MockPlayer(source_list=sources, supported_features={"select_source"}) + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + + def test_native_full_ignores_playlists(self) -> None: + """If native sources already fill all 10 slots, playlists are ignored.""" + sources = [MockPlayerSource(id=f"s{i}", name=f"Source {i}") for i in range(10)] + playlist_uris = ["library://playlist/extra"] + player = MockPlayer(source_list=sources, supported_features={"select_source"}) + desc = get_device_description(player, playlist_uris=playlist_uris) # type: ignore[arg-type] + mode_caps = [c for c in desc.capabilities if c.type == YandexCapabilityType.MODE] + assert len(mode_caps) == 1 + assert len(mode_caps[0].parameters.modes) == 10 # type: ignore[arg-type,union-attr] + + def test_state_with_native_active_in_combined_mode(self) -> None: + """Native active source still reports correct state when playlists also configured.""" + sources = [ + MockPlayerSource(id="hdmi1", name="HDMI 1"), + MockPlayerSource(id="optical", name="Optical"), + ] + player = MockPlayer( + source_list=sources, + active_source="Optical", + playback_state=PlaybackState.PLAYING, + supported_features={"select_source"}, + ) + state = get_device_state(player, playlist_uris=["library://playlist/x"]) # type: ignore[arg-type] + mode_states = [c for c in state.capabilities if c.state.instance == INSTANCE_INPUT_SOURCE] + assert len(mode_states) == 1 + assert mode_states[0].state.value == "two" + + @pytest.mark.asyncio + async def test_native_action_routes_to_select_source(self) -> None: + """Native slot value resolves to mass.players.select_source, not play_media.""" + sources = [MockPlayerSource(id="hdmi1", name="HDMI 1")] + player = MockPlayer( + player_id="p1", source_list=sources, supported_features={"select_source"} + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/x"] + ) + mass.players.select_source.assert_awaited_once_with("p1", "hdmi1") + mass.player_queues.play_media.assert_not_awaited() + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_calls_play_media(self) -> None: + """Playlist slot triggers player_queues.play_media with URI.""" + player = MockPlayer(player_id="p1", source_list=[], powered=True) + mass = MockMass() + mass.players._players["p1"] = player + uris = ["library://playlist/jazz", "library://playlist/rock"] + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="two"), + ) + result = await execute_capability_action(mass, "p1", action, playlist_uris=uris) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://playlist/rock" + ) + mass.players.select_source.assert_not_awaited() + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_powers_on_if_off(self) -> None: + """Player with power feature off should be powered on before playback.""" + player = MockPlayer( + player_id="p1", + source_list=[], + powered=False, + supported_features={"power"}, + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.players.cmd_power.assert_awaited_once_with("p1", True) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://playlist/jazz" + ) + assert result.state.action_result.status == "DONE" + + @pytest.mark.asyncio + async def test_playlist_action_skips_power_when_already_on(self) -> None: + """Powered player skips cmd_power but still plays media.""" + player = MockPlayer( + player_id="p1", + source_list=[], + powered=True, + supported_features={"power"}, + ) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="one"), + ) + await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.players.cmd_power.assert_not_awaited() + mass.player_queues.play_media.assert_awaited_once() + + @pytest.mark.asyncio + async def test_slot_past_combined_returns_error(self) -> None: + """Mode value beyond combined native+playlists → INVALID_ACTION.""" + player = MockPlayer(player_id="p1", source_list=[]) + mass = MockMass() + mass.players._players["p1"] = player + + action = CapabilityAction( + type=YandexCapabilityType.MODE, + state=CapabilityActionState(instance="input_source", value="three"), + ) + result = await execute_capability_action( + mass, "p1", action, playlist_uris=["library://playlist/jazz"] + ) + mass.player_queues.play_media.assert_not_awaited() + assert result.state.action_result.status == "ERROR" + assert result.state.action_result.error_code == "INVALID_ACTION" + + # --------------------------------------------------------------------------- # Tests: player filter (exposed_ids) # --------------------------------------------------------------------------- diff --git a/tests/providers/yandex_smarthome/test_direct.py b/tests/providers/yandex_smarthome/test_direct.py index 7323b68e91..fdc05b592a 100644 --- a/tests/providers/yandex_smarthome/test_direct.py +++ b/tests/providers/yandex_smarthome/test_direct.py @@ -795,7 +795,12 @@ async def test_start_direct_mode_registers_routes(mock_mass: MagicMock) -> None: @pytest.mark.asyncio async def test_start_direct_mode_missing_skill_id(mock_mass: MagicMock) -> None: - """Direct mode without skill_id should log error and not start.""" + """Direct mode still registers HTTP routes when skill_id is missing. + + HTTP routes need to be live so Yandex's backend validation during + auto-create can succeed; the state notifier is skipped because + there is no skill to push state to yet. + """ config = _make_direct_config(skill_id="") plugin = YandexSmartHomePlugin( mass=mock_mass, @@ -807,8 +812,10 @@ async def test_start_direct_mode_missing_skill_id(mock_mass: MagicMock) -> None: await plugin.handle_async_init() await plugin.loaded_in_mass() - assert plugin._direct_handler is None - plugin.logger.error.assert_called() + assert plugin._direct_handler is not None + assert mock_mass.webserver.register_dynamic_route.call_count == 10 + assert plugin._state_notifier is None + plugin.logger.info.assert_called() @pytest.mark.asyncio diff --git a/tests/providers/yandex_smarthome/test_handlers.py b/tests/providers/yandex_smarthome/test_handlers.py index 3f547d2c19..fd49050468 100644 --- a/tests/providers/yandex_smarthome/test_handlers.py +++ b/tests/providers/yandex_smarthome/test_handlers.py @@ -19,7 +19,10 @@ handle_user_unlink, parse_action_payload, ) -from music_assistant.providers.yandex_smarthome.schema import DeviceListPayload +from music_assistant.providers.yandex_smarthome.schema import ( + DeviceListPayload, + YandexCapabilityType, +) @dataclass @@ -61,6 +64,7 @@ def _make_mass(players: list[MockPlayer]) -> MagicMock: mass.players.cmd_power = AsyncMock() mass.players.cmd_volume_set = AsyncMock() mass.players.cmd_volume_mute = AsyncMock() + mass.player_queues.play_media = AsyncMock() return mass @@ -117,6 +121,26 @@ async def test_filters_synced(self) -> None: assert len(result.devices) == 1 assert result.devices[0].id == "leader" + @pytest.mark.asyncio + async def test_playlist_uris_register_mode_capability(self) -> None: + """Players without native sources get mode(input_source) when playlists are configured.""" + players = [MockPlayer(player_id="p1")] + mass = _make_mass(players) + result = await handle_device_list( + mass, + "user1", + playlist_uris=("library://playlist/a", "library://playlist/b"), + ) + assert len(result.devices) == 1 + mode_caps = [ + c for c in result.devices[0].capabilities if c.type == YandexCapabilityType.MODE + ] + assert len(mode_caps) == 1 + assert mode_caps[0].parameters is not None + modes = mode_caps[0].parameters.modes + assert modes is not None + assert [m.value for m in modes] == ["one", "two"] + @pytest.mark.asyncio async def test_filters_by_exposed_ids(self) -> None: """Test filters by exposed ids.""" diff --git a/tests/providers/yandex_smarthome/test_ma_authenticator.py b/tests/providers/yandex_smarthome/test_ma_authenticator.py new file mode 100644 index 0000000000..e11f8a359b --- /dev/null +++ b/tests/providers/yandex_smarthome/test_ma_authenticator.py @@ -0,0 +1,109 @@ +"""Tests for provider/ma_authenticator.py. + +Scope is intentionally narrow — the inline-imported PassportClient + +AuthenticationHelper integration is verified end-to-end against a real +Yandex Passport account in V.4 of the rollout plan. Here we cover what +is fast, deterministic, and unit-testable without stubbing those upstream +packages: + +- session_id validation (the only synchronous gate inside ``make_authenticator``) +- HTML activation page rendering (escaping, JS payload safety) +""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.yandex_smarthome.ma_authenticator import ( + _build_device_code_page, + make_authenticator, +) + + +class TestSessionIdValidation: + """make_authenticator rejects unsafe session ids before doing anything else.""" + + def test_safe_id_accepted(self) -> None: + """Letters, digits, dashes, underscores, len <= 64 are allowed.""" + # Should not raise; we discard the returned factory. + make_authenticator( + mass=None, # type: ignore[arg-type] + session_id="abc-123_DEF", + ) + + def test_max_length_accepted(self) -> None: + """64-character ids are at the inclusive upper bound.""" + make_authenticator(mass=None, session_id="a" * 64) # type: ignore[arg-type] + + @pytest.mark.parametrize( + "bad", + [ + "", # empty + "a" * 65, # too long + "has spaces", + "../etc/passwd", # path traversal + "with/slash", + "with\\backslash", + "with;semicolon", + "", + verification_url="https://ya.ru/device", + status_url="https://ma.example.com/status", + ) + assert "" not in html + assert "<script>" in html + + def test_escapes_quotes_in_verification_url(self) -> None: + """Quote-escaping protects the href attribute.""" + html = _build_device_code_page( + user_code="X", + verification_url='https://ya.ru/device">', + status_url="https://ma.example.com/status", + ) + assert '">' not in html + # Either " or " depending on html.escape config + assert """ in html or """ in html + + def test_status_url_with_closing_script_tag_escaped(self) -> None: + """Defensive escape of sequence inside the JS string literal. + + Without the replace, a malicious status_url containing ```` would + prematurely close the surrounding ``", + ) + assert "" in html diff --git a/tests/providers/yandex_smarthome/test_notifier.py b/tests/providers/yandex_smarthome/test_notifier.py index f802035c08..4c36e7d392 100644 --- a/tests/providers/yandex_smarthome/test_notifier.py +++ b/tests/providers/yandex_smarthome/test_notifier.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging from dataclasses import dataclass, field from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -13,7 +14,10 @@ # Use mock enums from conftest from music_assistant_models.enums import EventType, PlaybackState -from music_assistant.providers.yandex_smarthome.notifier import StateNotifier +from music_assistant.providers.yandex_smarthome.notifier import ( + StateNotifier, + _CallbackErrorAlreadyLogged, +) @dataclass @@ -396,7 +400,7 @@ async def test_accepts_http_202(self) -> None: @pytest.mark.asyncio async def test_rejects_http_500(self) -> None: - """Non-success status codes should re-queue dirty IDs and raise.""" + """HTTP 500: re-queue dirty IDs, swallow exception (already logged).""" mock_resp = AsyncMock() mock_resp.status = 500 mock_resp.text = AsyncMock(return_value="Internal Server Error") @@ -414,13 +418,77 @@ async def test_rejects_http_500(self) -> None: notifier._dirty_player_ids.add("p1") - with pytest.raises(RuntimeError, match="State callback failed"): - await notifier._flush_pending() + # No raise — _send_state_callback's _CallbackErrorAlreadyLogged is + # deduped + swallowed here so MA's task scheduler does not re-log + # it as "Task exception was never retrieved" on every retry. + await notifier._flush_pending() session.post.assert_called_once() - # Player IDs should be re-queued after failure assert "p1" in notifier._dirty_player_ids + @pytest.mark.asyncio + async def test_http_5xx_dedupe_warns_once(self, caplog: pytest.LogCaptureFixture) -> None: + """Repeated HTTP 5xx logs WARNING once, then DEBUG on retries.""" + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="Internal Server Error") + + session = MagicMock(spec=aiohttp.ClientSession) + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session.post.return_value = ctx + + player = MockPlayer(player_id="p1") + mass = _make_mass([player]) + mass.players.get_player = MagicMock(return_value=player) + notifier = _make_notifier(mass=mass, session=session) + + caplog.set_level(logging.DEBUG, logger=notifier._logger.name) + + # First failure → WARNING + with pytest.raises(_CallbackErrorAlreadyLogged): + await notifier._send_state_callback( + [MagicMock()] # devices payload — content irrelevant for this test + ) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "HTTP 500" in warnings[0].message + + # Second failure with same fingerprint → DEBUG, no new WARNING + caplog.clear() + with pytest.raises(_CallbackErrorAlreadyLogged): + await notifier._send_state_callback([MagicMock()]) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + debugs = [r for r in caplog.records if r.levelno == logging.DEBUG] + assert warnings == [] + assert any("still failing" in r.message for r in debugs) + + @pytest.mark.asyncio + async def test_recovery_logs_info_and_clears_fingerprint( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """A successful callback after a failure logs INFO and resets state.""" + # Pre-set the fingerprint to simulate prior failure + mass = _make_mass() + session = MagicMock(spec=aiohttp.ClientSession) + mock_resp = AsyncMock() + mock_resp.status = 200 + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session.post.return_value = ctx + + notifier = _make_notifier(mass=mass, session=session) + notifier._last_error_fingerprint = "http_500" + + caplog.set_level(logging.INFO, logger=notifier._logger.name) + await notifier._send_state_callback([MagicMock()]) + + infos = [r for r in caplog.records if r.levelno == logging.INFO] + assert any("recovered" in r.message for r in infos) + assert notifier._last_error_fingerprint is None + @pytest.mark.asyncio async def test_discovery_url_cloud_plus(self) -> None: """Discovery URL should use replace('/state', '/discovery') for Dialogs API.""" diff --git a/tests/providers/yandex_smarthome/test_smarthome_auto_create.py b/tests/providers/yandex_smarthome/test_smarthome_auto_create.py new file mode 100644 index 0000000000..9cb1d85dcd --- /dev/null +++ b/tests/providers/yandex_smarthome/test_smarthome_auto_create.py @@ -0,0 +1,149 @@ +"""Tests for provider/_smarthome_auto_create.py — URL derivations + helpers.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from music_assistant.providers.yandex_smarthome._smarthome_auto_create import ( + SmartHomeUrls, + derive_smart_home_urls, + resolve_base_url, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + + +class TestResolveBaseUrl: + """resolve_base_url picks override over mass.webserver.base_url.""" + + def test_override_wins(self) -> None: + """Explicit override is preferred over MA's global base_url.""" + mass = MagicMock() + mass.webserver.base_url = "https://internal.example.local" + assert resolve_base_url(mass, "https://public.example.com") == "https://public.example.com" + + def test_override_strips_trailing_slash(self) -> None: + """Trailing slash + whitespace are stripped before use.""" + mass = MagicMock() + mass.webserver.base_url = "https://x" + assert resolve_base_url(mass, "https://ma.example.com/ ") == "https://ma.example.com" + + def test_no_override_uses_mass_base_url(self) -> None: + """Empty override falls back to MA's webserver base URL.""" + mass = MagicMock() + mass.webserver.base_url = "https://ma.example.com/" + assert resolve_base_url(mass, None) == "https://ma.example.com" + + def test_empty_override_treated_as_none(self) -> None: + """Whitespace-only override is treated as no override.""" + mass = MagicMock() + mass.webserver.base_url = "https://ma.example.com" + assert resolve_base_url(mass, " ") == "https://ma.example.com" + + +class TestDeriveSmartHomeUrlsCloudPlus: + """cloud_plus mode → all five values come from yaha-cloud constants.""" + + def test_happy_path(self) -> None: + """All five URLs are pre-computed from constants + instance_id.""" + urls = derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + base_url="https://ma.example.com", # ignored for cloud_plus + cloud_instance_id="inst-abc-123", + direct_client_secret="ignored", + ) + assert isinstance(urls, SmartHomeUrls) + assert urls.backend_uri == "https://yaha-cloud.ru/api/yandex_smart_home" + assert urls.oauth_authorize_url == "https://yaha-cloud.ru/oauth/authorize" + assert urls.oauth_token_url == "https://yaha-cloud.ru/oauth/token" + assert urls.oauth_client_id == "yandex_smart_home:inst-abc-123" + assert urls.oauth_client_secret == "secret" # literal yaha-cloud protocol value + + def test_missing_instance_id_raises(self) -> None: + """cloud_plus without a registered instance is rejected.""" + with pytest.raises(ValueError, match="yaha-cloud instance"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + base_url="https://x", + cloud_instance_id="", + direct_client_secret="", + ) + + +class TestDeriveSmartHomeUrlsDirect: + """direct mode → URLs computed from base_url + per-install secret.""" + + def test_happy_path(self) -> None: + """All five URLs match the direct-mode endpoint structure.""" + urls = derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://ma.example.com", + cloud_instance_id="ignored", + direct_client_secret="abc-deadbeef", + ) + assert urls.backend_uri == "https://ma.example.com/api/yandex_smarthome" + assert ( + urls.oauth_authorize_url == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + ) + assert urls.oauth_token_url == "https://ma.example.com/api/yandex_smarthome/auth/token" + assert urls.oauth_client_id == "https://social.yandex.net/" + assert urls.oauth_client_secret == "abc-deadbeef" + + def test_missing_secret_raises(self) -> None: + """Direct mode without per-install client secret is rejected.""" + with pytest.raises(ValueError, match="Client Secret"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://x", + cloud_instance_id="", + direct_client_secret="", + ) + + def test_non_https_base_url_raises(self) -> None: + """Yandex rejects non-HTTPS backends; we surface this client-side.""" + with pytest.raises(ValueError, match="HTTPS"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="http://insecure.example.com", + cloud_instance_id="", + direct_client_secret="abc", + ) + + def test_empty_base_url_raises(self) -> None: + """Empty base_url fails the HTTPS check.""" + with pytest.raises(ValueError, match="HTTPS"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_DIRECT, + base_url="", + cloud_instance_id="", + direct_client_secret="abc", + ) + + +class TestDeriveSmartHomeUrlsUnsupported: + """Plain 'cloud' or any other connection_type is rejected.""" + + def test_cloud_rejected(self) -> None: + """Plain 'cloud' (public Yaha Cloud) doesn't go through auto-create.""" + with pytest.raises(ValueError, match="connection_type"): + derive_smart_home_urls( + connection_type=CONNECTION_TYPE_CLOUD, + base_url="https://x", + cloud_instance_id="i", + direct_client_secret="", + ) + + def test_unknown_rejected(self) -> None: + """Unrecognised connection_type produces a descriptive error.""" + with pytest.raises(ValueError, match="connection_type"): + derive_smart_home_urls( + connection_type="bogus", + base_url="https://x", + cloud_instance_id="i", + direct_client_secret="", + )