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.
@@ -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)