-
-
Notifications
You must be signed in to change notification settings - Fork 381
Add Yandex Smart Home plugin provider #3615
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
MarvinSchenkel
merged 64 commits into
music-assistant:dev
from
trudenboy:upstream/yandex_smarthome
Apr 20, 2026
Merged
Changes from 14 commits
Commits
Show all changes
64 commits
Select commit
Hold shift + click to select a range
3a14919
feat(yandex_smarthome): add yandex_smarthome provider v1.0.0
github-actions[bot] 6a800e9
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 7d41ac9
fix: add mypy type annotations to all test functions
trudenboy de8af3b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 394c91e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 9601d79
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] a21e227
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] b879472
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 2e5669c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] f7001aa
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] d4b3f86
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] bf59edf
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy e9fc00b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] acf6086
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] e2f46eb
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] e57fc27
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 54e9190
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 1e15466
style: ruff format test_direct.py for server CI
trudenboy e1c906b
chore: regenerate requirements_all.txt (add ya-passport-auth)
trudenboy 027092b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 23f2bf8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 348c6a0
style: ruff format test_direct.py
trudenboy 0771d3c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 9137b53
style: ruff format test_direct.py
trudenboy 6ab9f7b
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy e8ec0fd
fix: provide domain in manifest mock and log_level in config defaults
trudenboy e0d0804
Merge branch 'upstream/yandex_smarthome' of https://github.com/truden…
trudenboy 36c9196
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 220637b
style: ruff format test_direct.py
trudenboy 6357a64
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 7d6661a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] fc93a3e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] c9cf421
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 1c4496f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 691f58a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 4a7d90b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 45c7152
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 82feb91
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 514c295
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 9c9b4af
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 6dd0872
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 050c552
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] db72d84
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] ecf6225
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 71fad70
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] a2382a3
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy d3e46f9
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 3444293
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 47cd0a6
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] e1ed519
chore: update ya-passport-auth to 1.2.3 in requirements_all.txt
trudenboy fd32f85
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy 83fd373
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 26dd8e7
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] e8dac52
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy 5383089
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] b0ccc55
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 011adc7
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] bfc4e05
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy d6ad8d9
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy 7fbc58e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] a7075e0
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] 6cec97a
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy 5b1566a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] c713c32
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| """Cloud connection manager for Yandex Smart Home via yaha-cloud.ru relay. | ||
|
|
||
| Manages a persistent WebSocket connection to the yaha-cloud.ru relay service. | ||
| Incoming Yandex Smart Home API requests are received over WS, processed by | ||
| the on_request callback, and the response is sent back over WS. | ||
|
|
||
| Adapted from dext0r/yandex_smart_home cloud.py, stripped of HA dependencies. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import json | ||
| import logging | ||
| from collections.abc import Awaitable, Callable | ||
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| import aiohttp | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ya_passport_auth import SecretStr | ||
|
|
||
| from .constants import ( | ||
| CLOUD_BASE_URL, | ||
| CLOUD_HEARTBEAT_INTERVAL, | ||
| CLOUD_RECONNECT_MAX, | ||
| CLOUD_RECONNECT_MIN, | ||
| CLOUD_REGISTER_URL, | ||
| CLOUD_WS_URL, | ||
| ) | ||
| from .schema import CloudRequest | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class CloudManager: | ||
| """Manages WebSocket connection to yaha-cloud.ru for Smart Home API relay.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| session: aiohttp.ClientSession, | ||
| connection_token: SecretStr, | ||
| on_request: Callable[[CloudRequest], Awaitable[dict[str, Any]]], | ||
| logger: logging.Logger | None = None, | ||
| ) -> None: | ||
| """Initialize cloud relay manager.""" | ||
| self._session = session | ||
| self._token = connection_token | ||
| self._on_request = on_request | ||
| self._logger = logger or _LOGGER | ||
| self._ws: aiohttp.ClientWebSocketResponse | None = None | ||
| self._running = False | ||
| self._reconnect_delay = CLOUD_RECONNECT_MIN | ||
|
|
||
| @property | ||
| def connected(self) -> bool: | ||
| """Return True if WebSocket is connected.""" | ||
| return self._ws is not None and not self._ws.closed | ||
|
|
||
| async def connect(self) -> None: | ||
| """Start the WebSocket connection loop (runs until disconnect is called).""" | ||
| self._running = True | ||
| while self._running: | ||
| try: | ||
| await self._connect_once() | ||
| except asyncio.CancelledError: | ||
| break | ||
| except Exception: | ||
| if not self._running: | ||
| break # type: ignore[unreachable] | ||
| self._logger.exception( | ||
| "Cloud connection error, reconnecting in %ds", self._reconnect_delay | ||
| ) | ||
| await asyncio.sleep(self._reconnect_delay) | ||
| self._reconnect_delay = min(self._reconnect_delay * 2, CLOUD_RECONNECT_MAX) | ||
|
|
||
|
trudenboy marked this conversation as resolved.
|
||
| async def _connect_once(self) -> None: | ||
| """Single WebSocket connection attempt + message loop.""" | ||
| headers = {"Authorization": f"Bearer {self._token.get_secret()}"} | ||
| async with self._session.ws_connect( | ||
| CLOUD_WS_URL, | ||
| headers=headers, | ||
| heartbeat=CLOUD_HEARTBEAT_INTERVAL, | ||
| ) as ws: | ||
| self._ws = ws | ||
| self._reconnect_delay = CLOUD_RECONNECT_MIN | ||
| self._logger.info("Connected to cloud relay at %s", CLOUD_WS_URL) | ||
|
|
||
| async for msg in ws: | ||
| if not self._running: | ||
| break | ||
|
|
||
| if msg.type == aiohttp.WSMsgType.TEXT: | ||
| await self._handle_message(ws, msg.json()) | ||
|
trudenboy marked this conversation as resolved.
Outdated
|
||
| elif msg.type == aiohttp.WSMsgType.ERROR: | ||
| self._logger.error("WebSocket error: %s", ws.exception()) | ||
| break | ||
| elif msg.type in ( | ||
| aiohttp.WSMsgType.CLOSE, | ||
| aiohttp.WSMsgType.CLOSING, | ||
| aiohttp.WSMsgType.CLOSED, | ||
| ): | ||
| break | ||
|
|
||
| self._ws = None | ||
| self._logger.info("Cloud relay connection closed") | ||
|
|
||
| async def _handle_message( | ||
| self, ws: aiohttp.ClientWebSocketResponse, data: dict[str, Any] | ||
| ) -> None: | ||
| """Parse incoming WS message, call handler, and send response.""" | ||
| try: | ||
| # message may be a JSON string or already parsed dict | ||
| raw_message = data.get("message") | ||
| if isinstance(raw_message, str) and raw_message: | ||
| raw_message = json.loads(raw_message) | ||
| request = CloudRequest( | ||
| request_id=data["request_id"], | ||
| action=data["action"], | ||
| message=raw_message if isinstance(raw_message, dict) else None, | ||
| ) | ||
| self._logger.debug("Cloud request: action=%s", request.action) | ||
| response = await self._on_request(request) | ||
| await ws.send_json(response) | ||
| except Exception: | ||
| self._logger.exception("Error handling cloud message: %s", data) | ||
|
|
||
|
trudenboy marked this conversation as resolved.
|
||
| async def disconnect(self) -> None: | ||
| """Stop the connection loop and close WebSocket.""" | ||
| self._running = False | ||
| if self._ws and not self._ws.closed: | ||
| await self._ws.close() | ||
| self._ws = None | ||
| self._logger.info("Cloud relay disconnected") | ||
|
|
||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Cloud instance registration helpers | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
|
|
||
| async def register_cloud_instance( | ||
| session: aiohttp.ClientSession, | ||
| platform: str | None = None, | ||
| ) -> dict[str, str]: | ||
| """Register a new cloud instance on yaha-cloud.ru. | ||
|
|
||
| Returns dict with 'id', 'password', 'connection_token'. | ||
| No authentication is required — the relay auto-generates credentials. | ||
|
|
||
| For Cloud Plus mode, pass platform="yandex" so the relay can validate | ||
| the client_id during OAuth account linking. | ||
| """ | ||
| json_body = {"platform": platform} if platform else None | ||
| async with session.post(CLOUD_REGISTER_URL, json=json_body) as resp: | ||
|
trudenboy marked this conversation as resolved.
Outdated
|
||
| resp.raise_for_status() | ||
| # yaha-cloud.ru may return text/plain content-type for JSON | ||
| data = await resp.json(content_type=None) | ||
| _LOGGER.info("Registered cloud instance: %s", data.get("id")) | ||
| return dict(data) | ||
|
|
||
|
|
||
| async def get_cloud_otp( | ||
| session: aiohttp.ClientSession, | ||
| instance_id: str, | ||
| token: SecretStr, | ||
| ) -> str: | ||
| """Get a one-time password for linking the instance in the Yandex app. | ||
|
|
||
| User enters this OTP in the Yandex Smart Home app to link their account. | ||
| The token parameter is the connection_token from registration. | ||
| """ | ||
| url = f"{CLOUD_BASE_URL}/api/home_assistant/v1/instance/{instance_id}/otp" | ||
| headers = {"Authorization": f"Bearer {token.get_secret()}"} | ||
| async with session.post(url, headers=headers) as resp: | ||
| resp.raise_for_status() | ||
| # yaha-cloud.ru may return text/plain content-type for JSON | ||
| data = await resp.json(content_type=None) | ||
| return str(data["code"]) | ||
118 changes: 118 additions & 0 deletions
118
music_assistant/providers/yandex_smarthome/constants.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| """Constants for Yandex Smart Home provider.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Config entry keys | ||
| # --------------------------------------------------------------------------- | ||
| CONF_INSTANCE_NAME = "instance_name" | ||
| CONF_CONNECTION_TYPE = "connection_type" | ||
| 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" | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Config actions | ||
| # --------------------------------------------------------------------------- | ||
| CONF_ACTION_REGISTER = "register_cloud" | ||
| CONF_ACTION_GET_OTP = "get_otp" | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Connection types | ||
| # --------------------------------------------------------------------------- | ||
| CONNECTION_TYPE_CLOUD = "cloud" | ||
| CONNECTION_TYPE_CLOUD_PLUS = "cloud_plus" | ||
| CONNECTION_TYPE_DIRECT = "direct" | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Cloud relay — yaha-cloud.ru (dext0r's relay service) | ||
| # --------------------------------------------------------------------------- | ||
| CLOUD_BASE_URL = "https://yaha-cloud.ru" | ||
| CLOUD_WS_URL = "wss://yaha-cloud.ru/api/home_assistant/v1/connect" | ||
| CLOUD_REGISTER_URL = f"{CLOUD_BASE_URL}/api/home_assistant/v1/instance/register" | ||
| CLOUD_CALLBACK_URL = f"{CLOUD_BASE_URL}/api/home_assistant/v2/callback" | ||
| CLOUD_OAUTH_AUTHORIZE_URL = f"{CLOUD_BASE_URL}/oauth/authorize" | ||
| CLOUD_OAUTH_TOKEN_URL = f"{CLOUD_BASE_URL}/oauth/token" | ||
|
|
||
| # Platform identifier sent to the cloud relay | ||
| CLOUD_PLATFORM = "music_assistant" | ||
|
|
||
| # Account linking template: client_id = "yandex_smart_home:{instance_id}" | ||
| CLOUD_SKILL_CLIENT_ID_TEMPLATE = "yandex_smart_home:{instance_id}" | ||
| CLOUD_SKILL_CLIENT_SECRET = "secret" | ||
|
|
||
|
trudenboy marked this conversation as resolved.
|
||
| # --------------------------------------------------------------------------- | ||
| # Cloud Plus / Direct mode — Yandex Dialogs API | ||
| # --------------------------------------------------------------------------- | ||
| YANDEX_DIALOGS_CALLBACK_BASE = "https://dialogs.yandex.net/api/v1/skills" | ||
| YANDEX_DIALOGS_DEVELOPER_URL = "https://dialogs.yandex.ru/developer/smart-home" | ||
| YANDEX_OAUTH_URL = "https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd" | ||
|
|
||
| # Webhook URL template for yaha-cloud relay (private skill points here) | ||
| CLOUD_SKILL_WEBHOOK_TEMPLATE = "https://yaha-cloud.ru/api/yandex_smart_home" | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Direct connection — HTTP endpoints on MA webserver | ||
| # --------------------------------------------------------------------------- | ||
| DIRECT_API_BASE_PATH = "/api/yandex_smarthome/v1.0" | ||
| DIRECT_AUTH_BASE_PATH = "/api/yandex_smarthome/auth" | ||
| DIRECT_HEALTH_RESPONSE = "Yandex Smart Home for Music Assistant" | ||
| CONF_DIRECT_ACCESS_TOKEN = "direct_access_token" | ||
| CONF_DIRECT_CLIENT_SECRET = "direct_client_secret" | ||
| DIRECT_OAUTH_CLIENT_ID = "https://social.yandex.net/" | ||
| OAUTH_CODE_EXPIRY = 300 # pending authorization codes expire after 5 minutes | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Timing (seconds) | ||
| # --------------------------------------------------------------------------- | ||
| STATE_REPORT_DELAY = 1.0 # debounce window for batched state reports | ||
| STATE_HEARTBEAT_INTERVAL = 3600 # report all states hourly | ||
| STATE_INITIAL_REPORT_DELAY = 15 # initial report after startup | ||
| CLOUD_RECONNECT_MIN = 2 # initial reconnect delay | ||
| CLOUD_RECONNECT_MAX = 180 # max reconnect delay (exponential backoff cap) | ||
| CLOUD_HEARTBEAT_INTERVAL = 45 # WebSocket heartbeat | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Yandex Smart Home API — device & capability constants | ||
| # --------------------------------------------------------------------------- | ||
| YANDEX_DEVICE_TYPE_MEDIA = "devices.types.media_device" | ||
| YANDEX_DEVICE_TYPE_RECEIVER = "devices.types.media_device.receiver" | ||
|
|
||
| CAPABILITY_ON_OFF = "devices.capabilities.on_off" | ||
| CAPABILITY_RANGE = "devices.capabilities.range" | ||
| CAPABILITY_TOGGLE = "devices.capabilities.toggle" | ||
|
|
||
| INSTANCE_ON = "on" | ||
| INSTANCE_VOLUME = "volume" | ||
| INSTANCE_MUTE = "mute" | ||
| INSTANCE_PAUSE = "pause" | ||
| INSTANCE_CHANNEL = "channel" | ||
| INSTANCE_INPUT_SOURCE = "input_source" | ||
|
|
||
| UNIT_PERCENT = "unit.percent" | ||
|
|
||
| # Yandex mode values for input_source mapping (by index position) | ||
| YANDEX_MODE_VALUES = ( | ||
| "one", | ||
| "two", | ||
| "three", | ||
| "four", | ||
| "five", | ||
| "six", | ||
| "seven", | ||
| "eight", | ||
| "nine", | ||
| "ten", | ||
| ) | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Yandex Smart Home API — response codes | ||
| # --------------------------------------------------------------------------- | ||
| RESPONSE_OK = "DONE" | ||
| ERROR_DEVICE_UNREACHABLE = "DEVICE_UNREACHABLE" | ||
| ERROR_INVALID_ACTION = "INVALID_ACTION" | ||
| ERROR_INTERNAL_ERROR = "INTERNAL_ERROR" | ||
| ERROR_DEVICE_NOT_FOUND = "DEVICE_NOT_FOUND" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.