python-linkplay 0.0.0__tar.gz → 0.0.2__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.0/src/python_linkplay.egg-info → python_linkplay-0.0.2}/PKG-INFO +3 -4
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/README.md +1 -2
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/setup.cfg +1 -1
- python_linkplay-0.0.2/src/linkplay/__version__.py +1 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/bridge.py +39 -41
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/consts.py +50 -34
- python_linkplay-0.0.2/src/linkplay/discovery.py +86 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/utils.py +6 -3
- {python_linkplay-0.0.0 → python_linkplay-0.0.2/src/python_linkplay.egg-info}/PKG-INFO +3 -4
- python_linkplay-0.0.0/src/linkplay/__version__.py +0 -1
- python_linkplay-0.0.0/src/linkplay/discovery.py +0 -63
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/LICENSE +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/pyproject.toml +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/setup.py +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/__init__.py +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/__main__.py +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/exceptions.py +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/SOURCES.txt +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/not-zip-safe +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/requires.txt +0 -0
- {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/top_level.txt +0 -0
@@ -1,11 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: python_linkplay
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.2
|
4
4
|
Summary: A Python Library for Seamless LinkPlay Device Control
|
5
5
|
Author: Velleman Group nv
|
6
6
|
License: MIT
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
8
|
-
Requires-Python: >=3.
|
8
|
+
Requires-Python: >=3.11
|
9
9
|
Description-Content-Type: text/markdown
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: async-timeout==4.0.3
|
@@ -23,8 +23,7 @@ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
|
|
23
23
|
|
24
24
|
|
25
25
|
[](https://pypi.org/project/python-linkplay/)
|
26
|
-
|
27
|
-
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
26
|
+
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
28
27
|
|
29
28
|
# python-linkplay
|
30
29
|
A Python Library for Seamless LinkPlay Device Control
|
@@ -1,7 +1,6 @@
|
|
1
1
|
|
2
2
|
[](https://pypi.org/project/python-linkplay/)
|
3
|
-
|
4
|
-
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
3
|
+
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
5
4
|
|
6
5
|
# python-linkplay
|
7
6
|
A Python Library for Seamless LinkPlay Device Control
|
@@ -0,0 +1 @@
|
|
1
|
+
__version__ = '0.0.2'
|
@@ -1,5 +1,4 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Dict, List
|
3
2
|
|
4
3
|
from aiohttp import ClientSession
|
5
4
|
|
@@ -11,11 +10,12 @@ from linkplay.consts import (
|
|
11
10
|
MuteMode,
|
12
11
|
EqualizerMode,
|
13
12
|
LoopMode,
|
14
|
-
|
15
|
-
PLAYBACK_MODE_MAP,
|
13
|
+
PLAY_MODE_SEND_MAP,
|
16
14
|
PlayingStatus,
|
17
|
-
|
18
|
-
SpeakerType
|
15
|
+
InputMode,
|
16
|
+
SpeakerType,
|
17
|
+
PlayingMode,
|
18
|
+
INPUT_MODE_MAP
|
19
19
|
)
|
20
20
|
from linkplay.utils import session_call_api_json, session_call_api_ok, decode_hexstr
|
21
21
|
|
@@ -24,7 +24,7 @@ class LinkPlayDevice():
|
|
24
24
|
"""Represents a LinkPlay device."""
|
25
25
|
|
26
26
|
bridge: LinkPlayBridge
|
27
|
-
properties:
|
27
|
+
properties: dict[DeviceAttribute, str] = dict.fromkeys(DeviceAttribute.__members__.values(), "")
|
28
28
|
|
29
29
|
def __init__(self, bridge: LinkPlayBridge):
|
30
30
|
self.bridge = bridge
|
@@ -33,7 +33,7 @@ class LinkPlayDevice():
|
|
33
33
|
"""Update the device status."""
|
34
34
|
self.properties = await self.bridge.json_request(LinkPlayCommand.DEVICE_STATUS) # type: ignore[assignment]
|
35
35
|
|
36
|
-
async def reboot(self):
|
36
|
+
async def reboot(self) -> None:
|
37
37
|
"""Reboot the device."""
|
38
38
|
await self.bridge.request(LinkPlayCommand.REBOOT)
|
39
39
|
|
@@ -48,9 +48,11 @@ class LinkPlayDevice():
|
|
48
48
|
return self.properties[DeviceAttribute.DEVICE_NAME]
|
49
49
|
|
50
50
|
@property
|
51
|
-
def playmode_support(self) ->
|
51
|
+
def playmode_support(self) -> list[PlayingMode]:
|
52
52
|
"""Returns the player playmode support."""
|
53
|
-
|
53
|
+
|
54
|
+
flags = InputMode(int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16))
|
55
|
+
return [INPUT_MODE_MAP[flag] for flag in flags]
|
54
56
|
|
55
57
|
@property
|
56
58
|
def eth(self) -> str:
|
@@ -62,79 +64,75 @@ class LinkPlayPlayer():
|
|
62
64
|
"""Represents a LinkPlay player."""
|
63
65
|
|
64
66
|
bridge: LinkPlayBridge
|
65
|
-
properties:
|
67
|
+
properties: dict[PlayerAttribute, str] = dict.fromkeys(PlayerAttribute.__members__.values(), "")
|
66
68
|
|
67
69
|
def __init__(self, bridge: LinkPlayBridge):
|
68
70
|
self.bridge = bridge
|
69
71
|
|
70
|
-
async def update_status(self):
|
72
|
+
async def update_status(self) -> None:
|
71
73
|
"""Update the player status."""
|
72
74
|
self.properties = await self.bridge.json_request(LinkPlayCommand.PLAYER_STATUS) # type: ignore[assignment]
|
73
75
|
self.properties[PlayerAttribute.TITLE] = decode_hexstr(self.title)
|
74
76
|
self.properties[PlayerAttribute.ARTIST] = decode_hexstr(self.artist)
|
75
77
|
self.properties[PlayerAttribute.ALBUM] = decode_hexstr(self.album)
|
76
78
|
|
77
|
-
async def next(self):
|
79
|
+
async def next(self) -> None:
|
78
80
|
"""Play the next song in the playlist."""
|
79
81
|
await self.bridge.request(LinkPlayCommand.NEXT)
|
80
82
|
|
81
|
-
async def previous(self):
|
83
|
+
async def previous(self) -> None:
|
82
84
|
"""Play the previous song in the playlist."""
|
83
85
|
await self.bridge.request(LinkPlayCommand.PREVIOUS)
|
84
86
|
|
85
|
-
async def play(self, value: str):
|
87
|
+
async def play(self, value: str) -> None:
|
86
88
|
"""Start playing the selected track."""
|
87
89
|
await self.bridge.request(LinkPlayCommand.PLAY.format(value)) # type: ignore[str-format]
|
88
90
|
|
89
|
-
async def resume(self):
|
91
|
+
async def resume(self) -> None:
|
90
92
|
"""Resume playing the current track."""
|
91
93
|
await self.bridge.request(LinkPlayCommand.RESUME)
|
92
94
|
|
93
|
-
async def mute(self):
|
95
|
+
async def mute(self) -> None:
|
94
96
|
"""Mute the player."""
|
95
97
|
await self.bridge.request(LinkPlayCommand.MUTE)
|
96
98
|
self.properties[PlayerAttribute.MUTED] = MuteMode.MUTED
|
97
99
|
|
98
|
-
async def unmute(self):
|
100
|
+
async def unmute(self) -> None:
|
99
101
|
"""Unmute the player."""
|
100
102
|
await self.bridge.request(LinkPlayCommand.UNMUTE)
|
101
103
|
self.properties[PlayerAttribute.MUTED] = MuteMode.UNMUTED
|
102
104
|
|
103
|
-
async def play_playlist(self, index: int):
|
105
|
+
async def play_playlist(self, index: int) -> None:
|
104
106
|
"""Start playing chosen playlist by index number."""
|
105
107
|
await self.bridge.request(LinkPlayCommand.PLAYLIST.format(index)) # type: ignore[str-format]
|
106
108
|
|
107
|
-
async def pause(self):
|
109
|
+
async def pause(self) -> None:
|
108
110
|
"""Pause the current playing track."""
|
109
111
|
await self.bridge.request(LinkPlayCommand.PAUSE)
|
110
112
|
self.properties[PlayerAttribute.PLAYING_STATUS] = PlayingStatus.PAUSED
|
111
113
|
|
112
|
-
async def toggle(self):
|
114
|
+
async def toggle(self) -> None:
|
113
115
|
"""Start playing if the player is currently not playing. Stops playing if it is."""
|
114
116
|
await self.bridge.request(LinkPlayCommand.TOGGLE)
|
115
117
|
|
116
|
-
async def set_volume(self, value: int):
|
118
|
+
async def set_volume(self, value: int) -> None:
|
117
119
|
"""Set the player volume."""
|
118
120
|
if not 0 <= value <= 100:
|
119
121
|
raise ValueError("Volume must be between 0 and 100.")
|
120
122
|
|
121
123
|
await self.bridge.request(LinkPlayCommand.VOLUME.format(value)) # type: ignore[str-format]
|
122
|
-
self.properties[PlayerAttribute.VOLUME] = str(value)
|
123
124
|
|
124
|
-
async def set_equalizer_mode(self, mode: EqualizerMode):
|
125
|
+
async def set_equalizer_mode(self, mode: EqualizerMode) -> None:
|
125
126
|
"""Set the equalizer mode."""
|
126
127
|
await self.bridge.request(LinkPlayCommand.EQUALIZER_MODE.format(mode)) # type: ignore[str-format]
|
127
|
-
self.properties[PlayerAttribute.EQUALIZER_MODE] = mode
|
128
128
|
|
129
|
-
async def set_loop_mode(self, mode: LoopMode):
|
129
|
+
async def set_loop_mode(self, mode: LoopMode) -> None:
|
130
130
|
"""Set the loop mode."""
|
131
131
|
await self.bridge.request(LinkPlayCommand.LOOP_MODE.format(mode)) # type: ignore[str-format]
|
132
|
-
self.properties[PlayerAttribute.PLAYLIST_MODE] = mode
|
133
132
|
|
134
|
-
async def set_play_mode(self, mode:
|
133
|
+
async def set_play_mode(self, mode: PlayingMode) -> None:
|
135
134
|
"""Set the play mode."""
|
136
|
-
await self.bridge.request(LinkPlayCommand.SWITCH_MODE.format(
|
137
|
-
self.properties[PlayerAttribute.PLAYBACK_MODE] = mode
|
135
|
+
await self.bridge.request(LinkPlayCommand.SWITCH_MODE.format(PLAY_MODE_SEND_MAP[mode])) # type: ignore[str-format]
|
138
136
|
|
139
137
|
@property
|
140
138
|
def muted(self) -> bool:
|
@@ -192,9 +190,9 @@ class LinkPlayPlayer():
|
|
192
190
|
return ChannelType(self.properties[PlayerAttribute.CHANNEL_TYPE])
|
193
191
|
|
194
192
|
@property
|
195
|
-
def
|
196
|
-
"""Returns the
|
197
|
-
return
|
193
|
+
def play_mode(self) -> PlayingMode:
|
194
|
+
"""Returns the current playing mode of the player."""
|
195
|
+
return PlayingMode(self.properties[PlayerAttribute.PLAYBACK_MODE])
|
198
196
|
|
199
197
|
@property
|
200
198
|
def loop_mode(self) -> LoopMode:
|
@@ -229,7 +227,7 @@ class LinkPlayBridge():
|
|
229
227
|
"""Returns the current player endpoint."""
|
230
228
|
return f"{self.protocol}://{self.ip_address}"
|
231
229
|
|
232
|
-
async def json_request(self, command: str) ->
|
230
|
+
async def json_request(self, command: str) -> dict[str, str]:
|
233
231
|
"""Performs a GET request on the given command and returns the result as a JSON object."""
|
234
232
|
return await session_call_api_json(self.endpoint, self.session, command)
|
235
233
|
|
@@ -243,28 +241,28 @@ class LinkPlayMultiroom():
|
|
243
241
|
The leader is the device that controls the group."""
|
244
242
|
|
245
243
|
leader: LinkPlayBridge
|
246
|
-
followers:
|
244
|
+
followers: list[LinkPlayBridge]
|
247
245
|
|
248
|
-
def __init__(self, leader: LinkPlayBridge, followers:
|
246
|
+
def __init__(self, leader: LinkPlayBridge, followers: list[LinkPlayBridge]):
|
249
247
|
self.leader = leader
|
250
248
|
self.followers = followers
|
251
249
|
|
252
|
-
async def ungroup(self):
|
250
|
+
async def ungroup(self) -> None:
|
253
251
|
"""Ungroups the multiroom group."""
|
254
252
|
await self.leader.request(LinkPlayCommand.MULTIROOM_UNGROUP)
|
255
253
|
self.followers = []
|
256
254
|
|
257
|
-
async def add_follower(self, follower: LinkPlayBridge):
|
255
|
+
async def add_follower(self, follower: LinkPlayBridge) -> None:
|
258
256
|
"""Adds a follower to the multiroom group."""
|
259
257
|
await follower.request(LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)) # type: ignore[str-format]
|
260
258
|
self.followers.append(follower)
|
261
259
|
|
262
|
-
async def remove_follower(self, follower: LinkPlayBridge):
|
260
|
+
async def remove_follower(self, follower: LinkPlayBridge) -> None:
|
263
261
|
"""Removes a follower from the multiroom group."""
|
264
262
|
await self.leader.request(LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)) # type: ignore[str-format]
|
265
263
|
self.followers.remove(follower)
|
266
264
|
|
267
|
-
async def set_volume(self, value: int):
|
265
|
+
async def set_volume(self, value: int) -> None:
|
268
266
|
"""Sets the volume for the multiroom group."""
|
269
267
|
assert 0 < value <= 100
|
270
268
|
str_vol = str(value)
|
@@ -273,10 +271,10 @@ class LinkPlayMultiroom():
|
|
273
271
|
for bridge in [self.leader] + self.followers:
|
274
272
|
bridge.player.properties[PlayerAttribute.VOLUME] = str_vol
|
275
273
|
|
276
|
-
async def mute(self):
|
274
|
+
async def mute(self) -> None:
|
277
275
|
"""Mutes the multiroom group."""
|
278
276
|
await self.leader.request(LinkPlayCommand.MULTIROOM_MUTE)
|
279
277
|
|
280
|
-
async def unmute(self):
|
278
|
+
async def unmute(self) -> None:
|
281
279
|
"""Unmutes the multiroom group."""
|
282
280
|
await self.leader.request(LinkPlayCommand.MULTIROOM_UNMUTE)
|
@@ -1,5 +1,4 @@
|
|
1
1
|
from enum import StrEnum, IntFlag
|
2
|
-
from typing import Dict
|
3
2
|
|
4
3
|
API_ENDPOINT: str = "{}/httpapi.asp?command={}"
|
5
4
|
API_TIMEOUT: int = 2
|
@@ -51,8 +50,8 @@ class ChannelType(StrEnum):
|
|
51
50
|
RIGHT_CHANNEL = "2"
|
52
51
|
|
53
52
|
|
54
|
-
class
|
55
|
-
"""Defines
|
53
|
+
class PlayingMode(StrEnum):
|
54
|
+
"""Defines a possible playing mode."""
|
56
55
|
IDLE = "-1"
|
57
56
|
NONE = "0"
|
58
57
|
AIRPLAY = "1"
|
@@ -75,7 +74,7 @@ class PlaybackMode(StrEnum):
|
|
75
74
|
EXT_LOCAL = "42"
|
76
75
|
OPTICAL = "43"
|
77
76
|
RCA = "44"
|
78
|
-
|
77
|
+
COAXIAL = "45"
|
79
78
|
FM = "46"
|
80
79
|
LINE_IN_2 = "47"
|
81
80
|
XLR = "48"
|
@@ -83,37 +82,39 @@ class PlaybackMode(StrEnum):
|
|
83
82
|
MIRROR = "50"
|
84
83
|
USB_DAC = "51"
|
85
84
|
TF_CARD_2 = "52"
|
85
|
+
OPTICAL_2 = "56"
|
86
86
|
TALK = "60"
|
87
87
|
SLAVE = "99"
|
88
88
|
|
89
89
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
90
|
+
# Map between a play mode and how to activate the play mode
|
91
|
+
PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = { # case sensitive!
|
92
|
+
PlayingMode.NONE: 'Idle',
|
93
|
+
PlayingMode.AIRPLAY: 'Airplay',
|
94
|
+
PlayingMode.DLNA: 'DLNA',
|
95
|
+
PlayingMode.QPLAY: 'QPlay',
|
96
|
+
PlayingMode.NETWORK: 'wifi',
|
97
|
+
PlayingMode.WIIMU_LOCAL: 'udisk',
|
98
|
+
PlayingMode.TF_CARD_1: 'TFcard',
|
99
|
+
PlayingMode.API: 'API',
|
100
|
+
PlayingMode.UDISK: 'udisk',
|
101
|
+
PlayingMode.ALARM: 'Alarm',
|
102
|
+
PlayingMode.SPOTIFY: 'Spotify',
|
103
|
+
PlayingMode.LINE_IN: 'line-in',
|
104
|
+
PlayingMode.BLUETOOTH: 'bluetooth',
|
105
|
+
PlayingMode.OPTICAL: 'optical',
|
106
|
+
PlayingMode.RCA: 'RCA',
|
107
|
+
PlayingMode.COAXIAL: 'co-axial',
|
108
|
+
PlayingMode.FM: 'FM',
|
109
|
+
PlayingMode.LINE_IN_2: 'line-in2',
|
110
|
+
PlayingMode.XLR: 'XLR',
|
111
|
+
PlayingMode.HDMI: 'HDMI',
|
112
|
+
PlayingMode.MIRROR: 'cd',
|
113
|
+
PlayingMode.USB_DAC: 'USB DAC',
|
114
|
+
PlayingMode.TF_CARD_2: 'TFcard',
|
115
|
+
PlayingMode.TALK: 'Talk',
|
116
|
+
PlayingMode.SLAVE: 'Idle',
|
117
|
+
PlayingMode.OPTICAL_2: 'optical2'
|
117
118
|
}
|
118
119
|
|
119
120
|
|
@@ -124,6 +125,7 @@ class LoopMode(StrEnum):
|
|
124
125
|
CONTINUOUS_PLAYBACK = "1"
|
125
126
|
RANDOM_PLAYBACK = "2"
|
126
127
|
LIST_CYCLE = "3"
|
128
|
+
FOUR = "4"
|
127
129
|
|
128
130
|
|
129
131
|
class EqualizerMode(StrEnum):
|
@@ -149,18 +151,31 @@ class MuteMode(StrEnum):
|
|
149
151
|
MUTED = "1"
|
150
152
|
|
151
153
|
|
152
|
-
class
|
153
|
-
"""Defines which
|
154
|
+
class InputMode(IntFlag):
|
155
|
+
"""Defines which inputs the player supports."""
|
154
156
|
LINE_IN = 2
|
155
157
|
BLUETOOTH = 4
|
156
158
|
USB = 8
|
157
159
|
OPTICAL = 16
|
158
160
|
COAXIAL = 64
|
159
161
|
LINE_IN_2 = 256
|
160
|
-
|
162
|
+
USB_DAC = 32768
|
161
163
|
OPTICAL_2 = 262144
|
162
164
|
|
163
165
|
|
166
|
+
# Map between the input modes and the play mode
|
167
|
+
INPUT_MODE_MAP: dict[InputMode, PlayingMode] = {
|
168
|
+
InputMode.LINE_IN: PlayingMode.LINE_IN,
|
169
|
+
InputMode.BLUETOOTH: PlayingMode.BLUETOOTH,
|
170
|
+
InputMode.USB: PlayingMode.UDISK,
|
171
|
+
InputMode.OPTICAL: PlayingMode.OPTICAL,
|
172
|
+
InputMode.COAXIAL: PlayingMode.COAXIAL,
|
173
|
+
InputMode.LINE_IN_2: PlayingMode.LINE_IN_2,
|
174
|
+
InputMode.USB_DAC: PlayingMode.USB_DAC,
|
175
|
+
InputMode.OPTICAL_2: PlayingMode.OPTICAL_2
|
176
|
+
}
|
177
|
+
|
178
|
+
|
164
179
|
class PlayerAttribute(StrEnum):
|
165
180
|
"""Defines the player attributes."""
|
166
181
|
SPEAKER_TYPE = "type"
|
@@ -270,3 +285,4 @@ class MultiroomAttribute(StrEnum):
|
|
270
285
|
NUM_FOLLOWERS = "slaves"
|
271
286
|
FOLLOWER_LIST = "slave_list"
|
272
287
|
UUID = "uuid"
|
288
|
+
IP = "ip"
|
@@ -0,0 +1,86 @@
|
|
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, LinkPlayMultiroom
|
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) -> 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
|
+
multiroom_discovered_bridges: dict[str, LinkPlayBridge] = {}
|
44
|
+
for bridge in bridges.values():
|
45
|
+
for new_bridge in await discover_bridges_through_multiroom(bridge, session):
|
46
|
+
multiroom_discovered_bridges[new_bridge.device.uuid] = new_bridge
|
47
|
+
|
48
|
+
bridges = bridges | multiroom_discovered_bridges
|
49
|
+
return list(bridges.values())
|
50
|
+
|
51
|
+
|
52
|
+
async def discover_multirooms(bridges: list[LinkPlayBridge]) -> list[LinkPlayMultiroom]:
|
53
|
+
"""Discovers multirooms through the list of provided bridges."""
|
54
|
+
multirooms: list[LinkPlayMultiroom] = []
|
55
|
+
bridges_dict: dict[str, LinkPlayBridge] = {bridge.device.uuid: bridge for bridge in bridges}
|
56
|
+
|
57
|
+
for bridge in bridges:
|
58
|
+
properties: dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
|
59
|
+
|
60
|
+
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
61
|
+
continue
|
62
|
+
|
63
|
+
followers: list[LinkPlayBridge] = []
|
64
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
65
|
+
if follower[MultiroomAttribute.UUID] in bridges_dict:
|
66
|
+
followers.append(bridges_dict[follower[MultiroomAttribute.UUID]])
|
67
|
+
|
68
|
+
multirooms.append(LinkPlayMultiroom(bridge, followers))
|
69
|
+
|
70
|
+
return multirooms
|
71
|
+
|
72
|
+
|
73
|
+
async def discover_bridges_through_multiroom(bridge: LinkPlayBridge,
|
74
|
+
session: ClientSession) -> list[LinkPlayBridge]:
|
75
|
+
"""Discovers bridges through the multiroom of the provided bridge."""
|
76
|
+
properties: dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
|
77
|
+
|
78
|
+
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
79
|
+
return []
|
80
|
+
|
81
|
+
followers: list[LinkPlayBridge] = []
|
82
|
+
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
83
|
+
if new_bridge := await linkplay_factory_bridge(follower[MultiroomAttribute.IP], session):
|
84
|
+
followers.append(new_bridge)
|
85
|
+
|
86
|
+
return followers
|
@@ -10,7 +10,7 @@ from linkplay.consts import API_ENDPOINT, API_TIMEOUT
|
|
10
10
|
from linkplay.exceptions import LinkPlayRequestException
|
11
11
|
|
12
12
|
|
13
|
-
async def session_call_api(endpoint: str, session: ClientSession, command: str):
|
13
|
+
async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
|
14
14
|
"""Calls the LinkPlay API and returns the result as a string.
|
15
15
|
|
16
16
|
Args:
|
@@ -46,7 +46,7 @@ async def session_call_api_json(endpoint: str, session: ClientSession,
|
|
46
46
|
return json.loads(result) # type: ignore
|
47
47
|
|
48
48
|
|
49
|
-
async def session_call_api_ok(endpoint: str, session: ClientSession, command: str):
|
49
|
+
async def session_call_api_ok(endpoint: str, session: ClientSession, command: str) -> None:
|
50
50
|
"""Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
|
51
51
|
result = await session_call_api(endpoint, session, command)
|
52
52
|
|
@@ -56,4 +56,7 @@ async def session_call_api_ok(endpoint: str, session: ClientSession, command: st
|
|
56
56
|
|
57
57
|
def decode_hexstr(hexstr: str) -> str:
|
58
58
|
"""Decode a hex string."""
|
59
|
-
|
59
|
+
try:
|
60
|
+
return bytes.fromhex(hexstr).decode("utf-8")
|
61
|
+
except ValueError:
|
62
|
+
return hexstr
|
@@ -1,11 +1,11 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: python_linkplay
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.2
|
4
4
|
Summary: A Python Library for Seamless LinkPlay Device Control
|
5
5
|
Author: Velleman Group nv
|
6
6
|
License: MIT
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
8
|
-
Requires-Python: >=3.
|
8
|
+
Requires-Python: >=3.11
|
9
9
|
Description-Content-Type: text/markdown
|
10
10
|
License-File: LICENSE
|
11
11
|
Requires-Dist: async-timeout==4.0.3
|
@@ -23,8 +23,7 @@ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
|
|
23
23
|
|
24
24
|
|
25
25
|
[](https://pypi.org/project/python-linkplay/)
|
26
|
-
|
27
|
-
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
26
|
+
[](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
|
28
27
|
|
29
28
|
# python-linkplay
|
30
29
|
A Python Library for Seamless LinkPlay Device Control
|
@@ -1 +0,0 @@
|
|
1
|
-
__version__ = '0.0.0'
|
@@ -1,63 +0,0 @@
|
|
1
|
-
from typing import Any, Dict, List
|
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, LinkPlayMultiroom
|
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) -> List[LinkPlayBridge]:
|
25
|
-
"""Attempts to discover LinkPlay devices on the local network."""
|
26
|
-
devices: List[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
|
-
devices.append(bridge)
|
36
|
-
|
37
|
-
await async_search(
|
38
|
-
search_target=UPNP_DEVICE_TYPE,
|
39
|
-
async_callback=add_linkplay_device_to_list
|
40
|
-
)
|
41
|
-
|
42
|
-
return devices
|
43
|
-
|
44
|
-
|
45
|
-
async def discover_multirooms(bridges: List[LinkPlayBridge]) -> List[LinkPlayMultiroom]:
|
46
|
-
"""Discovers multirooms through the list of provided bridges."""
|
47
|
-
multirooms: List[LinkPlayMultiroom] = []
|
48
|
-
|
49
|
-
for bridge in bridges:
|
50
|
-
properties: Dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
|
51
|
-
|
52
|
-
if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
|
53
|
-
continue
|
54
|
-
|
55
|
-
followers: List[LinkPlayBridge] = []
|
56
|
-
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
57
|
-
follower_uuid = follower[MultiroomAttribute.UUID]
|
58
|
-
if follower_bridge := next((b for b in bridges if b.device.uuid == follower_uuid), None):
|
59
|
-
followers.append(follower_bridge)
|
60
|
-
|
61
|
-
multirooms.append(LinkPlayMultiroom(bridge, followers))
|
62
|
-
|
63
|
-
return multirooms
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/dependency_links.txt
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|