/
/
/
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 collections.abc import Iterable
14
15 from pywiim.models import DeviceInfo as PywiimDeviceInfo
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 linkplay_group_compatible(
25 first: PywiimDeviceInfo | None, second: PywiimDeviceInfo | None
26) -> bool:
27 """
28 Return whether two generic LinkPlay devices can share a router-based multiroom group.
29
30 Grouping is only allowed between devices that both use modern router-based multiroom
31 and belong to the same, known WiiM multiroom (WMRM) major generation. Legacy Wi-Fi
32 Direct devices are rejected because MA does not move a follower onto the master's
33 private network, and a device whose generation cannot be determined is not grouped.
34
35 :param first: The cached device info of one device, if known.
36 :param second: The cached device info of the other device, if known.
37 """
38 if first is None or second is None:
39 return False
40 if getattr(first, "needs_wifi_direct_multiroom", False) or getattr(
41 second, "needs_wifi_direct_multiroom", False
42 ):
43 return False
44 first_major = _wmrm_major(first)
45 second_major = _wmrm_major(second)
46 return first_major is not None and first_major == second_major
47
48
49def is_official_manufacturer(manufacturer: str | None) -> bool:
50 """
51 Return whether a UPnP manufacturer belongs to the official WiiM/Audio Pro backend.
52
53 :param manufacturer: The manufacturer string from the device's UPnP description.
54 """
55 if not manufacturer:
56 return False
57 manufacturer = manufacturer.lower()
58 return any(official.lower() in manufacturer for official in OFFICIAL_MANUFACTURERS)
59
60
61def linkplay_slave_uuid_to_udn(slave_uuid: str) -> str | None:
62 """
63 Convert a LinkPlay slave-list UUID to its canonical UPnP UDN.
64
65 Accepts both forms a slave list can report: the 24-character HTTP UUID (from
66 which LinkPlay derives the UDN by appending the UUID's first 8 characters) and
67 an already-full 32-character UPnP UDN (plain, dashed, or ``uuid:``-prefixed).
68 Returns ``None`` when the input is not one of those hex forms.
69
70 :param slave_uuid: The UUID of a slave device as reported in the slave list.
71 """
72 if not slave_uuid:
73 return None
74 hex_str = slave_uuid.strip().removeprefix("uuid:").replace("-", "")
75 if not _HEX.match(hex_str):
76 return None
77 if len(hex_str) == 24:
78 full = hex_str + hex_str[:8]
79 elif len(hex_str) == 32:
80 full = hex_str
81 else:
82 return None
83 full = full.upper()
84 formatted = f"{full[0:8]}-{full[8:12]}-{full[12:16]}-{full[16:20]}-{full[20:32]}"
85 return f"uuid:{formatted}"
86
87
88def linkplay_slave_uuid_to_player_id(slave_uuid: str) -> str | None:
89 """
90 Convert a LinkPlay slave-list UUID to a Music Assistant player id.
91
92 :param slave_uuid: The UUID of a slave device as reported in the slave list.
93 """
94 if (udn := linkplay_slave_uuid_to_udn(slave_uuid)) is None:
95 return None
96 return f"{PLAYER_ID_PREFIX}{udn}"
97
98
99def match_slave_uuid_to_player_id(
100 slave_uuid: str | None, candidate_player_ids: Iterable[str]
101) -> str | None:
102 """
103 Resolve a slave-list UUID to one of the given registered player ids.
104
105 Both backends key their players on the UPnP UDN, so a slave reported in either the
106 24-char HTTP or full 32-hex form is matched against the candidate ids by their
107 normalized hex, spanning the official and generic backends.
108
109 :param slave_uuid: The UUID of a slave device as reported in the slave list.
110 :param candidate_player_ids: The player ids to match the slave against.
111 """
112 if not slave_uuid or (udn := linkplay_slave_uuid_to_udn(slave_uuid)) is None:
113 return None
114 target_hex = udn.removeprefix("uuid:").replace("-", "").upper()
115 for player_id in candidate_player_ids:
116 if not player_id.startswith(PLAYER_ID_PREFIX):
117 continue
118 candidate_hex = (
119 player_id[len(PLAYER_ID_PREFIX) :].removeprefix("uuid:").replace("-", "").upper()
120 )
121 if candidate_hex == target_hex:
122 return player_id
123 return None
124
125
126def _wmrm_major(device_info: PywiimDeviceInfo) -> int | None:
127 """Return the WiiM multiroom (WMRM) major generation, or None when unknown."""
128 version = getattr(device_info, "wmrm_version", None)
129 if not version:
130 return None
131 try:
132 return int(str(version).split(".", 1)[0])
133 except ValueError:
134 return None
135