python-linkplay 0.0.8__tar.gz → 0.0.9__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 (23) hide show
  1. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/PKG-INFO +1 -1
  2. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/setup.py +0 -1
  3. python_linkplay-0.0.9/src/linkplay/__version__.py +1 -0
  4. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/bridge.py +39 -13
  5. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/consts.py +13 -0
  6. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/endpoint.py +25 -1
  7. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/utils.py +105 -6
  8. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/PKG-INFO +1 -1
  9. python_linkplay-0.0.8/src/linkplay/__version__.py +0 -1
  10. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/LICENSE +0 -0
  11. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/README.md +0 -0
  12. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/pyproject.toml +0 -0
  13. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/setup.cfg +0 -0
  14. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/__init__.py +0 -0
  15. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/__main__.py +0 -0
  16. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/controller.py +0 -0
  17. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/discovery.py +0 -0
  18. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/linkplay/exceptions.py +0 -0
  19. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/SOURCES.txt +0 -0
  20. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
  21. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/not-zip-safe +0 -0
  22. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/requires.txt +0 -0
  23. {python_linkplay-0.0.8 → python_linkplay-0.0.9}/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.8
3
+ Version: 0.0.9
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
@@ -1,5 +1,4 @@
1
1
  from setuptools import setup
2
2
 
3
-
4
3
  if __name__ == "__main__":
5
4
  setup()
@@ -0,0 +1 @@
1
+ __version__ = '0.0.9'
@@ -19,7 +19,7 @@ from linkplay.consts import (
19
19
  SpeakerType,
20
20
  )
21
21
  from linkplay.endpoint import LinkPlayEndpoint
22
- from linkplay.utils import decode_hexstr
22
+ from linkplay.utils import fixup_player_properties
23
23
 
24
24
 
25
25
  class LinkPlayDevice:
@@ -83,10 +83,11 @@ class LinkPlayPlayer:
83
83
 
84
84
  async def update_status(self) -> None:
85
85
  """Update the player status."""
86
- self.properties = await self.bridge.json_request(LinkPlayCommand.PLAYER_STATUS) # type: ignore[assignment]
87
- self.properties[PlayerAttribute.TITLE] = decode_hexstr(self.title)
88
- self.properties[PlayerAttribute.ARTIST] = decode_hexstr(self.artist)
89
- self.properties[PlayerAttribute.ALBUM] = decode_hexstr(self.album)
86
+ properties: dict[PlayerAttribute, str] = await self.bridge.json_request(
87
+ LinkPlayCommand.PLAYER_STATUS
88
+ ) # type: ignore[assignment]
89
+
90
+ self.properties = fixup_player_properties(properties)
90
91
 
91
92
  async def next(self) -> None:
92
93
  """Play the next song in the playlist."""
@@ -148,10 +149,19 @@ class LinkPlayPlayer:
148
149
  LinkPlayCommand.SWITCH_MODE.format(PLAY_MODE_SEND_MAP[mode])
149
150
  ) # type: ignore[str-format]
150
151
 
152
+ async def play_preset(self, preset_number: int) -> None:
153
+ """Play a preset."""
154
+ if not 0 < preset_number <= 10:
155
+ raise ValueError("Preset must be between 1 and 10.")
156
+ await self.bridge.request(LinkPlayCommand.PLAY_PRESET.format(preset_number))
157
+
151
158
  @property
152
159
  def muted(self) -> bool:
153
160
  """Returns if the player is muted."""
154
- return self.properties.get(PlayerAttribute.MUTED, MuteMode.UNMUTED) == MuteMode.MUTED
161
+ return (
162
+ self.properties.get(PlayerAttribute.MUTED, MuteMode.UNMUTED)
163
+ == MuteMode.MUTED
164
+ )
155
165
 
156
166
  @property
157
167
  def title(self) -> str:
@@ -186,32 +196,46 @@ class LinkPlayPlayer:
186
196
  @property
187
197
  def status(self) -> PlayingStatus:
188
198
  """Returns the current playing status."""
189
- return PlayingStatus(self.properties.get(PlayerAttribute.PLAYING_STATUS, PlayingStatus.STOPPED))
199
+ return PlayingStatus(
200
+ self.properties.get(PlayerAttribute.PLAYING_STATUS, PlayingStatus.STOPPED)
201
+ )
190
202
 
191
203
  @property
192
204
  def equalizer_mode(self) -> EqualizerMode:
193
205
  """Returns the current equalizer mode."""
194
- return EqualizerMode(self.properties.get(PlayerAttribute.EQUALIZER_MODE, EqualizerMode.CLASSIC))
206
+ return EqualizerMode(
207
+ self.properties.get(PlayerAttribute.EQUALIZER_MODE, EqualizerMode.CLASSIC)
208
+ )
195
209
 
196
210
  @property
197
211
  def speaker_type(self) -> SpeakerType:
198
212
  """Returns the current speaker the player is playing on."""
199
- return SpeakerType(self.properties.get(PlayerAttribute.SPEAKER_TYPE, SpeakerType.MAIN_SPEAKER))
213
+ return SpeakerType(
214
+ self.properties.get(PlayerAttribute.SPEAKER_TYPE, SpeakerType.MAIN_SPEAKER)
215
+ )
200
216
 
201
217
  @property
202
218
  def channel_type(self) -> ChannelType:
203
219
  """Returns the channel the player is playing on."""
204
- return ChannelType(self.properties.get(PlayerAttribute.CHANNEL_TYPE, ChannelType.STEREO))
220
+ return ChannelType(
221
+ self.properties.get(PlayerAttribute.CHANNEL_TYPE, ChannelType.STEREO)
222
+ )
205
223
 
206
224
  @property
207
225
  def play_mode(self) -> PlayingMode:
208
226
  """Returns the current playing mode of the player."""
209
- return PlayingMode(self.properties.get(PlayerAttribute.PLAYBACK_MODE, PlayingMode.IDLE))
227
+ return PlayingMode(
228
+ self.properties.get(PlayerAttribute.PLAYBACK_MODE, PlayingMode.IDLE)
229
+ )
210
230
 
211
231
  @property
212
232
  def loop_mode(self) -> LoopMode:
213
233
  """Returns the current playlist mode."""
214
- return LoopMode(self.properties.get(PlayerAttribute.PLAYLIST_MODE, LoopMode.CONTINUOUS_PLAYBACK))
234
+ return LoopMode(
235
+ self.properties.get(
236
+ PlayerAttribute.PLAYLIST_MODE, LoopMode.CONTINUOUS_PLAYBACK
237
+ )
238
+ )
215
239
 
216
240
 
217
241
  class LinkPlayBridge:
@@ -292,7 +316,9 @@ class LinkPlayMultiroom:
292
316
 
293
317
  async def set_volume(self, value: int) -> None:
294
318
  """Sets the volume for the multiroom group."""
295
- assert 0 < value <= 100
319
+ if not 0 <= value <= 100:
320
+ raise ValueError("Volume must be between 0 and 100")
321
+
296
322
  str_vol = str(value)
297
323
  await self.leader.request(LinkPlayCommand.MULTIROOM_VOL.format(str_vol)) # type: ignore[str-format]
298
324
 
@@ -4,6 +4,8 @@ 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
+ TCPPORT = 8899
8
+ TCP_MESSAGE_LENGTH = 1024
7
9
 
8
10
  MTLS_CERTIFICATE_CONTENTS = """
9
11
  -----BEGIN PRIVATE KEY-----
@@ -89,6 +91,17 @@ class LinkPlayCommand(StrEnum):
89
91
  MULTIROOM_MUTE = "setPlayerCmd:slave_mute:mute"
90
92
  MULTIROOM_UNMUTE = "setPlayerCmd:slave_mute:unmute"
91
93
  MULTIROOM_JOIN = "ConnectMasterAp:JoinGroupMaster:eth{}:wifi0.0.0.0"
94
+ PLAY_PRESET = "MCUKeyShortClick:{}"
95
+
96
+
97
+ class LinkPlayTcpUartCommand(StrEnum):
98
+ """Defined LinkPlay TCPUART commands."""
99
+
100
+ GET_METADATA = "MCU+MEA+GET"
101
+ PRESET_PLAY = "MCU+KEY+{:03}"
102
+ PRESET_NEXT = "MCU+KEY+NXT"
103
+ INPUT_WIFI = "MCU+PLM+000"
104
+ INPUT_BLUETOOTH = "MCU+PLM+006"
92
105
 
93
106
 
94
107
  class SpeakerType(StrEnum):
@@ -1,8 +1,15 @@
1
+ import asyncio
1
2
  from abc import ABC, abstractmethod
2
3
 
3
4
  from aiohttp import ClientSession
4
5
 
5
- from linkplay.utils import session_call_api_json, session_call_api_ok
6
+ from linkplay.consts import TCPPORT
7
+ from linkplay.utils import (
8
+ call_tcpuart,
9
+ call_tcpuart_json,
10
+ session_call_api_json,
11
+ session_call_api_ok,
12
+ )
6
13
 
7
14
 
8
15
  class LinkPlayEndpoint(ABC):
@@ -38,3 +45,20 @@ class LinkPlayApiEndpoint(LinkPlayEndpoint):
38
45
 
39
46
  def __str__(self) -> str:
40
47
  return self._endpoint
48
+
49
+
50
+ class LinkPlayTcpUartEndpoint(LinkPlayEndpoint):
51
+ """Represents a LinkPlay TCPUART API endpoint."""
52
+
53
+ def __init__(
54
+ self, *, connection: tuple[asyncio.StreamReader, asyncio.StreamWriter]
55
+ ):
56
+ self._connection = connection
57
+
58
+ async def request(self, command: str) -> None:
59
+ reader, writer = self._connection
60
+ await call_tcpuart(reader, writer, command)
61
+
62
+ async def json_request(self, command: str) -> dict[str, str]:
63
+ reader, writer = self._connection
64
+ return await call_tcpuart_json(reader, writer, command)
@@ -1,20 +1,31 @@
1
1
  import asyncio
2
2
  import contextlib
3
3
  import json
4
+ import logging
4
5
  import os
5
6
  import socket
6
7
  import ssl
8
+ from concurrent.futures import ThreadPoolExecutor
7
9
  from http import HTTPStatus
8
- from typing import Dict
9
10
 
10
11
  import aiofiles
11
12
  import async_timeout
12
13
  from aiohttp import ClientError, ClientSession, TCPConnector
13
14
  from appdirs import AppDirs
14
-
15
- from linkplay.consts import API_ENDPOINT, API_TIMEOUT, MTLS_CERTIFICATE_CONTENTS
15
+ from deprecated import deprecated
16
+
17
+ from linkplay.consts import (
18
+ API_ENDPOINT,
19
+ API_TIMEOUT,
20
+ MTLS_CERTIFICATE_CONTENTS,
21
+ TCP_MESSAGE_LENGTH,
22
+ PlayerAttribute,
23
+ PlayingStatus,
24
+ )
16
25
  from linkplay.exceptions import LinkPlayRequestException
17
26
 
27
+ _LOGGER = logging.getLogger(__name__)
28
+
18
29
 
19
30
  async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
20
31
  """Calls the LinkPlay API and returns the result as a string.
@@ -51,7 +62,7 @@ async def session_call_api(endpoint: str, session: ClientSession, command: str)
51
62
 
52
63
  async def session_call_api_json(
53
64
  endpoint: str, session: ClientSession, command: str
54
- ) -> Dict[str, str]:
65
+ ) -> dict[str, str]:
55
66
  """Calls the LinkPlay API and returns the result as a JSON object."""
56
67
  result = await session_call_api(endpoint, session, command)
57
68
  return json.loads(result) # type: ignore
@@ -67,6 +78,44 @@ async def session_call_api_ok(
67
78
  raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
68
79
 
69
80
 
81
+ async def call_tcpuart(
82
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cmd: str
83
+ ) -> str:
84
+ """Get the latest data from TCP UART service."""
85
+ payload_header: str = "18 96 18 20 "
86
+ payload_length: str = format(len(cmd), "02x")
87
+ payload_command_header: str = " 00 00 00 c1 02 00 00 00 00 00 00 00 00 00 00 "
88
+ payload_command_content: str = " ".join(hex(ord(c))[2:] for c in cmd)
89
+
90
+ async with async_timeout.timeout(API_TIMEOUT):
91
+ writer.write(
92
+ bytes.fromhex(
93
+ payload_header
94
+ + payload_length
95
+ + payload_command_header
96
+ + payload_command_content
97
+ )
98
+ )
99
+
100
+ data: bytes = await reader.read(TCP_MESSAGE_LENGTH)
101
+
102
+ if data == b"":
103
+ raise LinkPlayRequestException("No data received from socket")
104
+
105
+ return str(repr(data))
106
+
107
+
108
+ async def call_tcpuart_json(
109
+ reader: asyncio.StreamReader, writer: asyncio.StreamWriter, cmd: str
110
+ ) -> dict[str, str]:
111
+ """Get JSON data from TCPUART service."""
112
+ raw_response: str = await call_tcpuart(reader, writer, cmd)
113
+ strip_start = raw_response.find("{")
114
+ strip_end = raw_response.find("}", strip_start) + 1
115
+ data = raw_response[strip_start:strip_end]
116
+ return json.loads(data) # type: ignore
117
+
118
+
70
119
  def decode_hexstr(hexstr: str) -> str:
71
120
  """Decode a hex string."""
72
121
  try:
@@ -75,6 +124,7 @@ def decode_hexstr(hexstr: str) -> str:
75
124
  return hexstr
76
125
 
77
126
 
127
+ @deprecated(version="0.0.9", reason="Use async_create_unverified_context instead")
78
128
  def create_unverified_context() -> ssl.SSLContext:
79
129
  """Creates an unverified SSL context with the default mTLS certificate."""
80
130
  dirs = AppDirs("python-linkplay")
@@ -90,16 +140,38 @@ def create_unverified_context() -> ssl.SSLContext:
90
140
  return create_ssl_context(path=mtls_certificate_path)
91
141
 
92
142
 
93
- async def async_create_unverified_context() -> ssl.SSLContext:
143
+ async def async_create_unverified_context(
144
+ executor: ThreadPoolExecutor | None = None,
145
+ ) -> ssl.SSLContext:
94
146
  """Asynchronously creates an unverified SSL context with the default mTLS certificate."""
95
147
  async with aiofiles.tempfile.NamedTemporaryFile(
96
148
  "w", encoding="utf-8"
97
149
  ) as mtls_certificate:
98
150
  await mtls_certificate.write(MTLS_CERTIFICATE_CONTENTS)
99
151
  await mtls_certificate.flush()
100
- return create_ssl_context(path=str(mtls_certificate.name))
152
+ certfile: str = str(mtls_certificate.name)
153
+ return await async_create_ssl_context(certfile=certfile, executor=executor)
101
154
 
102
155
 
156
+ async def async_create_ssl_context(
157
+ *, certfile: str, executor: ThreadPoolExecutor | None = None
158
+ ) -> ssl.SSLContext:
159
+ """Creates an SSL context from given certificate file."""
160
+ sslcontext: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
161
+ sslcontext.check_hostname = False
162
+ sslcontext.verify_mode = ssl.CERT_NONE
163
+
164
+ loop = asyncio.get_running_loop()
165
+ await loop.run_in_executor(executor, sslcontext.load_cert_chain, certfile)
166
+
167
+ with contextlib.suppress(AttributeError):
168
+ # This only works for OpenSSL >= 1.0.0
169
+ sslcontext.options |= ssl.OP_NO_COMPRESSION
170
+ sslcontext.set_default_verify_paths()
171
+ return sslcontext
172
+
173
+
174
+ @deprecated(version="0.0.9", reason="Use async_create_ssl_context instead")
103
175
  def create_ssl_context(path: str) -> ssl.SSLContext:
104
176
  """Creates an SSL context from given certificate file."""
105
177
  sslcontext: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
@@ -113,6 +185,9 @@ def create_ssl_context(path: str) -> ssl.SSLContext:
113
185
  return sslcontext
114
186
 
115
187
 
188
+ @deprecated(
189
+ version="0.0.9", reason="Use async_create_unverified_client_session instead"
190
+ )
116
191
  def create_unverified_client_session() -> ClientSession:
117
192
  """Creates a ClientSession using the default unverified SSL context"""
118
193
  context: ssl.SSLContext = create_unverified_context()
@@ -125,3 +200,27 @@ async def async_create_unverified_client_session() -> ClientSession:
125
200
  context: ssl.SSLContext = await async_create_unverified_context()
126
201
  connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
127
202
  return ClientSession(connector=connector)
203
+
204
+
205
+ def fixup_player_properties(
206
+ properties: dict[PlayerAttribute, str],
207
+ ) -> dict[PlayerAttribute, str]:
208
+ """Fixes up PlayerAttribute in a dict."""
209
+ properties[PlayerAttribute.TITLE] = decode_hexstr(
210
+ properties.get(PlayerAttribute.TITLE, "")
211
+ )
212
+ properties[PlayerAttribute.ARTIST] = decode_hexstr(
213
+ properties.get(PlayerAttribute.ARTIST, "")
214
+ )
215
+ properties[PlayerAttribute.ALBUM] = decode_hexstr(
216
+ properties.get(PlayerAttribute.ALBUM, "")
217
+ )
218
+
219
+ # Fixup playing status "none" by setting it to "stopped"
220
+ if (
221
+ properties.get(PlayerAttribute.PLAYING_STATUS, "")
222
+ not in PlayingStatus.__members__.values()
223
+ ):
224
+ properties[PlayerAttribute.PLAYING_STATUS] = PlayingStatus.STOPPED
225
+
226
+ return properties
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python_linkplay
3
- Version: 0.0.8
3
+ Version: 0.0.9
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
@@ -1 +0,0 @@
1
- __version__ = '0.0.8'
File without changes