pyblu 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
pyblu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.1
2
+ Name: pyblu
3
+ Version: 0.1.0
4
+ Summary:
5
+ Home-page: https://github.com/LouisChrist/pyblu
6
+ License: MIT
7
+ Author: Louis Christ
8
+ Author-email: mail@louischrist.de
9
+ Requires-Python: >=3.11,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: aiohttp (>=3.9.3,<4.0.0)
15
+ Requires-Dist: xmltodict (>=0.13.0,<0.14.0)
16
+ Project-URL: Repository, https://github.com/LouisChrist/pyblu
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pyblu
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/pyblu)](https://pypi.org/project/pyblu/)
22
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyblu)](https://pypi.org/project/pyblu/)
23
+ [![PyPI - License](https://img.shields.io/pypi/l/pyblu)](https://github.com/LouisChrist/python-pyblu/blob/main/LICENSE)
24
+
25
+ This is a Python library for interfacing with BluOS players.
26
+ It uses the
27
+ [BluOS API](https://bluesound-deutschland.de/wp-content/uploads/2022/01/Custom-Integration-API-v1.0_March-2021.pdf)
28
+ to control and query the status of BluOS players.
29
+ Authentication is not required.
30
+
31
+ Documentation is available at [here](https://louischrist.github.io/pyblu/)
32
+
33
+ ```python
34
+ from pyblu import Player
35
+
36
+
37
+ async def main():
38
+ async with Player("<host>") as player:
39
+ status = await player.status()
40
+ print(status)
41
+ ```
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install pyblu
47
+ ```
48
+
49
+
50
+
pyblu-0.1.0/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # pyblu
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pyblu)](https://pypi.org/project/pyblu/)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pyblu)](https://pypi.org/project/pyblu/)
5
+ [![PyPI - License](https://img.shields.io/pypi/l/pyblu)](https://github.com/LouisChrist/python-pyblu/blob/main/LICENSE)
6
+
7
+ This is a Python library for interfacing with BluOS players.
8
+ It uses the
9
+ [BluOS API](https://bluesound-deutschland.de/wp-content/uploads/2022/01/Custom-Integration-API-v1.0_March-2021.pdf)
10
+ to control and query the status of BluOS players.
11
+ Authentication is not required.
12
+
13
+ Documentation is available at [here](https://louischrist.github.io/pyblu/)
14
+
15
+ ```python
16
+ from pyblu import Player
17
+
18
+
19
+ async def main():
20
+ async with Player("<host>") as player:
21
+ status = await player.status()
22
+ print(status)
23
+ ```
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install pyblu
29
+ ```
30
+
31
+
@@ -0,0 +1,46 @@
1
+ [tool.poetry]
2
+ name = "pyblu"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = ["Louis Christ <mail@louischrist.de>"]
6
+ repository = "https://github.com/LouisChrist/pyblu"
7
+ license = "MIT"
8
+ readme = "README.md"
9
+ packages = [
10
+ { include = "pyblu", from = "src" },
11
+ ]
12
+
13
+ [tool.poetry.dependencies]
14
+ python = "^3.11"
15
+ xmltodict = "^0.13.0"
16
+ aiohttp = "^3.9.3"
17
+
18
+ [tool.poetry.group.dev.dependencies]
19
+ pylint = "^3.1.0"
20
+ black = "^24.2.0"
21
+ pytest = "^8.1.1"
22
+ aioresponses = "^0.7.6"
23
+ pytest-asyncio = "^0.23.5.post1"
24
+ sphinx = "^7.2.6"
25
+
26
+ [build-system]
27
+ requires = ["poetry-core"]
28
+ build-backend = "poetry.core.masonry.api"
29
+
30
+ [tool.pylint."messages control"]
31
+ disable = [
32
+ "missing-function-docstring",
33
+ "missing-module-docstring",
34
+ "missing-class-docstring",
35
+ "too-many-instance-attributes",
36
+ ]
37
+
38
+ [tool.pylint.format]
39
+ max-line-length = 160
40
+
41
+ [tool.black]
42
+ line-length = 160
43
+
44
+ [tool.pytest.ini_options]
45
+ pythonpath = "src"
46
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ """A Python library for controlling BluOS players."""
2
+
3
+ from ._player import Player
4
+ from ._entities import Status, Volume, SyncStatus, PairedPlayer
@@ -0,0 +1,99 @@
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
+ state: str
9
+ """Playback state"""
10
+
11
+ album: str
12
+ """Album name"""
13
+ artist: str
14
+ """Artist name"""
15
+ name: str
16
+ """Track name"""
17
+ image: str
18
+ """URL of the album art"""
19
+
20
+ volume: int
21
+ """Volume level with a range of 0-100"""
22
+ mute: bool
23
+ """Mute status"""
24
+ seconds: int
25
+ """Current playback position in seconds"""
26
+ total_seconds: float
27
+ """Total track length in seconds"""
28
+
29
+
30
+ @dataclass
31
+ class PairedPlayer:
32
+ ip: str
33
+ """IP address of the player"""
34
+ port: int
35
+ """Port of the player"""
36
+
37
+
38
+ @dataclass
39
+ class SyncStatus:
40
+ etag: str
41
+ """Cursor for long polling requests. Can be passed to next sync_status call."""
42
+ sync_stat: str
43
+ """Id of sync status. Changes whenever the sync status changes."""
44
+
45
+ id: str
46
+ """Player IP and port"""
47
+ mac: str
48
+ """MAC address of the player"""
49
+ name: str
50
+ """Name of the player"""
51
+
52
+ icon_url: str
53
+ """URL of the player icon"""
54
+ initialized: bool
55
+ """True means the player is already setup, false means the player needs to be setup"""
56
+
57
+ group: str | None
58
+ """Group name of the player"""
59
+ master: PairedPlayer | None
60
+ """Master player IP and port. Only present if the player is grouped and not master itself"""
61
+ slaves: PairedPlayer | None
62
+ """List of slave players. Every entry is IP and port. Only present if the player is master"""
63
+
64
+ zone: str | None
65
+ """Name of the zone the player is in. Zones are fixed groups."""
66
+ zone_master: bool | None
67
+ """True if the player is the master of the zone, false otherwise"""
68
+ zone_slave: bool | None
69
+ """True if the player is a slave in the zone, false otherwise"""
70
+
71
+ brand: str
72
+ """Brand name of the player"""
73
+ model: str
74
+ """Model name of the player"""
75
+ model_name: str
76
+ """Model name of the player"""
77
+
78
+ mute_volume_db: int | None
79
+ """If the player is muted, then this is the unmuted volume level in dB"""
80
+ mute_volume: int | None
81
+ """If the player is muted, then this is the unmuted volume level"""
82
+
83
+ volume_db: float
84
+ """Volume level in dB"""
85
+ volume: int
86
+ """Volume level with a range of 0-100. -1 means fixed volume."""
87
+
88
+ schema_version: int
89
+ """Software schema version"""
90
+
91
+
92
+ @dataclass
93
+ class Volume:
94
+ volume: int
95
+ """Volume level with a range of 0-100"""
96
+ db: float
97
+ """Volume level in dB"""
98
+ mute: bool
99
+ """Mute status"""
@@ -0,0 +1,288 @@
1
+ from typing import TypeVar, Union, Callable, TypeAlias
2
+
3
+ import aiohttp
4
+ import xmltodict
5
+
6
+ from pyblu._entities import Status, Volume, SyncStatus, PairedPlayer
7
+
8
+ StringDict: TypeAlias = dict[str, Union[str, "StringDict"]]
9
+ # pylint: disable=invalid-name
10
+ T: TypeAlias = TypeVar("T")
11
+
12
+
13
+ def chained_get(data: StringDict, *keys, _map: Callable[[str], T] = lambda x: x) -> T | None:
14
+ local_data = data
15
+ for key in keys:
16
+ local_data = local_data.get(key)
17
+ if not local_data:
18
+ return None
19
+ return _map(local_data)
20
+
21
+
22
+ class Player:
23
+ def __init__(self, host: str, port: int = 11000, session: aiohttp.ClientSession = None):
24
+ """Client for a BluOS player. Uses the HTTP API of the BluOS players to control it.
25
+
26
+ The passed sessions will not be closed when the player is closed and has to be closed by the caller.
27
+ If no session is passed, a new session will be created and closed when the player is closed.
28
+
29
+ *Player* is an async context manager and can be used with *async with*.
30
+
31
+ :param host: The hostname or IP address of the player.
32
+ :param port: The port of the player. Default is 11000.
33
+ :param session: An optional aiohttp.ClientSession to use for requests.
34
+
35
+ :return: A new Player.
36
+ """
37
+ self.base_url = f"http://{host}:{port}"
38
+ if session:
39
+ self._session_owned = False
40
+ self._session = session
41
+ else:
42
+ self._session_owned = True
43
+ self._session = aiohttp.ClientSession()
44
+
45
+ async def close(self):
46
+ if self._session_owned:
47
+ await self._session.close()
48
+
49
+ async def __aenter__(self):
50
+ return self
51
+
52
+ async def __aexit__(self, *args):
53
+ await self.close()
54
+
55
+ async def status(self, etag: str | None = None, timeout: int = 30) -> Status:
56
+ """Get the current status of the player.
57
+
58
+ This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
59
+ **etag** has to be the last etag received from the server.
60
+
61
+ :param etag: The last etag received from the server. Triggers long polling if set.
62
+ :param timeout: The timeout in seconds for long polling.
63
+
64
+ :return: The current status of the player. Only selected fields are returned.
65
+ """
66
+ params = {}
67
+ if etag:
68
+ params["etag"] = etag
69
+ params["timeout"] = timeout
70
+ async with self._session.get(f"{self.base_url}/Status", params=params) as response:
71
+ response.raise_for_status()
72
+ response_data = await response.text()
73
+ response_dict = xmltodict.parse(response_data)
74
+
75
+ status = Status(
76
+ etag=chained_get(response_dict, "status", "@etag"),
77
+ state=chained_get(response_dict, "status", "state"),
78
+ album=chained_get(response_dict, "status", "album"),
79
+ artist=chained_get(response_dict, "status", "artist"),
80
+ name=chained_get(response_dict, "status", "title1"),
81
+ image=chained_get(response_dict, "status", "image"),
82
+ volume=chained_get(response_dict, "status", "volume", _map=int),
83
+ mute=chained_get(response_dict, "status", "mute") == "1",
84
+ seconds=chained_get(response_dict, "status", "secs", _map=int),
85
+ total_seconds=chained_get(response_dict, "status", "totlen", _map=float),
86
+ )
87
+
88
+ return status
89
+
90
+ async def sync_status(self, etag: str | None = None, timeout: int = 30) -> SyncStatus:
91
+ """Get the SyncStatus of the player.
92
+
93
+ This endpoint supports long polling. If **etag** is set, the server will wait until the status changes or the timeout is reached.
94
+ **etag** has to be the last etag received from the server.
95
+
96
+ :param etag: The last etag received from the server. Triggers long polling if set.
97
+ :param timeout: The timeout in seconds for long polling.
98
+
99
+ :return: The SyncStatus of the player.
100
+ """
101
+ params = {}
102
+ if etag:
103
+ params["etag"] = etag
104
+ params["timeout"] = timeout
105
+ async with self._session.get(f"{self.base_url}/SyncStatus", params=params) as response:
106
+ response.raise_for_status()
107
+ response_data = await response.text()
108
+ response_dict = xmltodict.parse(response_data)
109
+
110
+ master_ip = chained_get(response_dict, "SyncStatus", "master", "#text")
111
+ master_port = chained_get(response_dict, "SyncStatus", "master", "@port")
112
+ master = PairedPlayer(ip=master_ip, port=int(master_port)) if master_ip and master_port else None
113
+
114
+ slaves_raw = chained_get(response_dict, "SyncStatus", "slave")
115
+ slaves = _parse_slave_list(slaves_raw)
116
+
117
+ sync_status = SyncStatus(
118
+ etag=chained_get(response_dict, "SyncStatus", "@etag"),
119
+ sync_stat=chained_get(response_dict, "SyncStatus", "@syncStat"),
120
+ id=chained_get(response_dict, "SyncStatus", "@id"),
121
+ mac=chained_get(response_dict, "SyncStatus", "@mac"),
122
+ name=chained_get(response_dict, "SyncStatus", "@name"),
123
+ icon_url=chained_get(response_dict, "SyncStatus", "@icon"),
124
+ initialized=chained_get(response_dict, "SyncStatus", "@initialized") == "true",
125
+ group=chained_get(response_dict, "SyncStatus", "@group"),
126
+ master=master,
127
+ slaves=slaves,
128
+ zone=chained_get(response_dict, "SyncStatus", "@zone"),
129
+ zone_master=chained_get(response_dict, "SyncStatus", "@zoneMaster") == "true",
130
+ zone_slave=chained_get(response_dict, "SyncStatus", "@zoneSlave") == "true",
131
+ brand=chained_get(response_dict, "SyncStatus", "@brand"),
132
+ model=chained_get(response_dict, "SyncStatus", "@model"),
133
+ model_name=chained_get(response_dict, "SyncStatus", "@modelName"),
134
+ mute_volume_db=chained_get(response_dict, "SyncStatus", "@muteDb", _map=int),
135
+ mute_volume=chained_get(response_dict, "SyncStatus", "@muteVolume", _map=int),
136
+ volume_db=chained_get(response_dict, "SyncStatus", "@db", _map=int),
137
+ volume=chained_get(response_dict, "SyncStatus", "@volume", _map=int),
138
+ schema_version=chained_get(response_dict, "SyncStatus", "@schemaVersion", _map=int),
139
+ )
140
+
141
+ return sync_status
142
+
143
+ async def volume(self, level: int = None, mute: bool = None, tell_slaves: bool = None) -> Volume:
144
+ """Get or set the volume of the player.
145
+ Call without parameters to get the current volume. Call with parameters to set the volume.
146
+
147
+ :param level: The volume level to set. Range is 0-100.
148
+ :param mute: Whether to mute the player.
149
+ :param tell_slaves: Whether to tell grouped speakers to change their volume as well.
150
+
151
+ :return: The current volume of the player.
152
+ """
153
+ params = {}
154
+ if level:
155
+ params["level"] = level
156
+ if mute:
157
+ params["mute"] = "1" if mute else "0"
158
+ if tell_slaves:
159
+ params["tell_slaves"] = "1" if tell_slaves else "0"
160
+
161
+ async with self._session.get(f"{self.base_url}/Volume", params=params) as response:
162
+ response.raise_for_status()
163
+ response_data = await response.text()
164
+ response_dict = xmltodict.parse(response_data)
165
+
166
+ volume = Volume(
167
+ volume=chained_get(response_dict, "volume", "#text", _map=int),
168
+ db=chained_get(response_dict, "volume", "@db", _map=float),
169
+ mute=chained_get(response_dict, "volume", "@mute") == "1",
170
+ )
171
+
172
+ return volume
173
+
174
+ async def play(self, seek: int = None) -> str:
175
+ """Start playing the current track. Can also be used to seek within the current track.
176
+ Works only when paused, not when stopped.
177
+
178
+ :param seek: The position in seconds to seek to.
179
+
180
+ :return: The playback state after command execution.
181
+ """
182
+ params = {}
183
+ if seek:
184
+ params["seek"] = seek
185
+
186
+ async with self._session.get(f"{self.base_url}/Play", params=params) as response:
187
+ response.raise_for_status()
188
+ response_data = await response.text()
189
+ response_dict = xmltodict.parse(response_data)
190
+
191
+ return chained_get(response_dict, "state")
192
+
193
+ async def pause(self, toggle: bool = None) -> str:
194
+ """Pause the current track. **toggle** can be used to toggle between playing and pause.
195
+
196
+ :param toggle: Toggle between playing and pause.
197
+
198
+ :return: The playback state after command execution.
199
+ """
200
+ params = {}
201
+ if toggle:
202
+ params["toggle"] = "1"
203
+
204
+ async with self._session.get(f"{self.base_url}/Pause", params=params) as response:
205
+ response.raise_for_status()
206
+ response_data = await response.text()
207
+ response_dict = xmltodict.parse(response_data)
208
+
209
+ return chained_get(response_dict, "state")
210
+
211
+ async def stop(self) -> str:
212
+ """Stop the current track. Stopped playback cannot be resumed.
213
+
214
+ :return: The playback state after command execution.
215
+ """
216
+ async with self._session.get(f"{self.base_url}/Stop") as response:
217
+ response.raise_for_status()
218
+ response_data = await response.text()
219
+ response_dict = xmltodict.parse(response_data)
220
+
221
+ return chained_get(response_dict, "state")
222
+
223
+ async def skip(self) -> None:
224
+ """Skip to the next track."""
225
+ async with self._session.get(f"{self.base_url}/Skip") as response:
226
+ response.raise_for_status()
227
+
228
+ async def back(self) -> None:
229
+ """Go back to the previous track."""
230
+ async with self._session.get(f"{self.base_url}/Back") as response:
231
+ response.raise_for_status()
232
+
233
+ async def add_slave(self, ip: str, port: int = 11000) -> list[PairedPlayer]:
234
+ """Add a secondary player to the current player as a slave.
235
+ If it fails the player won't be in the returned list.
236
+
237
+ :param ip: The IP address of the player to add.
238
+ :param port: The port of the player to add. Default is 11000.
239
+
240
+ :return: The list of slaves of the player.
241
+ """
242
+ params = {
243
+ "slave": ip,
244
+ "port": port,
245
+ }
246
+ async with self._session.get(f"{self.base_url}/AddSlave", params=params) as response:
247
+ response.raise_for_status()
248
+ response_data = await response.text()
249
+ response_dict = xmltodict.parse(response_data)
250
+
251
+ slaves_raw = chained_get(response_dict, "addSlave", "slave")
252
+ slaves = _parse_slave_list(slaves_raw)
253
+
254
+ return slaves
255
+
256
+ async def add_slaves(self, slaves: list[PairedPlayer]) -> list[PairedPlayer]:
257
+ """Add a list of secondary players to the current player as slaves.
258
+ If it fails the player won't be in the returned list.
259
+
260
+ Same as *add_slave* but with a list of players. Makes only one request to player.
261
+
262
+ :param slaves: The list of players to add.
263
+
264
+ :return: The list of slaves of the player.
265
+ """
266
+ params = {
267
+ "slaves": ",".join(x.ip for x in slaves),
268
+ "ports": ",".join(str(x.port) for x in slaves),
269
+ }
270
+ async with self._session.get(f"{self.base_url}/AddSlave", params=params) as response:
271
+ response.raise_for_status()
272
+ response_data = await response.text()
273
+ response_dict = xmltodict.parse(response_data)
274
+
275
+ slaves_raw = chained_get(response_dict, "addSlave", "slave")
276
+ slaves = _parse_slave_list(slaves_raw)
277
+
278
+ return slaves
279
+
280
+
281
+ def _parse_slave_list(slaves_raw: list[dict[str, str]]) -> list[PairedPlayer] | None:
282
+ match slaves_raw:
283
+ case {"@id": ip, "@port": port}:
284
+ return [PairedPlayer(ip=ip, port=int(port))]
285
+ case [*slaves_raw]:
286
+ return [PairedPlayer(ip=slave["@id"], port=int(slave["@port"])) for slave in slaves_raw]
287
+ case _:
288
+ return None