Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
b9c7539
feat(yandex_smarthome): add yandex_smarthome provider v1.4.0
github-actions[bot] Apr 24, 2026
c1be547
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 24, 2026
f108d57
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 24, 2026
51d0920
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 24, 2026
996bb1c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 24, 2026
0d16cd0
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] Apr 24, 2026
6b9fa26
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
1941751
feat(yandex_smarthome): add yandex_smarthome provider v1.5.0
github-actions[bot] May 4, 2026
1dc381d
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy May 4, 2026
0326e29
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
0914446
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
c6a6f77
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
a3d2e0f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
01c3172
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
18c922f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 4, 2026
72d55cf
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
dfaab6b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
5628fe8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
687b293
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
e47472a
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy May 5, 2026
a830e7b
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
fc02259
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
dce8385
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
55135c9
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
d0555a8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
c507840
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
442f7d3
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
086c0a8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
468964f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
00574c9
Potential fix for pull request finding
trudenboy May 5, 2026
e08ca79
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
fd8b62f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
bcfcb11
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
4e017c8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
a098b55
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
304eebd
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
03ebec1
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
461c029
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
6ca5b01
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
734e60c
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
26591c8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
8f64f54
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 5, 2026
cf24e7f
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
1ad84a8
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
65266bd
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
2fddc2d
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
999dca5
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
e37cd36
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 6, 2026
85c66cc
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 7, 2026
0c19262
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy May 7, 2026
af42754
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy May 7, 2026
5937e76
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 7, 2026
214a246
Merge branch 'dev' into upstream/yandex_smarthome
trudenboy May 7, 2026
82db110
feat(yandex_smarthome): sync provider from ma-provider-yandex-smartho…
github-actions[bot] May 7, 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
33 changes: 31 additions & 2 deletions music_assistant/providers/yandex_smarthome/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,16 @@
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,
)
from .playlists import fetch_playlist_options
from .plugin import YandexSmartHomePlugin

if TYPE_CHECKING:
Expand Down Expand Up @@ -291,6 +294,12 @@ async def get_config_entries(
except Exception: # noqa: S110
pass

# Build playlist options from MA library (any music provider). Fail-soft: empty
# list if music controller isn't ready (e.g. provider load order at first run).
playlist_options: list[ConfigValueOption] = []
with contextlib.suppress(Exception):
playlist_options = await fetch_playlist_options(mass)
Comment thread
trudenboy marked this conversation as resolved.
Outdated

entries: list[ConfigEntry] = [
# Instance name
ConfigEntry(
Expand Down Expand Up @@ -387,7 +396,7 @@ async def get_config_entries(
# duplicate hidden round-trip entry here.

# -- Tail: player filter + hidden round-trip fields (all modes) --
entries.extend(_common_tail_entries(player_options, values))
entries.extend(_common_tail_entries(player_options, playlist_options, values))
return tuple(entries)


Expand Down Expand Up @@ -456,7 +465,9 @@ def _cloud_mode_entries(


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 [
Expand All @@ -473,6 +484,24 @@ 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=(
"Pick up to "
f"{MAX_INPUT_SOURCES} playlists from your MA library — they appear "
"as input_source modes on every exposed player. After saving, "
"open the device in the Yandex app and assign voice aliases "
'(e.g. "Rock" for mode «one») so Alice can pick playlists by name. '
"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,
Expand Down
5 changes: 5 additions & 0 deletions music_assistant/providers/yandex_smarthome/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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"
Expand Down Expand Up @@ -117,6 +118,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
# ---------------------------------------------------------------------------
Expand Down
163 changes: 130 additions & 33 deletions music_assistant/providers/yandex_smarthome/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,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,
Expand Down Expand Up @@ -90,6 +92,44 @@ def _build_source_modes(source_list: list[PlayerSource]) -> list[ModeValue]:
return [ModeValue(value=YANDEX_MODE_VALUES[i]) for i in range(min(len(source_list), 10))]

Comment thread
trudenboy marked this conversation as resolved.
Outdated

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:
"""Map active MA source name/id to a Yandex mode value."""
if not active_source or not source_list:
Expand All @@ -105,7 +145,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):
Expand Down Expand Up @@ -147,7 +187,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),
Expand Down Expand Up @@ -178,20 +222,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:
Expand All @@ -206,7 +260,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)
Expand Down Expand Up @@ -256,9 +314,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:
Expand All @@ -273,7 +333,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:
Expand All @@ -288,23 +354,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:
Expand Down Expand Up @@ -346,6 +439,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.

Expand Down Expand Up @@ -430,7 +525,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

Comment thread
trudenboy marked this conversation as resolved.
Expand Down
17 changes: 14 additions & 3 deletions music_assistant/providers/yandex_smarthome/direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ 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.

Expand All @@ -117,6 +118,7 @@ def __init__(
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]] = []
Expand Down Expand Up @@ -219,7 +221,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:
Expand Down Expand Up @@ -247,7 +252,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:
Expand All @@ -268,7 +276,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:
Expand Down
Loading
Loading