python-linkplay 0.0.6__py3-none-any.whl → 0.0.7__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.
- linkplay/__main__.py +2 -2
- linkplay/__version__.py +1 -1
- linkplay/bridge.py +38 -43
- linkplay/consts.py +117 -9
- linkplay/discovery.py +51 -8
- linkplay/endpoint.py +40 -0
- linkplay/utils.py +44 -5
- {python_linkplay-0.0.6.dist-info → python_linkplay-0.0.7.dist-info}/METADATA +15 -12
- python_linkplay-0.0.7.dist-info/RECORD +15 -0
- python_linkplay-0.0.6.dist-info/RECORD +0 -14
- {python_linkplay-0.0.6.dist-info → python_linkplay-0.0.7.dist-info}/LICENSE +0 -0
- {python_linkplay-0.0.6.dist-info → python_linkplay-0.0.7.dist-info}/WHEEL +0 -0
- {python_linkplay-0.0.6.dist-info → python_linkplay-0.0.7.dist-info}/top_level.txt +0 -0
linkplay/__main__.py
CHANGED
@@ -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()
|
linkplay/__version__.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
__version__ = '0.0.
|
1
|
+
__version__ = '0.0.7'
|
linkplay/bridge.py
CHANGED
@@ -1,25 +1,25 @@
|
|
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
25
|
class LinkPlayDevice:
|
@@ -44,12 +44,12 @@ class LinkPlayDevice:
|
|
44
44
|
@property
|
45
45
|
def uuid(self) -> str:
|
46
46
|
"""The UUID of the device."""
|
47
|
-
return self.properties
|
47
|
+
return self.properties.get(DeviceAttribute.UUID, "")
|
48
48
|
|
49
49
|
@property
|
50
50
|
def name(self) -> str:
|
51
51
|
"""The name of the device."""
|
52
|
-
return self.properties
|
52
|
+
return self.properties.get(DeviceAttribute.DEVICE_NAME, "")
|
53
53
|
|
54
54
|
@property
|
55
55
|
def playmode_support(self) -> list[PlayingMode]:
|
@@ -63,7 +63,11 @@ class LinkPlayDevice:
|
|
63
63
|
@property
|
64
64
|
def eth(self) -> str:
|
65
65
|
"""Returns the ethernet address."""
|
66
|
-
return
|
66
|
+
return (
|
67
|
+
self.properties[DeviceAttribute.ETH_DHCP]
|
68
|
+
if DeviceAttribute.ETH_DHCP in self.properties
|
69
|
+
else self.properties[DeviceAttribute.ETH0]
|
70
|
+
)
|
67
71
|
|
68
72
|
|
69
73
|
class LinkPlayPlayer:
|
@@ -147,103 +151,94 @@ class LinkPlayPlayer:
|
|
147
151
|
@property
|
148
152
|
def muted(self) -> bool:
|
149
153
|
"""Returns if the player is muted."""
|
150
|
-
return self.properties
|
154
|
+
return self.properties.get(PlayerAttribute.MUTED, MuteMode.UNMUTED) == MuteMode.MUTED
|
151
155
|
|
152
156
|
@property
|
153
157
|
def title(self) -> str:
|
154
158
|
"""Returns if the currently playing title of the track."""
|
155
|
-
return self.properties
|
159
|
+
return self.properties.get(PlayerAttribute.TITLE, "")
|
156
160
|
|
157
161
|
@property
|
158
162
|
def artist(self) -> str:
|
159
163
|
"""Returns if the currently playing artist."""
|
160
|
-
return self.properties
|
164
|
+
return self.properties.get(PlayerAttribute.ARTIST, "")
|
161
165
|
|
162
166
|
@property
|
163
167
|
def album(self) -> str:
|
164
168
|
"""Returns if the currently playing album."""
|
165
|
-
return self.properties
|
169
|
+
return self.properties.get(PlayerAttribute.ALBUM, "")
|
166
170
|
|
167
171
|
@property
|
168
172
|
def volume(self) -> int:
|
169
173
|
"""Returns the player volume, expressed in %."""
|
170
|
-
return int(self.properties
|
174
|
+
return int(self.properties.get(PlayerAttribute.VOLUME, 0))
|
171
175
|
|
172
176
|
@property
|
173
177
|
def current_position(self) -> int:
|
174
178
|
"""Returns the current position of the track in milliseconds."""
|
175
|
-
return int(self.properties
|
179
|
+
return int(self.properties.get(PlayerAttribute.CURRENT_POSITION, 0))
|
176
180
|
|
177
181
|
@property
|
178
182
|
def total_length(self) -> int:
|
179
183
|
"""Returns the total length of the track in milliseconds."""
|
180
|
-
return int(self.properties
|
184
|
+
return int(self.properties.get(PlayerAttribute.TOTAL_LENGTH, 0))
|
181
185
|
|
182
186
|
@property
|
183
187
|
def status(self) -> PlayingStatus:
|
184
188
|
"""Returns the current playing status."""
|
185
|
-
return PlayingStatus(self.properties
|
189
|
+
return PlayingStatus(self.properties.get(PlayerAttribute.PLAYING_STATUS, PlayingStatus.STOPPED))
|
186
190
|
|
187
191
|
@property
|
188
192
|
def equalizer_mode(self) -> EqualizerMode:
|
189
193
|
"""Returns the current equalizer mode."""
|
190
|
-
return EqualizerMode(self.properties
|
194
|
+
return EqualizerMode(self.properties.get(PlayerAttribute.EQUALIZER_MODE, EqualizerMode.CLASSIC))
|
191
195
|
|
192
196
|
@property
|
193
197
|
def speaker_type(self) -> SpeakerType:
|
194
198
|
"""Returns the current speaker the player is playing on."""
|
195
|
-
return SpeakerType(self.properties
|
199
|
+
return SpeakerType(self.properties.get(PlayerAttribute.SPEAKER_TYPE, SpeakerType.MAIN_SPEAKER))
|
196
200
|
|
197
201
|
@property
|
198
202
|
def channel_type(self) -> ChannelType:
|
199
203
|
"""Returns the channel the player is playing on."""
|
200
|
-
return ChannelType(self.properties
|
204
|
+
return ChannelType(self.properties.get(PlayerAttribute.CHANNEL_TYPE, ChannelType.STEREO))
|
201
205
|
|
202
206
|
@property
|
203
207
|
def play_mode(self) -> PlayingMode:
|
204
208
|
"""Returns the current playing mode of the player."""
|
205
|
-
return PlayingMode(self.properties
|
209
|
+
return PlayingMode(self.properties.get(PlayerAttribute.PLAYBACK_MODE, PlayingMode.IDLE))
|
206
210
|
|
207
211
|
@property
|
208
212
|
def loop_mode(self) -> LoopMode:
|
209
213
|
"""Returns the current playlist mode."""
|
210
|
-
return LoopMode(self.properties
|
214
|
+
return LoopMode(self.properties.get(PlayerAttribute.PLAYLIST_MODE, LoopMode.CONTINUOUS_PLAYBACK))
|
211
215
|
|
212
216
|
|
213
217
|
class LinkPlayBridge:
|
214
218
|
"""Represents a LinkPlay bridge to control the device and player attached to it."""
|
215
219
|
|
216
|
-
|
217
|
-
ip_address: str
|
218
|
-
session: ClientSession
|
220
|
+
endpoint: LinkPlayEndpoint
|
219
221
|
device: LinkPlayDevice
|
220
222
|
player: LinkPlayPlayer
|
221
223
|
|
222
|
-
def __init__(self,
|
223
|
-
self.
|
224
|
-
self.ip_address = ip_address
|
225
|
-
self.session = session
|
224
|
+
def __init__(self, *, endpoint: LinkPlayEndpoint):
|
225
|
+
self.endpoint = endpoint
|
226
226
|
self.device = LinkPlayDevice(self)
|
227
227
|
self.player = LinkPlayPlayer(self)
|
228
228
|
|
229
|
-
def
|
229
|
+
def __str__(self) -> str:
|
230
230
|
if self.device.name == "":
|
231
|
-
return self.endpoint
|
231
|
+
return f"{self.endpoint}"
|
232
232
|
|
233
233
|
return self.device.name
|
234
234
|
|
235
|
-
@property
|
236
|
-
def endpoint(self) -> str:
|
237
|
-
"""Returns the current player endpoint."""
|
238
|
-
return f"{self.protocol}://{self.ip_address}"
|
239
|
-
|
240
235
|
async def json_request(self, command: str) -> dict[str, str]:
|
241
236
|
"""Performs a GET request on the given command and returns the result as a JSON object."""
|
242
|
-
return await
|
237
|
+
return await self.endpoint.json_request(command)
|
243
238
|
|
244
239
|
async def request(self, command: str) -> None:
|
245
240
|
"""Performs a GET request on the given command and verifies the result."""
|
246
|
-
await
|
241
|
+
await self.endpoint.request(command)
|
247
242
|
|
248
243
|
|
249
244
|
class LinkPlayMultiroom:
|
linkplay/consts.py
CHANGED
@@ -1,19 +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
6
|
UPNP_DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
7
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
|
+
"""
|
60
|
+
|
8
61
|
|
9
62
|
class LinkPlayCommand(StrEnum):
|
10
63
|
"""Defines the LinkPlay commands."""
|
11
64
|
|
12
|
-
DEVICE_STATUS = "
|
65
|
+
DEVICE_STATUS = "getStatusEx"
|
13
66
|
SYSLOG = "getsyslog"
|
14
67
|
UPDATE_SERVER = "GetUpdateServer"
|
15
68
|
REBOOT = "reboot"
|
16
|
-
PLAYER_STATUS = "
|
69
|
+
PLAYER_STATUS = "getPlayerStatusEx"
|
17
70
|
NEXT = "setPlayerCmd:next"
|
18
71
|
PREVIOUS = "setPlayerCmd:prev"
|
19
72
|
UNMUTE = "setPlayerCmd:mute:0"
|
@@ -56,6 +109,7 @@ class ChannelType(StrEnum):
|
|
56
109
|
class PlayingMode(StrEnum):
|
57
110
|
"""Defines a possible playing mode."""
|
58
111
|
|
112
|
+
DAB = "-96" # Unmapped
|
59
113
|
IDLE = "-1"
|
60
114
|
NONE = "0"
|
61
115
|
AIRPLAY = "1"
|
@@ -75,7 +129,6 @@ class PlayingMode(StrEnum):
|
|
75
129
|
SPOTIFY = "31"
|
76
130
|
LINE_IN = "40"
|
77
131
|
BLUETOOTH = "41"
|
78
|
-
EXT_LOCAL = "42"
|
79
132
|
OPTICAL = "43"
|
80
133
|
RCA = "44"
|
81
134
|
COAXIAL = "45"
|
@@ -83,12 +136,16 @@ class PlayingMode(StrEnum):
|
|
83
136
|
LINE_IN_2 = "47"
|
84
137
|
XLR = "48"
|
85
138
|
HDMI = "49"
|
86
|
-
|
139
|
+
CD = "50"
|
87
140
|
USB_DAC = "51"
|
88
141
|
TF_CARD_2 = "52"
|
142
|
+
EXTERN_BLUETOOTH = "53"
|
143
|
+
PHONO = "54"
|
89
144
|
OPTICAL_2 = "56"
|
145
|
+
COAXIAL_2 = "57"
|
146
|
+
ARC = "58"
|
90
147
|
TALK = "60"
|
91
|
-
|
148
|
+
FOLLOWER = "99"
|
92
149
|
|
93
150
|
|
94
151
|
# Map between a play mode and how to activate the play mode
|
@@ -113,12 +170,17 @@ PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = { # case sensitive!
|
|
113
170
|
PlayingMode.LINE_IN_2: "line-in2",
|
114
171
|
PlayingMode.XLR: "XLR",
|
115
172
|
PlayingMode.HDMI: "HDMI",
|
116
|
-
PlayingMode.
|
117
|
-
PlayingMode.USB_DAC: "
|
173
|
+
PlayingMode.CD: "cd",
|
174
|
+
PlayingMode.USB_DAC: "PCUSB",
|
118
175
|
PlayingMode.TF_CARD_2: "TFcard",
|
119
176
|
PlayingMode.TALK: "Talk",
|
120
|
-
PlayingMode.
|
177
|
+
PlayingMode.FOLLOWER: "Idle",
|
121
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",
|
122
184
|
}
|
123
185
|
|
124
186
|
|
@@ -167,10 +229,21 @@ class InputMode(IntFlag):
|
|
167
229
|
BLUETOOTH = 4
|
168
230
|
USB = 8
|
169
231
|
OPTICAL = 16
|
232
|
+
RCA = 32
|
170
233
|
COAXIAL = 64
|
234
|
+
FM = 128
|
171
235
|
LINE_IN_2 = 256
|
236
|
+
XLR = 512
|
237
|
+
HDMI = 1024
|
238
|
+
CD = 2048
|
239
|
+
TF_CARD_1 = 8192
|
240
|
+
EXTERN_BLUETOOTH = 16384
|
172
241
|
USB_DAC = 32768
|
242
|
+
PHONO = 65536
|
173
243
|
OPTICAL_2 = 262144
|
244
|
+
COAXIAL_2 = 524288
|
245
|
+
FOLLOWER = 2097152 # unknown: is capable to follow leader?
|
246
|
+
ARC = 4194304
|
174
247
|
|
175
248
|
|
176
249
|
# Map between the input modes and the play mode
|
@@ -179,10 +252,21 @@ INPUT_MODE_MAP: dict[InputMode, PlayingMode] = {
|
|
179
252
|
InputMode.BLUETOOTH: PlayingMode.BLUETOOTH,
|
180
253
|
InputMode.USB: PlayingMode.UDISK,
|
181
254
|
InputMode.OPTICAL: PlayingMode.OPTICAL,
|
255
|
+
InputMode.RCA: PlayingMode.RCA,
|
182
256
|
InputMode.COAXIAL: PlayingMode.COAXIAL,
|
257
|
+
InputMode.FM: PlayingMode.FM,
|
183
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,
|
184
264
|
InputMode.USB_DAC: PlayingMode.USB_DAC,
|
265
|
+
InputMode.PHONO: PlayingMode.PHONO,
|
185
266
|
InputMode.OPTICAL_2: PlayingMode.OPTICAL_2,
|
267
|
+
InputMode.COAXIAL_2: PlayingMode.COAXIAL_2,
|
268
|
+
InputMode.FOLLOWER: PlayingMode.FOLLOWER,
|
269
|
+
InputMode.ARC: PlayingMode.ARC,
|
186
270
|
}
|
187
271
|
|
188
272
|
|
@@ -290,6 +374,30 @@ class DeviceAttribute(StrEnum):
|
|
290
374
|
ETH_MAC_ADDRESS = "ETH_MAC"
|
291
375
|
SECURITY = "security"
|
292
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"
|
293
401
|
|
294
402
|
|
295
403
|
class MultiroomAttribute(StrEnum):
|
linkplay/discovery.py
CHANGED
@@ -3,26 +3,63 @@ from typing import Any
|
|
3
3
|
from aiohttp import ClientSession
|
4
4
|
from async_upnp_client.search import async_search
|
5
5
|
from async_upnp_client.utils import CaseInsensitiveDict
|
6
|
+
from deprecated import deprecated
|
6
7
|
|
7
|
-
from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
|
8
8
|
from linkplay.bridge import LinkPlayBridge
|
9
|
+
from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
|
10
|
+
from linkplay.endpoint import LinkPlayApiEndpoint, LinkPlayEndpoint
|
9
11
|
from linkplay.exceptions import LinkPlayRequestException
|
10
12
|
|
11
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
|
+
)
|
12
18
|
async def linkplay_factory_bridge(
|
13
19
|
ip_address: str, session: ClientSession
|
14
20
|
) -> LinkPlayBridge | None:
|
15
21
|
"""Attempts to create a LinkPlayBridge from the given IP address.
|
16
22
|
Returns None if the device is not an expected LinkPlay device."""
|
17
|
-
|
23
|
+
endpoint: LinkPlayApiEndpoint = LinkPlayApiEndpoint(
|
24
|
+
protocol="http", endpoint=ip_address, session=session
|
25
|
+
)
|
18
26
|
try:
|
19
|
-
await
|
20
|
-
await bridge.player.update_status()
|
27
|
+
return await linkplay_factory_bridge_endpoint(endpoint)
|
21
28
|
except LinkPlayRequestException:
|
22
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()
|
23
41
|
return bridge
|
24
42
|
|
25
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
|
+
|
26
63
|
async def discover_linkplay_bridges(
|
27
64
|
session: ClientSession, discovery_through_multiroom: bool = True
|
28
65
|
) -> list[LinkPlayBridge]:
|
@@ -35,8 +72,11 @@ async def discover_linkplay_bridges(
|
|
35
72
|
if not ip_address:
|
36
73
|
return
|
37
74
|
|
38
|
-
|
75
|
+
try:
|
76
|
+
bridge = await linkplay_factory_httpapi_bridge(ip_address, session)
|
39
77
|
bridges[bridge.device.uuid] = bridge
|
78
|
+
except LinkPlayRequestException:
|
79
|
+
pass
|
40
80
|
|
41
81
|
await async_search(
|
42
82
|
search_target=UPNP_DEVICE_TYPE, async_callback=add_linkplay_device_to_list
|
@@ -67,9 +107,12 @@ async def discover_bridges_through_multiroom(
|
|
67
107
|
|
68
108
|
followers: list[LinkPlayBridge] = []
|
69
109
|
for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
|
70
|
-
|
71
|
-
|
72
|
-
|
110
|
+
try:
|
111
|
+
new_bridge = await linkplay_factory_httpapi_bridge(
|
112
|
+
follower[MultiroomAttribute.IP], session
|
113
|
+
)
|
73
114
|
followers.append(new_bridge)
|
115
|
+
except LinkPlayRequestException:
|
116
|
+
pass
|
74
117
|
|
75
118
|
return followers
|
linkplay/endpoint.py
ADDED
@@ -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
|
linkplay/utils.py
CHANGED
@@ -1,12 +1,17 @@
|
|
1
1
|
import asyncio
|
2
|
-
|
2
|
+
import contextlib
|
3
3
|
import json
|
4
|
+
import os
|
5
|
+
import socket
|
6
|
+
import ssl
|
4
7
|
from http import HTTPStatus
|
8
|
+
from typing import Dict
|
5
9
|
|
6
10
|
import async_timeout
|
7
|
-
from aiohttp import ClientSession,
|
11
|
+
from aiohttp import ClientError, ClientSession, TCPConnector
|
12
|
+
from appdirs import AppDirs
|
8
13
|
|
9
|
-
from linkplay.consts import API_ENDPOINT, API_TIMEOUT
|
14
|
+
from linkplay.consts import API_ENDPOINT, API_TIMEOUT, MTLS_CERTIFICATE_CONTENTS
|
10
15
|
from linkplay.exceptions import LinkPlayRequestException
|
11
16
|
|
12
17
|
|
@@ -28,10 +33,12 @@ async def session_call_api(endpoint: str, session: ClientSession, command: str)
|
|
28
33
|
|
29
34
|
try:
|
30
35
|
async with async_timeout.timeout(API_TIMEOUT):
|
31
|
-
response = await session.get(url
|
36
|
+
response = await session.get(url)
|
32
37
|
|
33
38
|
except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
|
34
|
-
raise LinkPlayRequestException(
|
39
|
+
raise LinkPlayRequestException(
|
40
|
+
f"{error} error requesting data from '{url}'"
|
41
|
+
) from error
|
35
42
|
|
36
43
|
if response.status != HTTPStatus.OK:
|
37
44
|
raise LinkPlayRequestException(
|
@@ -65,3 +72,35 @@ def decode_hexstr(hexstr: str) -> str:
|
|
65
72
|
return bytes.fromhex(hexstr).decode("utf-8")
|
66
73
|
except ValueError:
|
67
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
|
@@ -8,18 +8,21 @@ Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Requires-Python: >=3.11
|
9
9
|
Description-Content-Type: text/markdown
|
10
10
|
License-File: LICENSE
|
11
|
-
Requires-Dist: async-timeout
|
12
|
-
Requires-Dist: aiohttp
|
13
|
-
Requires-Dist:
|
11
|
+
Requires-Dist: async-timeout>=4.0.3
|
12
|
+
Requires-Dist: aiohttp>=3.8.5
|
13
|
+
Requires-Dist: appdirs>=1.4.4
|
14
|
+
Requires-Dist: async-upnp-client>=0.36.2
|
15
|
+
Requires-Dist: deprecated>=1.2.14
|
14
16
|
Provides-Extra: testing
|
15
|
-
Requires-Dist: pytest
|
16
|
-
Requires-Dist: pytest-cov
|
17
|
-
Requires-Dist: pytest-mock
|
18
|
-
Requires-Dist: pytest-asyncio
|
19
|
-
Requires-Dist: mypy
|
20
|
-
Requires-Dist: ruff
|
21
|
-
Requires-Dist: tox
|
22
|
-
Requires-Dist: typing-extensions
|
17
|
+
Requires-Dist: pytest>=7.3.1; extra == "testing"
|
18
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
|
19
|
+
Requires-Dist: pytest-mock>=3.10.0; extra == "testing"
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23.3; extra == "testing"
|
21
|
+
Requires-Dist: mypy>=1.3.0; extra == "testing"
|
22
|
+
Requires-Dist: ruff>=0.5.4; extra == "testing"
|
23
|
+
Requires-Dist: tox>=4.6.0; extra == "testing"
|
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/)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
linkplay/__init__.py,sha256=y9ZehEq-KhS3cwn-PUpwVSJGfDUx7e5wf_G6guODcTk,56
|
2
|
+
linkplay/__main__.py,sha256=rH3diy9W57N1mw16XeTYkymImeSTIWIaBeB2AGzlL28,559
|
3
|
+
linkplay/__version__.py,sha256=7EgDv-timUu06OY6bkABnaoin_WSpRfqEKwS3w4SJZ4,22
|
4
|
+
linkplay/bridge.py,sha256=KGD-gwJRhDIdE9jAOUyVlcpfGDb1diDUkggTDvVkf-M,11164
|
5
|
+
linkplay/consts.py,sha256=wz1lVRz-9hkymc9ucV_LHldcu-msYvimI0tjr2Ncgoc,12734
|
6
|
+
linkplay/controller.py,sha256=JIQAKPs3EK7ZwzoyzSy0HBl21gH9Cc9RrLXIGOMzkCM,2146
|
7
|
+
linkplay/discovery.py,sha256=aEzN_94pKLmHKYIL7DxSW0FYRsaF2ruZe2bwXz0zf5U,4299
|
8
|
+
linkplay/endpoint.py,sha256=qbB977_KltNRZlWlm-3JiByPZiie84Hn2TL523IfqGs,1486
|
9
|
+
linkplay/exceptions.py,sha256=tWJWHsKVkUEq3Yet1Z739IxcaQT8YamDeSp0tqHde9c,107
|
10
|
+
linkplay/utils.py,sha256=IdUtceKTA3vdY_HNzKUFZzPGXkQFW8E4yUNe5K9TNbo,3583
|
11
|
+
python_linkplay-0.0.7.dist-info/LICENSE,sha256=bgEtxMyjEHX_4uwaAY3GCFTm234D4AOZ5dM15sk26ms,1073
|
12
|
+
python_linkplay-0.0.7.dist-info/METADATA,sha256=VCLgUHUz00tc0tLzAiOpMkPDeIbrHDfkYrItjH8VZXA,2955
|
13
|
+
python_linkplay-0.0.7.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
|
14
|
+
python_linkplay-0.0.7.dist-info/top_level.txt,sha256=CpSaOVPTzJf5TVIL7MrotSCR34gcIOQy-11l4zGmxxM,9
|
15
|
+
python_linkplay-0.0.7.dist-info/RECORD,,
|
@@ -1,14 +0,0 @@
|
|
1
|
-
linkplay/__init__.py,sha256=y9ZehEq-KhS3cwn-PUpwVSJGfDUx7e5wf_G6guODcTk,56
|
2
|
-
linkplay/__main__.py,sha256=JsBilnXYIeAb8SpsL67n590FVlm6hTNUxV2xPd1Xhg4,503
|
3
|
-
linkplay/__version__.py,sha256=CHUdJvPS3l7PphMt_r17ufrpB0TIJkmifpqmTNjwh84,22
|
4
|
-
linkplay/bridge.py,sha256=g5iDvMo2cb2K37LoSh6FHJzheAkUCfVioUIiyIdBFjU,11135
|
5
|
-
linkplay/consts.py,sha256=6akvbDb4i-S6v5h6TrPY29KJFe6L1ld1WHorlxFltd4,7836
|
6
|
-
linkplay/controller.py,sha256=JIQAKPs3EK7ZwzoyzSy0HBl21gH9Cc9RrLXIGOMzkCM,2146
|
7
|
-
linkplay/discovery.py,sha256=tBrzFJ2gIbd6yXTMG6t7769j9FDfnFVIUZyvMberGiY,2643
|
8
|
-
linkplay/exceptions.py,sha256=tWJWHsKVkUEq3Yet1Z739IxcaQT8YamDeSp0tqHde9c,107
|
9
|
-
linkplay/utils.py,sha256=dBICpU2yKPRqAcvw77NcXNnI3fDCG7PT7rt_t2qzeKQ,2139
|
10
|
-
python_linkplay-0.0.6.dist-info/LICENSE,sha256=bgEtxMyjEHX_4uwaAY3GCFTm234D4AOZ5dM15sk26ms,1073
|
11
|
-
python_linkplay-0.0.6.dist-info/METADATA,sha256=RbpbldRdQX7p-7aS6I3TPHjzAY_n-2f6Zyitu3QpkUM,2857
|
12
|
-
python_linkplay-0.0.6.dist-info/WHEEL,sha256=R0nc6qTxuoLk7ShA2_Y-UWkN8ZdfDBG2B6Eqpz2WXbs,91
|
13
|
-
python_linkplay-0.0.6.dist-info/top_level.txt,sha256=CpSaOVPTzJf5TVIL7MrotSCR34gcIOQy-11l4zGmxxM,9
|
14
|
-
python_linkplay-0.0.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|