/
/
1"""Helpers for the WiiM/LinkPlay provider."""
2
3from __future__ import annotations
4
5import re
6from typing import TYPE_CHECKING
7
8from wiim.consts import MANUFACTURER_AUDIO_PRO, MANUFACTURER_WIIM
9
10from .constants import PLAYER_ID_PREFIX
11
12if TYPE_CHECKING:
13 from pywiim.models import DeviceInfo as PywiimDeviceInfo
14
15 from music_assistant.models.player import Player
16
17# Manufacturers handled by the official WiiM/Linkplay SDK. Everything else that
18# still speaks the LinkPlay API (e.g. Edifier) is driven by the generic backend.
19OFFICIAL_MANUFACTURERS = (MANUFACTURER_WIIM, MANUFACTURER_AUDIO_PRO)
20
21_HEX = re.compile(r"^[0-9a-fA-F]+$")
22
23
24def is_in_mixed_group(player: Player) -> bool:
25 """
26 Return whether a player is in an externally-created cross-backend (mixed) group.
27
28 A group is mixed when this player leads a group that includes a member on another
29 backend, or another registered player on a different backend currently lists this
30 player as a member. Such groups are read-only until cross-backend grouping is
31 supported, so grouping is withdrawn while it holds. The check reads only the players
32 already registered by the provider, so it needs no extra device requests.
33
34 :param player: The player to check, expected to carry a ``linkplay_backend`` marker.
35 """
36 own_backend = getattr(player, "linkplay_backend", None)
37 own_members = player._attr_group_members
38 if own_members and own_members[0] == player.player_id:
39 for member_id in own_members[1:]:
40 member = player.mass.players.get_player(member_id)
41 if member is not None and getattr(member, "linkplay_backend", None) != own_backend:
42 return True
43 for other in player.provider.players:
44 if other is player or getattr(other, "linkplay_backend", None) == own_backend:
45 continue
46 other_members = other._attr_group_members
47 if (
48 other_members
49 and other_members[0] == other.player_id
50 and player.player_id in other_members[1:]
51 ):
52 return True
53 return False
54
55
56def linkplay_group_compatible(
57 first: PywiimDeviceInfo | None, second: PywiimDeviceInfo | None
58) -> bool:
59 """
60 Return whether two generic LinkPlay devices can share a router-based multiroom group.
61
62 Grouping is only allowed between devices that both use modern router-based multiroom
63 and belong to the same, known WiiM multiroom (WMRM) major generation. Legacy Wi-Fi
64 Direct devices are rejected because MA does not move a follower onto the master's
65 private network, and a device whose generation cannot be determined is not grouped.
66
67 :param first: The cached device info of one device, if known.
68 :param second: The cached device info of the other device, if known.
69 """
70 if first is None or second is None:
71 return False
72 if getattr(first, "needs_wifi_direct_multiroom", False) or getattr(
73 second, "needs_wifi_direct_multiroom", False
74 ):
75 return False
76 first_major = _wmrm_major(first)
77 second_major = _wmrm_major(second)
78 return first_major is not None and first_major == second_major
79
80
81def is_official_manufacturer(manufacturer: str | None) -> bool:
82 """
83 Return whether a UPnP manufacturer belongs to the official WiiM/Audio Pro backend.
84
85 :param manufacturer: The manufacturer string from the device's UPnP description.
86 """
87 if not manufacturer:
88 return False
89 manufacturer = manufacturer.lower()
90 return any(official.lower() in manufacturer for official in OFFICIAL_MANUFACTURERS)
91
92
93def linkplay_slave_uuid_to_udn(slave_uuid: str) -> str | None:
94 """
95 Convert a LinkPlay slave-list UUID to its canonical UPnP UDN.
96
97 Accepts both forms a slave list can report: the 24-character HTTP UUID (from
98 which LinkPlay derives the UDN by appending the UUID's first 8 characters) and
99 an already-full 32-character UPnP UDN (plain, dashed, or ``uuid:``-prefixed).
100 Returns ``None`` when the input is not one of those hex forms.
101
102 :param slave_uuid: The UUID of a slave device as reported in the slave list.
103 """
104 if not slave_uuid:
105 return None
106 hex_str = slave_uuid.strip().removeprefix("uuid:").replace("-", "")
107 if not _HEX.match(hex_str):
108 return None
109 if len(hex_str) == 24:
110 full = hex_str + hex_str[:8]
111 elif len(hex_str) == 32:
112 full = hex_str
113 else:
114 return None
115 full = full.upper()
116 formatted = f"{full[0:8]}-{full[8:12]}-{full[12:16]}-{full[16:20]}-{full[20:32]}"
117 return f"uuid:{formatted}"
118
119
120def linkplay_slave_uuid_to_player_id(slave_uuid: str) -> str | None:
121 """
122 Convert a LinkPlay slave-list UUID to a Music Assistant player id.
123
124 :param slave_uuid: The UUID of a slave device as reported in the slave list.
125 """
126 if (udn := linkplay_slave_uuid_to_udn(slave_uuid)) is None:
127 return None
128 return f"{PLAYER_ID_PREFIX}{udn}"
129
130
131def _wmrm_major(device_info: PywiimDeviceInfo) -> int | None:
132 """Return the WiiM multiroom (WMRM) major generation, or None when unknown."""
133 version = getattr(device_info, "wmrm_version", None)
134 if not version:
135 return None
136 try:
137 return int(str(version).split(".", 1)[0])
138 except ValueError:
139 return None
140