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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concrete PyLoxone-Audio API, independent of Home Assistant.
|
|
3
|
+
|
|
4
|
+
Apache-2.0. Version 0.1.0 is a local release candidate, not a published release.
|
|
5
|
+
The historical fake-backed facade contract is outside this distribution.
|
|
6
|
+
"""
|
|
7
|
+
from .announcement import async_send_prepared_announcement
|
|
8
|
+
from .catalogue import AudioCatalogue, CatalogueTarget, ConfiguredSource
|
|
9
|
+
from .metadata import AudioMetadataCoordinator
|
|
10
|
+
|
|
11
|
+
from .models import MediaKind, MediaNode, SearchResult
|
|
12
|
+
from .native import (
|
|
13
|
+
AudioCommandError,
|
|
14
|
+
AudioEventReceiver,
|
|
15
|
+
AudioServerCommands,
|
|
16
|
+
AudioServerListener,
|
|
17
|
+
AudioZoneMetadata,
|
|
18
|
+
audioserver_uri,
|
|
19
|
+
parse_audio_events,
|
|
20
|
+
)
|
|
21
|
+
from .sources import (
|
|
22
|
+
AudioFavourite,
|
|
23
|
+
SourceEnvironment,
|
|
24
|
+
async_native_catalogue_noop,
|
|
25
|
+
async_seek_track,
|
|
26
|
+
async_select_catalogue,
|
|
27
|
+
async_select_favourite,
|
|
28
|
+
async_select_radio_station,
|
|
29
|
+
passive_history_eligible,
|
|
30
|
+
)
|
|
31
|
+
from .selection import (
|
|
32
|
+
ManualSeekQualification,
|
|
33
|
+
NativeResumeOutcome,
|
|
34
|
+
SourceContentRequiredError,
|
|
35
|
+
SourceRoute,
|
|
36
|
+
async_source_route,
|
|
37
|
+
async_optional_native_resume,
|
|
38
|
+
confirmed_library_session,
|
|
39
|
+
manual_seek_position,
|
|
40
|
+
manual_seek_supported,
|
|
41
|
+
native_track_identity,
|
|
42
|
+
)
|
|
43
|
+
from .spotify import (
|
|
44
|
+
NativeSessionUnavailableError,
|
|
45
|
+
SpotifyNativeSession,
|
|
46
|
+
SpotifySessionBridge,
|
|
47
|
+
async_resume_native_session,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"AudioCatalogue",
|
|
52
|
+
"AudioCommandError",
|
|
53
|
+
"AudioEventReceiver",
|
|
54
|
+
"AudioFavourite",
|
|
55
|
+
"AudioMetadataCoordinator",
|
|
56
|
+
"AudioServerCommands",
|
|
57
|
+
"AudioServerListener",
|
|
58
|
+
"AudioZoneMetadata",
|
|
59
|
+
"CatalogueTarget",
|
|
60
|
+
"ConfiguredSource",
|
|
61
|
+
"ManualSeekQualification",
|
|
62
|
+
"MediaKind",
|
|
63
|
+
"MediaNode",
|
|
64
|
+
"NativeResumeOutcome",
|
|
65
|
+
"NativeSessionUnavailableError",
|
|
66
|
+
"SearchResult",
|
|
67
|
+
"SourceContentRequiredError",
|
|
68
|
+
|
|
69
|
+
"SourceEnvironment",
|
|
70
|
+
"SourceRoute",
|
|
71
|
+
"SpotifyNativeSession",
|
|
72
|
+
"SpotifySessionBridge",
|
|
73
|
+
"async_native_catalogue_noop",
|
|
74
|
+
"async_optional_native_resume",
|
|
75
|
+
"async_resume_native_session",
|
|
76
|
+
"async_seek_track",
|
|
77
|
+
"async_select_catalogue",
|
|
78
|
+
"async_select_favourite",
|
|
79
|
+
"async_select_radio_station",
|
|
80
|
+
"async_send_prepared_announcement",
|
|
81
|
+
"async_source_route",
|
|
82
|
+
"audioserver_uri",
|
|
83
|
+
"confirmed_library_session",
|
|
84
|
+
|
|
85
|
+
"manual_seek_position",
|
|
86
|
+
"manual_seek_supported",
|
|
87
|
+
"native_track_identity",
|
|
88
|
+
"parse_audio_events",
|
|
89
|
+
"passive_history_eligible",
|
|
90
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Reviewed native announcement ownership; no HA resolution, conversion or login."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from .native import AudioCommandError, AudioServerCommands
|
|
11
|
+
from .metadata import AudioMetadataCoordinator
|
|
12
|
+
|
|
13
|
+
async def async_send_prepared_announcement( # noqa: PLR0913, PLR0915
|
|
14
|
+
session_provider: Callable[[], Any],
|
|
15
|
+
commands: AudioServerCommands,
|
|
16
|
+
metadata: AudioMetadataCoordinator,
|
|
17
|
+
player_id: int,
|
|
18
|
+
data: bytes,
|
|
19
|
+
duration: float,
|
|
20
|
+
volume: int,
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Require this owned event to start/end; let native firmware restore media."""
|
|
23
|
+
fences = getattr(commands, "announcement_fences", None)
|
|
24
|
+
if fences is None:
|
|
25
|
+
commands.announcement_fences = fences = {}
|
|
26
|
+
if player_id in fences or player_id in getattr(commands, "source_fences", {}):
|
|
27
|
+
message = "Previous announcement outcome is uncertain; no new announcement was sent"
|
|
28
|
+
raise AudioCommandError(message)
|
|
29
|
+
filename = f"pyloxone-{uuid4().hex}.wav"
|
|
30
|
+
finished = asyncio.get_running_loop().create_future()
|
|
31
|
+
# Caller cancellation must not discard ownership after a possible write.
|
|
32
|
+
finished.add_done_callback(lambda future: None if future.cancelled() else future.exception())
|
|
33
|
+
started = dispatched = completed = False
|
|
34
|
+
|
|
35
|
+
def failed(message: str) -> None:
|
|
36
|
+
nonlocal started
|
|
37
|
+
# A later idle frame cannot complete a sequence whose ownership broke.
|
|
38
|
+
# Only a newly observed exact-owned start can establish it again.
|
|
39
|
+
started = False
|
|
40
|
+
if not finished.done():
|
|
41
|
+
finished.set_exception(AudioCommandError(message))
|
|
42
|
+
|
|
43
|
+
def release() -> None:
|
|
44
|
+
remove()
|
|
45
|
+
if fences.get(player_id) is release:
|
|
46
|
+
fences.pop(player_id)
|
|
47
|
+
if not finished.done():
|
|
48
|
+
finished.cancel()
|
|
49
|
+
|
|
50
|
+
def updated() -> None:
|
|
51
|
+
nonlocal started, completed
|
|
52
|
+
snapshot = metadata.get(player_id)
|
|
53
|
+
if snapshot is None:
|
|
54
|
+
failed("Announcement event stream was lost; playback may have occurred; no replay was sent")
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
parsed = urlsplit(snapshot.audio_path or "")
|
|
58
|
+
except ValueError:
|
|
59
|
+
failed("Malformed native event path; playback may have occurred; no replay was sent")
|
|
60
|
+
return
|
|
61
|
+
owned = parsed.scheme == "file" and parsed.path.rsplit("/", 1)[-1] == filename
|
|
62
|
+
if dispatched and snapshot.event_active is True and owned:
|
|
63
|
+
started = True
|
|
64
|
+
elif started and snapshot.event_active is False and snapshot.event_idle and not owned:
|
|
65
|
+
completed = True
|
|
66
|
+
if not finished.done():
|
|
67
|
+
finished.set_result(None)
|
|
68
|
+
release()
|
|
69
|
+
elif snapshot.event_active is True and not owned:
|
|
70
|
+
failed("Another native event intervened; playback may have occurred; no replay was sent")
|
|
71
|
+
|
|
72
|
+
def is_current() -> bool:
|
|
73
|
+
snapshot = metadata.get(player_id)
|
|
74
|
+
return not finished.done() and snapshot is not None and snapshot.event_active is False and snapshot.event_idle
|
|
75
|
+
|
|
76
|
+
def on_send() -> None:
|
|
77
|
+
nonlocal dispatched
|
|
78
|
+
dispatched = True
|
|
79
|
+
|
|
80
|
+
remove = metadata.async_add_listener(player_id, updated)
|
|
81
|
+
fences[player_id] = release
|
|
82
|
+
try:
|
|
83
|
+
if not is_current():
|
|
84
|
+
message = "Native event state is unavailable or active; no announcement was sent"
|
|
85
|
+
raise AudioCommandError(message)
|
|
86
|
+
await commands.async_upload_announcement(session_provider(), filename, data)
|
|
87
|
+
if not is_current():
|
|
88
|
+
message = "Native event state changed; no announcement was sent"
|
|
89
|
+
raise AudioCommandError(message)
|
|
90
|
+
await commands.async_announce(player_id, filename, volume, is_current=is_current, on_send=on_send)
|
|
91
|
+
async with asyncio.timeout(duration + 15):
|
|
92
|
+
await asyncio.shield(finished)
|
|
93
|
+
finally:
|
|
94
|
+
if not dispatched or completed:
|
|
95
|
+
release()
|
|
96
|
+
if not finished.done():
|
|
97
|
+
finished.cancel()
|
|
98
|
+
# Otherwise retain ownership until the exact owned start/end is observed.
|
|
99
|
+
# ACK loss/cancellation never authorises a potentially overlapping send.
|
|
100
|
+
|
|
101
|
+
|