python-linkplay 0.0.5__tar.gz → 0.0.7__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.
- {python_linkplay-0.0.5/src/python_linkplay.egg-info → python_linkplay-0.0.7}/PKG-INFO +5 -2
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/pyproject.toml +4 -1
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/setup.cfg +4 -6
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/__main__.py +3 -2
- python_linkplay-0.0.7/src/linkplay/__version__.py +1 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/bridge.py +70 -56
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/consts.py +157 -36
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/controller.py +12 -4
- python_linkplay-0.0.7/src/linkplay/discovery.py +118 -0
- python_linkplay-0.0.7/src/linkplay/endpoint.py +40 -0
- python_linkplay-0.0.7/src/linkplay/utils.py +106 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7/src/python_linkplay.egg-info}/PKG-INFO +5 -2
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/SOURCES.txt +1 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/requires.txt +4 -1
- python_linkplay-0.0.5/src/linkplay/__version__.py +0 -1
- python_linkplay-0.0.5/src/linkplay/discovery.py +0 -67
- python_linkplay-0.0.5/src/linkplay/utils.py +0 -62
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/LICENSE +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/README.md +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/setup.py +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/__init__.py +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/linkplay/exceptions.py +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/not-zip-safe +0 -0
- {python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: python_linkplay
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.7
|
4
4
|
Summary: A Python Library for Seamless LinkPlay Device Control
|
5
5
|
Author: Velleman Group nv
|
6
6
|
License: MIT
|
@@ -10,16 +10,19 @@ Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: async-timeout>=4.0.3
|
12
12
|
Requires-Dist: aiohttp>=3.8.5
|
13
|
+
Requires-Dist: appdirs>=1.4.4
|
13
14
|
Requires-Dist: async_upnp_client>=0.36.2
|
15
|
+
Requires-Dist: deprecated>=1.2.14
|
14
16
|
Provides-Extra: testing
|
15
17
|
Requires-Dist: pytest>=7.3.1; extra == "testing"
|
16
18
|
Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
|
17
19
|
Requires-Dist: pytest-mock>=3.10.0; extra == "testing"
|
18
20
|
Requires-Dist: pytest-asyncio>=0.23.3; extra == "testing"
|
19
21
|
Requires-Dist: mypy>=1.3.0; extra == "testing"
|
20
|
-
Requires-Dist:
|
22
|
+
Requires-Dist: ruff>=0.5.4; extra == "testing"
|
21
23
|
Requires-Dist: tox>=4.6.0; extra == "testing"
|
22
24
|
Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
|
25
|
+
Requires-Dist: pre-commit>=3.8.0; extra == "testing"
|
23
26
|
|
24
27
|
|
25
28
|
[](https://pypi.org/project/python-linkplay/)
|
@@ -14,7 +14,9 @@ packages = find_namespace:
|
|
14
14
|
install_requires =
|
15
15
|
async-timeout>=4.0.3
|
16
16
|
aiohttp>=3.8.5
|
17
|
+
appdirs>=1.4.4
|
17
18
|
async_upnp_client>=0.36.2
|
19
|
+
deprecated>=1.2.14
|
18
20
|
python_requires = >=3.11
|
19
21
|
package_dir =
|
20
22
|
=src
|
@@ -33,14 +35,10 @@ testing =
|
|
33
35
|
pytest-mock>=3.10.0
|
34
36
|
pytest-asyncio>=0.23.3
|
35
37
|
mypy>=1.3.0
|
36
|
-
|
38
|
+
ruff>=0.5.4
|
37
39
|
tox>=4.6.0
|
38
40
|
typing-extensions>=4.6.3
|
39
|
-
|
40
|
-
[flake8]
|
41
|
-
max-line-length = 160
|
42
|
-
per-file-ignores =
|
43
|
-
*/__init__.py: F401
|
41
|
+
pre-commit>=3.8.0
|
44
42
|
|
45
43
|
[egg_info]
|
46
44
|
tag_build =
|
@@ -1,11 +1,11 @@
|
|
1
1
|
import asyncio
|
2
|
-
import aiohttp
|
3
2
|
|
4
3
|
from linkplay.controller import LinkPlayController
|
4
|
+
from linkplay.utils import create_unverified_client_session
|
5
5
|
|
6
6
|
|
7
7
|
async def main():
|
8
|
-
async with
|
8
|
+
async with create_unverified_client_session() as session:
|
9
9
|
controller = LinkPlayController(session)
|
10
10
|
|
11
11
|
await controller.discover_bridges()
|
@@ -16,5 +16,6 @@ async def main():
|
|
16
16
|
for multiroom in controller.multirooms:
|
17
17
|
print(multiroom.followers)
|
18
18
|
|
19
|
+
|
19
20
|
if __name__ == "__main__":
|
20
21
|
asyncio.run(main())
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = '0.0.7'
|
@@ -1,32 +1,34 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
+
|
2
3
|
from typing import Any
|
3
|
-
from aiohttp import ClientSession
|
4
4
|
|
5
5
|
from linkplay.consts import (
|
6
|
+
INPUT_MODE_MAP,
|
7
|
+
PLAY_MODE_SEND_MAP,
|
6
8
|
ChannelType,
|
7
|
-
LinkPlayCommand,
|
8
9
|
DeviceAttribute,
|
9
|
-
PlayerAttribute,
|
10
|
-
MuteMode,
|
11
10
|
EqualizerMode,
|
11
|
+
InputMode,
|
12
|
+
LinkPlayCommand,
|
12
13
|
LoopMode,
|
13
|
-
|
14
|
+
MultiroomAttribute,
|
15
|
+
MuteMode,
|
16
|
+
PlayerAttribute,
|
17
|
+
PlayingMode,
|
14
18
|
PlayingStatus,
|
15
|
-
InputMode,
|
16
19
|
SpeakerType,
|
17
|
-
PlayingMode,
|
18
|
-
INPUT_MODE_MAP,
|
19
|
-
MultiroomAttribute
|
20
20
|
)
|
21
|
-
|
22
|
-
from linkplay.utils import
|
21
|
+
from linkplay.endpoint import LinkPlayEndpoint
|
22
|
+
from linkplay.utils import decode_hexstr
|
23
23
|
|
24
24
|
|
25
|
-
class LinkPlayDevice
|
25
|
+
class LinkPlayDevice:
|
26
26
|
"""Represents a LinkPlay device."""
|
27
27
|
|
28
28
|
bridge: LinkPlayBridge
|
29
|
-
properties: dict[DeviceAttribute, str] = dict.fromkeys(
|
29
|
+
properties: dict[DeviceAttribute, str] = dict.fromkeys(
|
30
|
+
DeviceAttribute.__members__.values(), ""
|
31
|
+
)
|
30
32
|
|
31
33
|
def __init__(self, bridge: LinkPlayBridge):
|
32
34
|
self.bridge = bridge
|
@@ -42,31 +44,39 @@ class LinkPlayDevice():
|
|
42
44
|
@property
|
43
45
|
def uuid(self) -> str:
|
44
46
|
"""The UUID of the device."""
|
45
|
-
return self.properties
|
47
|
+
return self.properties.get(DeviceAttribute.UUID, "")
|
46
48
|
|
47
49
|
@property
|
48
50
|
def name(self) -> str:
|
49
51
|
"""The name of the device."""
|
50
|
-
return self.properties
|
52
|
+
return self.properties.get(DeviceAttribute.DEVICE_NAME, "")
|
51
53
|
|
52
54
|
@property
|
53
55
|
def playmode_support(self) -> list[PlayingMode]:
|
54
56
|
"""Returns the player playmode support."""
|
55
57
|
|
56
|
-
flags = InputMode(
|
58
|
+
flags = InputMode(
|
59
|
+
int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16)
|
60
|
+
)
|
57
61
|
return [INPUT_MODE_MAP[flag] for flag in flags]
|
58
62
|
|
59
63
|
@property
|
60
64
|
def eth(self) -> str:
|
61
65
|
"""Returns the ethernet address."""
|
62
|
-
return
|
66
|
+
return (
|
67
|
+
self.properties[DeviceAttribute.ETH_DHCP]
|
68
|
+
if DeviceAttribute.ETH_DHCP in self.properties
|
69
|
+
else self.properties[DeviceAttribute.ETH0]
|
70
|
+
)
|
63
71
|
|
64
72
|
|
65
|
-
class LinkPlayPlayer
|
73
|
+
class LinkPlayPlayer:
|
66
74
|
"""Represents a LinkPlay player."""
|
67
75
|
|
68
76
|
bridge: LinkPlayBridge
|
69
|
-
properties: dict[PlayerAttribute, str] = dict.fromkeys(
|
77
|
+
properties: dict[PlayerAttribute, str] = dict.fromkeys(
|
78
|
+
PlayerAttribute.__members__.values(), ""
|
79
|
+
)
|
70
80
|
|
71
81
|
def __init__(self, bridge: LinkPlayBridge):
|
72
82
|
self.bridge = bridge
|
@@ -134,111 +144,104 @@ class LinkPlayPlayer():
|
|
134
144
|
|
135
145
|
async def set_play_mode(self, mode: PlayingMode) -> None:
|
136
146
|
"""Set the play mode."""
|
137
|
-
await self.bridge.request(
|
147
|
+
await self.bridge.request(
|
148
|
+
LinkPlayCommand.SWITCH_MODE.format(PLAY_MODE_SEND_MAP[mode])
|
149
|
+
) # type: ignore[str-format]
|
138
150
|
|
139
151
|
@property
|
140
152
|
def muted(self) -> bool:
|
141
153
|
"""Returns if the player is muted."""
|
142
|
-
return self.properties
|
154
|
+
return self.properties.get(PlayerAttribute.MUTED, MuteMode.UNMUTED) == MuteMode.MUTED
|
143
155
|
|
144
156
|
@property
|
145
157
|
def title(self) -> str:
|
146
158
|
"""Returns if the currently playing title of the track."""
|
147
|
-
return self.properties
|
159
|
+
return self.properties.get(PlayerAttribute.TITLE, "")
|
148
160
|
|
149
161
|
@property
|
150
162
|
def artist(self) -> str:
|
151
163
|
"""Returns if the currently playing artist."""
|
152
|
-
return self.properties
|
164
|
+
return self.properties.get(PlayerAttribute.ARTIST, "")
|
153
165
|
|
154
166
|
@property
|
155
167
|
def album(self) -> str:
|
156
168
|
"""Returns if the currently playing album."""
|
157
|
-
return self.properties
|
169
|
+
return self.properties.get(PlayerAttribute.ALBUM, "")
|
158
170
|
|
159
171
|
@property
|
160
172
|
def volume(self) -> int:
|
161
173
|
"""Returns the player volume, expressed in %."""
|
162
|
-
return int(self.properties
|
174
|
+
return int(self.properties.get(PlayerAttribute.VOLUME, 0))
|
163
175
|
|
164
176
|
@property
|
165
177
|
def current_position(self) -> int:
|
166
178
|
"""Returns the current position of the track in milliseconds."""
|
167
|
-
return int(self.properties
|
179
|
+
return int(self.properties.get(PlayerAttribute.CURRENT_POSITION, 0))
|
168
180
|
|
169
181
|
@property
|
170
182
|
def total_length(self) -> int:
|
171
183
|
"""Returns the total length of the track in milliseconds."""
|
172
|
-
return int(self.properties
|
184
|
+
return int(self.properties.get(PlayerAttribute.TOTAL_LENGTH, 0))
|
173
185
|
|
174
186
|
@property
|
175
187
|
def status(self) -> PlayingStatus:
|
176
188
|
"""Returns the current playing status."""
|
177
|
-
return PlayingStatus(self.properties
|
189
|
+
return PlayingStatus(self.properties.get(PlayerAttribute.PLAYING_STATUS, PlayingStatus.STOPPED))
|
178
190
|
|
179
191
|
@property
|
180
192
|
def equalizer_mode(self) -> EqualizerMode:
|
181
193
|
"""Returns the current equalizer mode."""
|
182
|
-
return EqualizerMode(self.properties
|
194
|
+
return EqualizerMode(self.properties.get(PlayerAttribute.EQUALIZER_MODE, EqualizerMode.CLASSIC))
|
183
195
|
|
184
196
|
@property
|
185
197
|
def speaker_type(self) -> SpeakerType:
|
186
198
|
"""Returns the current speaker the player is playing on."""
|
187
|
-
return SpeakerType(self.properties
|
199
|
+
return SpeakerType(self.properties.get(PlayerAttribute.SPEAKER_TYPE, SpeakerType.MAIN_SPEAKER))
|
188
200
|
|
189
201
|
@property
|
190
202
|
def channel_type(self) -> ChannelType:
|
191
203
|
"""Returns the channel the player is playing on."""
|
192
|
-
return ChannelType(self.properties
|
204
|
+
return ChannelType(self.properties.get(PlayerAttribute.CHANNEL_TYPE, ChannelType.STEREO))
|
193
205
|
|
194
206
|
@property
|
195
207
|
def play_mode(self) -> PlayingMode:
|
196
208
|
"""Returns the current playing mode of the player."""
|
197
|
-
return PlayingMode(self.properties
|
209
|
+
return PlayingMode(self.properties.get(PlayerAttribute.PLAYBACK_MODE, PlayingMode.IDLE))
|
198
210
|
|
199
211
|
@property
|
200
212
|
def loop_mode(self) -> LoopMode:
|
201
213
|
"""Returns the current playlist mode."""
|
202
|
-
return LoopMode(self.properties
|
214
|
+
return LoopMode(self.properties.get(PlayerAttribute.PLAYLIST_MODE, LoopMode.CONTINUOUS_PLAYBACK))
|
203
215
|
|
204
216
|
|
205
|
-
class LinkPlayBridge
|
217
|
+
class LinkPlayBridge:
|
206
218
|
"""Represents a LinkPlay bridge to control the device and player attached to it."""
|
207
219
|
|
208
|
-
|
209
|
-
ip_address: str
|
210
|
-
session: ClientSession
|
220
|
+
endpoint: LinkPlayEndpoint
|
211
221
|
device: LinkPlayDevice
|
212
222
|
player: LinkPlayPlayer
|
213
223
|
|
214
|
-
def __init__(self,
|
215
|
-
self.
|
216
|
-
self.ip_address = ip_address
|
217
|
-
self.session = session
|
224
|
+
def __init__(self, *, endpoint: LinkPlayEndpoint):
|
225
|
+
self.endpoint = endpoint
|
218
226
|
self.device = LinkPlayDevice(self)
|
219
227
|
self.player = LinkPlayPlayer(self)
|
220
228
|
|
221
|
-
def
|
229
|
+
def __str__(self) -> str:
|
222
230
|
if self.device.name == "":
|
223
|
-
return self.endpoint
|
231
|
+
return f"{self.endpoint}"
|
224
232
|
|
225
233
|
return self.device.name
|
226
234
|
|
227
|
-
@property
|
228
|
-
def endpoint(self) -> str:
|
229
|
-
"""Returns the current player endpoint."""
|
230
|
-
return f"{self.protocol}://{self.ip_address}"
|
231
|
-
|
232
235
|
async def json_request(self, command: str) -> dict[str, str]:
|
233
236
|
"""Performs a GET request on the given command and returns the result as a JSON object."""
|
234
|
-
return await
|
237
|
+
return await self.endpoint.json_request(command)
|
235
238
|
|
236
239
|
async def request(self, command: str) -> None:
|
237
240
|
"""Performs a GET request on the given command and verifies the result."""
|
238
|
-
await
|
241
|
+
await self.endpoint.request(command)
|
239
242
|
|
240
243
|
|
241
|
-
class LinkPlayMultiroom
|
244
|
+
class LinkPlayMultiroom:
|
242
245
|
"""Represents a LinkPlay multiroom group. Contains a leader and a list of followers.
|
243
246
|
The leader is the device that controls the group."""
|
244
247
|
|
@@ -251,14 +254,21 @@ class LinkPlayMultiroom():
|
|
251
254
|
|
252
255
|
async def update_status(self, bridges: list[LinkPlayBridge]) -> None:
|
253
256
|
"""Updates the multiroom status."""
|
254
|
-
properties: dict[Any, Any] = await self.leader.json_request(
|
257
|
+
properties: dict[Any, Any] = await self.leader.json_request(
|
258
|
+
LinkPlayCommand.MULTIROOM_LIST
|
259
|
+
)
|
255
260
|
|
256
261
|
self.followers = []
|
257
262
|
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
258
263
|
return
|
259
264
|
|
260
|
-
follower_uuids = [
|
261
|
-
|
265
|
+
follower_uuids = [
|
266
|
+
follower[MultiroomAttribute.UUID]
|
267
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]
|
268
|
+
]
|
269
|
+
new_followers = [
|
270
|
+
bridge for bridge in bridges if bridge.device.uuid in follower_uuids
|
271
|
+
]
|
262
272
|
self.followers.extend(new_followers)
|
263
273
|
|
264
274
|
async def ungroup(self) -> None:
|
@@ -268,12 +278,16 @@ class LinkPlayMultiroom():
|
|
268
278
|
|
269
279
|
async def add_follower(self, follower: LinkPlayBridge) -> None:
|
270
280
|
"""Adds a follower to the multiroom group."""
|
271
|
-
await follower.request(
|
281
|
+
await follower.request(
|
282
|
+
LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)
|
283
|
+
) # type: ignore[str-format]
|
272
284
|
self.followers.append(follower)
|
273
285
|
|
274
286
|
async def remove_follower(self, follower: LinkPlayBridge) -> None:
|
275
287
|
"""Removes a follower from the multiroom group."""
|
276
|
-
await self.leader.request(
|
288
|
+
await self.leader.request(
|
289
|
+
LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)
|
290
|
+
) # type: ignore[str-format]
|
277
291
|
self.followers.remove(follower)
|
278
292
|
|
279
293
|
async def set_volume(self, value: int) -> None:
|
@@ -1,18 +1,72 @@
|
|
1
|
-
from enum import
|
1
|
+
from enum import IntFlag, StrEnum
|
2
2
|
|
3
3
|
API_ENDPOINT: str = "{}/httpapi.asp?command={}"
|
4
4
|
API_TIMEOUT: int = 2
|
5
5
|
UNKNOWN_TRACK_PLAYING: str = "Unknown"
|
6
|
-
UPNP_DEVICE_TYPE =
|
6
|
+
UPNP_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
7
|
+
|
8
|
+
MTLS_CERTIFICATE_CONTENTS = """
|
9
|
+
-----BEGIN PRIVATE KEY-----
|
10
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCk/u2tH0LwCOv8
|
11
|
+
JmqLvQdjNAdkxfSCPwrdHM7STlq5xaJGQe29yd8kjP7h0wERkeO/9JO62wUHBu0P
|
12
|
+
WIsS/jwLG+G3oAU+7BNfjmBhDXyHIRQLzAWEbtxbsSTke1losfvlQXGumXrMqf9X
|
13
|
+
LdIYvbA53mp8GImQbJkaCDvwnEdUFkuJ0W5Tofr58jJqfCt6OPwHmnP4oC6LpPtJ
|
14
|
+
YDy7r1Q9sLgCYEtDEw/Mhf+mKuC0pnst52e1qceVjvCuUBoeuhk6kpnEbpSdEKbD
|
15
|
+
bdE8cPoVRmrj1//PLFMVtNB7k2aPMb3CcoJ/dHxaCXwk9b3jIBs6CyWixN92CuaO
|
16
|
+
Q98Ug/YlAgMBAAECggEAHyCpHlwjeL12J9/nge1rk1+hdXWTJ29VUVm5+xslKp8K
|
17
|
+
ek6912xaWL7w5xGzxejMGs69gCcJz8WSu65srmygT0g3UTkzRCetj/2AWU7+C1BG
|
18
|
+
Q+N9tvpjQDkvSJusxn+tkhbCp7n03N/FeGEAngJLWN+JH1hRu5mBWNPs2vvgyRAO
|
19
|
+
Cv95G7uENavCUXcyYsKPoAfz3ebD/idwwWW2RKAd0ufYeafiFC0ImTLcpEjBvCTW
|
20
|
+
UoAniBSVx1PHK4IAUb3pMdPtIv1uBlIMotHS/GdEyHU6qOsX5ijHqncHHneaytmL
|
21
|
+
+wJukPqASEBl3F2UnzryBUgGqr1wyH9vtPGjklnngQKBgQDZv3oxZWul//2LV+jo
|
22
|
+
ZipbnP6nwG3J6pOWPDD3dHoZ6Q2DRyJXN5ty40PS393GVvrSJSdRGeD9+ox5sFoj
|
23
|
+
iUMgd6kHG4ME7Fre57zUkqy1Ln1K1fkP5tBUD0hviigHBWih2/Nyl2vrdvX5Wpxx
|
24
|
+
5r42UQa9nOzrNB03DTOhDrUszQKBgQDB+xdMRNSFfCatQj+y2KehcH9kaANPvT0l
|
25
|
+
l9vgb72qks01h05GSPBZnT1qfndh/Myno9KuVPhJ0HrVwRAjZTd4T69fAH3imW+R
|
26
|
+
7HP+RgDen4SRTxj6UTJh2KZ8fdPeCby1xTwxYNjq8HqpiO6FHZpE+l4FE8FalZK+
|
27
|
+
Z3GhE7DuuQKBgDq7b+0U6xVKWAwWuSa+L9yoGvQKblKRKB/Uumx0iV6lwtRPAo89
|
28
|
+
23sAm9GsOnh+C4dVKCay8UHwK6XDEH0XT/jY7cmR/SP90IDhRsibi2QPVxIxZs2I
|
29
|
+
N1cFDEexnxxNtCw8VIzrFNvdKXmJnDsIvvONpWDNjAXg96RatjtR6UJdAoGBAIAx
|
30
|
+
HU5r1j54s16gf1QD1ZPcsnN6QWX622PynX4OmjsVVMPhLRtJrHysax/rf52j4OOQ
|
31
|
+
YfSPdp3hRqvoMHATvbqmfnC79HVBjPfUWTtaq8xzgro8mXcjHbaH5E41IUSFDs7Z
|
32
|
+
D1Raej+YuJc9RNN3orGe+29DhO4GFrn5xp/6UV0RAoGBAKUdRgryWzaN4auzWaRD
|
33
|
+
lxoMhlwQdCXzBI1YLH2QUL8elJOHMNfmja5G9iW07ZrhhvQBGNDXFbFrX4hI3c/0
|
34
|
+
JC3SPhaaedIjOe9Qd3tn5KgYxbBnWnCTt0kxgro+OM3ORgJseSWbKdRrjOkUxkab
|
35
|
+
/NDvel7IF63U4UEkrVVt1bYg
|
36
|
+
-----END PRIVATE KEY-----
|
37
|
+
-----BEGIN CERTIFICATE-----
|
38
|
+
MIIDmDCCAoACAQEwDQYJKoZIhvcNAQELBQAwgZExCzAJBgNVBAYTAkNOMREwDwYD
|
39
|
+
VQQIDAhTaGFuZ2hhaTERMA8GA1UEBwwIU2hhbmdoYWkxETAPBgNVBAoMCExpbmtw
|
40
|
+
bGF5MQwwCgYDVQQLDANpbmMxGTAXBgNVBAMMEHd3dy5saW5rcGxheS5jb20xIDAe
|
41
|
+
BgkqhkiG9w0BCQEWEW1haWxAbGlua3BsYXkuY29tMB4XDTE4MTExNTAzMzI1OVoX
|
42
|
+
DTQ2MDQwMTAzMzI1OVowgZExCzAJBgNVBAYTAkNOMREwDwYDVQQIDAhTaGFuZ2hh
|
43
|
+
aTERMA8GA1UEBwwIU2hhbmdoYWkxETAPBgNVBAoMCExpbmtwbGF5MQwwCgYDVQQL
|
44
|
+
DANpbmMxGTAXBgNVBAMMEHd3dy5saW5rcGxheS5jb20xIDAeBgkqhkiG9w0BCQEW
|
45
|
+
EW1haWxAbGlua3BsYXkuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
|
46
|
+
AQEApP7trR9C8Ajr/CZqi70HYzQHZMX0gj8K3RzO0k5aucWiRkHtvcnfJIz+4dMB
|
47
|
+
EZHjv/STutsFBwbtD1iLEv48Cxvht6AFPuwTX45gYQ18hyEUC8wFhG7cW7Ek5HtZ
|
48
|
+
aLH75UFxrpl6zKn/Vy3SGL2wOd5qfBiJkGyZGgg78JxHVBZLidFuU6H6+fIyanwr
|
49
|
+
ejj8B5pz+KAui6T7SWA8u69UPbC4AmBLQxMPzIX/pirgtKZ7LedntanHlY7wrlAa
|
50
|
+
HroZOpKZxG6UnRCmw23RPHD6FUZq49f/zyxTFbTQe5NmjzG9wnKCf3R8Wgl8JPW9
|
51
|
+
4yAbOgslosTfdgrmjkPfFIP2JQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQARmy6f
|
52
|
+
esrifhW5NM9i3xsEVp945iSXhqHgrtIROgrC7F1EIAyoIiBdaOvitZVtsYc7Ivys
|
53
|
+
QtyVmEGscyjuYTdfigvwTVVj2oCeFv1Xjf+t/kSuk6X3XYzaxPPnFG4nAe2VwghE
|
54
|
+
rbZG0K5l8iXM7Lm+ZdqQaAYVWsQDBG8lbczgkB9q5ed4zbDPf6Fsrsynxji/+xa4
|
55
|
+
9ARfyHlkCDBThGNnnl+QITtfOWxm/+eReILUQjhwX+UwbY07q/nUxLlK6yrzyjnn
|
56
|
+
wi2B2GovofQ/4icVZ3ecTqYK3q9gEtJi72V+dVHM9kSA4Upy28Y0U1v56uoqeWQ6
|
57
|
+
uc2m8y8O/hXPSfKd
|
58
|
+
-----END CERTIFICATE-----
|
59
|
+
"""
|
7
60
|
|
8
61
|
|
9
62
|
class LinkPlayCommand(StrEnum):
|
10
63
|
"""Defines the LinkPlay commands."""
|
11
|
-
|
64
|
+
|
65
|
+
DEVICE_STATUS = "getStatusEx"
|
12
66
|
SYSLOG = "getsyslog"
|
13
67
|
UPDATE_SERVER = "GetUpdateServer"
|
14
68
|
REBOOT = "reboot"
|
15
|
-
PLAYER_STATUS = "
|
69
|
+
PLAYER_STATUS = "getPlayerStatusEx"
|
16
70
|
NEXT = "setPlayerCmd:next"
|
17
71
|
PREVIOUS = "setPlayerCmd:prev"
|
18
72
|
UNMUTE = "setPlayerCmd:mute:0"
|
@@ -39,12 +93,14 @@ class LinkPlayCommand(StrEnum):
|
|
39
93
|
|
40
94
|
class SpeakerType(StrEnum):
|
41
95
|
"""Defines the speaker type."""
|
96
|
+
|
42
97
|
MAIN_SPEAKER = "0"
|
43
98
|
SUB_SPEAKER = "1"
|
44
99
|
|
45
100
|
|
46
101
|
class ChannelType(StrEnum):
|
47
102
|
"""Defines the channel type."""
|
103
|
+
|
48
104
|
STEREO = "0"
|
49
105
|
LEFT_CHANNEL = "1"
|
50
106
|
RIGHT_CHANNEL = "2"
|
@@ -52,6 +108,8 @@ class ChannelType(StrEnum):
|
|
52
108
|
|
53
109
|
class PlayingMode(StrEnum):
|
54
110
|
"""Defines a possible playing mode."""
|
111
|
+
|
112
|
+
DAB = "-96" # Unmapped
|
55
113
|
IDLE = "-1"
|
56
114
|
NONE = "0"
|
57
115
|
AIRPLAY = "1"
|
@@ -71,7 +129,6 @@ class PlayingMode(StrEnum):
|
|
71
129
|
SPOTIFY = "31"
|
72
130
|
LINE_IN = "40"
|
73
131
|
BLUETOOTH = "41"
|
74
|
-
EXT_LOCAL = "42"
|
75
132
|
OPTICAL = "43"
|
76
133
|
RCA = "44"
|
77
134
|
COAXIAL = "45"
|
@@ -79,57 +136,69 @@ class PlayingMode(StrEnum):
|
|
79
136
|
LINE_IN_2 = "47"
|
80
137
|
XLR = "48"
|
81
138
|
HDMI = "49"
|
82
|
-
|
139
|
+
CD = "50"
|
83
140
|
USB_DAC = "51"
|
84
141
|
TF_CARD_2 = "52"
|
142
|
+
EXTERN_BLUETOOTH = "53"
|
143
|
+
PHONO = "54"
|
85
144
|
OPTICAL_2 = "56"
|
145
|
+
COAXIAL_2 = "57"
|
146
|
+
ARC = "58"
|
86
147
|
TALK = "60"
|
87
|
-
|
148
|
+
FOLLOWER = "99"
|
88
149
|
|
89
150
|
|
90
151
|
# Map between a play mode and how to activate the play mode
|
91
|
-
PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = {
|
92
|
-
PlayingMode.NONE:
|
93
|
-
PlayingMode.AIRPLAY:
|
94
|
-
PlayingMode.DLNA:
|
95
|
-
PlayingMode.QPLAY:
|
96
|
-
PlayingMode.NETWORK:
|
97
|
-
PlayingMode.WIIMU_LOCAL:
|
98
|
-
PlayingMode.TF_CARD_1:
|
99
|
-
PlayingMode.API:
|
100
|
-
PlayingMode.UDISK:
|
101
|
-
PlayingMode.ALARM:
|
102
|
-
PlayingMode.SPOTIFY:
|
103
|
-
PlayingMode.LINE_IN:
|
104
|
-
PlayingMode.BLUETOOTH:
|
105
|
-
PlayingMode.OPTICAL:
|
106
|
-
PlayingMode.RCA:
|
107
|
-
PlayingMode.COAXIAL:
|
108
|
-
PlayingMode.FM:
|
109
|
-
PlayingMode.LINE_IN_2:
|
110
|
-
PlayingMode.XLR:
|
111
|
-
PlayingMode.HDMI:
|
112
|
-
PlayingMode.
|
113
|
-
PlayingMode.USB_DAC:
|
114
|
-
PlayingMode.TF_CARD_2:
|
115
|
-
PlayingMode.TALK:
|
116
|
-
PlayingMode.
|
117
|
-
PlayingMode.OPTICAL_2:
|
152
|
+
PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = { # case sensitive!
|
153
|
+
PlayingMode.NONE: "Idle",
|
154
|
+
PlayingMode.AIRPLAY: "Airplay",
|
155
|
+
PlayingMode.DLNA: "DLNA",
|
156
|
+
PlayingMode.QPLAY: "QPlay",
|
157
|
+
PlayingMode.NETWORK: "wifi",
|
158
|
+
PlayingMode.WIIMU_LOCAL: "udisk",
|
159
|
+
PlayingMode.TF_CARD_1: "TFcard",
|
160
|
+
PlayingMode.API: "API",
|
161
|
+
PlayingMode.UDISK: "udisk",
|
162
|
+
PlayingMode.ALARM: "Alarm",
|
163
|
+
PlayingMode.SPOTIFY: "Spotify",
|
164
|
+
PlayingMode.LINE_IN: "line-in",
|
165
|
+
PlayingMode.BLUETOOTH: "bluetooth",
|
166
|
+
PlayingMode.OPTICAL: "optical",
|
167
|
+
PlayingMode.RCA: "RCA",
|
168
|
+
PlayingMode.COAXIAL: "co-axial",
|
169
|
+
PlayingMode.FM: "FM",
|
170
|
+
PlayingMode.LINE_IN_2: "line-in2",
|
171
|
+
PlayingMode.XLR: "XLR",
|
172
|
+
PlayingMode.HDMI: "HDMI",
|
173
|
+
PlayingMode.CD: "cd",
|
174
|
+
PlayingMode.USB_DAC: "PCUSB",
|
175
|
+
PlayingMode.TF_CARD_2: "TFcard",
|
176
|
+
PlayingMode.TALK: "Talk",
|
177
|
+
PlayingMode.FOLLOWER: "Idle",
|
178
|
+
PlayingMode.OPTICAL_2: "optical2",
|
179
|
+
PlayingMode.PHONO: "phono",
|
180
|
+
PlayingMode.COAXIAL_2: "co-axial2",
|
181
|
+
PlayingMode.ARC: "ARC",
|
182
|
+
PlayingMode.DAB: "DAB",
|
183
|
+
PlayingMode.EXTERN_BLUETOOTH: "extern_bluetooth",
|
118
184
|
}
|
119
185
|
|
120
186
|
|
121
187
|
class LoopMode(StrEnum):
|
122
188
|
"""Defines the loop mode."""
|
189
|
+
|
123
190
|
CONTINOUS_PLAY_ONE_SONG = "-1"
|
124
191
|
PLAY_IN_ORDER = "0"
|
125
192
|
CONTINUOUS_PLAYBACK = "1"
|
126
193
|
RANDOM_PLAYBACK = "2"
|
127
194
|
LIST_CYCLE = "3"
|
128
|
-
|
195
|
+
SHUFF_DISABLED_REPEAT_DISABLED = "4"
|
196
|
+
SHUFF_ENABLED_REPEAT_ENABLED_LOOP_ONCE = "5"
|
129
197
|
|
130
198
|
|
131
199
|
class EqualizerMode(StrEnum):
|
132
200
|
"""Defines the equalizer mode."""
|
201
|
+
|
133
202
|
NONE = "0"
|
134
203
|
CLASSIC = "1"
|
135
204
|
POP = "2"
|
@@ -139,6 +208,7 @@ class EqualizerMode(StrEnum):
|
|
139
208
|
|
140
209
|
class PlayingStatus(StrEnum):
|
141
210
|
"""Defines the playing status."""
|
211
|
+
|
142
212
|
PLAYING = "play"
|
143
213
|
LOADING = "load"
|
144
214
|
STOPPED = "stop"
|
@@ -147,20 +217,33 @@ class PlayingStatus(StrEnum):
|
|
147
217
|
|
148
218
|
class MuteMode(StrEnum):
|
149
219
|
"""Defines the mute mode."""
|
220
|
+
|
150
221
|
UNMUTED = "0"
|
151
222
|
MUTED = "1"
|
152
223
|
|
153
224
|
|
154
225
|
class InputMode(IntFlag):
|
155
226
|
"""Defines which inputs the player supports."""
|
227
|
+
|
156
228
|
LINE_IN = 2
|
157
229
|
BLUETOOTH = 4
|
158
230
|
USB = 8
|
159
231
|
OPTICAL = 16
|
232
|
+
RCA = 32
|
160
233
|
COAXIAL = 64
|
234
|
+
FM = 128
|
161
235
|
LINE_IN_2 = 256
|
236
|
+
XLR = 512
|
237
|
+
HDMI = 1024
|
238
|
+
CD = 2048
|
239
|
+
TF_CARD_1 = 8192
|
240
|
+
EXTERN_BLUETOOTH = 16384
|
162
241
|
USB_DAC = 32768
|
242
|
+
PHONO = 65536
|
163
243
|
OPTICAL_2 = 262144
|
244
|
+
COAXIAL_2 = 524288
|
245
|
+
FOLLOWER = 2097152 # unknown: is capable to follow leader?
|
246
|
+
ARC = 4194304
|
164
247
|
|
165
248
|
|
166
249
|
# Map between the input modes and the play mode
|
@@ -169,15 +252,27 @@ INPUT_MODE_MAP: dict[InputMode, PlayingMode] = {
|
|
169
252
|
InputMode.BLUETOOTH: PlayingMode.BLUETOOTH,
|
170
253
|
InputMode.USB: PlayingMode.UDISK,
|
171
254
|
InputMode.OPTICAL: PlayingMode.OPTICAL,
|
255
|
+
InputMode.RCA: PlayingMode.RCA,
|
172
256
|
InputMode.COAXIAL: PlayingMode.COAXIAL,
|
257
|
+
InputMode.FM: PlayingMode.FM,
|
173
258
|
InputMode.LINE_IN_2: PlayingMode.LINE_IN_2,
|
259
|
+
InputMode.XLR: PlayingMode.XLR,
|
260
|
+
InputMode.HDMI: PlayingMode.HDMI,
|
261
|
+
InputMode.CD: PlayingMode.CD,
|
262
|
+
InputMode.TF_CARD_1: PlayingMode.TF_CARD_1,
|
263
|
+
InputMode.EXTERN_BLUETOOTH: PlayingMode.EXTERN_BLUETOOTH,
|
174
264
|
InputMode.USB_DAC: PlayingMode.USB_DAC,
|
175
|
-
InputMode.
|
265
|
+
InputMode.PHONO: PlayingMode.PHONO,
|
266
|
+
InputMode.OPTICAL_2: PlayingMode.OPTICAL_2,
|
267
|
+
InputMode.COAXIAL_2: PlayingMode.COAXIAL_2,
|
268
|
+
InputMode.FOLLOWER: PlayingMode.FOLLOWER,
|
269
|
+
InputMode.ARC: PlayingMode.ARC,
|
176
270
|
}
|
177
271
|
|
178
272
|
|
179
273
|
class PlayerAttribute(StrEnum):
|
180
274
|
"""Defines the player attributes."""
|
275
|
+
|
181
276
|
SPEAKER_TYPE = "type"
|
182
277
|
CHANNEL_TYPE = "ch"
|
183
278
|
PLAYBACK_MODE = "mode"
|
@@ -199,6 +294,7 @@ class PlayerAttribute(StrEnum):
|
|
199
294
|
|
200
295
|
class DeviceAttribute(StrEnum):
|
201
296
|
"""Defines the device attributes."""
|
297
|
+
|
202
298
|
UUID = "uuid"
|
203
299
|
DEVICE_NAME = "DeviceName"
|
204
300
|
GROUP_NAME = "GroupName"
|
@@ -278,10 +374,35 @@ class DeviceAttribute(StrEnum):
|
|
278
374
|
ETH_MAC_ADDRESS = "ETH_MAC"
|
279
375
|
SECURITY = "security"
|
280
376
|
SECURITY_VERSION = "security_version"
|
377
|
+
FW_RELEASE_VERSION = "FW_Release_version"
|
378
|
+
PCB_VERSION = "PCB_version"
|
379
|
+
EXPIRED = "expired"
|
380
|
+
BT_MAC = "BT_MAC"
|
381
|
+
AP_MAC = "AP_MAC"
|
382
|
+
ETH0 = "eth0"
|
383
|
+
UPDATE_CHECK_COUNT = "update_check_count"
|
384
|
+
BLE_REMOTE_UPDATE_CHECKED_COUNTER = "BleRemote_update_checked_counter"
|
385
|
+
ALEXA_VER = "alexa_ver"
|
386
|
+
ALEXA_BETA_ENABLE = "alexa_beta_enable"
|
387
|
+
ALEXA_FORCE_BETA_CFG = "alexa_force_beta_cfg"
|
388
|
+
VOLUME_CONTROL = "volume_control"
|
389
|
+
WLAN_SNR = "wlanSnr"
|
390
|
+
WLAN_NOISE = "wlanNoise"
|
391
|
+
WLAN_FREQ = "wlanFreq"
|
392
|
+
WLAN_DATA_RATE = "wlanDataRate"
|
393
|
+
OTA_INTERFACE_VER = "ota_interface_ver"
|
394
|
+
EQ_SUPPORT = "EQ_support"
|
395
|
+
AUDIO_CHANNEL_CONFIG = "audio_channel_config"
|
396
|
+
APP_TIMEZONE_ID = "app_timezone_id"
|
397
|
+
AVS_TIMEZONE_ID = "avs_timezone_id"
|
398
|
+
TZ_INFO_VER = "tz_info_ver"
|
399
|
+
POWER_MODE = "power_mode"
|
400
|
+
SECURITY_CAPABILITIES = "security_capabilities"
|
281
401
|
|
282
402
|
|
283
403
|
class MultiroomAttribute(StrEnum):
|
284
404
|
"""Defines the player attributes."""
|
405
|
+
|
285
406
|
NUM_FOLLOWERS = "slaves"
|
286
407
|
FOLLOWER_LIST = "slave_list"
|
287
408
|
UUID = "uuid"
|
@@ -4,7 +4,7 @@ from linkplay.bridge import LinkPlayBridge, LinkPlayMultiroom
|
|
4
4
|
from linkplay.discovery import discover_linkplay_bridges
|
5
5
|
|
6
6
|
|
7
|
-
class LinkPlayController
|
7
|
+
class LinkPlayController:
|
8
8
|
"""Represents a LinkPlay controller to manage the devices and multirooms."""
|
9
9
|
|
10
10
|
session: ClientSession
|
@@ -22,7 +22,11 @@ class LinkPlayController():
|
|
22
22
|
# Discover new bridges
|
23
23
|
discovered_bridges = await discover_linkplay_bridges(self.session)
|
24
24
|
current_bridges = [bridge.device.uuid for bridge in self.bridges]
|
25
|
-
new_bridges = [
|
25
|
+
new_bridges = [
|
26
|
+
discovered_bridge
|
27
|
+
for discovered_bridge in discovered_bridges
|
28
|
+
if discovered_bridge.device.uuid not in current_bridges
|
29
|
+
]
|
26
30
|
self.bridges.extend(new_bridges)
|
27
31
|
|
28
32
|
async def discover_multirooms(self) -> None:
|
@@ -31,7 +35,9 @@ class LinkPlayController():
|
|
31
35
|
# Create new multirooms from new bridges
|
32
36
|
new_multirooms = []
|
33
37
|
for bridge in self.bridges:
|
34
|
-
has_multiroom = any(
|
38
|
+
has_multiroom = any(
|
39
|
+
multiroom for multiroom in self.multirooms if multiroom.leader == bridge
|
40
|
+
)
|
35
41
|
|
36
42
|
if has_multiroom:
|
37
43
|
continue
|
@@ -46,7 +52,9 @@ class LinkPlayController():
|
|
46
52
|
await multiroom.update_status(self.bridges)
|
47
53
|
|
48
54
|
# Remove multirooms if they have no followers
|
49
|
-
empty_multirooms = [
|
55
|
+
empty_multirooms = [
|
56
|
+
multiroom for multiroom in self.multirooms if not multiroom.followers
|
57
|
+
]
|
50
58
|
for empty_multiroom in empty_multirooms:
|
51
59
|
self.multirooms.remove(empty_multiroom)
|
52
60
|
|
@@ -0,0 +1,118 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from aiohttp import ClientSession
|
4
|
+
from async_upnp_client.search import async_search
|
5
|
+
from async_upnp_client.utils import CaseInsensitiveDict
|
6
|
+
from deprecated import deprecated
|
7
|
+
|
8
|
+
from linkplay.bridge import LinkPlayBridge
|
9
|
+
from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
|
10
|
+
from linkplay.endpoint import LinkPlayApiEndpoint, LinkPlayEndpoint
|
11
|
+
from linkplay.exceptions import LinkPlayRequestException
|
12
|
+
|
13
|
+
|
14
|
+
@deprecated(
|
15
|
+
reason="Use linkplay_factory_bridge_endpoint with a LinkPlayEndpoint or linkplay_factory_httpapi_bridge instead.",
|
16
|
+
version="0.0.7",
|
17
|
+
)
|
18
|
+
async def linkplay_factory_bridge(
|
19
|
+
ip_address: str, session: ClientSession
|
20
|
+
) -> LinkPlayBridge | None:
|
21
|
+
"""Attempts to create a LinkPlayBridge from the given IP address.
|
22
|
+
Returns None if the device is not an expected LinkPlay device."""
|
23
|
+
endpoint: LinkPlayApiEndpoint = LinkPlayApiEndpoint(
|
24
|
+
protocol="http", endpoint=ip_address, session=session
|
25
|
+
)
|
26
|
+
try:
|
27
|
+
return await linkplay_factory_bridge_endpoint(endpoint)
|
28
|
+
except LinkPlayRequestException:
|
29
|
+
return None
|
30
|
+
|
31
|
+
|
32
|
+
async def linkplay_factory_bridge_endpoint(
|
33
|
+
endpoint: LinkPlayEndpoint,
|
34
|
+
) -> LinkPlayBridge:
|
35
|
+
"""Attempts to create a LinkPlayBridge from given LinkPlayEndpoint.
|
36
|
+
Raises LinkPlayRequestException if the device is not an expected LinkPlay device."""
|
37
|
+
|
38
|
+
bridge: LinkPlayBridge = LinkPlayBridge(endpoint=endpoint)
|
39
|
+
await bridge.device.update_status()
|
40
|
+
await bridge.player.update_status()
|
41
|
+
return bridge
|
42
|
+
|
43
|
+
|
44
|
+
async def linkplay_factory_httpapi_bridge(
|
45
|
+
ip_address: str, session: ClientSession
|
46
|
+
) -> LinkPlayBridge:
|
47
|
+
"""Attempts to create a LinkPlayBridge from the given IP address.
|
48
|
+
Attempts to use HTTPS first, then falls back to HTTP.
|
49
|
+
Raises LinkPlayRequestException if the device is not an expected LinkPlay device."""
|
50
|
+
|
51
|
+
https_endpoint: LinkPlayApiEndpoint = LinkPlayApiEndpoint(
|
52
|
+
protocol="https", endpoint=ip_address, session=session
|
53
|
+
)
|
54
|
+
try:
|
55
|
+
return await linkplay_factory_bridge_endpoint(https_endpoint)
|
56
|
+
except LinkPlayRequestException:
|
57
|
+
http_endpoint: LinkPlayApiEndpoint = LinkPlayApiEndpoint(
|
58
|
+
protocol="http", endpoint=ip_address, session=session
|
59
|
+
)
|
60
|
+
return await linkplay_factory_bridge_endpoint(http_endpoint)
|
61
|
+
|
62
|
+
|
63
|
+
async def discover_linkplay_bridges(
|
64
|
+
session: ClientSession, discovery_through_multiroom: bool = True
|
65
|
+
) -> list[LinkPlayBridge]:
|
66
|
+
"""Attempts to discover LinkPlay devices on the local network."""
|
67
|
+
bridges: dict[str, LinkPlayBridge] = {}
|
68
|
+
|
69
|
+
async def add_linkplay_device_to_list(upnp_device: CaseInsensitiveDict):
|
70
|
+
ip_address: str | None = upnp_device.get("_host")
|
71
|
+
|
72
|
+
if not ip_address:
|
73
|
+
return
|
74
|
+
|
75
|
+
try:
|
76
|
+
bridge = await linkplay_factory_httpapi_bridge(ip_address, session)
|
77
|
+
bridges[bridge.device.uuid] = bridge
|
78
|
+
except LinkPlayRequestException:
|
79
|
+
pass
|
80
|
+
|
81
|
+
await async_search(
|
82
|
+
search_target=UPNP_DEVICE_TYPE, async_callback=add_linkplay_device_to_list
|
83
|
+
)
|
84
|
+
|
85
|
+
# Discover additional bridges through grouped multirooms
|
86
|
+
if discovery_through_multiroom:
|
87
|
+
multiroom_discovered_bridges: dict[str, LinkPlayBridge] = {}
|
88
|
+
for bridge in bridges.values():
|
89
|
+
for new_bridge in await discover_bridges_through_multiroom(bridge, session):
|
90
|
+
multiroom_discovered_bridges[new_bridge.device.uuid] = new_bridge
|
91
|
+
|
92
|
+
bridges = bridges | multiroom_discovered_bridges
|
93
|
+
|
94
|
+
return list(bridges.values())
|
95
|
+
|
96
|
+
|
97
|
+
async def discover_bridges_through_multiroom(
|
98
|
+
bridge: LinkPlayBridge, session: ClientSession
|
99
|
+
) -> list[LinkPlayBridge]:
|
100
|
+
"""Discovers bridges through the multiroom of the provided bridge."""
|
101
|
+
properties: dict[Any, Any] = await bridge.json_request(
|
102
|
+
LinkPlayCommand.MULTIROOM_LIST
|
103
|
+
)
|
104
|
+
|
105
|
+
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
106
|
+
return []
|
107
|
+
|
108
|
+
followers: list[LinkPlayBridge] = []
|
109
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
110
|
+
try:
|
111
|
+
new_bridge = await linkplay_factory_httpapi_bridge(
|
112
|
+
follower[MultiroomAttribute.IP], session
|
113
|
+
)
|
114
|
+
followers.append(new_bridge)
|
115
|
+
except LinkPlayRequestException:
|
116
|
+
pass
|
117
|
+
|
118
|
+
return followers
|
@@ -0,0 +1,40 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
|
3
|
+
from aiohttp import ClientSession
|
4
|
+
|
5
|
+
from linkplay.utils import session_call_api_json, session_call_api_ok
|
6
|
+
|
7
|
+
|
8
|
+
class LinkPlayEndpoint(ABC):
|
9
|
+
"""Represents an abstract LinkPlay endpoint."""
|
10
|
+
|
11
|
+
@abstractmethod
|
12
|
+
async def request(self, command: str) -> None:
|
13
|
+
"""Performs a request on the given command and verifies the result."""
|
14
|
+
|
15
|
+
@abstractmethod
|
16
|
+
async def json_request(self, command: str) -> dict[str, str]:
|
17
|
+
"""Performs a request on the given command and returns the result as a JSON object."""
|
18
|
+
|
19
|
+
|
20
|
+
class LinkPlayApiEndpoint(LinkPlayEndpoint):
|
21
|
+
"""Represents a LinkPlay HTTP API endpoint."""
|
22
|
+
|
23
|
+
def __init__(self, *, protocol: str, endpoint: str, session: ClientSession):
|
24
|
+
assert protocol in [
|
25
|
+
"http",
|
26
|
+
"https",
|
27
|
+
], "Protocol must be either 'http' or 'https'"
|
28
|
+
self._endpoint: str = f"{protocol}://{endpoint}"
|
29
|
+
self._session: ClientSession = session
|
30
|
+
|
31
|
+
async def request(self, command: str) -> None:
|
32
|
+
"""Performs a GET request on the given command and verifies the result."""
|
33
|
+
await session_call_api_ok(self._endpoint, self._session, command)
|
34
|
+
|
35
|
+
async def json_request(self, command: str) -> dict[str, str]:
|
36
|
+
"""Performs a GET request on the given command and returns the result as a JSON object."""
|
37
|
+
return await session_call_api_json(self._endpoint, self._session, command)
|
38
|
+
|
39
|
+
def __str__(self) -> str:
|
40
|
+
return self._endpoint
|
@@ -0,0 +1,106 @@
|
|
1
|
+
import asyncio
|
2
|
+
import contextlib
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
import socket
|
6
|
+
import ssl
|
7
|
+
from http import HTTPStatus
|
8
|
+
from typing import Dict
|
9
|
+
|
10
|
+
import async_timeout
|
11
|
+
from aiohttp import ClientError, ClientSession, TCPConnector
|
12
|
+
from appdirs import AppDirs
|
13
|
+
|
14
|
+
from linkplay.consts import API_ENDPOINT, API_TIMEOUT, MTLS_CERTIFICATE_CONTENTS
|
15
|
+
from linkplay.exceptions import LinkPlayRequestException
|
16
|
+
|
17
|
+
|
18
|
+
async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
|
19
|
+
"""Calls the LinkPlay API and returns the result as a string.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
endpoint (str): The endpoint to use.
|
23
|
+
session (ClientSession): The client session to use.
|
24
|
+
command (str): The command to use.
|
25
|
+
|
26
|
+
Raises:
|
27
|
+
LinkPlayRequestException: Thrown when the request fails or an invalid response is received.
|
28
|
+
|
29
|
+
Returns:
|
30
|
+
str: The response of the API call.
|
31
|
+
"""
|
32
|
+
url = API_ENDPOINT.format(endpoint, command)
|
33
|
+
|
34
|
+
try:
|
35
|
+
async with async_timeout.timeout(API_TIMEOUT):
|
36
|
+
response = await session.get(url)
|
37
|
+
|
38
|
+
except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
|
39
|
+
raise LinkPlayRequestException(
|
40
|
+
f"{error} error requesting data from '{url}'"
|
41
|
+
) from error
|
42
|
+
|
43
|
+
if response.status != HTTPStatus.OK:
|
44
|
+
raise LinkPlayRequestException(
|
45
|
+
f"Unexpected HTTPStatus {response.status} received from '{url}'"
|
46
|
+
)
|
47
|
+
|
48
|
+
return await response.text()
|
49
|
+
|
50
|
+
|
51
|
+
async def session_call_api_json(
|
52
|
+
endpoint: str, session: ClientSession, command: str
|
53
|
+
) -> Dict[str, str]:
|
54
|
+
"""Calls the LinkPlay API and returns the result as a JSON object."""
|
55
|
+
result = await session_call_api(endpoint, session, command)
|
56
|
+
return json.loads(result) # type: ignore
|
57
|
+
|
58
|
+
|
59
|
+
async def session_call_api_ok(
|
60
|
+
endpoint: str, session: ClientSession, command: str
|
61
|
+
) -> None:
|
62
|
+
"""Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
|
63
|
+
result = await session_call_api(endpoint, session, command)
|
64
|
+
|
65
|
+
if result != "OK":
|
66
|
+
raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
|
67
|
+
|
68
|
+
|
69
|
+
def decode_hexstr(hexstr: str) -> str:
|
70
|
+
"""Decode a hex string."""
|
71
|
+
try:
|
72
|
+
return bytes.fromhex(hexstr).decode("utf-8")
|
73
|
+
except ValueError:
|
74
|
+
return hexstr
|
75
|
+
|
76
|
+
|
77
|
+
def create_unverified_context() -> ssl.SSLContext:
|
78
|
+
"""Creates an unverified SSL context with the default mTLS certificate."""
|
79
|
+
dirs = AppDirs("python-linkplay")
|
80
|
+
mtls_certificate_path = os.path.join(dirs.user_data_dir, "linkplay.pem")
|
81
|
+
|
82
|
+
if not os.path.isdir(dirs.user_data_dir):
|
83
|
+
os.makedirs(dirs.user_data_dir, exist_ok=True)
|
84
|
+
|
85
|
+
if not os.path.isfile(mtls_certificate_path):
|
86
|
+
with open(mtls_certificate_path, "w", encoding="utf-8") as file:
|
87
|
+
file.write(MTLS_CERTIFICATE_CONTENTS)
|
88
|
+
|
89
|
+
sslcontext: ssl.SSLContext = ssl.create_default_context(
|
90
|
+
purpose=ssl.Purpose.SERVER_AUTH
|
91
|
+
)
|
92
|
+
sslcontext.load_cert_chain(certfile=mtls_certificate_path)
|
93
|
+
sslcontext.check_hostname = False
|
94
|
+
sslcontext.verify_mode = ssl.CERT_NONE
|
95
|
+
with contextlib.suppress(AttributeError):
|
96
|
+
# This only works for OpenSSL >= 1.0.0
|
97
|
+
sslcontext.options |= ssl.OP_NO_COMPRESSION
|
98
|
+
sslcontext.set_default_verify_paths()
|
99
|
+
return sslcontext
|
100
|
+
|
101
|
+
|
102
|
+
def create_unverified_client_session() -> ClientSession:
|
103
|
+
"""Creates a ClientSession using the default unverified SSL context"""
|
104
|
+
context: ssl.SSLContext = create_unverified_context()
|
105
|
+
connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
|
106
|
+
return ClientSession(connector=connector)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: python_linkplay
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.7
|
4
4
|
Summary: A Python Library for Seamless LinkPlay Device Control
|
5
5
|
Author: Velleman Group nv
|
6
6
|
License: MIT
|
@@ -10,16 +10,19 @@ Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: async-timeout>=4.0.3
|
12
12
|
Requires-Dist: aiohttp>=3.8.5
|
13
|
+
Requires-Dist: appdirs>=1.4.4
|
13
14
|
Requires-Dist: async_upnp_client>=0.36.2
|
15
|
+
Requires-Dist: deprecated>=1.2.14
|
14
16
|
Provides-Extra: testing
|
15
17
|
Requires-Dist: pytest>=7.3.1; extra == "testing"
|
16
18
|
Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
|
17
19
|
Requires-Dist: pytest-mock>=3.10.0; extra == "testing"
|
18
20
|
Requires-Dist: pytest-asyncio>=0.23.3; extra == "testing"
|
19
21
|
Requires-Dist: mypy>=1.3.0; extra == "testing"
|
20
|
-
Requires-Dist:
|
22
|
+
Requires-Dist: ruff>=0.5.4; extra == "testing"
|
21
23
|
Requires-Dist: tox>=4.6.0; extra == "testing"
|
22
24
|
Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
|
25
|
+
Requires-Dist: pre-commit>=3.8.0; extra == "testing"
|
23
26
|
|
24
27
|
|
25
28
|
[](https://pypi.org/project/python-linkplay/)
|
@@ -1,6 +1,8 @@
|
|
1
1
|
async-timeout>=4.0.3
|
2
2
|
aiohttp>=3.8.5
|
3
|
+
appdirs>=1.4.4
|
3
4
|
async_upnp_client>=0.36.2
|
5
|
+
deprecated>=1.2.14
|
4
6
|
|
5
7
|
[testing]
|
6
8
|
pytest>=7.3.1
|
@@ -8,6 +10,7 @@ pytest-cov>=4.1.0
|
|
8
10
|
pytest-mock>=3.10.0
|
9
11
|
pytest-asyncio>=0.23.3
|
10
12
|
mypy>=1.3.0
|
11
|
-
|
13
|
+
ruff>=0.5.4
|
12
14
|
tox>=4.6.0
|
13
15
|
typing-extensions>=4.6.3
|
16
|
+
pre-commit>=3.8.0
|
@@ -1 +0,0 @@
|
|
1
|
-
__version__ = '0.0.5'
|
@@ -1,67 +0,0 @@
|
|
1
|
-
from typing import Any
|
2
|
-
|
3
|
-
from aiohttp import ClientSession
|
4
|
-
from async_upnp_client.search import async_search
|
5
|
-
from async_upnp_client.utils import CaseInsensitiveDict
|
6
|
-
|
7
|
-
from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
|
8
|
-
from linkplay.bridge import LinkPlayBridge
|
9
|
-
from linkplay.exceptions import LinkPlayRequestException
|
10
|
-
|
11
|
-
|
12
|
-
async def linkplay_factory_bridge(ip_address: str, session: ClientSession) -> LinkPlayBridge | None:
|
13
|
-
"""Attempts to create a LinkPlayBridge from the given IP address.
|
14
|
-
Returns None if the device is not an expected LinkPlay device."""
|
15
|
-
bridge = LinkPlayBridge("http", ip_address, session)
|
16
|
-
try:
|
17
|
-
await bridge.device.update_status()
|
18
|
-
await bridge.player.update_status()
|
19
|
-
except LinkPlayRequestException:
|
20
|
-
return None
|
21
|
-
return bridge
|
22
|
-
|
23
|
-
|
24
|
-
async def discover_linkplay_bridges(session: ClientSession, discovery_through_multiroom: bool = True) -> list[LinkPlayBridge]:
|
25
|
-
"""Attempts to discover LinkPlay devices on the local network."""
|
26
|
-
bridges: dict[str, LinkPlayBridge] = {}
|
27
|
-
|
28
|
-
async def add_linkplay_device_to_list(upnp_device: CaseInsensitiveDict):
|
29
|
-
ip_address: str | None = upnp_device.get('_host')
|
30
|
-
|
31
|
-
if not ip_address:
|
32
|
-
return
|
33
|
-
|
34
|
-
if bridge := await linkplay_factory_bridge(ip_address, session):
|
35
|
-
bridges[bridge.device.uuid] = bridge
|
36
|
-
|
37
|
-
await async_search(
|
38
|
-
search_target=UPNP_DEVICE_TYPE,
|
39
|
-
async_callback=add_linkplay_device_to_list
|
40
|
-
)
|
41
|
-
|
42
|
-
# Discover additional bridges through grouped multirooms
|
43
|
-
if discovery_through_multiroom:
|
44
|
-
multiroom_discovered_bridges: dict[str, LinkPlayBridge] = {}
|
45
|
-
for bridge in bridges.values():
|
46
|
-
for new_bridge in await discover_bridges_through_multiroom(bridge, session):
|
47
|
-
multiroom_discovered_bridges[new_bridge.device.uuid] = new_bridge
|
48
|
-
|
49
|
-
bridges = bridges | multiroom_discovered_bridges
|
50
|
-
|
51
|
-
return list(bridges.values())
|
52
|
-
|
53
|
-
|
54
|
-
async def discover_bridges_through_multiroom(bridge: LinkPlayBridge,
|
55
|
-
session: ClientSession) -> list[LinkPlayBridge]:
|
56
|
-
"""Discovers bridges through the multiroom of the provided bridge."""
|
57
|
-
properties: dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
|
58
|
-
|
59
|
-
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
60
|
-
return []
|
61
|
-
|
62
|
-
followers: list[LinkPlayBridge] = []
|
63
|
-
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
64
|
-
if new_bridge := await linkplay_factory_bridge(follower[MultiroomAttribute.IP], session):
|
65
|
-
followers.append(new_bridge)
|
66
|
-
|
67
|
-
return followers
|
@@ -1,62 +0,0 @@
|
|
1
|
-
import asyncio
|
2
|
-
from typing import Dict
|
3
|
-
import json
|
4
|
-
from http import HTTPStatus
|
5
|
-
|
6
|
-
import async_timeout
|
7
|
-
from aiohttp import ClientSession, ClientError
|
8
|
-
|
9
|
-
from linkplay.consts import API_ENDPOINT, API_TIMEOUT
|
10
|
-
from linkplay.exceptions import LinkPlayRequestException
|
11
|
-
|
12
|
-
|
13
|
-
async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
|
14
|
-
"""Calls the LinkPlay API and returns the result as a string.
|
15
|
-
|
16
|
-
Args:
|
17
|
-
endpoint (str): The endpoint to use.
|
18
|
-
session (ClientSession): The client session to use.
|
19
|
-
command (str): The command to use.
|
20
|
-
|
21
|
-
Raises:
|
22
|
-
LinkPlayRequestException: Thrown when the request fails or an invalid response is received.
|
23
|
-
|
24
|
-
Returns:
|
25
|
-
str: The response of the API call.
|
26
|
-
"""
|
27
|
-
url = API_ENDPOINT.format(endpoint, command)
|
28
|
-
|
29
|
-
try:
|
30
|
-
async with async_timeout.timeout(API_TIMEOUT):
|
31
|
-
response = await session.get(url, ssl=False)
|
32
|
-
|
33
|
-
except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
|
34
|
-
raise LinkPlayRequestException(f"Error requesting data from '{url}'") from error
|
35
|
-
|
36
|
-
if response.status != HTTPStatus.OK:
|
37
|
-
raise LinkPlayRequestException(f"Unexpected HTTPStatus {response.status} received from '{url}'")
|
38
|
-
|
39
|
-
return await response.text()
|
40
|
-
|
41
|
-
|
42
|
-
async def session_call_api_json(endpoint: str, session: ClientSession,
|
43
|
-
command: str) -> Dict[str, str]:
|
44
|
-
"""Calls the LinkPlay API and returns the result as a JSON object."""
|
45
|
-
result = await session_call_api(endpoint, session, command)
|
46
|
-
return json.loads(result) # type: ignore
|
47
|
-
|
48
|
-
|
49
|
-
async def session_call_api_ok(endpoint: str, session: ClientSession, command: str) -> None:
|
50
|
-
"""Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
|
51
|
-
result = await session_call_api(endpoint, session, command)
|
52
|
-
|
53
|
-
if result != "OK":
|
54
|
-
raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
|
55
|
-
|
56
|
-
|
57
|
-
def decode_hexstr(hexstr: str) -> str:
|
58
|
-
"""Decode a hex string."""
|
59
|
-
try:
|
60
|
-
return bytes.fromhex(hexstr).decode("utf-8")
|
61
|
-
except ValueError:
|
62
|
-
return hexstr
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{python_linkplay-0.0.5 → python_linkplay-0.0.7}/src/python_linkplay.egg-info/dependency_links.txt
RENAMED
File without changes
|
File without changes
|
File without changes
|