Skip to content
Merged
Show file tree
Hide file tree
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] Apr 8, 2026
6a800e9
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
7d41ac9
fix: add mypy type annotations to all test functions
trudenboy Apr 8, 2026
de8af3b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
394c91e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
9601d79
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
a21e227
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
b879472
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
2e5669c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
f7001aa
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
d4b3f86
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 8, 2026
bf59edf
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 9, 2026
e9fc00b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 9, 2026
acf6086
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 10, 2026
e2f46eb
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
e57fc27
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
54e9190
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
1e15466
style: ruff format test_direct.py for server CI
trudenboy Apr 11, 2026
e1c906b
chore: regenerate requirements_all.txt (add ya-passport-auth)
trudenboy Apr 11, 2026
027092b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
23f2bf8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
348c6a0
style: ruff format test_direct.py
trudenboy Apr 11, 2026
0771d3c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
9137b53
style: ruff format test_direct.py
trudenboy Apr 11, 2026
6ab9f7b
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 11, 2026
e8ec0fd
fix: provide domain in manifest mock and log_level in config defaults
trudenboy Apr 11, 2026
e0d0804
Merge branch 'upstream/yandex_smarthome' of https://github.com/truden…
trudenboy Apr 11, 2026
36c9196
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
220637b
style: ruff format test_direct.py
trudenboy Apr 11, 2026
6357a64
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
7d6661a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
fc93a3e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
c9cf421
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
1c4496f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
691f58a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
4a7d90b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
45c7152
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
82feb91
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
514c295
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
9c9b4af
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
6dd0872
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
050c552
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
db72d84
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
ecf6225
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
71fad70
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 11, 2026
a2382a3
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 12, 2026
d3e46f9
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 12, 2026
3444293
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 12, 2026
47cd0a6
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 12, 2026
e1ed519
chore: update ya-passport-auth to 1.2.3 in requirements_all.txt
trudenboy Apr 12, 2026
fd32f85
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 13, 2026
83fd373
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 13, 2026
26dd8e7
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 13, 2026
e8dac52
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 15, 2026
5383089
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 15, 2026
b0ccc55
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 15, 2026
011adc7
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 15, 2026
bfc4e05
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 15, 2026
d6ad8d9
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 16, 2026
7fbc58e
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 16, 2026
a7075e0
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 16, 2026
6cec97a
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 17, 2026
5b1566a
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 17, 2026
c713c32
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
598 changes: 598 additions & 0 deletions music_assistant/providers/yandex_smarthome/__init__.py

Large diffs are not rendered by default.

179 changes: 179 additions & 0 deletions music_assistant/providers/yandex_smarthome/cloud.py
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]]],
Comment thread
trudenboy marked this conversation as resolved.
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)

Comment thread
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())
Comment thread
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)

Comment thread
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:
Comment thread
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 music_assistant/providers/yandex_smarthome/constants.py
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"

Comment thread
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"
Loading
Loading