/
/
/
1"""
2Yandex Smart Home Plugin Provider.
3
4Bridges Music Assistant players to the Yandex Smart Home ecosystem,
5allowing Alice voice control of playback, volume, and transport.
6
7The plugin:
81. Listens for MA player events (added, removed, updated)
92. Exposes them as Yandex Smart Home media_device devices
103. Handles capability actions (on_off, volume, pause) from Alice
114. Reports state changes back to Yandex
12
13Connection modes:
14- Cloud: WebSocket relay through yaha-cloud.ru (no public URL needed)
15- Cloud Plus: Private skill via yaha-cloud.ru relay (custom Yandex.Dialogs skill)
16- Direct: HTTP endpoints on MA webserver that Yandex calls directly (requires public URL)
17"""
18
19from __future__ import annotations
20
21import asyncio
22from dataclasses import asdict
23from typing import Any
24
25from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption
26from music_assistant_models.enums import ConfigEntryType
27from ya_dialogs_api import SecretStr
28
29from music_assistant.models.plugin import PluginProvider
30
31from .cloud import CloudManager
32from .constants import (
33 CLOUD_CALLBACK_URL,
34 CONF_CLOUD_CONNECTION_TOKEN,
35 CONF_CLOUD_INSTANCE_ID,
36 CONF_CLOUD_INSTANCE_PASSWORD,
37 CONF_CONNECTION_TYPE,
38 CONF_DIRECT_ACCESS_TOKEN,
39 CONF_DIRECT_CLIENT_SECRET,
40 CONF_EXPOSED_PLAYERS,
41 CONF_EXPOSED_PLAYLISTS,
42 CONF_INSTANCE_NAME,
43 CONF_SKILL_ID,
44 CONF_SKILL_TOKEN,
45 CONNECTION_TYPE_CLOUD,
46 CONNECTION_TYPE_CLOUD_PLUS,
47 CONNECTION_TYPE_DIRECT,
48 MAX_INPUT_SOURCES,
49 YANDEX_DIALOGS_CALLBACK_BASE,
50)
51from .direct import DirectConnectionHandler
52from .handlers import (
53 build_response,
54 handle_device_list,
55 handle_devices_action,
56 handle_devices_query,
57 handle_user_unlink,
58 parse_action_payload,
59)
60from .notifier import StateNotifier
61from .playlists import fetch_playlist_options
62from .schema import CloudRequest
63
64
65class YandexSmartHomePlugin(PluginProvider):
66 """
67 Plugin provider that exposes MA players to Yandex Alice via Smart Home API.
68
69 Follows the same pattern as the HASS plugin provider: subscribes to MA events,
70 maintains a mapping of MA players to Yandex Smart Home devices, and handles
71 capability actions from Alice by translating them to MA player commands.
72 """
73
74 _cloud_manager: CloudManager | None = None
75 _state_notifier: StateNotifier | None = None
76 _direct_handler: DirectConnectionHandler | None = None
77 _cloud_task: Any = None
78 _user_id: str = ""
79
80 async def get_config_entries(self) -> tuple[ConfigEntry, ...]:
81 """
82 Return Config entries to configure this provider.
83
84 Authentication and cloud/skill provisioning are handled by the setup flow (see
85 setup_flow.py); only the genuine playback options are configurable here.
86 """
87 player_options = await self._list_player_options()
88 playlist_options: list[ConfigValueOption] = []
89 try:
90 playlist_options = await fetch_playlist_options(self.mass)
91 except asyncio.CancelledError:
92 raise
93 except Exception:
94 self.logger.debug("could not enumerate playlists")
95
96 return (
97 ConfigEntry(
98 key=CONF_INSTANCE_NAME,
99 type=ConfigEntryType.STRING,
100 required=False,
101 default_value="Music Assistant",
102 ),
103 ConfigEntry(
104 key=CONF_EXPOSED_PLAYERS,
105 type=ConfigEntryType.STRING,
106 required=False,
107 multi_value=True,
108 default_value=[],
109 options=list(player_options) if player_options else [],
110 ),
111 ConfigEntry(
112 key=CONF_EXPOSED_PLAYLISTS,
113 type=ConfigEntryType.STRING,
114 required=False,
115 multi_value=True,
116 default_value=[],
117 options=list(playlist_options) if playlist_options else [],
118 ),
119 )
120
121 async def handle_async_init(self) -> None:
122 """Handle async initialization of the plugin."""
123 # credentials collected by the setup flow live in setup_data; get_setup_value reads
124 # them (and transparently falls back to the legacy config values for installs
125 # configured before the flow existed, so no data migration is needed)
126 self._connection_type = str(
127 self.get_setup_value(CONF_CONNECTION_TYPE) or CONNECTION_TYPE_CLOUD
128 )
129 self._instance_name = str(self.config.get_value(CONF_INSTANCE_NAME) or "Music Assistant")
130 cloud_token_raw = str(self.get_setup_value(CONF_CLOUD_INSTANCE_PASSWORD) or "")
131 self._cloud_token: SecretStr | None = (
132 SecretStr(cloud_token_raw) if cloud_token_raw else None
133 )
134 conn_token_raw = str(self.get_setup_value(CONF_CLOUD_CONNECTION_TOKEN) or "")
135 self._connection_token: SecretStr | None = (
136 SecretStr(conn_token_raw) if conn_token_raw else None
137 )
138 self._cloud_instance_id = str(self.get_setup_value(CONF_CLOUD_INSTANCE_ID) or "")
139 self._skill_id = str(self.get_setup_value(CONF_SKILL_ID) or "")
140 skill_token_raw = str(self.get_setup_value(CONF_SKILL_TOKEN) or "")
141 self._skill_token: SecretStr | None = (
142 SecretStr(skill_token_raw) if skill_token_raw else None
143 )
144 self._direct_access_token = str(self.get_setup_value(CONF_DIRECT_ACCESS_TOKEN) or "")
145 self._direct_client_secret = str(self.get_setup_value(CONF_DIRECT_CLIENT_SECRET) or "")
146
147 # Parse exposed players filter
148 exposed_raw = self.config.get_value(CONF_EXPOSED_PLAYERS) or []
149 if isinstance(exposed_raw, str):
150 exposed_raw = [x.strip() for x in exposed_raw.split(",") if x.strip()]
151 elif isinstance(exposed_raw, list):
152 exposed_raw = [str(x) for x in exposed_raw if x]
153 else:
154 exposed_raw = []
155 self._exposed_ids: set[str] | None = set(exposed_raw) if exposed_raw else None
156
157 # Parse exposed playlists (URIs) â capped at MAX_INPUT_SOURCES.
158 playlists_raw = self.config.get_value(CONF_EXPOSED_PLAYLISTS) or []
159 if isinstance(playlists_raw, str):
160 playlists_raw = [x.strip() for x in playlists_raw.split(",") if x.strip()]
161 elif isinstance(playlists_raw, list):
162 playlists_raw = [str(x) for x in playlists_raw if x]
163 else:
164 playlists_raw = []
165 if len(playlists_raw) > MAX_INPUT_SOURCES:
166 self.logger.warning(
167 "Exposed playlists count (%d) exceeds cap %d; truncating",
168 len(playlists_raw),
169 MAX_INPUT_SOURCES,
170 )
171 playlists_raw = playlists_raw[:MAX_INPUT_SOURCES]
172 self._exposed_playlists: tuple[str, ...] = tuple(playlists_raw)
173
174 self.logger.info(
175 "Yandex Smart Home plugin init (mode=%s, name=%s)",
176 self._connection_type,
177 self._instance_name,
178 )
179
180 async def loaded_in_mass(self) -> None:
181 """
182 Call after the provider has been loaded.
183
184 Starts cloud WebSocket connection and state notifier.
185 """
186 self.logger.info("Yandex Smart Home plugin loaded")
187
188 if self._connection_type in (CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS):
189 await self._start_cloud_mode()
190 elif self._connection_type == CONNECTION_TYPE_DIRECT:
191 await self._start_direct_mode()
192 else:
193 self.logger.error("Unknown connection type: %s", self._connection_type)
194
195 async def unload(self, is_removed: bool = False) -> None:
196 """
197 Handle unload/close of the provider.
198
199 Called when provider is deregistered (e.g. MA exiting or config reloading).
200 is_removed will be set to True when the provider is removed from the configuration.
201 """
202 self.logger.info("Yandex Smart Home plugin unloading (removed=%s)", is_removed)
203
204 if self._state_notifier:
205 await self._state_notifier.stop()
206 self._state_notifier = None
207
208 if self._direct_handler:
209 self._direct_handler.unregister_routes()
210 self._direct_handler = None
211
212 if self._cloud_manager:
213 await self._cloud_manager.disconnect()
214 self._cloud_manager = None
215
216 if self._cloud_task:
217 cloud_task = self._cloud_task
218 self._cloud_task = None
219 if not cloud_task.done():
220 cloud_task.cancel()
221
222 async def _start_cloud_mode(self) -> None:
223 """Initialize and start cloud relay connection + state notifier."""
224 if not self._connection_token or not self._connection_token.get_secret():
225 self.logger.error(
226 "Cloud connection token not configured â "
227 "register an instance at yaha-cloud.ru and set the connection token"
228 )
229 return
230
231 # Validate Cloud Plus credentials before starting any tasks
232 if self._connection_type == CONNECTION_TYPE_CLOUD_PLUS:
233 if not self._skill_id or not self._skill_token or not self._skill_token.get_secret():
234 self.logger.error("Cloud Plus mode requires skill_id and skill_token")
235 return
236
237 # Validate cloud password (used for callback auth in basic cloud mode)
238 if self._connection_type == CONNECTION_TYPE_CLOUD and (
239 not self._cloud_token or not self._cloud_token.get_secret()
240 ):
241 self.logger.error(
242 "Cloud instance password not configured â "
243 "set the password from yaha-cloud.ru instance settings"
244 )
245 return
246
247 # Determine user_id once â used in both API responses and state callbacks
248 self._user_id = self._cloud_instance_id or self._instance_name
249
250 session = self.mass.http_session
251
252 # Cloud WebSocket manager
253 self._cloud_manager = CloudManager(
254 session=session,
255 connection_token=self._connection_token,
256 on_request=self._handle_cloud_request,
257 logger=self.logger,
258 )
259 self._cloud_task = self.mass.create_task(
260 self._cloud_manager.connect(),
261 task_id="yandex_smarthome_cloud",
262 )
263
264 # State notifier â different callback URL/auth for cloud_plus
265 if self._connection_type == CONNECTION_TYPE_CLOUD_PLUS:
266 assert self._skill_token is not None # validated above
267 callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state"
268 auth_header = {"Authorization": f"OAuth {self._skill_token.get_secret()}"}
269 else:
270 assert self._cloud_token is not None # validated above
271 callback_url = f"{CLOUD_CALLBACK_URL}/state"
272 auth_header = {"Authorization": f"Bearer {self._cloud_token.get_secret()}"}
273
274 self._state_notifier = StateNotifier(
275 mass=self.mass,
276 session=session,
277 user_id=self._user_id,
278 callback_url=callback_url,
279 auth_header=auth_header,
280 logger=self.logger,
281 exposed_ids=self._exposed_ids,
282 playlist_uris=self._exposed_playlists,
283 )
284 await self._state_notifier.start()
285
286 async def _start_direct_mode(self) -> None:
287 """
288 Initialize direct connection mode â HTTP endpoints + state notifier.
289
290 Two-stage: HTTP routes are registered as soon as ``direct_client_secret``
291 is available (auto-generated when the user opens the config form), so
292 Yandex's backend-validation step during ``request_deploy`` can reach
293 them before the skill is created. The state notifier (outgoing
294 callbacks to Yandex) only starts once ``skill_id``/``skill_token``
295 are populated by a successful auto-create â there is nothing to
296 report state to before that point.
297 """
298 if not self._direct_client_secret:
299 self.logger.error("Direct mode requires a client secret for OAuth account linking")
300 return
301
302 self._user_id = self._instance_name
303
304 def _on_token_created(token: str) -> None:
305 """Persist new access token generated during OAuth flow."""
306 self._direct_access_token = token
307 self._update_setup_data(CONF_DIRECT_ACCESS_TOKEN, token, immediate=True)
308
309 self._direct_handler = DirectConnectionHandler(
310 mass=self.mass,
311 user_id=self._user_id,
312 access_token=self._direct_access_token,
313 client_secret=self._direct_client_secret,
314 exposed_ids=self._exposed_ids,
315 logger=self.logger,
316 on_token_created=_on_token_created,
317 playlist_uris=self._exposed_playlists,
318 )
319 self._direct_handler.register_routes()
320
321 # State notifier needs skill_id + skill_token to push state callbacks
322 # to Yandex â these only exist after a successful auto-create AND the
323 # user pasting the OAuth token. Skip silently if either is missing;
324 # this is the normal "first run" / "skill created but token not yet
325 # pasted" state.
326 has_skill_id = bool(self._skill_id)
327 skill_token = self._skill_token
328 has_skill_token = skill_token is not None and bool(skill_token.get_secret())
329 if has_skill_id and has_skill_token and skill_token is not None:
330 session = self.mass.http_session
331 callback_url = f"{YANDEX_DIALOGS_CALLBACK_BASE}/{self._skill_id}/callback/state"
332 auth_header = {"Authorization": f"OAuth {skill_token.get_secret()}"}
333
334 self._state_notifier = StateNotifier(
335 mass=self.mass,
336 session=session,
337 user_id=self._user_id,
338 callback_url=callback_url,
339 auth_header=auth_header,
340 logger=self.logger,
341 exposed_ids=self._exposed_ids,
342 playlist_uris=self._exposed_playlists,
343 )
344 await self._state_notifier.start()
345 else:
346 missing = []
347 if not has_skill_id:
348 missing.append("Skill ID")
349 if not has_skill_token:
350 missing.append("Skill OAuth Token")
351 self.logger.info(
352 "Direct mode: HTTP routes registered, but state notifier is "
353 "idle (missing: %s). Open the plugin settings: 'Auto-create "
354 "Smart Home skill' fills the Skill ID for you, then open the "
355 "OAuth-token URL shown in the form, approve access, and paste "
356 "the resulting access_token into 'Skill OAuth Token'.",
357 " + ".join(missing),
358 )
359
360 self.logger.info("Direct connection mode started")
361
362 async def _handle_cloud_request(self, request: CloudRequest) -> dict[str, Any]:
363 """Route incoming cloud WS request to the appropriate handler."""
364 action = request.action
365 request_id = request.request_id
366 message = request.message or {}
367
368 # Normalize action path â relay may send with or without /v1.0 prefix
369 normalized = action.removeprefix("/v1.0")
370
371 self.logger.debug(
372 "Cloud request: action=%s, request_id=%s",
373 action,
374 request_id,
375 )
376
377 try:
378 if normalized == "/user/devices":
379 device_list = await handle_device_list(
380 self.mass,
381 self._user_id,
382 exposed_ids=self._exposed_ids,
383 playlist_uris=self._exposed_playlists,
384 )
385 return build_response(request_id, asdict(device_list))
386
387 if normalized == "/user/devices/query":
388 device_ids = [
389 device_id
390 for d in message.get("devices", [])
391 if isinstance(d, dict) and (device_id := d.get("id"))
392 ]
393 states = await handle_devices_query(
394 self.mass,
395 device_ids,
396 exposed_ids=self._exposed_ids,
397 playlist_uris=self._exposed_playlists,
398 )
399 return build_response(request_id, asdict(states))
400
401 if normalized == "/user/devices/action":
402 action_payload = parse_action_payload(message)
403 action_result = await handle_devices_action(
404 self.mass,
405 action_payload,
406 exposed_ids=self._exposed_ids,
407 playlist_uris=self._exposed_playlists,
408 )
409 return build_response(request_id, asdict(action_result))
410
411 if normalized == "/user/unlink":
412 unlink_result = await handle_user_unlink()
413 return build_response(request_id, unlink_result)
414
415 self.logger.warning("Unknown cloud action: %s", action)
416 return build_response(request_id, {})
417
418 except Exception:
419 self.logger.exception("Error handling cloud request: %s", action)
420 return build_response(request_id, {})
421
422 async def _list_player_options(self) -> list[ConfigValueOption]:
423 """Build the player-picker options list."""
424 options: list[ConfigValueOption] = []
425 try:
426 for player in self.mass.players.all_players():
427 state = player.state
428 options.append(
429 ConfigValueOption(title=state.name or state.player_id, value=state.player_id)
430 )
431 except Exception:
432 self.logger.debug("could not enumerate players")
433 return options
434