PyLoxone-Audio 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyloxone_audio/__init__.py +90 -0
- pyloxone_audio/announcement.py +101 -0
- pyloxone_audio/catalogue.py +1118 -0
- pyloxone_audio/clock.py +7 -0
- pyloxone_audio/metadata.py +98 -0
- pyloxone_audio/models.py +48 -0
- pyloxone_audio/native.py +1136 -0
- pyloxone_audio/py.typed +0 -0
- pyloxone_audio/radio.py +211 -0
- pyloxone_audio/selection.py +194 -0
- pyloxone_audio/sources.py +739 -0
- pyloxone_audio/spotify.py +88 -0
- pyloxone_audio-0.1.0.dist-info/METADATA +138 -0
- pyloxone_audio-0.1.0.dist-info/RECORD +18 -0
- pyloxone_audio-0.1.0.dist-info/WHEEL +5 -0
- pyloxone_audio-0.1.0.dist-info/licenses/LICENSE +201 -0
- pyloxone_audio-0.1.0.dist-info/licenses/NOTICE.md +15 -0
- pyloxone_audio-0.1.0.dist-info/top_level.txt +1 -0
pyloxone_audio/native.py
ADDED
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audioserver metadata/events and explicitly opt-in exact-player playback.
|
|
3
|
+
|
|
4
|
+
Event shapes follow the author-reported contract in PyLoxone issue #523.
|
|
5
|
+
The parser/receiver sends nothing. The optional connection owner performs only
|
|
6
|
+
the WebSocket handshake and receives events; it never authenticates or controls.
|
|
7
|
+
AudioServerCommands is a separate lazy connection used only for explicit actions.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from contextlib import suppress
|
|
18
|
+
from dataclasses import dataclass, replace
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from http import HTTPStatus
|
|
21
|
+
from inspect import signature
|
|
22
|
+
from ipaddress import IPv6Address, ip_address
|
|
23
|
+
from math import isfinite
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
from urllib.parse import quote
|
|
26
|
+
|
|
27
|
+
from Crypto.Cipher import AES, PKCS1_v1_5
|
|
28
|
+
from Crypto.PublicKey import RSA
|
|
29
|
+
from Crypto.Random import get_random_bytes
|
|
30
|
+
from Crypto.Util.Padding import pad
|
|
31
|
+
from websockets.asyncio.client import connect
|
|
32
|
+
from websockets.exceptions import WebSocketException
|
|
33
|
+
from websockets.typing import Subprotocol
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from collections.abc import AsyncIterable, Awaitable, Callable
|
|
38
|
+
|
|
39
|
+
_LOGGER = logging.getLogger(__name__)
|
|
40
|
+
_MAX_HOSTNAME_LENGTH = 253
|
|
41
|
+
_AIRPLAY_ANNOUNCEMENT = 7
|
|
42
|
+
# Event names adapted from SebastianEitzert's Apache-2.0 POC in #523,
|
|
43
|
+
# comment 6251138699. See tests/fixtures/audioserver/README.md for provenance.
|
|
44
|
+
# That historical attribution is retained; current fixture provenance lives at
|
|
45
|
+
# tests/library/fixtures/audioserver/README.md in this repository and source archive.
|
|
46
|
+
_EVENT_NAMES = {
|
|
47
|
+
1: "doorbell",
|
|
48
|
+
2: "alarm_clock",
|
|
49
|
+
3: "tts",
|
|
50
|
+
5: "custom_sound",
|
|
51
|
+
7: "announcement",
|
|
52
|
+
100: "alarm",
|
|
53
|
+
101: "fire_alarm",
|
|
54
|
+
}
|
|
55
|
+
# Adapted from the contributor's measured flapping sequence, not a safety signal.
|
|
56
|
+
_DEBOUNCED_EVENTS = frozenset({"alarm", "fire_alarm", "alarm_clock"})
|
|
57
|
+
_EVENT_END_DELAY = 1.5
|
|
58
|
+
_WAV_HEADER_BYTES = 44
|
|
59
|
+
_MAX_ANNOUNCEMENT_VOLUME = 100
|
|
60
|
+
_FAVOURITE_PAGE_SIZE = 32
|
|
61
|
+
_MAX_ROOM_FAVOURITES = 256
|
|
62
|
+
_MAX_RADIO_PATH = 2048
|
|
63
|
+
_MAX_RADIO_QUERY = 256
|
|
64
|
+
_MAX_RADIO_OFFSET = 1_000_000
|
|
65
|
+
_CONTROL_BOUNDARY = 32
|
|
66
|
+
_MAX_SEARCH_CATEGORIES = 32
|
|
67
|
+
# websockets 15 enables environment proxies by default; 14 has no proxy option.
|
|
68
|
+
# Keep the declared >=14,<16 range while connecting directly to the selected host.
|
|
69
|
+
_DIRECT_CONNECT: dict[str, Any] = {"proxy": None} if "proxy" in signature(connect).parameters else {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def audioserver_uri(host: str) -> str:
|
|
73
|
+
"""Build a fixed-port event origin from a bare hostname or IP address."""
|
|
74
|
+
message = "Audioserver host must be a hostname or IP address without a port or path"
|
|
75
|
+
if not isinstance(host, str) or not host or "%" in host:
|
|
76
|
+
raise ValueError(message)
|
|
77
|
+
bracketed = host.startswith("[") and host.endswith("]")
|
|
78
|
+
address = host[1:-1] if bracketed else host
|
|
79
|
+
try:
|
|
80
|
+
parsed = ip_address(address)
|
|
81
|
+
except ValueError:
|
|
82
|
+
labels = host.removesuffix(".").split(".")
|
|
83
|
+
if len(host) > _MAX_HOSTNAME_LENGTH or not all(
|
|
84
|
+
re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?", label) for label in labels
|
|
85
|
+
):
|
|
86
|
+
raise ValueError(message) from None
|
|
87
|
+
authority = host
|
|
88
|
+
else:
|
|
89
|
+
is_ipv6 = isinstance(parsed, IPv6Address)
|
|
90
|
+
if bracketed and not is_ipv6:
|
|
91
|
+
raise ValueError(message)
|
|
92
|
+
authority = f"[{address}]" if is_ipv6 else address
|
|
93
|
+
return f"ws://{authority}:7091/"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class AudioZoneMetadata:
|
|
98
|
+
"""Metadata for one exact Audioserver player, not a room display name."""
|
|
99
|
+
|
|
100
|
+
player_id: int
|
|
101
|
+
title: str | None = None
|
|
102
|
+
artist: str | None = None
|
|
103
|
+
album: str | None = None
|
|
104
|
+
cover_url: str | None = None
|
|
105
|
+
audio_path: str | None = None
|
|
106
|
+
source: str | None = None
|
|
107
|
+
event: str | None = None
|
|
108
|
+
event_active: bool | None = None
|
|
109
|
+
position: float | None = None
|
|
110
|
+
duration: float | None = None
|
|
111
|
+
position_updated_at: datetime | None = None
|
|
112
|
+
playback_mode: str | None = None
|
|
113
|
+
received_at: datetime | None = None
|
|
114
|
+
playback_restrictions: int | None = None
|
|
115
|
+
event_idle: bool = False
|
|
116
|
+
native_mode: str | None = None
|
|
117
|
+
parent_id: str | None = None
|
|
118
|
+
source_name: str | None = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _no_event(code: object) -> bool:
|
|
122
|
+
"""Only absent/null or an integer zero explicitly denotes no event."""
|
|
123
|
+
return code is None or (type(code) is int and code == 0)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _event_code(row: dict[str, Any]) -> object:
|
|
127
|
+
"""Use native events first, with the narrowly reported AirPlay type7 variant."""
|
|
128
|
+
code = row.get("eventype")
|
|
129
|
+
# #523 comment 5984328342 reports AirPlay announcements with type, not
|
|
130
|
+
# eventype. Other sources also use type, so it is NOT a generic event code.
|
|
131
|
+
if (
|
|
132
|
+
_no_event(code)
|
|
133
|
+
and row.get("audiopath") == "airplay"
|
|
134
|
+
and type(row.get("type")) is int
|
|
135
|
+
and row["type"] == _AIRPLAY_ANNOUNCEMENT
|
|
136
|
+
):
|
|
137
|
+
return _AIRPLAY_ANNOUNCEMENT
|
|
138
|
+
return code
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _event(row: dict[str, Any]) -> tuple[str | None, bool | None]:
|
|
142
|
+
"""Qualify events by actual per-zone playback, not broadcast data alone."""
|
|
143
|
+
code = _event_code(row)
|
|
144
|
+
if _no_event(code):
|
|
145
|
+
return None, False
|
|
146
|
+
if type(code) is not int or code < 0:
|
|
147
|
+
return None, None
|
|
148
|
+
mode = row.get("mode")
|
|
149
|
+
if mode == "play":
|
|
150
|
+
return _EVENT_NAMES.get(code, f"event_{code}"), True
|
|
151
|
+
return None, False if mode in ("pause", "stop") else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _source(row: dict[str, Any]) -> str | None:
|
|
155
|
+
"""Identify reported sources without treating audio type zero as Spotify."""
|
|
156
|
+
path = _text(row.get("audiopath")) or ""
|
|
157
|
+
if path == "airplay":
|
|
158
|
+
return "AirPlay"
|
|
159
|
+
if path.startswith("library:local:track:") and path.removeprefix("library:local:track:").strip():
|
|
160
|
+
return "Local Library"
|
|
161
|
+
for prefix, label in (
|
|
162
|
+
("spotify:", "Spotify"),
|
|
163
|
+
("tunein:", "TuneIn"),
|
|
164
|
+
("loxoneradio:", "Loxone Radio"),
|
|
165
|
+
("linein:", "Line-In"),
|
|
166
|
+
):
|
|
167
|
+
if path.startswith(prefix):
|
|
168
|
+
return label
|
|
169
|
+
if not path and (_text(row.get("sourceName")) or "").casefold() == "spotify":
|
|
170
|
+
return "Spotify"
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _text(value: object) -> str | None:
|
|
175
|
+
"""Retain original non-blank strings; reject malformed optional fields."""
|
|
176
|
+
return value if isinstance(value, str) and value.strip() else None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def audio_player_id(value: object) -> int | None:
|
|
180
|
+
"""Accept positive numeric IDs without coercing bools or fractional values."""
|
|
181
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
182
|
+
return None
|
|
183
|
+
if isinstance(value, str) and not (value.isascii() and value.isdecimal()):
|
|
184
|
+
return None
|
|
185
|
+
try:
|
|
186
|
+
result = int(value)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return None
|
|
189
|
+
return result if result > 0 else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _seconds(value: object) -> float | None:
|
|
193
|
+
"""Keep finite non-negative JSON numbers without bool/string coercion."""
|
|
194
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
result = float(value)
|
|
198
|
+
except OverflowError:
|
|
199
|
+
return None
|
|
200
|
+
return result if isfinite(result) and result >= 0 else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def audio_control_command(player_id: object, action: str) -> tuple[str, str]:
|
|
204
|
+
"""Build only reviewed player-scoped commands; continuity needs a live test."""
|
|
205
|
+
target = audio_player_id(player_id)
|
|
206
|
+
if target is None or action not in ("play", "pause"):
|
|
207
|
+
message = "Audio playback requires an exact player ID and play/pause action"
|
|
208
|
+
raise ValueError(message)
|
|
209
|
+
return f"audio/{target}/{action}", f"{action}_result"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_audio_events(payload: object) -> tuple[AudioZoneMetadata, ...]:
|
|
213
|
+
"""Extract valid independent zone snapshots, ignoring non-event replies."""
|
|
214
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("audio_event"), list):
|
|
215
|
+
return ()
|
|
216
|
+
updates = []
|
|
217
|
+
for row in payload["audio_event"]:
|
|
218
|
+
if not isinstance(row, dict) or (player_id := audio_player_id(row.get("playerid"))) is None:
|
|
219
|
+
continue
|
|
220
|
+
event, event_active = _event(row)
|
|
221
|
+
is_event = not _no_event(_event_code(row))
|
|
222
|
+
# TTS is broadcast to stopped non-target zones too. Neither its title
|
|
223
|
+
# nor its artwork is evidence of that zone playing the interruption.
|
|
224
|
+
display = row if not is_event or event_active is True else {}
|
|
225
|
+
path = _text(row.get("audiopath")) or ""
|
|
226
|
+
timed_track = (
|
|
227
|
+
not is_event
|
|
228
|
+
and row.get("mode") in ("play", "pause", "stop")
|
|
229
|
+
and any(
|
|
230
|
+
path.startswith(prefix) and path.removeprefix(prefix).strip()
|
|
231
|
+
for prefix in ("spotify:track:", "library:local:track:")
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
# Only time/duration seconds have an established contract. Do not
|
|
235
|
+
# mix position_ms/progress_ms/duration_ms into a guessed precedence.
|
|
236
|
+
timing = row if timed_track else {}
|
|
237
|
+
duration = _seconds(timing.get("duration")) or None
|
|
238
|
+
position = _seconds(timing.get("time"))
|
|
239
|
+
if duration is None or (position is not None and position > duration):
|
|
240
|
+
position = None
|
|
241
|
+
received_at = datetime.now(UTC)
|
|
242
|
+
updates.append(
|
|
243
|
+
AudioZoneMetadata(
|
|
244
|
+
player_id=player_id,
|
|
245
|
+
title=_text(display.get("title")),
|
|
246
|
+
artist=_text(display.get("artist")),
|
|
247
|
+
album=_text(display.get("album")),
|
|
248
|
+
cover_url=_text(display.get("coverurl")),
|
|
249
|
+
audio_path=_text(display.get("audiopath")),
|
|
250
|
+
source=None if is_event else _source(row),
|
|
251
|
+
parent_id=_text(row["parent"].get("id"))
|
|
252
|
+
if not is_event and isinstance(row.get("parent"), dict)
|
|
253
|
+
else None,
|
|
254
|
+
source_name=_text(row.get("sourceName")) if not is_event else None,
|
|
255
|
+
event=event,
|
|
256
|
+
event_active=event_active,
|
|
257
|
+
event_idle=(
|
|
258
|
+
not is_event
|
|
259
|
+
and row.get("mode") in ("play", "pause", "stop")
|
|
260
|
+
and (
|
|
261
|
+
(type(row.get("eventype")) is int and row["eventype"] == 0)
|
|
262
|
+
# Normal native source frames can omit the event field.
|
|
263
|
+
# Require positive content evidence, never a bare ID/mode.
|
|
264
|
+
or (
|
|
265
|
+
_source(row) in {"Spotify", "Local Library", "TuneIn", "Loxone Radio", "Line-In"}
|
|
266
|
+
and bool(path.partition(":")[2].strip())
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
),
|
|
270
|
+
position=position,
|
|
271
|
+
duration=duration,
|
|
272
|
+
position_updated_at=received_at if position is not None else None,
|
|
273
|
+
native_mode=row["mode"] if row.get("mode") in ("play", "pause", "stop") else None,
|
|
274
|
+
playback_mode=row["mode"] if timed_track else None,
|
|
275
|
+
received_at=received_at,
|
|
276
|
+
playback_restrictions=(row["pbr"] if type(row.get("pbr")) is int and row["pbr"] >= 0 else None),
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
return tuple(updates)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class AudioCommandError(Exception):
|
|
283
|
+
"""A player command was not positively acknowledged; never implies retry."""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class AudioServerCommands:
|
|
287
|
+
"""
|
|
288
|
+
Explicit opt-in commands, separate from the receive-only listener.
|
|
289
|
+
|
|
290
|
+
Reuse the integration's *current* Miniserver token, not its password or a
|
|
291
|
+
separately stored credential. Each explicit action gets one short-lived,
|
|
292
|
+
authenticated connection. Do not retry writes or fall back to room play:
|
|
293
|
+
room play can replace the selected source with the default favourite.
|
|
294
|
+
Wire contract follows the installed vendor client and the existing native
|
|
295
|
+
SDK's RSA-PKCS1v15/AES-CBC authentication, using existing dependencies only.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
def __init__(self, host: str, credentials: Callable[[], tuple[str, str]], *, timeout: float = 15.0) -> None:
|
|
299
|
+
"""Create a lazy, serial command client without connecting or sending."""
|
|
300
|
+
self._uri = audioserver_uri(host)
|
|
301
|
+
self._credentials = credentials
|
|
302
|
+
self._timeout = timeout
|
|
303
|
+
self._lock = asyncio.Lock()
|
|
304
|
+
self._closed = False
|
|
305
|
+
self._socket: Any = None
|
|
306
|
+
self.announcement_fences: dict[int, Callable[[], None]] = {}
|
|
307
|
+
self.source_fences: dict[int, Callable[[], None]] = {}
|
|
308
|
+
self.source_observations: dict[int, asyncio.Future[None]] = {}
|
|
309
|
+
|
|
310
|
+
async def async_close(self) -> None:
|
|
311
|
+
"""Reject queued/future requests and close an in-flight owned socket."""
|
|
312
|
+
self._closed = True
|
|
313
|
+
for release in (*tuple(self.announcement_fences.values()), *tuple(self.source_fences.values())):
|
|
314
|
+
release()
|
|
315
|
+
if self._socket is not None:
|
|
316
|
+
await self._socket.close()
|
|
317
|
+
|
|
318
|
+
async def async_execute(
|
|
319
|
+
self, player_id: object, action: str, *, is_current: Callable[[], bool] | None = None,
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Send one exact-player play/pause and require a literal true reply."""
|
|
322
|
+
command, reply_key = audio_control_command(player_id, action)
|
|
323
|
+
if await self._async_request(command, reply_key, is_current=is_current) is not True:
|
|
324
|
+
message = "Audio playback command was not acknowledged"
|
|
325
|
+
raise AudioCommandError(message)
|
|
326
|
+
|
|
327
|
+
async def async_upload_announcement(self, session: Any, filename: str, data: bytes) -> None:
|
|
328
|
+
"""Upload bounded, owned WAV bytes through the native temporary spool."""
|
|
329
|
+
if (
|
|
330
|
+
self._closed
|
|
331
|
+
or not isinstance(filename, str)
|
|
332
|
+
or not re.fullmatch(r"pyloxone-[a-f0-9]{16,32}\.wav", filename)
|
|
333
|
+
or not isinstance(data, bytes)
|
|
334
|
+
or not _WAV_HEADER_BYTES <= len(data) <= 8 * 1024 * 1024
|
|
335
|
+
or data[:4] != b"RIFF"
|
|
336
|
+
or data[8:12] != b"WAVE"
|
|
337
|
+
):
|
|
338
|
+
message = "Announcement upload requires an available client and a bounded owned WAV"
|
|
339
|
+
raise ValueError(message)
|
|
340
|
+
url = self._uri.replace("ws://", "http://", 1) + f"audio/cfg/upload/audioupload/add/{filename}"
|
|
341
|
+
async with asyncio.timeout(self._timeout):
|
|
342
|
+
async with session.put(
|
|
343
|
+
url,
|
|
344
|
+
data=data,
|
|
345
|
+
headers={"Content-Type": "audio/wav", "Accept-Encoding": "identity"},
|
|
346
|
+
allow_redirects=False,
|
|
347
|
+
auto_decompress=False,
|
|
348
|
+
) as reply:
|
|
349
|
+
if (
|
|
350
|
+
not HTTPStatus.OK <= reply.status < HTTPStatus.MULTIPLE_CHOICES
|
|
351
|
+
or reply.headers.get("Content-Encoding", "identity").lower() != "identity"
|
|
352
|
+
):
|
|
353
|
+
message = "Announcement upload failed; no playback was sent"
|
|
354
|
+
raise AudioCommandError(message)
|
|
355
|
+
await reply.content.read(1024)
|
|
356
|
+
|
|
357
|
+
async def async_announce(
|
|
358
|
+
self,
|
|
359
|
+
player_id: object,
|
|
360
|
+
filename: str,
|
|
361
|
+
volume: object,
|
|
362
|
+
*,
|
|
363
|
+
is_current: Callable[[], bool] | None = None,
|
|
364
|
+
on_send: Callable[[], None] | None = None,
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Play one uploaded WAV as a native temporary announcement, once."""
|
|
367
|
+
target = audio_player_id(player_id)
|
|
368
|
+
if (
|
|
369
|
+
target is None
|
|
370
|
+
or not isinstance(filename, str)
|
|
371
|
+
or not re.fullmatch(r"pyloxone-[a-f0-9]{16,32}\.wav", filename)
|
|
372
|
+
):
|
|
373
|
+
message = "Announcement requires an exact player and an owned WAV filename"
|
|
374
|
+
raise ValueError(message)
|
|
375
|
+
if type(volume) is not int or not 0 <= volume <= _MAX_ANNOUNCEMENT_VOLUME:
|
|
376
|
+
message = "Announcement volume must be an integer from 0 to 100"
|
|
377
|
+
raise ValueError(message)
|
|
378
|
+
command = f"audio/grouped/playuploadedfile/{filename}/{target}#{volume}"
|
|
379
|
+
if (
|
|
380
|
+
await self._async_request(command, "playuploadedfile_result", is_current=is_current, on_send=on_send)
|
|
381
|
+
is not True
|
|
382
|
+
):
|
|
383
|
+
message = "Audio announcement was not acknowledged; no replay was sent"
|
|
384
|
+
raise AudioCommandError(message)
|
|
385
|
+
|
|
386
|
+
async def async_room_favourites(self, player_id: object) -> Any:
|
|
387
|
+
"""Read up to 256 room favourites in bounded native pages, never truncate."""
|
|
388
|
+
target = audio_player_id(player_id)
|
|
389
|
+
if target is None:
|
|
390
|
+
message = "Configured favourites require an exact player"
|
|
391
|
+
raise ValueError(message)
|
|
392
|
+
async with asyncio.timeout(30):
|
|
393
|
+
payload = await self._async_request(f"audio/cfg/getroomfavs/{target}/0/32", "getroomfavs_result")
|
|
394
|
+
# Preserve the single-page discovery contract and validate combined
|
|
395
|
+
# payloads with the same exact-room parser used by source selection.
|
|
396
|
+
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
|
|
397
|
+
return payload
|
|
398
|
+
first = payload[0]
|
|
399
|
+
total = first.get("totalitems")
|
|
400
|
+
if type(total) is not int or total <= _FAVOURITE_PAGE_SIZE:
|
|
401
|
+
return payload
|
|
402
|
+
message = "Configured favourites changed, are incomplete or exceed the 256-item bound"
|
|
403
|
+
if total > _MAX_ROOM_FAVOURITES:
|
|
404
|
+
raise AudioCommandError(message)
|
|
405
|
+
items = []
|
|
406
|
+
observed_pages = []
|
|
407
|
+
seen = set()
|
|
408
|
+
page = first
|
|
409
|
+
while True:
|
|
410
|
+
rows = page.get("items")
|
|
411
|
+
if (
|
|
412
|
+
type(page.get("id")) is not int
|
|
413
|
+
or page["id"] != target
|
|
414
|
+
or type(page.get("start")) is not int
|
|
415
|
+
or page["start"] != len(items)
|
|
416
|
+
or type(page.get("totalitems")) is not int
|
|
417
|
+
or page["totalitems"] != total
|
|
418
|
+
or not isinstance(rows, list)
|
|
419
|
+
or not 0 < len(rows) <= _FAVOURITE_PAGE_SIZE
|
|
420
|
+
or len(items) + len(rows) > total
|
|
421
|
+
):
|
|
422
|
+
raise AudioCommandError(message)
|
|
423
|
+
for row in rows:
|
|
424
|
+
if (
|
|
425
|
+
not isinstance(row, dict)
|
|
426
|
+
or type(row.get("id")) is not int
|
|
427
|
+
or row["id"] <= 0
|
|
428
|
+
or row["id"] in seen
|
|
429
|
+
):
|
|
430
|
+
raise AudioCommandError(message)
|
|
431
|
+
seen.add(row["id"])
|
|
432
|
+
observed_pages.append(page)
|
|
433
|
+
items.extend(rows)
|
|
434
|
+
if len(items) == total:
|
|
435
|
+
# No native snapshot revision is available in this schema.
|
|
436
|
+
# Require two matching bounded reads of every large-list
|
|
437
|
+
# page, rather than accepting a mixed first pass. This is
|
|
438
|
+
# optimistic validation, not an atomic server snapshot.
|
|
439
|
+
for previous in observed_pages:
|
|
440
|
+
check = await self._async_request(
|
|
441
|
+
f"audio/cfg/getroomfavs/{target}/{previous['start']}/32", "getroomfavs_result"
|
|
442
|
+
)
|
|
443
|
+
if check != [previous]:
|
|
444
|
+
raise AudioCommandError(message)
|
|
445
|
+
return [{**first, "items": items}]
|
|
446
|
+
payload = await self._async_request(
|
|
447
|
+
f"audio/cfg/getroomfavs/{target}/{len(items)}/32", "getroomfavs_result"
|
|
448
|
+
)
|
|
449
|
+
if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict):
|
|
450
|
+
raise AudioCommandError(message)
|
|
451
|
+
page = payload[0]
|
|
452
|
+
|
|
453
|
+
async def async_source_inputs(self) -> Any:
|
|
454
|
+
"""Discover exact available Line-In identities, without selecting one."""
|
|
455
|
+
return await self._async_request("audio/cfg/getinputs", "getinputs_result")
|
|
456
|
+
|
|
457
|
+
async def async_player_status(
|
|
458
|
+
self, player_id: object, *, is_current: Callable[[], bool],
|
|
459
|
+
) -> AudioZoneMetadata:
|
|
460
|
+
"""Read one exact native player; classify with the existing event rules."""
|
|
461
|
+
target = audio_player_id(player_id)
|
|
462
|
+
if target is None:
|
|
463
|
+
message = "Player status requires an exact player"
|
|
464
|
+
raise ValueError(message)
|
|
465
|
+
payload = await self._async_request(f"audio/{target}/status", "status_result", is_current=is_current)
|
|
466
|
+
rows = [payload] if isinstance(payload, dict) else payload
|
|
467
|
+
if (
|
|
468
|
+
not isinstance(rows, list) or len(rows) != 1
|
|
469
|
+
or not isinstance(rows[0], dict)
|
|
470
|
+
or audio_player_id(rows[0].get("playerid")) != target
|
|
471
|
+
):
|
|
472
|
+
message = "Native player status is missing or ambiguous"
|
|
473
|
+
raise AudioCommandError(message)
|
|
474
|
+
return parse_audio_events({"audio_event": rows})[0]
|
|
475
|
+
|
|
476
|
+
async def async_recent_sources(self, player_id: object) -> Any:
|
|
477
|
+
"""Read native room-local recent selections; never create guessed history."""
|
|
478
|
+
target = audio_player_id(player_id)
|
|
479
|
+
if target is None:
|
|
480
|
+
message = "Recent sources require an exact player"
|
|
481
|
+
raise ValueError(message)
|
|
482
|
+
return await self._async_request(f"audio/{target}/recent", "recent_result")
|
|
483
|
+
|
|
484
|
+
async def async_search_capabilities(self) -> Any:
|
|
485
|
+
"""Read supported native indexed search providers/categories."""
|
|
486
|
+
return await self._async_request("audio/cfg/globalsearch/describe", "globalsearch_result")
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _catalogue_identifier(value: object, limit: int = 2048) -> str:
|
|
490
|
+
"""Reject malformed IDs; encode them only when building native paths."""
|
|
491
|
+
if (
|
|
492
|
+
not isinstance(value, str)
|
|
493
|
+
or not value.strip()
|
|
494
|
+
or len(value) > limit
|
|
495
|
+
or any(ord(c) < _CONTROL_BOUNDARY for c in value)
|
|
496
|
+
):
|
|
497
|
+
msg = "An exact bounded catalogue identifier is required"
|
|
498
|
+
raise ValueError(msg)
|
|
499
|
+
return value
|
|
500
|
+
|
|
501
|
+
async def async_media_folder(
|
|
502
|
+
self, service: str, account: str, folder: str, offset: int = 0
|
|
503
|
+
) -> Any:
|
|
504
|
+
"""Read one native indexed-library or account-specific service page."""
|
|
505
|
+
folder = self._catalogue_identifier(folder)
|
|
506
|
+
if type(offset) is not int or not 0 <= offset <= _MAX_RADIO_OFFSET:
|
|
507
|
+
msg = "A bounded catalogue offset is required"
|
|
508
|
+
raise ValueError(msg)
|
|
509
|
+
if service == "library" and account == "nouser":
|
|
510
|
+
command = f"audio/cfg/getmediafolder/{quote(folder, safe='')}/{offset}/50"
|
|
511
|
+
key = "getmediafolder_result"
|
|
512
|
+
elif (
|
|
513
|
+
service == "spotify" and isinstance(account, str) and re.fullmatch(r"[A-Za-z0-9_.@+-]{1,128}", account)
|
|
514
|
+
) or (service == "loxoneradio" and account == "noUser"):
|
|
515
|
+
command = f"audio/cfg/getservicefolder/{service}/{account}/{quote(folder, safe='')}/{offset}/50"
|
|
516
|
+
key = "getservicefolder_result"
|
|
517
|
+
else:
|
|
518
|
+
msg = "A discovered supported service account is required"
|
|
519
|
+
raise ValueError(msg)
|
|
520
|
+
return await self._async_request(command, key)
|
|
521
|
+
|
|
522
|
+
async def async_play_catalogue( # noqa: PLR0913 - exact native playback route
|
|
523
|
+
self,
|
|
524
|
+
player_id: object,
|
|
525
|
+
service: str,
|
|
526
|
+
account: str,
|
|
527
|
+
item_id: str,
|
|
528
|
+
*,
|
|
529
|
+
parent_path: str | None = None,
|
|
530
|
+
parent_index: int | None = None,
|
|
531
|
+
is_current: Callable[[], bool] | None = None,
|
|
532
|
+
on_send: Callable[[], None] | None = None,
|
|
533
|
+
) -> None:
|
|
534
|
+
"""Select one exact discovered source item; never an empty session import."""
|
|
535
|
+
target = audio_player_id(player_id)
|
|
536
|
+
item = quote(self._catalogue_identifier(item_id), safe="")
|
|
537
|
+
if target is None:
|
|
538
|
+
msg = "An exact player is required"
|
|
539
|
+
raise ValueError(msg)
|
|
540
|
+
if service in ("linein", "library", "playlist") and account == "nouser":
|
|
541
|
+
route = f"linein/{item}" if service == "linein" else f"{service}/play/{item}"
|
|
542
|
+
elif service == "spotify" and isinstance(account, str) and re.fullmatch(r"[A-Za-z0-9_.@+-]{1,128}", account):
|
|
543
|
+
route = f"serviceplay/spotify/{account}/{item}"
|
|
544
|
+
else:
|
|
545
|
+
msg = "A discovered supported source is required"
|
|
546
|
+
raise ValueError(msg)
|
|
547
|
+
if parent_path is not None or parent_index is not None:
|
|
548
|
+
if service not in ("library", "playlist", "spotify") or type(parent_index) is not int or parent_index < 0:
|
|
549
|
+
raise ValueError("Parent context requires an exact supported source cursor")
|
|
550
|
+
parent = quote(self._catalogue_identifier(parent_path), safe="")
|
|
551
|
+
route += f"/parentpath/{parent}/{parent_index}"
|
|
552
|
+
await self._async_request(f"audio/{target}/{route}", None, is_current=is_current, on_send=on_send)
|
|
553
|
+
|
|
554
|
+
async def async_global_search(
|
|
555
|
+
self, query: str, scopes: dict[str, tuple[str, ...]], *, spotify_account: str | None = None
|
|
556
|
+
) -> Any:
|
|
557
|
+
"""Collect every requested provider for one correlated native ticket."""
|
|
558
|
+
query = self._catalogue_identifier(query, _MAX_RADIO_QUERY)
|
|
559
|
+
allowed = {
|
|
560
|
+
"local": {"track", "album", "artist", "playlist", "folder"},
|
|
561
|
+
"spotify": {"track", "album", "artist", "playlist", "episode", "show"},
|
|
562
|
+
"tunein": {"station", "custom"},
|
|
563
|
+
}
|
|
564
|
+
if not isinstance(scopes, dict) or not scopes or set(scopes) - allowed.keys():
|
|
565
|
+
msg = "Search requires supported discovered scopes"
|
|
566
|
+
raise ValueError(msg)
|
|
567
|
+
parts = []
|
|
568
|
+
for scope, categories in scopes.items():
|
|
569
|
+
if (
|
|
570
|
+
not isinstance(categories, tuple)
|
|
571
|
+
or not categories
|
|
572
|
+
or len(categories) > _MAX_SEARCH_CATEGORIES
|
|
573
|
+
or any(c not in allowed[scope] for c in categories)
|
|
574
|
+
or len(set(categories)) != len(categories)
|
|
575
|
+
):
|
|
576
|
+
msg = "Search categories must be supported and bounded"
|
|
577
|
+
raise ValueError(msg)
|
|
578
|
+
provider = scope
|
|
579
|
+
if scope == "spotify":
|
|
580
|
+
if not isinstance(spotify_account, str) or not re.fullmatch(
|
|
581
|
+
r"[A-Za-z0-9_.@+-]{1,128}", spotify_account
|
|
582
|
+
):
|
|
583
|
+
msg = "Spotify search requires an exact linked account"
|
|
584
|
+
raise ValueError(msg)
|
|
585
|
+
provider += "@" + spotify_account
|
|
586
|
+
parts.append(provider + ":" + ",".join(c + "#50" for c in categories))
|
|
587
|
+
return await self._async_request(
|
|
588
|
+
"audio/cfg/globalsearch/" + "|".join(parts) + "/" + quote(query, safe=""),
|
|
589
|
+
"globalsearch_result",
|
|
590
|
+
search_scopes=tuple(scopes),
|
|
591
|
+
spotify_account=spotify_account,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
@staticmethod
|
|
595
|
+
async def _global_search_reply(socket: Any, scopes: tuple[str, ...], account: str | None) -> Any:
|
|
596
|
+
"""Do not mistake the ticket, a wrong account or partial scopes for results."""
|
|
597
|
+
ticket = None
|
|
598
|
+
results = {}
|
|
599
|
+
while len(results) < len(scopes):
|
|
600
|
+
await asyncio.sleep(0)
|
|
601
|
+
frame = await socket.recv()
|
|
602
|
+
if not isinstance(frame, str):
|
|
603
|
+
continue
|
|
604
|
+
try:
|
|
605
|
+
reply = json.loads(frame)
|
|
606
|
+
except (TypeError, ValueError): # fmt: skip
|
|
607
|
+
continue
|
|
608
|
+
if not isinstance(reply, dict) or not isinstance(reply.get("globalsearch_result"), dict):
|
|
609
|
+
continue
|
|
610
|
+
result = reply["globalsearch_result"]
|
|
611
|
+
if ticket is None:
|
|
612
|
+
if reply.get("error") or result.get("error"):
|
|
613
|
+
msg = "Native search rejected the query"
|
|
614
|
+
raise AudioCommandError(msg)
|
|
615
|
+
unique = result.get("unique")
|
|
616
|
+
if isinstance(unique, str) and 0 < len(unique) <= _MAX_RADIO_QUERY:
|
|
617
|
+
ticket = unique
|
|
618
|
+
continue
|
|
619
|
+
scope = reply.get("type")
|
|
620
|
+
if reply.get("unique") != ticket or scope not in scopes or scope in results:
|
|
621
|
+
continue
|
|
622
|
+
content = result.get("result", result)
|
|
623
|
+
if not isinstance(content, dict):
|
|
624
|
+
msg = "Native search returned malformed results"
|
|
625
|
+
raise AudioCommandError(msg)
|
|
626
|
+
if scope == "spotify" and "user" in content and content["user"] != account:
|
|
627
|
+
continue
|
|
628
|
+
if reply.get("error") or result.get("error"):
|
|
629
|
+
msg = "A native search provider failed; no empty success was returned"
|
|
630
|
+
raise AudioCommandError(msg)
|
|
631
|
+
if scope == "spotify" and content.get("user") != account:
|
|
632
|
+
continue
|
|
633
|
+
results[scope] = content
|
|
634
|
+
return results
|
|
635
|
+
|
|
636
|
+
async def async_radio_services(self) -> Any:
|
|
637
|
+
"""Discover the native radio catalogue roots, without playing content."""
|
|
638
|
+
return await self._async_request("audio/cfg/getradios", "getradios_result")
|
|
639
|
+
|
|
640
|
+
async def async_radio_folder(self, service: str, folder: str, offset: int = 0) -> Any:
|
|
641
|
+
"""Read one 50-item provider page using literal, encoded identifiers."""
|
|
642
|
+
if (
|
|
643
|
+
not isinstance(service, str)
|
|
644
|
+
or not re.fullmatch(r"[A-Za-z0-9_-]{1,32}", service)
|
|
645
|
+
or not isinstance(folder, str)
|
|
646
|
+
or not folder.strip()
|
|
647
|
+
or len(folder) > _MAX_RADIO_PATH
|
|
648
|
+
or any(ord(c) < _CONTROL_BOUNDARY for c in folder)
|
|
649
|
+
or type(offset) is not int
|
|
650
|
+
or not 0 <= offset <= _MAX_RADIO_OFFSET
|
|
651
|
+
):
|
|
652
|
+
message = "Radio browsing requires an exact provider, folder and bounded offset"
|
|
653
|
+
raise ValueError(message)
|
|
654
|
+
return await self._async_request(
|
|
655
|
+
f"audio/cfg/getservicefolder/{service}/{service}/{quote(folder, safe='')}/{offset}/50",
|
|
656
|
+
"getservicefolder_result",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
async def async_radio_search(self, query: str) -> Any:
|
|
660
|
+
"""Read radio-only global-search results, not the initial ticket ACK."""
|
|
661
|
+
if (
|
|
662
|
+
not isinstance(query, str)
|
|
663
|
+
or not query.strip()
|
|
664
|
+
or len(query) > _MAX_RADIO_QUERY
|
|
665
|
+
or any(ord(c) < _CONTROL_BOUNDARY for c in query)
|
|
666
|
+
):
|
|
667
|
+
message = "Radio search requires a nonempty bounded query"
|
|
668
|
+
raise ValueError(message)
|
|
669
|
+
return await self._async_request(
|
|
670
|
+
f"audio/cfg/globalsearch/tunein:station#100,custom#100/{quote(query, safe='')}",
|
|
671
|
+
"globalsearch_result",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
@staticmethod
|
|
675
|
+
async def _radio_search_reply(socket: Any) -> Any:
|
|
676
|
+
"""Correlate the delayed radio result with the acknowledged native ticket."""
|
|
677
|
+
ticket = None
|
|
678
|
+
while True:
|
|
679
|
+
await asyncio.sleep(0)
|
|
680
|
+
frame = await socket.recv()
|
|
681
|
+
if not isinstance(frame, str):
|
|
682
|
+
continue
|
|
683
|
+
try:
|
|
684
|
+
reply = json.loads(frame)
|
|
685
|
+
except (ValueError, TypeError): # fmt: skip
|
|
686
|
+
continue
|
|
687
|
+
if not isinstance(reply, dict) or not isinstance(reply.get("globalsearch_result"), dict):
|
|
688
|
+
continue
|
|
689
|
+
result = reply["globalsearch_result"]
|
|
690
|
+
if ticket is None:
|
|
691
|
+
value = result.get("unique")
|
|
692
|
+
if isinstance(value, str) and value and len(value) <= _MAX_RADIO_QUERY:
|
|
693
|
+
ticket = value
|
|
694
|
+
continue
|
|
695
|
+
if reply.get("unique") == ticket and reply.get("type") == "tunein":
|
|
696
|
+
if result.get("error"):
|
|
697
|
+
message = "Radio search was rejected"
|
|
698
|
+
raise AudioCommandError(message)
|
|
699
|
+
return result
|
|
700
|
+
|
|
701
|
+
async def async_play_radio_station(
|
|
702
|
+
self,
|
|
703
|
+
player_id: object,
|
|
704
|
+
service: str,
|
|
705
|
+
audio_path: str,
|
|
706
|
+
*,
|
|
707
|
+
is_current: Callable[[], bool] | None = None,
|
|
708
|
+
on_send: Callable[[], None] | None = None,
|
|
709
|
+
) -> None:
|
|
710
|
+
"""Send one discovered radio station through the native radio service."""
|
|
711
|
+
target = audio_player_id(player_id)
|
|
712
|
+
if (
|
|
713
|
+
target is None
|
|
714
|
+
or not isinstance(service, str)
|
|
715
|
+
or not re.fullmatch(r"[A-Za-z0-9_-]{1,32}", service)
|
|
716
|
+
or not isinstance(audio_path, str)
|
|
717
|
+
or len(audio_path) > _MAX_RADIO_PATH
|
|
718
|
+
or not audio_path.startswith(("tunein:", "loxoneradio:"))
|
|
719
|
+
or not audio_path.partition(":")[2].strip()
|
|
720
|
+
or any(ord(c) < _CONTROL_BOUNDARY for c in audio_path)
|
|
721
|
+
):
|
|
722
|
+
message = "Radio playback requires an exact player and discovered native station"
|
|
723
|
+
raise ValueError(message)
|
|
724
|
+
account = "nouser" if service == "loxoneradio" else service
|
|
725
|
+
await self._async_request(
|
|
726
|
+
f"audio/{target}/serviceplay/radio/{account}/{quote(audio_path, safe='')}",
|
|
727
|
+
None,
|
|
728
|
+
is_current=is_current,
|
|
729
|
+
on_send=on_send,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
async def async_service_accounts(self) -> Any:
|
|
733
|
+
"""Read configured service accounts without browsing their content."""
|
|
734
|
+
return await self._async_request("audio/cfg/getservices", "getservices_result")
|
|
735
|
+
|
|
736
|
+
async def async_play_room_favourite(
|
|
737
|
+
self,
|
|
738
|
+
player_id: object,
|
|
739
|
+
favourite_id: object,
|
|
740
|
+
*,
|
|
741
|
+
is_current: Callable[[], bool] | None = None,
|
|
742
|
+
on_send: Callable[[], None] | None = None,
|
|
743
|
+
) -> None:
|
|
744
|
+
"""Send one exact configured favourite; completion comes from fresh events."""
|
|
745
|
+
target, favourite = audio_player_id(player_id), audio_player_id(favourite_id)
|
|
746
|
+
if target is None or favourite is None:
|
|
747
|
+
message = "Favourite selection requires exact player and favourite IDs"
|
|
748
|
+
raise ValueError(message)
|
|
749
|
+
await self._async_request(
|
|
750
|
+
f"audio/{target}/roomfav/play/{favourite}", None, is_current=is_current, on_send=on_send
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
async def async_spotify_handoff(
|
|
754
|
+
self,
|
|
755
|
+
player_id: object,
|
|
756
|
+
account_id: object,
|
|
757
|
+
*,
|
|
758
|
+
is_current: Callable[[], bool] | None = None,
|
|
759
|
+
on_send: Callable[[], None] | None = None,
|
|
760
|
+
) -> None:
|
|
761
|
+
"""Import one linked Spotify session, retaining the empty final path."""
|
|
762
|
+
target = audio_player_id(player_id)
|
|
763
|
+
if (
|
|
764
|
+
target is None
|
|
765
|
+
or not isinstance(account_id, str)
|
|
766
|
+
or not re.fullmatch(r"[A-Za-z0-9_.@+-]{1,128}", account_id)
|
|
767
|
+
):
|
|
768
|
+
message = "Spotify takeover requires an exact player and configured account ID"
|
|
769
|
+
raise ValueError(message)
|
|
770
|
+
await self._async_request(
|
|
771
|
+
f"audio/{target}/serviceplay/spotify/{account_id}/", None, is_current=is_current, on_send=on_send
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
async def async_seek(
|
|
775
|
+
self,
|
|
776
|
+
player_id: object,
|
|
777
|
+
seconds: object,
|
|
778
|
+
*,
|
|
779
|
+
is_current: Callable[[], bool] | None = None,
|
|
780
|
+
on_send: Callable[[], None] | None = None,
|
|
781
|
+
) -> float:
|
|
782
|
+
"""Send one position request; its numeric reply is an echo, not success."""
|
|
783
|
+
target = audio_player_id(player_id)
|
|
784
|
+
position = _seconds(seconds)
|
|
785
|
+
if target is None or position is None:
|
|
786
|
+
message = "Audio seeking requires an exact player and nonnegative seconds"
|
|
787
|
+
raise ValueError(message)
|
|
788
|
+
reply = await self._async_request(
|
|
789
|
+
f"audio/{target}/position/{position}",
|
|
790
|
+
"position_result",
|
|
791
|
+
is_current=is_current,
|
|
792
|
+
on_send=on_send,
|
|
793
|
+
)
|
|
794
|
+
if isinstance(reply, list) and reply and isinstance(reply[0], dict):
|
|
795
|
+
confirmed = _seconds(reply[0].get("position"))
|
|
796
|
+
if confirmed is not None:
|
|
797
|
+
return confirmed
|
|
798
|
+
message = "Audio seeking was not acknowledged with a valid position"
|
|
799
|
+
raise AudioCommandError(message)
|
|
800
|
+
|
|
801
|
+
async def _async_request( # noqa: PLR0913 - shared native request/correlation ownership
|
|
802
|
+
self,
|
|
803
|
+
command: str,
|
|
804
|
+
reply_key: str | None,
|
|
805
|
+
*,
|
|
806
|
+
is_current: Callable[[], bool] | None = None,
|
|
807
|
+
on_send: Callable[[], None] | None = None,
|
|
808
|
+
search_scopes: tuple[str, ...] | None = None,
|
|
809
|
+
spotify_account: str | None = None,
|
|
810
|
+
) -> Any:
|
|
811
|
+
"""Share the existing single-send authentication and ownership boundary."""
|
|
812
|
+
owned_socket = None
|
|
813
|
+
try:
|
|
814
|
+
|
|
815
|
+
async with asyncio.timeout(self._timeout), self._lock:
|
|
816
|
+
|
|
817
|
+
if self._closed:
|
|
818
|
+
message = "Audio playback connection is closed"
|
|
819
|
+
raise AudioCommandError(message)
|
|
820
|
+
username, token = self._credentials()
|
|
821
|
+
if not isinstance(username, str) or not username.strip() or not isinstance(token, str) or not token:
|
|
822
|
+
message = "Audio playback credentials are unavailable"
|
|
823
|
+
raise AudioCommandError(message)
|
|
824
|
+
async with connect(
|
|
825
|
+
self._uri,
|
|
826
|
+
subprotocols=[Subprotocol("remotecontrol")],
|
|
827
|
+
open_timeout=self._timeout,
|
|
828
|
+
close_timeout=2,
|
|
829
|
+
ping_interval=None,
|
|
830
|
+
max_size=1024 * 1024,
|
|
831
|
+
**_DIRECT_CONNECT,
|
|
832
|
+
) as socket:
|
|
833
|
+
owned_socket = self._socket = socket
|
|
834
|
+
if self._closed:
|
|
835
|
+
message = "Audio playback connection is closed"
|
|
836
|
+
raise AudioCommandError(message)
|
|
837
|
+
await self._authenticate(socket, username, token)
|
|
838
|
+
|
|
839
|
+
if self._closed:
|
|
840
|
+
message = "Audio playback connection is closed"
|
|
841
|
+
raise AudioCommandError(message)
|
|
842
|
+
if is_current is not None and not is_current():
|
|
843
|
+
message = "Audio seek is no longer applicable to the current track"
|
|
844
|
+
raise AudioCommandError(message)
|
|
845
|
+
if on_send is not None:
|
|
846
|
+
on_send()
|
|
847
|
+
await socket.send(command)
|
|
848
|
+
|
|
849
|
+
if search_scopes is not None:
|
|
850
|
+
return await self._global_search_reply(socket, search_scopes, spotify_account)
|
|
851
|
+
if reply_key == "globalsearch_result" and not command.endswith("/describe"):
|
|
852
|
+
return await self._radio_search_reply(socket)
|
|
853
|
+
if reply_key is not None:
|
|
854
|
+
return await self._reply(socket, reply_key)
|
|
855
|
+
# Native source results are optional, not the BaseResponse
|
|
856
|
+
# command echo. Keep this socket until that exact echo, so
|
|
857
|
+
# an immediate close cannot discard an unresolved command.
|
|
858
|
+
player = audio_player_id(command.split("/")[1])
|
|
859
|
+
await self._command_echo(socket, command, self.source_observations.get(player))
|
|
860
|
+
|
|
861
|
+
return None
|
|
862
|
+
except (OSError, WebSocketException, TimeoutError, ValueError, TypeError, KeyError, IndexError): # fmt: skip
|
|
863
|
+
# Neither native errors nor authentication envelopes may reach logs.
|
|
864
|
+
message = "Audio playback failed; no fallback or automatic retry was sent"
|
|
865
|
+
raise AudioCommandError(message) from None
|
|
866
|
+
finally:
|
|
867
|
+
if self._socket is owned_socket:
|
|
868
|
+
self._socket = None
|
|
869
|
+
|
|
870
|
+
async def _authenticate(self, socket: Any, username: str, token: str) -> None:
|
|
871
|
+
"""Authenticate the owned socket without sending a player command."""
|
|
872
|
+
# Native event broadcasts can arrive before this socket's greeting.
|
|
873
|
+
# Skip only known unsolicited event envelopes, under _async_request's
|
|
874
|
+
# existing deadline; never retry authentication or a player command.
|
|
875
|
+
while True:
|
|
876
|
+
await asyncio.sleep(0)
|
|
877
|
+
greeting = await socket.recv()
|
|
878
|
+
if isinstance(greeting, str):
|
|
879
|
+
try:
|
|
880
|
+
unsolicited = json.loads(greeting)
|
|
881
|
+
except ValueError:
|
|
882
|
+
unsolicited = None
|
|
883
|
+
if (
|
|
884
|
+
isinstance(unsolicited, dict) and not unsolicited.get("error")
|
|
885
|
+
and any(isinstance(unsolicited.get(field), list) for field in ("audio_event", "audio_sync_event"))
|
|
886
|
+
):
|
|
887
|
+
continue
|
|
888
|
+
break
|
|
889
|
+
if not isinstance(greeting, str) or "Session-Token:" not in greeting:
|
|
890
|
+
message = "Audio playback session is unavailable"
|
|
891
|
+
raise AudioCommandError(message)
|
|
892
|
+
session = greeting.split("Session-Token:", 1)[1].splitlines()[0].strip()
|
|
893
|
+
if not session:
|
|
894
|
+
message = "Audio playback session is unavailable"
|
|
895
|
+
raise AudioCommandError(message)
|
|
896
|
+
await socket.send("audio/cfg/getkey/full")
|
|
897
|
+
keys = await self._reply(socket, "getkey_result")
|
|
898
|
+
if not isinstance(keys, list) or not keys or not isinstance(keys[0], dict):
|
|
899
|
+
message = "Audio playback key is unavailable"
|
|
900
|
+
raise AudioCommandError(message)
|
|
901
|
+
key_pem = keys[0].get("pubkey")
|
|
902
|
+
if not isinstance(key_pem, str) or not key_pem:
|
|
903
|
+
message = "Audio playback key is unavailable"
|
|
904
|
+
raise AudioCommandError(message)
|
|
905
|
+
# Native firmware can return a single-line PEM; PyCryptodome's parser
|
|
906
|
+
# requires whitespace after the header and before the footer.
|
|
907
|
+
key_pem = key_pem.replace("-----BEGIN PUBLIC KEY-----", "-----BEGIN PUBLIC KEY-----\n").replace(
|
|
908
|
+
"-----END PUBLIC KEY-----", "\n-----END PUBLIC KEY-----"
|
|
909
|
+
)
|
|
910
|
+
public_key = RSA.import_key(key_pem)
|
|
911
|
+
key, iv = get_random_bytes(32), get_random_bytes(16)
|
|
912
|
+
envelope = f"{key.hex()}:{iv.hex()}:{session}".encode()
|
|
913
|
+
rsa_part = PKCS1_v1_5.new(public_key).encrypt(envelope)
|
|
914
|
+
encrypted = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(token.encode(), AES.block_size))
|
|
915
|
+
encoded = [
|
|
916
|
+
quote(username, safe=""),
|
|
917
|
+
*(quote(base64.b64encode(part).decode(), safe="") for part in (rsa_part, encrypted)),
|
|
918
|
+
]
|
|
919
|
+
await socket.send("secure/authenticate/" + "/".join(encoded))
|
|
920
|
+
if await self._reply(socket, "authenticate_result") != "authentication successful":
|
|
921
|
+
message = "Audio playback authentication was rejected"
|
|
922
|
+
raise AudioCommandError(message)
|
|
923
|
+
|
|
924
|
+
@staticmethod
|
|
925
|
+
async def _command_echo(
|
|
926
|
+
socket: Any, command: str, observation: asyncio.Future[None] | None = None,
|
|
927
|
+
) -> None:
|
|
928
|
+
"""
|
|
929
|
+
Settle by exact echo or independently qualified post-send source proof.
|
|
930
|
+
|
|
931
|
+
Some native serviceplay implementations execute without a response.
|
|
932
|
+
Only the source coordinator supplies exact-player/account playing
|
|
933
|
+
evidence; a completed socket write alone is never confirmation.
|
|
934
|
+
"""
|
|
935
|
+
while True:
|
|
936
|
+
await asyncio.sleep(0)
|
|
937
|
+
if observation is None:
|
|
938
|
+
message = await socket.recv()
|
|
939
|
+
else:
|
|
940
|
+
receive = asyncio.create_task(socket.recv())
|
|
941
|
+
try:
|
|
942
|
+
await asyncio.wait((receive, observation), return_when=asyncio.FIRST_COMPLETED)
|
|
943
|
+
if receive.done():
|
|
944
|
+
message = receive.result()
|
|
945
|
+
else:
|
|
946
|
+
observation.result()
|
|
947
|
+
|
|
948
|
+
return
|
|
949
|
+
finally:
|
|
950
|
+
if not receive.done():
|
|
951
|
+
receive.cancel()
|
|
952
|
+
with suppress(asyncio.CancelledError):
|
|
953
|
+
await receive
|
|
954
|
+
try:
|
|
955
|
+
reply = json.loads(message) if isinstance(message, str) else None
|
|
956
|
+
except (ValueError, TypeError): # fmt: skip
|
|
957
|
+
reply = None
|
|
958
|
+
if isinstance(reply, dict) and reply.get("command") == command:
|
|
959
|
+
error = reply.get("error")
|
|
960
|
+
if error is not None and error != "":
|
|
961
|
+
message = "Audio source command was rejected; no fallback or retry was sent"
|
|
962
|
+
raise AudioCommandError(message)
|
|
963
|
+
|
|
964
|
+
return
|
|
965
|
+
# A readable stream can win every wait alongside qualified proof.
|
|
966
|
+
# Inspect the ready frame for an exact rejection first, then honour
|
|
967
|
+
# that proof instead of starving it behind unrelated broadcasts.
|
|
968
|
+
if observation is not None and observation.done():
|
|
969
|
+
observation.result()
|
|
970
|
+
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
@staticmethod
|
|
974
|
+
async def _reply(socket: Any, key: str) -> Any:
|
|
975
|
+
"""Receive under the caller's deadline, tolerating interleaved event frames."""
|
|
976
|
+
while True:
|
|
977
|
+
# recv() can complete immediately from a busy socket's buffer. Yield
|
|
978
|
+
# so cancellation and the command-wide timeout cannot be starved.
|
|
979
|
+
await asyncio.sleep(0)
|
|
980
|
+
message = await socket.recv()
|
|
981
|
+
if not isinstance(message, str):
|
|
982
|
+
continue
|
|
983
|
+
try:
|
|
984
|
+
reply = json.loads(message)
|
|
985
|
+
except (ValueError, TypeError): # fmt: skip
|
|
986
|
+
continue
|
|
987
|
+
if isinstance(reply, dict) and key in reply:
|
|
988
|
+
if reply.get("error"):
|
|
989
|
+
msg = "Native audio request was rejected"
|
|
990
|
+
raise AudioCommandError(msg)
|
|
991
|
+
return reply[key]
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
class AudioEventReceiver:
|
|
995
|
+
"""
|
|
996
|
+
Consume one externally owned text stream without any send capability.
|
|
997
|
+
|
|
998
|
+
Empty update tuples invalidate all metadata on disconnect/reconnect. The
|
|
999
|
+
caller owns connection establishment, backoff and HA-tracked task lifetime.
|
|
1000
|
+
No initial metadata is invented before an event for that exact player.
|
|
1001
|
+
Only flapping event-end attributes are held; zone metadata remains current.
|
|
1002
|
+
All pending end timers are cancelled before invalidating a lost stream.
|
|
1003
|
+
"""
|
|
1004
|
+
|
|
1005
|
+
def __init__(self, on_update: Callable[[tuple[AudioZoneMetadata, ...]], None]) -> None:
|
|
1006
|
+
"""Create a receiver whose notifications run on its owning event loop."""
|
|
1007
|
+
self._on_update = on_update
|
|
1008
|
+
self._metadata: dict[int, AudioZoneMetadata] = {}
|
|
1009
|
+
self._end_timers: dict[int, asyncio.TimerHandle] = {}
|
|
1010
|
+
self._listening = False
|
|
1011
|
+
self._stream_generation = 0
|
|
1012
|
+
|
|
1013
|
+
def get(self, player_id: int) -> AudioZoneMetadata | None:
|
|
1014
|
+
"""Return the latest known snapshot, or unknown after loss of the stream."""
|
|
1015
|
+
return self._metadata.get(player_id)
|
|
1016
|
+
|
|
1017
|
+
def _reset(self) -> None:
|
|
1018
|
+
"""Invalidate stale snapshots rather than presenting them as current."""
|
|
1019
|
+
self._stream_generation += 1
|
|
1020
|
+
for timer in self._end_timers.values():
|
|
1021
|
+
timer.cancel()
|
|
1022
|
+
self._end_timers.clear()
|
|
1023
|
+
self._metadata.clear()
|
|
1024
|
+
self._on_update(())
|
|
1025
|
+
|
|
1026
|
+
async def async_ensure_snapshot(
|
|
1027
|
+
self, player_id: int, read: Callable[[], Awaitable[AudioZoneMetadata]], *, is_current: Callable[[], bool],
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""Bootstrap only a missing player on the same live passive stream."""
|
|
1030
|
+
if self.get(player_id) is not None:
|
|
1031
|
+
return
|
|
1032
|
+
generation = self._stream_generation
|
|
1033
|
+
if not self._listening or not is_current():
|
|
1034
|
+
message = "Native event stream is unavailable; no content was sent"
|
|
1035
|
+
raise AudioCommandError(message)
|
|
1036
|
+
row = await read()
|
|
1037
|
+
if (
|
|
1038
|
+
not self._listening or generation != self._stream_generation or not is_current()
|
|
1039
|
+
or self.get(player_id) is not None or row.player_id != player_id
|
|
1040
|
+
):
|
|
1041
|
+
message = "Native status read was superseded; no content was sent"
|
|
1042
|
+
raise AudioCommandError(message)
|
|
1043
|
+
self._on_update((self._apply(row),))
|
|
1044
|
+
|
|
1045
|
+
def _apply(self, row: AudioZoneMetadata) -> AudioZoneMetadata:
|
|
1046
|
+
"""Publish metadata promptly, stabilising only known flapping event ends."""
|
|
1047
|
+
player_id = row.player_id
|
|
1048
|
+
old = self._metadata.get(player_id)
|
|
1049
|
+
if row.event_active is False and old is not None and old.event in _DEBOUNCED_EVENTS:
|
|
1050
|
+
if player_id not in self._end_timers:
|
|
1051
|
+
self._end_timers[player_id] = asyncio.get_running_loop().call_later(
|
|
1052
|
+
_EVENT_END_DELAY, self._finish_event, player_id
|
|
1053
|
+
)
|
|
1054
|
+
row = replace(row, event=old.event, event_active=True)
|
|
1055
|
+
elif timer := self._end_timers.pop(player_id, None):
|
|
1056
|
+
# A renewed/replaced event or unknown state invalidates a pending end.
|
|
1057
|
+
timer.cancel()
|
|
1058
|
+
self._metadata[player_id] = row
|
|
1059
|
+
return row
|
|
1060
|
+
|
|
1061
|
+
def _finish_event(self, player_id: int) -> None:
|
|
1062
|
+
"""End a stable interruption without restoring an older metadata snapshot."""
|
|
1063
|
+
self._end_timers.pop(player_id, None)
|
|
1064
|
+
row = replace(self._metadata[player_id], event=None, event_active=False)
|
|
1065
|
+
self._metadata[player_id] = row
|
|
1066
|
+
self._on_update((row,))
|
|
1067
|
+
|
|
1068
|
+
async def async_listen(self, messages: AsyncIterable[str | bytes]) -> None:
|
|
1069
|
+
"""Read passive events; cancellation propagates after stale-data cleanup."""
|
|
1070
|
+
if self._listening:
|
|
1071
|
+
message = "Audio event receiver is already listening"
|
|
1072
|
+
raise RuntimeError(message)
|
|
1073
|
+
self._listening = True
|
|
1074
|
+
try:
|
|
1075
|
+
self._reset()
|
|
1076
|
+
async for frame in messages:
|
|
1077
|
+
if not isinstance(frame, str):
|
|
1078
|
+
continue
|
|
1079
|
+
try:
|
|
1080
|
+
payload = json.loads(frame)
|
|
1081
|
+
except ValueError:
|
|
1082
|
+
# Includes malformed JSON and Python's oversized-integer limit.
|
|
1083
|
+
continue
|
|
1084
|
+
if updates := parse_audio_events(payload):
|
|
1085
|
+
updates = tuple(self._apply(row) for row in updates)
|
|
1086
|
+
self._on_update(updates)
|
|
1087
|
+
finally:
|
|
1088
|
+
self._listening = False
|
|
1089
|
+
self._reset()
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
class AudioServerListener:
|
|
1093
|
+
"""Own an optional, unauthenticated, receive-only event connection."""
|
|
1094
|
+
|
|
1095
|
+
def __init__(self, host: str, on_update: Callable[[tuple[AudioZoneMetadata, ...]], None]) -> None:
|
|
1096
|
+
"""Create the connection owner without opening a socket."""
|
|
1097
|
+
self._uri = audioserver_uri(host)
|
|
1098
|
+
self._on_update = on_update
|
|
1099
|
+
self._retry_delay = 1
|
|
1100
|
+
self._receiver = AudioEventReceiver(self._receive_update)
|
|
1101
|
+
|
|
1102
|
+
def _receive_update(self, updates: tuple[AudioZoneMetadata, ...]) -> None:
|
|
1103
|
+
"""Reset backoff only after a valid event, not a flapping handshake."""
|
|
1104
|
+
if updates:
|
|
1105
|
+
self._retry_delay = 1
|
|
1106
|
+
self._on_update(updates)
|
|
1107
|
+
|
|
1108
|
+
def get(self, player_id: int) -> AudioZoneMetadata | None:
|
|
1109
|
+
"""Return metadata only while its event stream is current."""
|
|
1110
|
+
return self._receiver.get(player_id)
|
|
1111
|
+
|
|
1112
|
+
async def async_ensure_snapshot(
|
|
1113
|
+
self, player_id: int, read: Callable[[], Awaitable[AudioZoneMetadata]], *, is_current: Callable[[], bool],
|
|
1114
|
+
) -> None:
|
|
1115
|
+
"""Keep explicit read bootstrap separate from receive-only connection IO."""
|
|
1116
|
+
await self._receiver.async_ensure_snapshot(player_id, read, is_current=is_current)
|
|
1117
|
+
|
|
1118
|
+
async def async_run(self) -> None:
|
|
1119
|
+
"""Receive events until the owning HA entry cancels this coroutine."""
|
|
1120
|
+
while True:
|
|
1121
|
+
try:
|
|
1122
|
+
async with connect(
|
|
1123
|
+
self._uri,
|
|
1124
|
+
subprotocols=[Subprotocol("remotecontrol")],
|
|
1125
|
+
ping_interval=None,
|
|
1126
|
+
open_timeout=10,
|
|
1127
|
+
close_timeout=5,
|
|
1128
|
+
max_size=1048576,
|
|
1129
|
+
**_DIRECT_CONNECT,
|
|
1130
|
+
) as socket:
|
|
1131
|
+
await self._receiver.async_listen(socket)
|
|
1132
|
+
except (OSError, TimeoutError, WebSocketException) as err:
|
|
1133
|
+
# Never log raw frames, server replies or credential-bearing URLs.
|
|
1134
|
+
_LOGGER.debug("Audioserver event connection lost (%s)", type(err).__name__)
|
|
1135
|
+
await asyncio.sleep(self._retry_delay)
|
|
1136
|
+
self._retry_delay = min(self._retry_delay * 2, 30)
|