pyblu 2.0.2__py2.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.
pyblu/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """A Python library for controlling BluOS players."""
2
+
3
+ from .player import Player
4
+ from .entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input
5
+
6
+ __all__ = ["Player", "Status", "Volume", "SyncStatus", "PairedPlayer", "PlayQueue", "Preset", "Input"]
pyblu/entities.py ADDED
@@ -0,0 +1,165 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Status:
6
+ etag: str
7
+ """Cursor for long polling requests. Can be passed to next status call."""
8
+
9
+ input_id: str | None
10
+ """Unique id of the input. Is not set for radio."""
11
+ service: str | None
12
+ """Service id of current input. 'Capture' for regular inputs."""
13
+
14
+ state: str
15
+ """Playback state"""
16
+ shuffle: bool
17
+ """Shuffle enabled"""
18
+
19
+ album: str | None
20
+ """Album name"""
21
+ artist: str | None
22
+ """Artist name"""
23
+ name: str | None
24
+ """Track name"""
25
+ image: str | None
26
+ """URL of the album art"""
27
+
28
+ volume: int
29
+ """Volume level with a range of 0-100"""
30
+ volume_db: float
31
+ """Volume level in dB"""
32
+
33
+ mute: bool
34
+ """Mute status"""
35
+ mute_volume: int | None
36
+ """If the player is muted, then this is the unmuted volume level. Absent if the player is not muted."""
37
+ mute_volume_db: float | None
38
+ """If the player is muted, then this is the unmuted volume level in dB. Absent if the player is not muted."""
39
+
40
+ seconds: float | None
41
+ """Current playback position in seconds"""
42
+ total_seconds: float | None
43
+ """Total track length in seconds"""
44
+ can_seek: bool
45
+ """True if the current track can be seeked"""
46
+
47
+ sleep: int
48
+ """Sleep timer in minutes. 0 means the sleep timer is off."""
49
+
50
+ group_name: str | None
51
+ """Name of the group the player is in. Only present on leader."""
52
+ group_volume: int | None
53
+ """Volume level of the group. Only present on leader. Range is 0-100."""
54
+
55
+ indexing: bool
56
+ """True if the player is currently indexing."""
57
+
58
+ stream_url: str | None
59
+ """The presence of this element should be treated as a flag and its contents as an opaque value.
60
+ Seems to be present for radio stations and to be the same as the url from the matching preset(for Radio Stations)."""
61
+
62
+
63
+ @dataclass
64
+ class PairedPlayer:
65
+ ip: str
66
+ """IP address of the player"""
67
+ port: int
68
+ """Port of the player"""
69
+
70
+
71
+ @dataclass
72
+ class SyncStatus:
73
+ etag: str
74
+ """Cursor for long polling requests. Can be passed to next sync_status call."""
75
+
76
+ id: str
77
+ """Player IP and port"""
78
+ mac: str
79
+ """MAC address of the player"""
80
+ name: str
81
+ """Name of the player"""
82
+
83
+ image: str
84
+ """URL of the player image"""
85
+ initialized: bool
86
+ """True means the player is already setup, false means the player needs to be setup"""
87
+
88
+ group: str | None
89
+ """Group name of the player"""
90
+ leader: PairedPlayer | None
91
+ """Player leading the group. Only present if the player is grouped and not leader itself"""
92
+ followers: list[PairedPlayer] | None
93
+ """List of following players. Only present if the player is leader of a group"""
94
+
95
+ zone: str | None
96
+ """Name of the zone the player is in. Zones are fixed groups."""
97
+ zone_leader: bool | None
98
+ """True if the player is the leader of the zone, false otherwise"""
99
+ zone_follower: bool | None
100
+ """True if the player is a follower in the zone, false otherwise"""
101
+
102
+ brand: str
103
+ """Brand name of the player"""
104
+ model: str
105
+ """Model name of the player"""
106
+ model_name: str
107
+ """Model name of the player"""
108
+
109
+ mute_volume_db: float | None
110
+ """If the player is muted, then this is the unmuted volume level in dB. Absent if the player is not muted."""
111
+ mute_volume: int | None
112
+ """If the player is muted, then this is the unmuted volume level. Absent if the player is not muted."""
113
+
114
+ volume_db: float
115
+ """Volume level in dB"""
116
+ volume: int
117
+ """Volume level with a range of 0-100. -1 means fixed volume."""
118
+
119
+
120
+ @dataclass
121
+ class Volume:
122
+ volume: int
123
+ """Volume level with a range of 0-100"""
124
+ db: float
125
+ """Volume level in dB"""
126
+ mute: bool
127
+ """Mute status"""
128
+
129
+
130
+ @dataclass
131
+ class PlayQueue:
132
+ id: str
133
+ """Unique id for the current play queue state. Changes whenever the play queue changes."""
134
+ shuffle: bool
135
+ """PlayQueue is shuffled"""
136
+ modified: bool
137
+ """PlayQueue was modified since it was loaded"""
138
+ length: int
139
+ """Number of tracks in the play queue"""
140
+
141
+
142
+ @dataclass
143
+ class Preset:
144
+ name: str
145
+ """Name of the preset"""
146
+ id: int
147
+ """Unique id of the preset"""
148
+ url: str
149
+ """URL of the preset. Can be used with *play_url* to play the preset"""
150
+ image: str | None
151
+ """URL of the preset image"""
152
+ volume: int | None
153
+ """Volume level with a range of 0-100. None means the volume is not set."""
154
+
155
+
156
+ @dataclass
157
+ class Input:
158
+ id: str | None
159
+ """Unique id of the input"""
160
+ text: str
161
+ """User friendly name of the input"""
162
+ image: str
163
+ """URL of the input image"""
164
+ url: str
165
+ """URL to play the input. Can be passed to *play_url*"""
pyblu/errors.py ADDED
@@ -0,0 +1,52 @@
1
+ from typing import Awaitable, Callable, TypeVar, Any, cast
2
+
3
+ from functools import wraps
4
+
5
+ import aiohttp
6
+
7
+ __all__ = ["PlayerError", "PlayerUnreachableError", "PlayerUnexpectedResponseError"]
8
+
9
+ FuncT = TypeVar("FuncT", bound=Callable[..., Any])
10
+ AsyncFuncT = TypeVar("AsyncFuncT", bound=Callable[..., Awaitable[Any]])
11
+
12
+
13
+ class PlayerError(Exception):
14
+ """Base class for exceptions in this package."""
15
+
16
+ def __init__(self, message: str):
17
+ super().__init__(message)
18
+
19
+
20
+ class PlayerUnreachableError(PlayerError):
21
+ """Exception raised when the player is not reachable.
22
+
23
+ This could be due to a timeout or the player being offline.
24
+ """
25
+
26
+
27
+ class PlayerUnexpectedResponseError(PlayerError):
28
+ """Exception raised when the player returns an unexpected response. This is likely a bug in this library."""
29
+
30
+
31
+ def _wrap_in_unxpected_response_error(func: FuncT) -> FuncT:
32
+ @wraps(func)
33
+ def wrapped(*args, **kwargs):
34
+ try:
35
+ return func(*args, **kwargs)
36
+ except Exception as e:
37
+ raise PlayerUnexpectedResponseError(f"Unexpected response from player: {e}") from e
38
+
39
+ return cast(FuncT, wrapped)
40
+
41
+
42
+ def _wrap_in_unreachable_error(func: AsyncFuncT) -> AsyncFuncT:
43
+ @wraps(func)
44
+ async def wrapped(*args, **kwargs):
45
+ try:
46
+ return await func(*args, **kwargs)
47
+ except TimeoutError as e:
48
+ raise PlayerUnreachableError(f"Timout during request: {e}") from e
49
+ except aiohttp.ClientConnectionError as e:
50
+ raise PlayerUnreachableError(f"Connection error: {e}") from e
51
+
52
+ return cast(AsyncFuncT, wrapped)
pyblu/parse.py ADDED
@@ -0,0 +1,236 @@
1
+ from urllib.parse import unquote
2
+
3
+ from lxml import etree
4
+
5
+ from pyblu.entities import Input, PairedPlayer, SyncStatus, Status, Volume, PlayQueue, Preset
6
+ from pyblu.errors import _wrap_in_unxpected_response_error
7
+
8
+
9
+ @_wrap_in_unxpected_response_error
10
+ def parse_add_follower(response: bytes) -> list[PairedPlayer]:
11
+ """
12
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
13
+ """
14
+ # pylint: disable=c-extension-no-member
15
+ tree = etree.fromstring(response)
16
+ follower_elements = tree.xpath("//addSlave/slave")
17
+
18
+ return [PairedPlayer(ip=x.attrib["id"], port=int(x.attrib["port"])) for x in follower_elements]
19
+
20
+
21
+ @_wrap_in_unxpected_response_error
22
+ def parse_sync_status(response: bytes) -> SyncStatus:
23
+ """
24
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
25
+ """
26
+ # pylint: disable=c-extension-no-member
27
+ tree = etree.fromstring(response)
28
+
29
+ leader: PairedPlayer | None = None
30
+ leader_elements = tree.xpath("//SyncStatus/master")
31
+ if leader_elements:
32
+ leader_element = leader_elements[0]
33
+ leader_ip = leader_element.text
34
+ leader_port = leader_element.attrib["port"]
35
+ leader = PairedPlayer(ip=leader_ip, port=int(leader_port))
36
+
37
+ followers: list[PairedPlayer] | None = None
38
+ follower_elements = tree.xpath("//SyncStatus/slave")
39
+ if follower_elements:
40
+ followers = [PairedPlayer(ip=x.attrib["id"], port=int(x.attrib["port"])) for x in follower_elements]
41
+
42
+ sync_status_elements = tree.xpath("//SyncStatus")
43
+ assert len(sync_status_elements) == 1, "SyncStatus element not found or multiple found"
44
+ sync_status_element = sync_status_elements[0]
45
+
46
+ sync_status = SyncStatus(
47
+ etag=sync_status_element.attrib["etag"],
48
+ id=sync_status_element.attrib["id"],
49
+ mac=sync_status_element.attrib["mac"],
50
+ name=sync_status_element.attrib["name"],
51
+ image=sync_status_element.attrib["icon"],
52
+ initialized=sync_status_element.attrib.get("initialized") == "true",
53
+ group=sync_status_element.attrib.get("group"),
54
+ leader=leader,
55
+ followers=followers,
56
+ zone=sync_status_element.attrib.get("zone"),
57
+ zone_leader=sync_status_element.attrib.get("zoneMaster") == "true",
58
+ zone_follower=sync_status_element.attrib.get("zoneMaster") == "true",
59
+ brand=sync_status_element.attrib["brand"],
60
+ model=sync_status_element.attrib["model"],
61
+ model_name=sync_status_element.attrib["modelName"],
62
+ mute_volume_db=float(sync_status_element.attrib["muteDb"]) if "muteDb" in sync_status_element.attrib else None,
63
+ mute_volume=int(sync_status_element.attrib["muteVolume"]) if "muteVolume" in sync_status_element.attrib else None,
64
+ volume_db=float(sync_status_element.attrib["db"]),
65
+ volume=int(sync_status_element.attrib["volume"]),
66
+ )
67
+
68
+ return sync_status
69
+
70
+
71
+ @_wrap_in_unxpected_response_error
72
+ def parse_status(response: bytes) -> Status:
73
+ """
74
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
75
+ """
76
+ # pylint: disable=c-extension-no-member
77
+ tree = etree.fromstring(response)
78
+ status_elements = tree.xpath("//status")
79
+
80
+ assert len(status_elements) == 1, "Status element not found or multiple found"
81
+ status_element = status_elements[0]
82
+
83
+ name = status_element.findtext("name")
84
+ if name is None:
85
+ name = status_element.findtext("title1")
86
+ artist = status_element.findtext("artist")
87
+ if artist is None:
88
+ artist = status_element.findtext("title2")
89
+ album = status_element.findtext("album")
90
+ if album is None:
91
+ album = status_element.findtext("title3")
92
+
93
+ status = Status(
94
+ etag=status_element.attrib["etag"],
95
+ input_id=status_element.findtext("inputId"),
96
+ service=status_element.findtext("service"),
97
+ state=status_element.findtext("state"),
98
+ shuffle=status_element.findtext("shuffle") == "1",
99
+ album=album,
100
+ artist=artist,
101
+ name=name,
102
+ image=status_element.findtext("image"),
103
+ volume=int(status_element.findtext("volume")),
104
+ volume_db=float(status_element.findtext("db")),
105
+ mute=status_element.findtext("mute") == "1",
106
+ mute_volume=int(status_element.findtext("muteVolume")) if status_element.findtext("muteVolume") else None,
107
+ mute_volume_db=float(status_element.findtext("muteDb")) if status_element.findtext("muteDb") else None,
108
+ seconds=float(status_element.findtext("secs")) if status_element.findtext("secs") else None,
109
+ total_seconds=float(status_element.findtext("totlen")) if status_element.findtext("totlen") else None,
110
+ can_seek=status_element.findtext("canSeek") == "1",
111
+ sleep=int(status_element.findtext("sleep")) if status_element.findtext("sleep") else 0,
112
+ group_name=status_element.findtext("groupName"),
113
+ group_volume=int(status_element.findtext("groupVolume")) if status_element.findtext("groupVolume") else None,
114
+ indexing=status_element.findtext("indexing") == "1",
115
+ stream_url=status_element.findtext("streamUrl"),
116
+ )
117
+
118
+ return status
119
+
120
+
121
+ @_wrap_in_unxpected_response_error
122
+ def parse_volume(response: bytes) -> Volume:
123
+ """
124
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
125
+ """
126
+ # pylint: disable=c-extension-no-member
127
+ tree = etree.fromstring(response)
128
+ volume_elements = tree.xpath("//volume")
129
+
130
+ assert len(volume_elements) == 1, "Volume element not found or multiple found"
131
+ volume_element = volume_elements[0]
132
+
133
+ volume = Volume(
134
+ volume=int(volume_element.text),
135
+ db=float(volume_element.attrib["db"]),
136
+ mute=volume_element.attrib.get("mute") == "1",
137
+ )
138
+
139
+ return volume
140
+
141
+
142
+ @_wrap_in_unxpected_response_error
143
+ def parse_play_queue(response: bytes) -> PlayQueue:
144
+ """
145
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
146
+ """
147
+ # pylint: disable=c-extension-no-member
148
+ tree = etree.fromstring(response)
149
+ playlist_elements = tree.xpath("//playlist")
150
+
151
+ assert len(playlist_elements) == 1, "Playlist element not found or multiple found"
152
+ playlist_element = playlist_elements[0]
153
+
154
+ play_queue = PlayQueue(
155
+ id=playlist_element.attrib["id"],
156
+ modified=playlist_element.attrib.get("modified") == "1",
157
+ length=int(playlist_element.attrib["length"]),
158
+ shuffle=playlist_element.attrib.get("shuffle") == "1",
159
+ )
160
+
161
+ return play_queue
162
+
163
+
164
+ @_wrap_in_unxpected_response_error
165
+ def parse_presets(response: bytes) -> list[Preset]:
166
+ """
167
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
168
+ """
169
+ # pylint: disable=c-extension-no-member
170
+ tree = etree.fromstring(response)
171
+ preset_elements = tree.xpath("//presets/preset")
172
+
173
+ presets = [
174
+ Preset(
175
+ name=x.attrib["name"],
176
+ id=int(x.attrib["id"]),
177
+ url=x.attrib["url"],
178
+ image=x.attrib.get("image"),
179
+ volume=int(x.attrib.get("volume")) if x.attrib.get("volume") else None,
180
+ )
181
+ for x in preset_elements
182
+ ]
183
+
184
+ return presets
185
+
186
+
187
+ @_wrap_in_unxpected_response_error
188
+ def parse_state(response: bytes) -> str:
189
+ """
190
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
191
+ """
192
+ # pylint: disable=c-extension-no-member
193
+ tree = etree.fromstring(response)
194
+ state_elements = tree.xpath("//state")
195
+
196
+ assert len(state_elements) == 1, "State element not found or multiple found"
197
+ state_element = state_elements[0]
198
+
199
+ return state_element.text
200
+
201
+
202
+ @_wrap_in_unxpected_response_error
203
+ def parse_sleep(response: bytes) -> int:
204
+ """
205
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
206
+ """
207
+ # pylint: disable=c-extension-no-member
208
+ tree = etree.fromstring(response)
209
+ sleep_elements = tree.xpath("//sleep")
210
+
211
+ assert len(sleep_elements) == 1, "Sleep element not found or multiple found"
212
+ sleep_element = sleep_elements[0]
213
+
214
+ return int(sleep_element.text) if sleep_element.text else 0
215
+
216
+
217
+ @_wrap_in_unxpected_response_error
218
+ def parse_inputs(response: bytes) -> list[Input]:
219
+ """
220
+ :raises PlayerUnexpectedResponseError: If the response is not as expected.
221
+ """
222
+ # pylint: disable=c-extension-no-member
223
+ tree = etree.fromstring(response)
224
+ input_elements = tree.xpath("//radiotime/item")
225
+
226
+ inputs = [
227
+ Input(
228
+ id=x.attrib.get("id"),
229
+ text=x.attrib["text"],
230
+ image=x.attrib["image"],
231
+ url=unquote(x.attrib["URL"]),
232
+ )
233
+ for x in input_elements
234
+ ]
235
+
236
+ return inputs
pyblu/player.py ADDED
@@ -0,0 +1,514 @@
1
+ import aiohttp
2
+
3
+ from pyblu.entities import Status, Volume, SyncStatus, PairedPlayer, PlayQueue, Preset, Input
4
+ from pyblu.parse import (
5
+ parse_add_follower,
6
+ parse_inputs,
7
+ parse_sleep,
8
+ parse_state,
9
+ parse_sync_status,
10
+ parse_status,
11
+ parse_volume,
12
+ parse_play_queue,
13
+ parse_presets,
14
+ )
15
+ from pyblu.errors import _wrap_in_unreachable_error
16
+
17
+
18
+ class Player:
19
+ def __init__(self, host: str, port: int = 11000, session: aiohttp.ClientSession | None = None, default_timeout: float = 5.0):
20
+ """Client for a BluOS player. Uses the HTTP API of the BluOS players to control it.
21
+
22
+ The passed sessions will not be closed when the player is closed and has to be closed by the caller.
23
+ If no session is passed, a new session will be created and closed when the player is closed.
24
+
25
+ *Player* is an async context manager and can be used with *async with*.
26
+
27
+ :param host: The hostname or IP address of the player.
28
+ :param port: The port of the player. Default is 11000.
29
+ :param session: An optional aiohttp.ClientSession to use for requests.
30
+ :param default_timeout: The default timeout in seconds for requests. Can be overridden in each request.
31
+
32
+ :return: A new Player.
33
+ """
34
+ self.base_url = f"http://{host}:{port}"
35
+ self._default_timeout = default_timeout
36
+ if session:
37
+ self._session_owned = False
38
+ self._session = session
39
+ else:
40
+ self._session_owned = True
41
+ self._session = aiohttp.ClientSession()
42
+
43
+ @property
44
+ def default_timeout(self) -> float:
45
+ return self._default_timeout
46
+
47
+ async def close(self):
48
+ if self._session_owned:
49
+ await self._session.close()
50
+
51
+ async def __aenter__(self):
52
+ return self
53
+
54
+ async def __aexit__(self, *args):
55
+ await self.close()
56
+
57
+ @_wrap_in_unreachable_error
58
+ async def status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> Status:
59
+ """Get the current status of the player.
60
+
61
+ This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
62
+ **etag** has to be the last etag received from the server.
63
+
64
+ **poll_timeout** has to be smaller than **timeout**. The **default_timout** and the default value for **poll_timeout** do not fulfill this requirement.
65
+ This means that **timeout** has to be set when using long polling in most cases.
66
+
67
+ :param etag: The last etag received from the server. Triggers long polling if set.
68
+ :param poll_timeout: The timeout in seconds for long polling. Has to be smaller than timeout.
69
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout. Has to be larger than poll_timeout.
70
+
71
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
72
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
73
+
74
+ :return: The current status of the player. Only selected fields are returned.
75
+ """
76
+ used_timeout = timeout if timeout is not None else self._default_timeout
77
+
78
+ params: dict[str, str] = {}
79
+ if etag is not None:
80
+ if poll_timeout >= used_timeout:
81
+ raise ValueError("poll_timeout has to be smaller than timeout")
82
+ params["etag"] = etag
83
+ params["timeout"] = str(poll_timeout)
84
+
85
+ async with self._session.get(f"{self.base_url}/Status", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
86
+ response.raise_for_status()
87
+ response_data = await response.read()
88
+
89
+ status = parse_status(response_data)
90
+
91
+ return status
92
+
93
+ @_wrap_in_unreachable_error
94
+ async def sync_status(self, etag: str | None = None, poll_timeout: int = 30, timeout: float | None = None) -> SyncStatus:
95
+ """Get the SyncStatus of the player.
96
+
97
+ This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
98
+ **etag** has to be the last etag received from the server.
99
+
100
+ **poll_timeout** has to be smaller than **timeout**. The **default_timout** and the default value for **poll_timeout** do not fulfill this requirement.
101
+ This means that **timeout** has to be set when using long polling in most cases.
102
+
103
+ :param etag: The last etag received from the server. Triggers long polling if set.
104
+ :param poll_timeout: The timeout in seconds for long polling. Has to be smaller than timeout.
105
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout. Has to be larger than poll_timeout.
106
+
107
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
108
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
109
+
110
+ :return: The SyncStatus of the player.
111
+ """
112
+ used_timeout = timeout if timeout is not None else self._default_timeout
113
+
114
+ params: dict[str, str] = {}
115
+ if etag is not None:
116
+ if poll_timeout >= used_timeout:
117
+ raise ValueError("poll_timeout has to be smaller than timeout")
118
+ params["etag"] = etag
119
+ params["timeout"] = str(poll_timeout)
120
+
121
+ async with self._session.get(f"{self.base_url}/SyncStatus", params=params) as response:
122
+ response.raise_for_status()
123
+ response_data = await response.read()
124
+
125
+ sync_status = parse_sync_status(response_data)
126
+
127
+ return sync_status
128
+
129
+ @_wrap_in_unreachable_error
130
+ async def volume(self, level: int | None = None, mute: bool | None = None, tell_followers: bool | None = None, timeout: float | None = None) -> Volume:
131
+ """Get or set the volume of the player.
132
+ Call without parameters to get the current volume. Call with parameters to set the volume.
133
+
134
+ :param level: The volume level to set. Range is 0-100.
135
+ :param mute: Whether to mute the player.
136
+ :param tell_followers: Whether to tell grouped speakers to change their volume as well.
137
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
138
+
139
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
140
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
141
+
142
+ :return: The current volume of the player.
143
+ """
144
+ used_timeout = timeout if timeout is not None else self._default_timeout
145
+
146
+ params: dict[str, str] = {}
147
+ if level is not None:
148
+ params["level"] = str(level)
149
+ if mute is not None:
150
+ params["mute"] = "1" if mute else "0"
151
+ if tell_followers is not None:
152
+ params["tell_slaves"] = "1" if tell_followers else "0"
153
+
154
+ async with self._session.get(f"{self.base_url}/Volume", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
155
+ response.raise_for_status()
156
+ response_data = await response.read()
157
+
158
+ volume = parse_volume(response_data)
159
+ return volume
160
+
161
+ @_wrap_in_unreachable_error
162
+ async def play(self, seek: int | None = None, timeout: float | None = None) -> str:
163
+ """Start playing the current track. Can also be used to seek within the current track.
164
+ Works only when paused, not when stopped.
165
+
166
+ :param seek: The position in seconds to seek to.
167
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
168
+
169
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
170
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
171
+
172
+ :return: The playback state after command execution.
173
+ """
174
+ used_timeout = timeout if timeout is not None else self._default_timeout
175
+
176
+ params = {}
177
+ if seek is not None:
178
+ params["seek"] = seek
179
+
180
+ async with self._session.get(f"{self.base_url}/Play", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
181
+ response.raise_for_status()
182
+ response_data = await response.read()
183
+
184
+ return parse_state(response_data)
185
+
186
+ @_wrap_in_unreachable_error
187
+ async def play_url(self, url: str, timeout: float | None = None) -> str:
188
+ """Start playing a track from a URL. Can also be used to select inputs. See *inputs* for available inputs.
189
+
190
+ :param url: The URL of the track to play.
191
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
192
+
193
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
194
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
195
+
196
+ :return: The playback state after command execution.
197
+ """
198
+ used_timeout = timeout if timeout is not None else self._default_timeout
199
+
200
+ params = {
201
+ "url": url,
202
+ }
203
+ async with self._session.get(f"{self.base_url}/Play", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
204
+ response.raise_for_status()
205
+ response_data = await response.read()
206
+
207
+ return parse_state(response_data)
208
+
209
+ @_wrap_in_unreachable_error
210
+ async def pause(self, toggle: bool | None = None, timeout: float | None = None) -> str:
211
+ """Pause the current track. **toggle** can be used to toggle between playing and pause.
212
+
213
+ :param toggle: Toggle between playing and pause.
214
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
215
+
216
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
217
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
218
+
219
+ :return: The playback state after command execution.
220
+ """
221
+ used_timeout = timeout if timeout is not None else self._default_timeout
222
+
223
+ params = {}
224
+ if toggle is not None:
225
+ params["toggle"] = "1"
226
+
227
+ async with self._session.get(f"{self.base_url}/Pause", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
228
+ response.raise_for_status()
229
+ response_data = await response.read()
230
+
231
+ return parse_state(response_data)
232
+
233
+ @_wrap_in_unreachable_error
234
+ async def stop(self, timeout: float | None = None) -> str:
235
+ """Stop the current track. Stopped playback cannot be resumed.
236
+
237
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
238
+
239
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
240
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
241
+
242
+ :return: The playback state after command execution.
243
+ """
244
+ used_timeout = timeout if timeout is not None else self._default_timeout
245
+
246
+ async with self._session.get(f"{self.base_url}/Stop", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
247
+ response.raise_for_status()
248
+ response_data = await response.read()
249
+
250
+ return parse_state(response_data)
251
+
252
+ @_wrap_in_unreachable_error
253
+ async def skip(self, timeout: float | None = None) -> None:
254
+ """Skip to the next track.
255
+
256
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
257
+
258
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
259
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
260
+ """
261
+ used_timeout = timeout if timeout is not None else self._default_timeout
262
+ async with self._session.get(f"{self.base_url}/Skip", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
263
+ response.raise_for_status()
264
+
265
+ @_wrap_in_unreachable_error
266
+ async def back(self, timeout: float | None = None) -> None:
267
+ """Go back to the previous track.
268
+
269
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
270
+
271
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
272
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
273
+ """
274
+ used_timeout = timeout if timeout is not None else self._default_timeout
275
+
276
+ async with self._session.get(f"{self.base_url}/Back", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
277
+ response.raise_for_status()
278
+
279
+ @_wrap_in_unreachable_error
280
+ async def add_follower(self, ip: str, port: int = 11000, timeout: float | None = None) -> list[PairedPlayer]:
281
+ """Add a secondary player to the current player as a follower.
282
+ If it fails the player won't be in the returned list.
283
+
284
+ :param ip: The IP address of the player to add.
285
+ :param port: The port of the player to add. Default is 11000.
286
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
287
+
288
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
289
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
290
+
291
+ :return: The list of followers of the player.
292
+ """
293
+ used_timeout = timeout if timeout is not None else self._default_timeout
294
+
295
+ params: dict[str, str | int] = {
296
+ "slave": ip,
297
+ "port": port,
298
+ }
299
+ async with self._session.get(f"{self.base_url}/AddSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
300
+ response.raise_for_status()
301
+ response_data = await response.read()
302
+
303
+ followers_after_request = parse_add_follower(response_data)
304
+
305
+ return followers_after_request
306
+
307
+ @_wrap_in_unreachable_error
308
+ async def add_followers(self, followers: list[PairedPlayer], timeout: float | None = None) -> list[PairedPlayer]:
309
+ """Add a list of following players to the current player.
310
+ If it fails the player won't be in the returned list.
311
+
312
+ Same as *add_follower* but with a list of players. Makes only one request to player.
313
+
314
+ :param followers: The list of players to add.
315
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
316
+
317
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
318
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
319
+
320
+ :return: The list of followers of the player.
321
+ """
322
+ used_timeout = timeout if timeout is not None else self._default_timeout
323
+
324
+ params = {
325
+ "slaves": ",".join(x.ip for x in followers),
326
+ "ports": ",".join(str(x.port) for x in followers),
327
+ }
328
+ async with self._session.get(f"{self.base_url}/AddSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
329
+ response.raise_for_status()
330
+ response_data = await response.read()
331
+
332
+ followers_after_request = parse_add_follower(response_data)
333
+
334
+ return followers_after_request
335
+
336
+ @_wrap_in_unreachable_error
337
+ async def remove_follower(self, ip: str, port: int = 11000, timeout: float | None = None) -> SyncStatus:
338
+ """Remove a following player from the group.
339
+
340
+ :param ip: The IP address of the player to remove.
341
+ :param port: The port of the player to remove. Default is 11000.
342
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
343
+
344
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
345
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
346
+
347
+ :return: The SyncStatus of the player.
348
+ """
349
+ used_timeout = timeout if timeout is not None else self._default_timeout
350
+
351
+ params: dict[str, str | int] = {
352
+ "slave": ip,
353
+ "port": port,
354
+ }
355
+ async with self._session.get(f"{self.base_url}/RemoveSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
356
+ response.raise_for_status()
357
+ response_data = await response.read()
358
+
359
+ sync_status = parse_sync_status(response_data)
360
+
361
+ return sync_status
362
+
363
+ @_wrap_in_unreachable_error
364
+ async def remove_followers(self, followers: list[PairedPlayer], timeout: float | None = None) -> SyncStatus:
365
+ """Remove a list of following players from the group.
366
+
367
+ Same as *remove_follower* but with a list of players. Makes only one request to player.
368
+
369
+ :param followers: The list of players to remove.
370
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
371
+
372
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
373
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
374
+
375
+ :return: The SyncStatus of the player.
376
+ """
377
+ used_timeout = timeout if timeout is not None else self._default_timeout
378
+
379
+ params = {
380
+ "slaves": ",".join(x.ip for x in followers),
381
+ "ports": ",".join(str(x.port) for x in followers),
382
+ }
383
+ async with self._session.get(f"{self.base_url}/RemoveSlave", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
384
+ response.raise_for_status()
385
+ response_data = await response.read()
386
+
387
+ sync_status = parse_sync_status(response_data)
388
+
389
+ return sync_status
390
+
391
+ @_wrap_in_unreachable_error
392
+ async def shuffle(self, shuffle: bool, timeout: float | None = None) -> PlayQueue:
393
+ """Set shuffle on current play queue.
394
+
395
+ :param shuffle: Whether to shuffle the playlist.
396
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
397
+
398
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
399
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
400
+
401
+ :return: The current play queue.
402
+ """
403
+ used_timeout = timeout if timeout is not None else self._default_timeout
404
+
405
+ params = {
406
+ "shuffle": "1" if shuffle else "0",
407
+ }
408
+ async with self._session.get(f"{self.base_url}/Shuffle", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
409
+ response.raise_for_status()
410
+ response_data = await response.read()
411
+
412
+ play_queue = parse_play_queue(response_data)
413
+
414
+ return play_queue
415
+
416
+ @_wrap_in_unreachable_error
417
+ async def clear(self, timeout: float | None = None) -> PlayQueue:
418
+ """Clear the play queue.
419
+
420
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
421
+
422
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
423
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
424
+
425
+ :return: The current play queue.
426
+ """
427
+ used_timeout = timeout if timeout is not None else self._default_timeout
428
+
429
+ async with self._session.get(f"{self.base_url}/Clear", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
430
+ response.raise_for_status()
431
+ response_data = await response.read()
432
+
433
+ play_queue = parse_play_queue(response_data)
434
+
435
+ return play_queue
436
+
437
+ @_wrap_in_unreachable_error
438
+ async def sleep_timer(self, timeout: float | None = None) -> int:
439
+ """Set sleep timer. Time steps are 15, 30, 45, 60, 90 minutes. Each call goes to next step.
440
+ Resets to 0 if called when 90 minutes are set.
441
+
442
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
443
+
444
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
445
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
446
+
447
+ :return: The current sleep timer in minutes. 0 if no sleep timer is set.
448
+ """
449
+ used_timeout = timeout if timeout is not None else self._default_timeout
450
+
451
+ async with self._session.get(f"{self.base_url}/Sleep", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
452
+ response.raise_for_status()
453
+ response_data = await response.read()
454
+
455
+ return parse_sleep(response_data)
456
+
457
+ @_wrap_in_unreachable_error
458
+ async def presets(self, timeout: float | None = None) -> list[Preset]:
459
+ """Get the list of presets of the player.
460
+
461
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
462
+
463
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
464
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
465
+
466
+ :return: The list of presets of the player.
467
+ """
468
+ used_timeout = timeout if timeout is not None else self._default_timeout
469
+
470
+ async with self._session.get(f"{self.base_url}/Presets", timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
471
+ response.raise_for_status()
472
+ response_data = await response.read()
473
+
474
+ presets = parse_presets(response_data)
475
+
476
+ return presets
477
+
478
+ @_wrap_in_unreachable_error
479
+ async def load_preset(self, preset_id: int, timeout: float | None = None) -> None:
480
+ """Load a preset by ID.
481
+
482
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
483
+ :param preset_id: The ID of the preset to load.
484
+
485
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
486
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
487
+ """
488
+ used_timeout = timeout if timeout is not None else self._default_timeout
489
+
490
+ params = {
491
+ "id": preset_id,
492
+ }
493
+ async with self._session.get(f"{self.base_url}/Preset", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
494
+ response.raise_for_status()
495
+
496
+ @_wrap_in_unreachable_error
497
+ async def inputs(self, timeout: float | None = None) -> list[Input]:
498
+ """List all available inputs.
499
+
500
+ :param timeout: The timeout in seconds for the request. This overrides the default timeout.
501
+
502
+ :raises PlayerUnexpectedResponseError: If the response is not as expected. This is probably a bug in the library.
503
+ :raises PlayerUnreachableError: If the player is not reachable. Player is offline or request timed out.
504
+
505
+ :return: The list of inputs of the player.
506
+ """
507
+ used_timeout = timeout if timeout is not None else self._default_timeout
508
+
509
+ params = {"service": "Capture"}
510
+ async with self._session.get(f"{self.base_url}/RadioBrowse", params=params, timeout=aiohttp.ClientTimeout(total=used_timeout)) as response:
511
+ response.raise_for_status()
512
+ response_data = await response.read()
513
+
514
+ return parse_inputs(response_data)
pyblu/py.typed ADDED
File without changes
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyblu
3
+ Version: 2.0.2
4
+ Project-URL: repository, https://github.com/LouisChrist/pyblu
5
+ Author-email: Louis Christ <mail@louischrist.de>
6
+ License-File: LICENSE
7
+ Requires-Dist: aiohttp>=3.10.5
8
+ Requires-Dist: lxml>=5.3.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # pyblu
12
+
13
+ [![PyPI](https://img.shields.io/pypi/v/pyblu)](https://pypi.org/project/pyblu/)
14
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyblu)](https://pypi.org/project/pyblu/)
15
+ [![PyPI - License](https://img.shields.io/pypi/l/pyblu)](https://github.com/LouisChrist/pyblu/blob/main/LICENSE)
16
+
17
+ This is a Python library for interfacing with BluOS players.
18
+ It uses the
19
+ [BluOS API](https://bluesound-deutschland.de/wp-content/uploads/2022/01/Custom-Integration-API-v1.0_March-2021.pdf)
20
+ to control and query the status of BluOS players.
21
+ Authentication is not required.
22
+
23
+ Documentation is available at [here](https://louischrist.github.io/pyblu/)
24
+
25
+ ```python
26
+ from pyblu import Player
27
+
28
+
29
+ async def main():
30
+ async with Player("<host>") as player:
31
+ status = await player.status()
32
+ print(status)
33
+ ```
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install pyblu
39
+ ```
40
+
41
+
@@ -0,0 +1,10 @@
1
+ pyblu/__init__.py,sha256=iHHwwn8WzibjysOGi_MM_CbILIp-TrCs-Ruq0QHh0gA,275
2
+ pyblu/entities.py,sha256=_G-VfJIWe_vqpsNIJtEQegSJNMRSVuR-AZBiPVxTxLM,4696
3
+ pyblu/errors.py,sha256=PyMHq5J49enCyx662-MQ0S8g0BHRzOhIqq947n8N7ts,1600
4
+ pyblu/parse.py,sha256=5ODdXeOIWcKGaLe6PnikXIcQ75iuggyid-bd1PqB7sE,8626
5
+ pyblu/player.py,sha256=8cosYiR8KZGG2CpJhJwXYTcrnyLY77bVVUUZtnsLjFc,24323
6
+ pyblu/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pyblu-2.0.2.dist-info/METADATA,sha256=qzLmsLmY8o-zMg869POq9gGgZ5SY3IPZe0l5gNooM2g,1154
8
+ pyblu-2.0.2.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
9
+ pyblu-2.0.2.dist-info/licenses/LICENSE,sha256=-IzHfTBfUCjfyfsDXZgMBKx_IbyajmDw_XbvE5eQJ90,1069
10
+ pyblu-2.0.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Louis Christ
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.