python-linkplay 0.0.6__tar.gz → 0.0.8__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.
Files changed (24) hide show
  1. {python_linkplay-0.0.6/src/python_linkplay.egg-info → python_linkplay-0.0.8}/PKG-INFO +5 -1
  2. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/pyproject.toml +2 -2
  3. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/setup.cfg +4 -0
  4. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/__main__.py +2 -2
  5. python_linkplay-0.0.8/src/linkplay/__version__.py +1 -0
  6. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/bridge.py +38 -43
  7. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/consts.py +117 -9
  8. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/discovery.py +51 -8
  9. python_linkplay-0.0.8/src/linkplay/endpoint.py +40 -0
  10. python_linkplay-0.0.8/src/linkplay/utils.py +127 -0
  11. {python_linkplay-0.0.6 → python_linkplay-0.0.8/src/python_linkplay.egg-info}/PKG-INFO +5 -1
  12. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/python_linkplay.egg-info/SOURCES.txt +1 -0
  13. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/python_linkplay.egg-info/requires.txt +4 -0
  14. python_linkplay-0.0.6/src/linkplay/__version__.py +0 -1
  15. python_linkplay-0.0.6/src/linkplay/utils.py +0 -67
  16. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/LICENSE +0 -0
  17. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/README.md +0 -0
  18. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/setup.py +0 -0
  19. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/__init__.py +0 -0
  20. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/controller.py +0 -0
  21. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/linkplay/exceptions.py +0 -0
  22. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
  23. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/src/python_linkplay.egg-info/not-zip-safe +0 -0
  24. {python_linkplay-0.0.6 → python_linkplay-0.0.8}/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.6
3
+ Version: 0.0.8
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
@@ -10,7 +10,10 @@ 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
16
+ Requires-Dist: aiofiles>=24.1.0
14
17
  Provides-Extra: testing
15
18
  Requires-Dist: pytest>=7.3.1; extra == "testing"
16
19
  Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
@@ -20,6 +23,7 @@ Requires-Dist: mypy>=1.3.0; extra == "testing"
20
23
  Requires-Dist: ruff>=0.5.4; extra == "testing"
21
24
  Requires-Dist: tox>=4.6.0; extra == "testing"
22
25
  Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
26
+ Requires-Dist: pre-commit>=3.8.0; extra == "testing"
23
27
 
24
28
 
25
29
  [![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/)
@@ -27,5 +27,5 @@ warn_unreachable = true
27
27
  warn_unused_configs = true
28
28
  no_implicit_reexport = true
29
29
 
30
- [tool.ruff.lint.extend-per-file-ignores]
31
- "__init__.py" = ["F401"]
30
+ [tool.ruff.lint]
31
+ select = ["I"]
@@ -14,7 +14,10 @@ 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
20
+ aiofiles>=24.1.0
18
21
  python_requires = >=3.11
19
22
  package_dir =
20
23
  =src
@@ -36,6 +39,7 @@ testing =
36
39
  ruff>=0.5.4
37
40
  tox>=4.6.0
38
41
  typing-extensions>=4.6.3
42
+ pre-commit>=3.8.0
39
43
 
40
44
  [egg_info]
41
45
  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 async_create_unverified_client_session
5
5
 
6
6
 
7
7
  async def main():
8
- async with aiohttp.ClientSession() as session:
8
+ async with await async_create_unverified_client_session() as session:
9
9
  controller = LinkPlayController(session)
10
10
 
11
11
  await controller.discover_bridges()
@@ -0,0 +1 @@
1
+ __version__ = '0.0.8'
@@ -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
- PLAY_MODE_SEND_MAP,
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 session_call_api_json, session_call_api_ok, decode_hexstr
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[DeviceAttribute.UUID]
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[DeviceAttribute.DEVICE_NAME]
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 self.properties[DeviceAttribute.ETH_DHCP]
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[PlayerAttribute.MUTED] == MuteMode.MUTED
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[PlayerAttribute.TITLE]
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[PlayerAttribute.ARTIST]
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[PlayerAttribute.ALBUM]
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[PlayerAttribute.VOLUME])
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[PlayerAttribute.CURRENT_POSITION])
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[PlayerAttribute.TOTAL_LENGTH])
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[PlayerAttribute.PLAYING_STATUS])
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[PlayerAttribute.EQUALIZER_MODE])
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[PlayerAttribute.SPEAKER_TYPE])
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[PlayerAttribute.CHANNEL_TYPE])
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[PlayerAttribute.PLAYBACK_MODE])
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[PlayerAttribute.PLAYLIST_MODE])
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
- protocol: str
217
- ip_address: str
218
- session: ClientSession
220
+ endpoint: LinkPlayEndpoint
219
221
  device: LinkPlayDevice
220
222
  player: LinkPlayPlayer
221
223
 
222
- def __init__(self, protocol: str, ip_address: str, session: ClientSession):
223
- self.protocol = protocol
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 __repr__(self) -> str:
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 session_call_api_json(self.endpoint, self.session, command)
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 session_call_api_ok(self.endpoint, self.session, command)
241
+ await self.endpoint.request(command)
247
242
 
248
243
 
249
244
  class LinkPlayMultiroom:
@@ -1,19 +1,72 @@
1
- from enum import StrEnum, IntFlag
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 = "getStatus"
65
+ DEVICE_STATUS = "getStatusEx"
13
66
  SYSLOG = "getsyslog"
14
67
  UPDATE_SERVER = "GetUpdateServer"
15
68
  REBOOT = "reboot"
16
- PLAYER_STATUS = "getPlayerStatus"
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
- MIRROR = "50"
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
- SLAVE = "99"
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.MIRROR: "cd",
117
- PlayingMode.USB_DAC: "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.SLAVE: "Idle",
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):
@@ -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
- bridge = LinkPlayBridge("http", ip_address, session)
23
+ endpoint: LinkPlayApiEndpoint = LinkPlayApiEndpoint(
24
+ protocol="http", endpoint=ip_address, session=session
25
+ )
18
26
  try:
19
- await bridge.device.update_status()
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
- if bridge := await linkplay_factory_bridge(ip_address, session):
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
- if new_bridge := await linkplay_factory_bridge(
71
- follower[MultiroomAttribute.IP], session
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
@@ -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,127 @@
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 aiofiles
11
+ import async_timeout
12
+ from aiohttp import ClientError, ClientSession, TCPConnector
13
+ from appdirs import AppDirs
14
+
15
+ from linkplay.consts import API_ENDPOINT, API_TIMEOUT, MTLS_CERTIFICATE_CONTENTS
16
+ from linkplay.exceptions import LinkPlayRequestException
17
+
18
+
19
+ async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
20
+ """Calls the LinkPlay API and returns the result as a string.
21
+
22
+ Args:
23
+ endpoint (str): The endpoint to use.
24
+ session (ClientSession): The client session to use.
25
+ command (str): The command to use.
26
+
27
+ Raises:
28
+ LinkPlayRequestException: Thrown when the request fails or an invalid response is received.
29
+
30
+ Returns:
31
+ str: The response of the API call.
32
+ """
33
+ url = API_ENDPOINT.format(endpoint, command)
34
+
35
+ try:
36
+ async with async_timeout.timeout(API_TIMEOUT):
37
+ response = await session.get(url)
38
+
39
+ except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
40
+ raise LinkPlayRequestException(
41
+ f"{error} error requesting data from '{url}'"
42
+ ) from error
43
+
44
+ if response.status != HTTPStatus.OK:
45
+ raise LinkPlayRequestException(
46
+ f"Unexpected HTTPStatus {response.status} received from '{url}'"
47
+ )
48
+
49
+ return await response.text()
50
+
51
+
52
+ async def session_call_api_json(
53
+ endpoint: str, session: ClientSession, command: str
54
+ ) -> Dict[str, str]:
55
+ """Calls the LinkPlay API and returns the result as a JSON object."""
56
+ result = await session_call_api(endpoint, session, command)
57
+ return json.loads(result) # type: ignore
58
+
59
+
60
+ async def session_call_api_ok(
61
+ endpoint: str, session: ClientSession, command: str
62
+ ) -> None:
63
+ """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
64
+ result = await session_call_api(endpoint, session, command)
65
+
66
+ if result != "OK":
67
+ raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
68
+
69
+
70
+ def decode_hexstr(hexstr: str) -> str:
71
+ """Decode a hex string."""
72
+ try:
73
+ return bytes.fromhex(hexstr).decode("utf-8")
74
+ except ValueError:
75
+ return hexstr
76
+
77
+
78
+ def create_unverified_context() -> ssl.SSLContext:
79
+ """Creates an unverified SSL context with the default mTLS certificate."""
80
+ dirs = AppDirs("python-linkplay")
81
+ mtls_certificate_path = os.path.join(dirs.user_data_dir, "linkplay.pem")
82
+
83
+ if not os.path.isdir(dirs.user_data_dir):
84
+ os.makedirs(dirs.user_data_dir, exist_ok=True)
85
+
86
+ if not os.path.isfile(mtls_certificate_path):
87
+ with open(mtls_certificate_path, "w", encoding="utf-8") as file:
88
+ file.write(MTLS_CERTIFICATE_CONTENTS)
89
+
90
+ return create_ssl_context(path=mtls_certificate_path)
91
+
92
+
93
+ async def async_create_unverified_context() -> ssl.SSLContext:
94
+ """Asynchronously creates an unverified SSL context with the default mTLS certificate."""
95
+ async with aiofiles.tempfile.NamedTemporaryFile(
96
+ "w", encoding="utf-8"
97
+ ) as mtls_certificate:
98
+ await mtls_certificate.write(MTLS_CERTIFICATE_CONTENTS)
99
+ await mtls_certificate.flush()
100
+ return create_ssl_context(path=str(mtls_certificate.name))
101
+
102
+
103
+ def create_ssl_context(path: str) -> ssl.SSLContext:
104
+ """Creates an SSL context from given certificate file."""
105
+ sslcontext: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
106
+ sslcontext.check_hostname = False
107
+ sslcontext.verify_mode = ssl.CERT_NONE
108
+ sslcontext.load_cert_chain(certfile=path)
109
+ with contextlib.suppress(AttributeError):
110
+ # This only works for OpenSSL >= 1.0.0
111
+ sslcontext.options |= ssl.OP_NO_COMPRESSION
112
+ sslcontext.set_default_verify_paths()
113
+ return sslcontext
114
+
115
+
116
+ def create_unverified_client_session() -> ClientSession:
117
+ """Creates a ClientSession using the default unverified SSL context"""
118
+ context: ssl.SSLContext = create_unverified_context()
119
+ connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
120
+ return ClientSession(connector=connector)
121
+
122
+
123
+ async def async_create_unverified_client_session() -> ClientSession:
124
+ """Asynchronously creates a ClientSession using the default unverified SSL context"""
125
+ context: ssl.SSLContext = await async_create_unverified_context()
126
+ connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
127
+ return ClientSession(connector=connector)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python_linkplay
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
@@ -10,7 +10,10 @@ 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
16
+ Requires-Dist: aiofiles>=24.1.0
14
17
  Provides-Extra: testing
15
18
  Requires-Dist: pytest>=7.3.1; extra == "testing"
16
19
  Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
@@ -20,6 +23,7 @@ Requires-Dist: mypy>=1.3.0; extra == "testing"
20
23
  Requires-Dist: ruff>=0.5.4; extra == "testing"
21
24
  Requires-Dist: tox>=4.6.0; extra == "testing"
22
25
  Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
26
+ Requires-Dist: pre-commit>=3.8.0; extra == "testing"
23
27
 
24
28
 
25
29
  [![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/)
@@ -10,6 +10,7 @@ src/linkplay/bridge.py
10
10
  src/linkplay/consts.py
11
11
  src/linkplay/controller.py
12
12
  src/linkplay/discovery.py
13
+ src/linkplay/endpoint.py
13
14
  src/linkplay/exceptions.py
14
15
  src/linkplay/utils.py
15
16
  src/python_linkplay.egg-info/PKG-INFO
@@ -1,6 +1,9 @@
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
6
+ aiofiles>=24.1.0
4
7
 
5
8
  [testing]
6
9
  pytest>=7.3.1
@@ -11,3 +14,4 @@ mypy>=1.3.0
11
14
  ruff>=0.5.4
12
15
  tox>=4.6.0
13
16
  typing-extensions>=4.6.3
17
+ pre-commit>=3.8.0
@@ -1 +0,0 @@
1
- __version__ = '0.0.6'
@@ -1,67 +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(
38
- f"Unexpected HTTPStatus {response.status} received from '{url}'"
39
- )
40
-
41
- return await response.text()
42
-
43
-
44
- async def session_call_api_json(
45
- endpoint: str, session: ClientSession, command: str
46
- ) -> Dict[str, str]:
47
- """Calls the LinkPlay API and returns the result as a JSON object."""
48
- result = await session_call_api(endpoint, session, command)
49
- return json.loads(result) # type: ignore
50
-
51
-
52
- async def session_call_api_ok(
53
- endpoint: str, session: ClientSession, command: str
54
- ) -> None:
55
- """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
56
- result = await session_call_api(endpoint, session, command)
57
-
58
- if result != "OK":
59
- raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
60
-
61
-
62
- def decode_hexstr(hexstr: str) -> str:
63
- """Decode a hex string."""
64
- try:
65
- return bytes.fromhex(hexstr).decode("utf-8")
66
- except ValueError:
67
- return hexstr
File without changes