pyblu 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.
- pyblu/__init__.py +4 -0
- pyblu/_entities.py +99 -0
- pyblu/_player.py +288 -0
- pyblu-0.1.0.dist-info/LICENSE +21 -0
- pyblu-0.1.0.dist-info/METADATA +50 -0
- pyblu-0.1.0.dist-info/RECORD +7 -0
- pyblu-0.1.0.dist-info/WHEEL +4 -0
pyblu/__init__.py
ADDED
pyblu/_entities.py
ADDED
|
@@ -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"""
|
pyblu/_player.py
ADDED
|
@@ -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
|
|
@@ -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.
|
|
@@ -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
|
+
[](https://pypi.org/project/pyblu/)
|
|
22
|
+
[](https://pypi.org/project/pyblu/)
|
|
23
|
+
[](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
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pyblu/__init__.py,sha256=iTB60dGT25prfEKtFFta1nNpAO0Y5evIwrGQDOt_FZI,147
|
|
2
|
+
pyblu/_entities.py,sha256=8e2i1IHMrJAj9TZg6IQDnxcrW33ldIeFunquOi7e650,2554
|
|
3
|
+
pyblu/_player.py,sha256=GsftfMIFfKI8MJfAsTS4jEaB8tVqS88TVh9K16-0sRE,12244
|
|
4
|
+
pyblu-0.1.0.dist-info/LICENSE,sha256=-IzHfTBfUCjfyfsDXZgMBKx_IbyajmDw_XbvE5eQJ90,1069
|
|
5
|
+
pyblu-0.1.0.dist-info/METADATA,sha256=NImPqm87z9svM-ccSpaoSRlo1nmQHvePYaCtHDcVSbA,1473
|
|
6
|
+
pyblu-0.1.0.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
7
|
+
pyblu-0.1.0.dist-info/RECORD,,
|