python-linkplay 0.0.7__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 (24) hide show
  1. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/PKG-INFO +2 -1
  2. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/setup.cfg +1 -0
  3. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/setup.py +0 -1
  4. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/__main__.py +2 -2
  5. python_linkplay-0.0.9/src/linkplay/__version__.py +1 -0
  6. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/bridge.py +39 -13
  7. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/consts.py +13 -0
  8. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/endpoint.py +25 -1
  9. python_linkplay-0.0.9/src/linkplay/utils.py +226 -0
  10. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/PKG-INFO +2 -1
  11. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/requires.txt +1 -0
  12. python_linkplay-0.0.7/src/linkplay/__version__.py +0 -1
  13. python_linkplay-0.0.7/src/linkplay/utils.py +0 -106
  14. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/LICENSE +0 -0
  15. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/README.md +0 -0
  16. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/pyproject.toml +0 -0
  17. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/__init__.py +0 -0
  18. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/controller.py +0 -0
  19. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/discovery.py +0 -0
  20. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/linkplay/exceptions.py +0 -0
  21. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/SOURCES.txt +0 -0
  22. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
  23. {python_linkplay-0.0.7 → python_linkplay-0.0.9}/src/python_linkplay.egg-info/not-zip-safe +0 -0
  24. {python_linkplay-0.0.7 → 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.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"
@@ -17,6 +17,7 @@ install_requires =
17
17
  appdirs>=1.4.4
18
18
  async_upnp_client>=0.36.2
19
19
  deprecated>=1.2.14
20
+ aiofiles>=24.1.0
20
21
  python_requires = >=3.11
21
22
  package_dir =
22
23
  =src
@@ -1,5 +1,4 @@
1
1
  from setuptools import setup
2
2
 
3
-
4
3
  if __name__ == "__main__":
5
4
  setup()
@@ -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()
@@ -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)
@@ -0,0 +1,226 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import logging
5
+ import os
6
+ import socket
7
+ import ssl
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from http import HTTPStatus
10
+
11
+ import aiofiles
12
+ import async_timeout
13
+ from aiohttp import ClientError, ClientSession, TCPConnector
14
+ from appdirs import AppDirs
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
+ )
25
+ from linkplay.exceptions import LinkPlayRequestException
26
+
27
+ _LOGGER = logging.getLogger(__name__)
28
+
29
+
30
+ async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
31
+ """Calls the LinkPlay API and returns the result as a string.
32
+
33
+ Args:
34
+ endpoint (str): The endpoint to use.
35
+ session (ClientSession): The client session to use.
36
+ command (str): The command to use.
37
+
38
+ Raises:
39
+ LinkPlayRequestException: Thrown when the request fails or an invalid response is received.
40
+
41
+ Returns:
42
+ str: The response of the API call.
43
+ """
44
+ url = API_ENDPOINT.format(endpoint, command)
45
+
46
+ try:
47
+ async with async_timeout.timeout(API_TIMEOUT):
48
+ response = await session.get(url)
49
+
50
+ except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
51
+ raise LinkPlayRequestException(
52
+ f"{error} error requesting data from '{url}'"
53
+ ) from error
54
+
55
+ if response.status != HTTPStatus.OK:
56
+ raise LinkPlayRequestException(
57
+ f"Unexpected HTTPStatus {response.status} received from '{url}'"
58
+ )
59
+
60
+ return await response.text()
61
+
62
+
63
+ async def session_call_api_json(
64
+ endpoint: str, session: ClientSession, command: str
65
+ ) -> dict[str, str]:
66
+ """Calls the LinkPlay API and returns the result as a JSON object."""
67
+ result = await session_call_api(endpoint, session, command)
68
+ return json.loads(result) # type: ignore
69
+
70
+
71
+ async def session_call_api_ok(
72
+ endpoint: str, session: ClientSession, command: str
73
+ ) -> None:
74
+ """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
75
+ result = await session_call_api(endpoint, session, command)
76
+
77
+ if result != "OK":
78
+ raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
79
+
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
+
119
+ def decode_hexstr(hexstr: str) -> str:
120
+ """Decode a hex string."""
121
+ try:
122
+ return bytes.fromhex(hexstr).decode("utf-8")
123
+ except ValueError:
124
+ return hexstr
125
+
126
+
127
+ @deprecated(version="0.0.9", reason="Use async_create_unverified_context instead")
128
+ def create_unverified_context() -> ssl.SSLContext:
129
+ """Creates an unverified SSL context with the default mTLS certificate."""
130
+ dirs = AppDirs("python-linkplay")
131
+ mtls_certificate_path = os.path.join(dirs.user_data_dir, "linkplay.pem")
132
+
133
+ if not os.path.isdir(dirs.user_data_dir):
134
+ os.makedirs(dirs.user_data_dir, exist_ok=True)
135
+
136
+ if not os.path.isfile(mtls_certificate_path):
137
+ with open(mtls_certificate_path, "w", encoding="utf-8") as file:
138
+ file.write(MTLS_CERTIFICATE_CONTENTS)
139
+
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)
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")
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
+ )
191
+ def create_unverified_client_session() -> ClientSession:
192
+ """Creates a ClientSession using the default unverified SSL context"""
193
+ context: ssl.SSLContext = create_unverified_context()
194
+ connector: TCPConnector = TCPConnector(family=socket.AF_UNSPEC, ssl=context)
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"
@@ -3,6 +3,7 @@ aiohttp>=3.8.5
3
3
  appdirs>=1.4.4
4
4
  async_upnp_client>=0.36.2
5
5
  deprecated>=1.2.14
6
+ aiofiles>=24.1.0
6
7
 
7
8
  [testing]
8
9
  pytest>=7.3.1
@@ -1 +0,0 @@
1
- __version__ = '0.0.7'
@@ -1,106 +0,0 @@
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 async_timeout
11
- from aiohttp import ClientError, ClientSession, TCPConnector
12
- from appdirs import AppDirs
13
-
14
- from linkplay.consts import API_ENDPOINT, API_TIMEOUT, MTLS_CERTIFICATE_CONTENTS
15
- from linkplay.exceptions import LinkPlayRequestException
16
-
17
-
18
- async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
19
- """Calls the LinkPlay API and returns the result as a string.
20
-
21
- Args:
22
- endpoint (str): The endpoint to use.
23
- session (ClientSession): The client session to use.
24
- command (str): The command to use.
25
-
26
- Raises:
27
- LinkPlayRequestException: Thrown when the request fails or an invalid response is received.
28
-
29
- Returns:
30
- str: The response of the API call.
31
- """
32
- url = API_ENDPOINT.format(endpoint, command)
33
-
34
- try:
35
- async with async_timeout.timeout(API_TIMEOUT):
36
- response = await session.get(url)
37
-
38
- except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error:
39
- raise LinkPlayRequestException(
40
- f"{error} error requesting data from '{url}'"
41
- ) from error
42
-
43
- if response.status != HTTPStatus.OK:
44
- raise LinkPlayRequestException(
45
- f"Unexpected HTTPStatus {response.status} received from '{url}'"
46
- )
47
-
48
- return await response.text()
49
-
50
-
51
- async def session_call_api_json(
52
- endpoint: str, session: ClientSession, command: str
53
- ) -> Dict[str, str]:
54
- """Calls the LinkPlay API and returns the result as a JSON object."""
55
- result = await session_call_api(endpoint, session, command)
56
- return json.loads(result) # type: ignore
57
-
58
-
59
- async def session_call_api_ok(
60
- endpoint: str, session: ClientSession, command: str
61
- ) -> None:
62
- """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
63
- result = await session_call_api(endpoint, session, command)
64
-
65
- if result != "OK":
66
- raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
67
-
68
-
69
- def decode_hexstr(hexstr: str) -> str:
70
- """Decode a hex string."""
71
- try:
72
- return bytes.fromhex(hexstr).decode("utf-8")
73
- except ValueError:
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)
File without changes