python-linkplay 0.0.7__py3-none-any.whl → 0.0.9__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 CHANGED
@@ -1,11 +1,11 @@
1
1
  import asyncio
2
2
 
3
3
  from linkplay.controller import LinkPlayController
4
- from linkplay.utils import create_unverified_client_session
4
+ from linkplay.utils import async_create_unverified_client_session
5
5
 
6
6
 
7
7
  async def main():
8
- async with create_unverified_client_session() 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()
linkplay/__version__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.0.7'
1
+ __version__ = '0.0.9'
linkplay/bridge.py CHANGED
@@ -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
 
linkplay/consts.py CHANGED
@@ -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):
linkplay/endpoint.py CHANGED
@@ -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)
linkplay/utils.py CHANGED
@@ -1,19 +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
 
11
+ import aiofiles
10
12
  import async_timeout
11
13
  from aiohttp import ClientError, ClientSession, TCPConnector
12
14
  from appdirs import AppDirs
13
-
14
- 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
+ )
15
25
  from linkplay.exceptions import LinkPlayRequestException
16
26
 
27
+ _LOGGER = logging.getLogger(__name__)
28
+
17
29
 
18
30
  async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
19
31
  """Calls the LinkPlay API and returns the result as a string.
@@ -50,7 +62,7 @@ async def session_call_api(endpoint: str, session: ClientSession, command: str)
50
62
 
51
63
  async def session_call_api_json(
52
64
  endpoint: str, session: ClientSession, command: str
53
- ) -> Dict[str, str]:
65
+ ) -> dict[str, str]:
54
66
  """Calls the LinkPlay API and returns the result as a JSON object."""
55
67
  result = await session_call_api(endpoint, session, command)
56
68
  return json.loads(result) # type: ignore
@@ -66,6 +78,44 @@ async def session_call_api_ok(
66
78
  raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
67
79
 
68
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
+
69
119
  def decode_hexstr(hexstr: str) -> str:
70
120
  """Decode a hex string."""
71
121
  try:
@@ -74,6 +124,7 @@ def decode_hexstr(hexstr: str) -> str:
74
124
  return hexstr
75
125
 
76
126
 
127
+ @deprecated(version="0.0.9", reason="Use async_create_unverified_context instead")
77
128
  def create_unverified_context() -> ssl.SSLContext:
78
129
  """Creates an unverified SSL context with the default mTLS certificate."""
79
130
  dirs = AppDirs("python-linkplay")
@@ -86,12 +137,33 @@ def create_unverified_context() -> ssl.SSLContext:
86
137
  with open(mtls_certificate_path, "w", encoding="utf-8") as file:
87
138
  file.write(MTLS_CERTIFICATE_CONTENTS)
88
139
 
89
- sslcontext: ssl.SSLContext = ssl.create_default_context(
90
- purpose=ssl.Purpose.SERVER_AUTH
91
- )
92
- sslcontext.load_cert_chain(certfile=mtls_certificate_path)
140
+ return create_ssl_context(path=mtls_certificate_path)
141
+
142
+
143
+ async def async_create_unverified_context(
144
+ executor: ThreadPoolExecutor | None = None,
145
+ ) -> ssl.SSLContext:
146
+ """Asynchronously creates an unverified SSL context with the default mTLS certificate."""
147
+ async with aiofiles.tempfile.NamedTemporaryFile(
148
+ "w", encoding="utf-8"
149
+ ) as mtls_certificate:
150
+ await mtls_certificate.write(MTLS_CERTIFICATE_CONTENTS)
151
+ await mtls_certificate.flush()
152
+ certfile: str = str(mtls_certificate.name)
153
+ return await async_create_ssl_context(certfile=certfile, executor=executor)
154
+
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)
93
161
  sslcontext.check_hostname = False
94
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
+
95
167
  with contextlib.suppress(AttributeError):
96
168
  # This only works for OpenSSL >= 1.0.0
97
169
  sslcontext.options |= ssl.OP_NO_COMPRESSION
@@ -99,8 +171,56 @@ def create_unverified_context() -> ssl.SSLContext:
99
171
  return sslcontext
100
172
 
101
173
 
174
+ @deprecated(version="0.0.9", reason="Use async_create_ssl_context instead")
175
+ def create_ssl_context(path: str) -> ssl.SSLContext:
176
+ """Creates an SSL context from given certificate file."""
177
+ sslcontext: ssl.SSLContext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
178
+ sslcontext.check_hostname = False
179
+ sslcontext.verify_mode = ssl.CERT_NONE
180
+ sslcontext.load_cert_chain(certfile=path)
181
+ with contextlib.suppress(AttributeError):
182
+ # This only works for OpenSSL >= 1.0.0
183
+ sslcontext.options |= ssl.OP_NO_COMPRESSION
184
+ sslcontext.set_default_verify_paths()
185
+ return sslcontext
186
+
187
+
188
+ @deprecated(
189
+ version="0.0.9", reason="Use async_create_unverified_client_session instead"
190
+ )
102
191
  def create_unverified_client_session() -> ClientSession:
103
192
  """Creates a ClientSession using the default unverified SSL context"""
104
193
  context: ssl.SSLContext = create_unverified_context()
105
194
  connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
106
195
  return ClientSession(connector=connector)
196
+
197
+
198
+ async def async_create_unverified_client_session() -> ClientSession:
199
+ """Asynchronously creates a ClientSession using the default unverified SSL context"""
200
+ context: ssl.SSLContext = await async_create_unverified_context()
201
+ connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
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.7
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
@@ -13,6 +13,7 @@ Requires-Dist: aiohttp>=3.8.5
13
13
  Requires-Dist: appdirs>=1.4.4
14
14
  Requires-Dist: async-upnp-client>=0.36.2
15
15
  Requires-Dist: deprecated>=1.2.14
16
+ Requires-Dist: aiofiles>=24.1.0
16
17
  Provides-Extra: testing
17
18
  Requires-Dist: pytest>=7.3.1; extra == "testing"
18
19
  Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
@@ -0,0 +1,15 @@
1
+ linkplay/__init__.py,sha256=y9ZehEq-KhS3cwn-PUpwVSJGfDUx7e5wf_G6guODcTk,56
2
+ linkplay/__main__.py,sha256=Wcza80QaWfOaHjyJEfQYhB9kiPLE0NOqIj4zVWv2Nqs,577
3
+ linkplay/__version__.py,sha256=VkqKNhCu7_SPtm2SJjQ7vIeLd6wBQJZl_t1N5uPUZuc,22
4
+ linkplay/bridge.py,sha256=LXUc1zcRh1Hx1QauhlpA9da5k7f6h3KLfGRA1jAbTPU,11602
5
+ linkplay/consts.py,sha256=OGEj34YTiEWRBPjIebokDOVKOsa-DpZkCkUpThO8IIc,13068
6
+ linkplay/controller.py,sha256=JIQAKPs3EK7ZwzoyzSy0HBl21gH9Cc9RrLXIGOMzkCM,2146
7
+ linkplay/discovery.py,sha256=aEzN_94pKLmHKYIL7DxSW0FYRsaF2ruZe2bwXz0zf5U,4299
8
+ linkplay/endpoint.py,sha256=aWNiiU6h3gIWiNzcnavfA8IMZLufv9A8Cm5qphRpRvA,2158
9
+ linkplay/exceptions.py,sha256=tWJWHsKVkUEq3Yet1Z739IxcaQT8YamDeSp0tqHde9c,107
10
+ linkplay/utils.py,sha256=WVKdxITDymLCmKGqlD9Ieyb96qZ-QSC9oIe-KGW4IFU,7827
11
+ python_linkplay-0.0.9.dist-info/LICENSE,sha256=bgEtxMyjEHX_4uwaAY3GCFTm234D4AOZ5dM15sk26ms,1073
12
+ python_linkplay-0.0.9.dist-info/METADATA,sha256=raTNkO4qPbCWo9MP2B-mugtvVgodiRjQZGFQqud8bIQ,2987
13
+ python_linkplay-0.0.9.dist-info/WHEEL,sha256=Mdi9PDNwEZptOjTlUcAth7XJDFtKrHYaQMPulZeBCiQ,91
14
+ python_linkplay-0.0.9.dist-info/top_level.txt,sha256=CpSaOVPTzJf5TVIL7MrotSCR34gcIOQy-11l4zGmxxM,9
15
+ python_linkplay-0.0.9.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (72.1.0)
2
+ Generator: setuptools (73.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,15 +0,0 @@
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,,