python-linkplay 0.0.0__tar.gz → 0.0.2__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 (22) hide show
  1. {python_linkplay-0.0.0/src/python_linkplay.egg-info → python_linkplay-0.0.2}/PKG-INFO +3 -4
  2. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/README.md +1 -2
  3. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/setup.cfg +1 -1
  4. python_linkplay-0.0.2/src/linkplay/__version__.py +1 -0
  5. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/bridge.py +39 -41
  6. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/consts.py +50 -34
  7. python_linkplay-0.0.2/src/linkplay/discovery.py +86 -0
  8. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/utils.py +6 -3
  9. {python_linkplay-0.0.0 → python_linkplay-0.0.2/src/python_linkplay.egg-info}/PKG-INFO +3 -4
  10. python_linkplay-0.0.0/src/linkplay/__version__.py +0 -1
  11. python_linkplay-0.0.0/src/linkplay/discovery.py +0 -63
  12. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/LICENSE +0 -0
  13. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/pyproject.toml +0 -0
  14. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/setup.py +0 -0
  15. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/__init__.py +0 -0
  16. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/__main__.py +0 -0
  17. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/linkplay/exceptions.py +0 -0
  18. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/SOURCES.txt +0 -0
  19. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/dependency_links.txt +0 -0
  20. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/not-zip-safe +0 -0
  21. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/requires.txt +0 -0
  22. {python_linkplay-0.0.0 → python_linkplay-0.0.2}/src/python_linkplay.egg-info/top_level.txt +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python_linkplay
3
- Version: 0.0.0
3
+ Version: 0.0.2
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
7
7
  Classifier: Programming Language :: Python :: 3
8
- Requires-Python: >=3.8
8
+ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: async-timeout==4.0.3
@@ -23,8 +23,7 @@ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
23
23
 
24
24
 
25
25
  [![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/)
26
-
27
- [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
26
+ [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
28
27
 
29
28
  # python-linkplay
30
29
  A Python Library for Seamless LinkPlay Device Control
@@ -1,7 +1,6 @@
1
1
 
2
2
  [![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/)
3
-
4
- [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
3
+ [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
5
4
 
6
5
  # python-linkplay
7
6
  A Python Library for Seamless LinkPlay Device Control
@@ -15,7 +15,7 @@ install_requires =
15
15
  async-timeout==4.0.3
16
16
  aiohttp==3.9.1
17
17
  async_upnp_client==0.38.0
18
- python_requires = >=3.8
18
+ python_requires = >=3.11
19
19
  package_dir =
20
20
  =src
21
21
  zip_safe = no
@@ -0,0 +1 @@
1
+ __version__ = '0.0.2'
@@ -1,5 +1,4 @@
1
1
  from __future__ import annotations
2
- from typing import Dict, List
3
2
 
4
3
  from aiohttp import ClientSession
5
4
 
@@ -11,11 +10,12 @@ from linkplay.consts import (
11
10
  MuteMode,
12
11
  EqualizerMode,
13
12
  LoopMode,
14
- PlaybackMode,
15
- PLAYBACK_MODE_MAP,
13
+ PLAY_MODE_SEND_MAP,
16
14
  PlayingStatus,
17
- PlaymodeSupport,
18
- SpeakerType
15
+ InputMode,
16
+ SpeakerType,
17
+ PlayingMode,
18
+ INPUT_MODE_MAP
19
19
  )
20
20
  from linkplay.utils import session_call_api_json, session_call_api_ok, decode_hexstr
21
21
 
@@ -24,7 +24,7 @@ class LinkPlayDevice():
24
24
  """Represents a LinkPlay device."""
25
25
 
26
26
  bridge: LinkPlayBridge
27
- properties: Dict[DeviceAttribute, str] = dict.fromkeys(DeviceAttribute.__members__.values(), "")
27
+ properties: dict[DeviceAttribute, str] = dict.fromkeys(DeviceAttribute.__members__.values(), "")
28
28
 
29
29
  def __init__(self, bridge: LinkPlayBridge):
30
30
  self.bridge = bridge
@@ -33,7 +33,7 @@ class LinkPlayDevice():
33
33
  """Update the device status."""
34
34
  self.properties = await self.bridge.json_request(LinkPlayCommand.DEVICE_STATUS) # type: ignore[assignment]
35
35
 
36
- async def reboot(self):
36
+ async def reboot(self) -> None:
37
37
  """Reboot the device."""
38
38
  await self.bridge.request(LinkPlayCommand.REBOOT)
39
39
 
@@ -48,9 +48,11 @@ class LinkPlayDevice():
48
48
  return self.properties[DeviceAttribute.DEVICE_NAME]
49
49
 
50
50
  @property
51
- def playmode_support(self) -> PlaymodeSupport:
51
+ def playmode_support(self) -> list[PlayingMode]:
52
52
  """Returns the player playmode support."""
53
- return PlaymodeSupport(int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16))
53
+
54
+ flags = InputMode(int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16))
55
+ return [INPUT_MODE_MAP[flag] for flag in flags]
54
56
 
55
57
  @property
56
58
  def eth(self) -> str:
@@ -62,79 +64,75 @@ class LinkPlayPlayer():
62
64
  """Represents a LinkPlay player."""
63
65
 
64
66
  bridge: LinkPlayBridge
65
- properties: Dict[PlayerAttribute, str] = dict.fromkeys(PlayerAttribute.__members__.values(), "")
67
+ properties: dict[PlayerAttribute, str] = dict.fromkeys(PlayerAttribute.__members__.values(), "")
66
68
 
67
69
  def __init__(self, bridge: LinkPlayBridge):
68
70
  self.bridge = bridge
69
71
 
70
- async def update_status(self):
72
+ async def update_status(self) -> None:
71
73
  """Update the player status."""
72
74
  self.properties = await self.bridge.json_request(LinkPlayCommand.PLAYER_STATUS) # type: ignore[assignment]
73
75
  self.properties[PlayerAttribute.TITLE] = decode_hexstr(self.title)
74
76
  self.properties[PlayerAttribute.ARTIST] = decode_hexstr(self.artist)
75
77
  self.properties[PlayerAttribute.ALBUM] = decode_hexstr(self.album)
76
78
 
77
- async def next(self):
79
+ async def next(self) -> None:
78
80
  """Play the next song in the playlist."""
79
81
  await self.bridge.request(LinkPlayCommand.NEXT)
80
82
 
81
- async def previous(self):
83
+ async def previous(self) -> None:
82
84
  """Play the previous song in the playlist."""
83
85
  await self.bridge.request(LinkPlayCommand.PREVIOUS)
84
86
 
85
- async def play(self, value: str):
87
+ async def play(self, value: str) -> None:
86
88
  """Start playing the selected track."""
87
89
  await self.bridge.request(LinkPlayCommand.PLAY.format(value)) # type: ignore[str-format]
88
90
 
89
- async def resume(self):
91
+ async def resume(self) -> None:
90
92
  """Resume playing the current track."""
91
93
  await self.bridge.request(LinkPlayCommand.RESUME)
92
94
 
93
- async def mute(self):
95
+ async def mute(self) -> None:
94
96
  """Mute the player."""
95
97
  await self.bridge.request(LinkPlayCommand.MUTE)
96
98
  self.properties[PlayerAttribute.MUTED] = MuteMode.MUTED
97
99
 
98
- async def unmute(self):
100
+ async def unmute(self) -> None:
99
101
  """Unmute the player."""
100
102
  await self.bridge.request(LinkPlayCommand.UNMUTE)
101
103
  self.properties[PlayerAttribute.MUTED] = MuteMode.UNMUTED
102
104
 
103
- async def play_playlist(self, index: int):
105
+ async def play_playlist(self, index: int) -> None:
104
106
  """Start playing chosen playlist by index number."""
105
107
  await self.bridge.request(LinkPlayCommand.PLAYLIST.format(index)) # type: ignore[str-format]
106
108
 
107
- async def pause(self):
109
+ async def pause(self) -> None:
108
110
  """Pause the current playing track."""
109
111
  await self.bridge.request(LinkPlayCommand.PAUSE)
110
112
  self.properties[PlayerAttribute.PLAYING_STATUS] = PlayingStatus.PAUSED
111
113
 
112
- async def toggle(self):
114
+ async def toggle(self) -> None:
113
115
  """Start playing if the player is currently not playing. Stops playing if it is."""
114
116
  await self.bridge.request(LinkPlayCommand.TOGGLE)
115
117
 
116
- async def set_volume(self, value: int):
118
+ async def set_volume(self, value: int) -> None:
117
119
  """Set the player volume."""
118
120
  if not 0 <= value <= 100:
119
121
  raise ValueError("Volume must be between 0 and 100.")
120
122
 
121
123
  await self.bridge.request(LinkPlayCommand.VOLUME.format(value)) # type: ignore[str-format]
122
- self.properties[PlayerAttribute.VOLUME] = str(value)
123
124
 
124
- async def set_equalizer_mode(self, mode: EqualizerMode):
125
+ async def set_equalizer_mode(self, mode: EqualizerMode) -> None:
125
126
  """Set the equalizer mode."""
126
127
  await self.bridge.request(LinkPlayCommand.EQUALIZER_MODE.format(mode)) # type: ignore[str-format]
127
- self.properties[PlayerAttribute.EQUALIZER_MODE] = mode
128
128
 
129
- async def set_loop_mode(self, mode: LoopMode):
129
+ async def set_loop_mode(self, mode: LoopMode) -> None:
130
130
  """Set the loop mode."""
131
131
  await self.bridge.request(LinkPlayCommand.LOOP_MODE.format(mode)) # type: ignore[str-format]
132
- self.properties[PlayerAttribute.PLAYLIST_MODE] = mode
133
132
 
134
- async def set_play_mode(self, mode: PlaybackMode):
133
+ async def set_play_mode(self, mode: PlayingMode) -> None:
135
134
  """Set the play mode."""
136
- await self.bridge.request(LinkPlayCommand.SWITCH_MODE.format(PLAYBACK_MODE_MAP[mode])) # type: ignore[str-format]
137
- self.properties[PlayerAttribute.PLAYBACK_MODE] = mode
135
+ await self.bridge.request(LinkPlayCommand.SWITCH_MODE.format(PLAY_MODE_SEND_MAP[mode])) # type: ignore[str-format]
138
136
 
139
137
  @property
140
138
  def muted(self) -> bool:
@@ -192,9 +190,9 @@ class LinkPlayPlayer():
192
190
  return ChannelType(self.properties[PlayerAttribute.CHANNEL_TYPE])
193
191
 
194
192
  @property
195
- def playback_mode(self) -> PlaybackMode:
196
- """Returns the channel the player is playing on."""
197
- return PlaybackMode(self.properties[PlayerAttribute.PLAYBACK_MODE])
193
+ def play_mode(self) -> PlayingMode:
194
+ """Returns the current playing mode of the player."""
195
+ return PlayingMode(self.properties[PlayerAttribute.PLAYBACK_MODE])
198
196
 
199
197
  @property
200
198
  def loop_mode(self) -> LoopMode:
@@ -229,7 +227,7 @@ class LinkPlayBridge():
229
227
  """Returns the current player endpoint."""
230
228
  return f"{self.protocol}://{self.ip_address}"
231
229
 
232
- async def json_request(self, command: str) -> Dict[str, str]:
230
+ async def json_request(self, command: str) -> dict[str, str]:
233
231
  """Performs a GET request on the given command and returns the result as a JSON object."""
234
232
  return await session_call_api_json(self.endpoint, self.session, command)
235
233
 
@@ -243,28 +241,28 @@ class LinkPlayMultiroom():
243
241
  The leader is the device that controls the group."""
244
242
 
245
243
  leader: LinkPlayBridge
246
- followers: List[LinkPlayBridge]
244
+ followers: list[LinkPlayBridge]
247
245
 
248
- def __init__(self, leader: LinkPlayBridge, followers: List[LinkPlayBridge]):
246
+ def __init__(self, leader: LinkPlayBridge, followers: list[LinkPlayBridge]):
249
247
  self.leader = leader
250
248
  self.followers = followers
251
249
 
252
- async def ungroup(self):
250
+ async def ungroup(self) -> None:
253
251
  """Ungroups the multiroom group."""
254
252
  await self.leader.request(LinkPlayCommand.MULTIROOM_UNGROUP)
255
253
  self.followers = []
256
254
 
257
- async def add_follower(self, follower: LinkPlayBridge):
255
+ async def add_follower(self, follower: LinkPlayBridge) -> None:
258
256
  """Adds a follower to the multiroom group."""
259
257
  await follower.request(LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)) # type: ignore[str-format]
260
258
  self.followers.append(follower)
261
259
 
262
- async def remove_follower(self, follower: LinkPlayBridge):
260
+ async def remove_follower(self, follower: LinkPlayBridge) -> None:
263
261
  """Removes a follower from the multiroom group."""
264
262
  await self.leader.request(LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)) # type: ignore[str-format]
265
263
  self.followers.remove(follower)
266
264
 
267
- async def set_volume(self, value: int):
265
+ async def set_volume(self, value: int) -> None:
268
266
  """Sets the volume for the multiroom group."""
269
267
  assert 0 < value <= 100
270
268
  str_vol = str(value)
@@ -273,10 +271,10 @@ class LinkPlayMultiroom():
273
271
  for bridge in [self.leader] + self.followers:
274
272
  bridge.player.properties[PlayerAttribute.VOLUME] = str_vol
275
273
 
276
- async def mute(self):
274
+ async def mute(self) -> None:
277
275
  """Mutes the multiroom group."""
278
276
  await self.leader.request(LinkPlayCommand.MULTIROOM_MUTE)
279
277
 
280
- async def unmute(self):
278
+ async def unmute(self) -> None:
281
279
  """Unmutes the multiroom group."""
282
280
  await self.leader.request(LinkPlayCommand.MULTIROOM_UNMUTE)
@@ -1,5 +1,4 @@
1
1
  from enum import StrEnum, IntFlag
2
- from typing import Dict
3
2
 
4
3
  API_ENDPOINT: str = "{}/httpapi.asp?command={}"
5
4
  API_TIMEOUT: int = 2
@@ -51,8 +50,8 @@ class ChannelType(StrEnum):
51
50
  RIGHT_CHANNEL = "2"
52
51
 
53
52
 
54
- class PlaybackMode(StrEnum):
55
- """Defines the playback mode."""
53
+ class PlayingMode(StrEnum):
54
+ """Defines a possible playing mode."""
56
55
  IDLE = "-1"
57
56
  NONE = "0"
58
57
  AIRPLAY = "1"
@@ -75,7 +74,7 @@ class PlaybackMode(StrEnum):
75
74
  EXT_LOCAL = "42"
76
75
  OPTICAL = "43"
77
76
  RCA = "44"
78
- CO_AXIAL = "45"
77
+ COAXIAL = "45"
79
78
  FM = "46"
80
79
  LINE_IN_2 = "47"
81
80
  XLR = "48"
@@ -83,37 +82,39 @@ class PlaybackMode(StrEnum):
83
82
  MIRROR = "50"
84
83
  USB_DAC = "51"
85
84
  TF_CARD_2 = "52"
85
+ OPTICAL_2 = "56"
86
86
  TALK = "60"
87
87
  SLAVE = "99"
88
88
 
89
89
 
90
- PLAYBACK_MODE_MAP: Dict[PlaybackMode, str] = {
91
- PlaybackMode.IDLE: 'Idle',
92
- PlaybackMode.NONE: 'Idle',
93
- PlaybackMode.AIRPLAY: 'Airplay',
94
- PlaybackMode.DLNA: 'DLNA',
95
- PlaybackMode.QPLAY: 'QPlay',
96
- PlaybackMode.NETWORK: 'wifi',
97
- PlaybackMode.WIIMU_LOCAL: 'udisk',
98
- PlaybackMode.TF_CARD_1: 'TFcard',
99
- PlaybackMode.API: 'API',
100
- PlaybackMode.UDISK: 'udisk',
101
- PlaybackMode.ALARM: 'Alarm',
102
- PlaybackMode.SPOTIFY: 'Spotify',
103
- PlaybackMode.LINE_IN: 'line-in',
104
- PlaybackMode.BLUETOOTH: 'bluetooth',
105
- PlaybackMode.OPTICAL: 'optical',
106
- PlaybackMode.RCA: 'RCA',
107
- PlaybackMode.CO_AXIAL: 'co-axial',
108
- PlaybackMode.FM: 'FM',
109
- PlaybackMode.LINE_IN_2: 'line-in2',
110
- PlaybackMode.XLR: 'XLR',
111
- PlaybackMode.HDMI: 'HDMI',
112
- PlaybackMode.MIRROR: 'cd',
113
- PlaybackMode.USB_DAC: 'USB DAC',
114
- PlaybackMode.TF_CARD_2: 'TFcard',
115
- PlaybackMode.TALK: 'Talk',
116
- PlaybackMode.SLAVE: 'Idle'
90
+ # Map between a play mode and how to activate the play mode
91
+ PLAY_MODE_SEND_MAP: dict[PlayingMode, str] = { # case sensitive!
92
+ PlayingMode.NONE: 'Idle',
93
+ PlayingMode.AIRPLAY: 'Airplay',
94
+ PlayingMode.DLNA: 'DLNA',
95
+ PlayingMode.QPLAY: 'QPlay',
96
+ PlayingMode.NETWORK: 'wifi',
97
+ PlayingMode.WIIMU_LOCAL: 'udisk',
98
+ PlayingMode.TF_CARD_1: 'TFcard',
99
+ PlayingMode.API: 'API',
100
+ PlayingMode.UDISK: 'udisk',
101
+ PlayingMode.ALARM: 'Alarm',
102
+ PlayingMode.SPOTIFY: 'Spotify',
103
+ PlayingMode.LINE_IN: 'line-in',
104
+ PlayingMode.BLUETOOTH: 'bluetooth',
105
+ PlayingMode.OPTICAL: 'optical',
106
+ PlayingMode.RCA: 'RCA',
107
+ PlayingMode.COAXIAL: 'co-axial',
108
+ PlayingMode.FM: 'FM',
109
+ PlayingMode.LINE_IN_2: 'line-in2',
110
+ PlayingMode.XLR: 'XLR',
111
+ PlayingMode.HDMI: 'HDMI',
112
+ PlayingMode.MIRROR: 'cd',
113
+ PlayingMode.USB_DAC: 'USB DAC',
114
+ PlayingMode.TF_CARD_2: 'TFcard',
115
+ PlayingMode.TALK: 'Talk',
116
+ PlayingMode.SLAVE: 'Idle',
117
+ PlayingMode.OPTICAL_2: 'optical2'
117
118
  }
118
119
 
119
120
 
@@ -124,6 +125,7 @@ class LoopMode(StrEnum):
124
125
  CONTINUOUS_PLAYBACK = "1"
125
126
  RANDOM_PLAYBACK = "2"
126
127
  LIST_CYCLE = "3"
128
+ FOUR = "4"
127
129
 
128
130
 
129
131
  class EqualizerMode(StrEnum):
@@ -149,18 +151,31 @@ class MuteMode(StrEnum):
149
151
  MUTED = "1"
150
152
 
151
153
 
152
- class PlaymodeSupport(IntFlag):
153
- """Defines which modes the player supports."""
154
+ class InputMode(IntFlag):
155
+ """Defines which inputs the player supports."""
154
156
  LINE_IN = 2
155
157
  BLUETOOTH = 4
156
158
  USB = 8
157
159
  OPTICAL = 16
158
160
  COAXIAL = 64
159
161
  LINE_IN_2 = 256
160
- USBDAC = 32768
162
+ USB_DAC = 32768
161
163
  OPTICAL_2 = 262144
162
164
 
163
165
 
166
+ # Map between the input modes and the play mode
167
+ INPUT_MODE_MAP: dict[InputMode, PlayingMode] = {
168
+ InputMode.LINE_IN: PlayingMode.LINE_IN,
169
+ InputMode.BLUETOOTH: PlayingMode.BLUETOOTH,
170
+ InputMode.USB: PlayingMode.UDISK,
171
+ InputMode.OPTICAL: PlayingMode.OPTICAL,
172
+ InputMode.COAXIAL: PlayingMode.COAXIAL,
173
+ InputMode.LINE_IN_2: PlayingMode.LINE_IN_2,
174
+ InputMode.USB_DAC: PlayingMode.USB_DAC,
175
+ InputMode.OPTICAL_2: PlayingMode.OPTICAL_2
176
+ }
177
+
178
+
164
179
  class PlayerAttribute(StrEnum):
165
180
  """Defines the player attributes."""
166
181
  SPEAKER_TYPE = "type"
@@ -270,3 +285,4 @@ class MultiroomAttribute(StrEnum):
270
285
  NUM_FOLLOWERS = "slaves"
271
286
  FOLLOWER_LIST = "slave_list"
272
287
  UUID = "uuid"
288
+ IP = "ip"
@@ -0,0 +1,86 @@
1
+ from typing import Any
2
+
3
+ from aiohttp import ClientSession
4
+ from async_upnp_client.search import async_search
5
+ from async_upnp_client.utils import CaseInsensitiveDict
6
+
7
+ from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
8
+ from linkplay.bridge import LinkPlayBridge, LinkPlayMultiroom
9
+ from linkplay.exceptions import LinkPlayRequestException
10
+
11
+
12
+ async def linkplay_factory_bridge(ip_address: str, session: ClientSession) -> LinkPlayBridge | None:
13
+ """Attempts to create a LinkPlayBridge from the given IP address.
14
+ Returns None if the device is not an expected LinkPlay device."""
15
+ bridge = LinkPlayBridge("http", ip_address, session)
16
+ try:
17
+ await bridge.device.update_status()
18
+ await bridge.player.update_status()
19
+ except LinkPlayRequestException:
20
+ return None
21
+ return bridge
22
+
23
+
24
+ async def discover_linkplay_bridges(session: ClientSession) -> list[LinkPlayBridge]:
25
+ """Attempts to discover LinkPlay devices on the local network."""
26
+ bridges: dict[str, LinkPlayBridge] = {}
27
+
28
+ async def add_linkplay_device_to_list(upnp_device: CaseInsensitiveDict):
29
+ ip_address: str | None = upnp_device.get('_host')
30
+
31
+ if not ip_address:
32
+ return
33
+
34
+ if bridge := await linkplay_factory_bridge(ip_address, session):
35
+ bridges[bridge.device.uuid] = bridge
36
+
37
+ await async_search(
38
+ search_target=UPNP_DEVICE_TYPE,
39
+ async_callback=add_linkplay_device_to_list
40
+ )
41
+
42
+ # Discover additional bridges through grouped multirooms
43
+ multiroom_discovered_bridges: dict[str, LinkPlayBridge] = {}
44
+ for bridge in bridges.values():
45
+ for new_bridge in await discover_bridges_through_multiroom(bridge, session):
46
+ multiroom_discovered_bridges[new_bridge.device.uuid] = new_bridge
47
+
48
+ bridges = bridges | multiroom_discovered_bridges
49
+ return list(bridges.values())
50
+
51
+
52
+ async def discover_multirooms(bridges: list[LinkPlayBridge]) -> list[LinkPlayMultiroom]:
53
+ """Discovers multirooms through the list of provided bridges."""
54
+ multirooms: list[LinkPlayMultiroom] = []
55
+ bridges_dict: dict[str, LinkPlayBridge] = {bridge.device.uuid: bridge for bridge in bridges}
56
+
57
+ for bridge in bridges:
58
+ properties: dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
59
+
60
+ if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
61
+ continue
62
+
63
+ followers: list[LinkPlayBridge] = []
64
+ for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
65
+ if follower[MultiroomAttribute.UUID] in bridges_dict:
66
+ followers.append(bridges_dict[follower[MultiroomAttribute.UUID]])
67
+
68
+ multirooms.append(LinkPlayMultiroom(bridge, followers))
69
+
70
+ return multirooms
71
+
72
+
73
+ async def discover_bridges_through_multiroom(bridge: LinkPlayBridge,
74
+ session: ClientSession) -> list[LinkPlayBridge]:
75
+ """Discovers bridges through the multiroom of the provided bridge."""
76
+ properties: dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
77
+
78
+ if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
79
+ return []
80
+
81
+ followers: list[LinkPlayBridge] = []
82
+ for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
83
+ if new_bridge := await linkplay_factory_bridge(follower[MultiroomAttribute.IP], session):
84
+ followers.append(new_bridge)
85
+
86
+ return followers
@@ -10,7 +10,7 @@ from linkplay.consts import API_ENDPOINT, API_TIMEOUT
10
10
  from linkplay.exceptions import LinkPlayRequestException
11
11
 
12
12
 
13
- async def session_call_api(endpoint: str, session: ClientSession, command: str):
13
+ async def session_call_api(endpoint: str, session: ClientSession, command: str) -> str:
14
14
  """Calls the LinkPlay API and returns the result as a string.
15
15
 
16
16
  Args:
@@ -46,7 +46,7 @@ async def session_call_api_json(endpoint: str, session: ClientSession,
46
46
  return json.loads(result) # type: ignore
47
47
 
48
48
 
49
- async def session_call_api_ok(endpoint: str, session: ClientSession, command: str):
49
+ async def session_call_api_ok(endpoint: str, session: ClientSession, command: str) -> None:
50
50
  """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
51
51
  result = await session_call_api(endpoint, session, command)
52
52
 
@@ -56,4 +56,7 @@ async def session_call_api_ok(endpoint: str, session: ClientSession, command: st
56
56
 
57
57
  def decode_hexstr(hexstr: str) -> str:
58
58
  """Decode a hex string."""
59
- return bytes.fromhex(hexstr).decode("utf-8")
59
+ try:
60
+ return bytes.fromhex(hexstr).decode("utf-8")
61
+ except ValueError:
62
+ return hexstr
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python_linkplay
3
- Version: 0.0.0
3
+ Version: 0.0.2
4
4
  Summary: A Python Library for Seamless LinkPlay Device Control
5
5
  Author: Velleman Group nv
6
6
  License: MIT
7
7
  Classifier: Programming Language :: Python :: 3
8
- Requires-Python: >=3.8
8
+ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  License-File: LICENSE
11
11
  Requires-Dist: async-timeout==4.0.3
@@ -23,8 +23,7 @@ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
23
23
 
24
24
 
25
25
  [![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/)
26
-
27
- [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
26
+ [![Release](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml)
28
27
 
29
28
  # python-linkplay
30
29
  A Python Library for Seamless LinkPlay Device Control
@@ -1 +0,0 @@
1
- __version__ = '0.0.0'
@@ -1,63 +0,0 @@
1
- from typing import Any, Dict, List
2
-
3
- from aiohttp import ClientSession
4
- from async_upnp_client.search import async_search
5
- from async_upnp_client.utils import CaseInsensitiveDict
6
-
7
- from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute
8
- from linkplay.bridge import LinkPlayBridge, LinkPlayMultiroom
9
- from linkplay.exceptions import LinkPlayRequestException
10
-
11
-
12
- async def linkplay_factory_bridge(ip_address: str, session: ClientSession) -> LinkPlayBridge | None:
13
- """Attempts to create a LinkPlayBridge from the given IP address.
14
- Returns None if the device is not an expected LinkPlay device."""
15
- bridge = LinkPlayBridge("http", ip_address, session)
16
- try:
17
- await bridge.device.update_status()
18
- await bridge.player.update_status()
19
- except LinkPlayRequestException:
20
- return None
21
- return bridge
22
-
23
-
24
- async def discover_linkplay_bridges(session: ClientSession) -> List[LinkPlayBridge]:
25
- """Attempts to discover LinkPlay devices on the local network."""
26
- devices: List[LinkPlayBridge] = []
27
-
28
- async def add_linkplay_device_to_list(upnp_device: CaseInsensitiveDict):
29
- ip_address: str | None = upnp_device.get('_host')
30
-
31
- if not ip_address:
32
- return
33
-
34
- if bridge := await linkplay_factory_bridge(ip_address, session):
35
- devices.append(bridge)
36
-
37
- await async_search(
38
- search_target=UPNP_DEVICE_TYPE,
39
- async_callback=add_linkplay_device_to_list
40
- )
41
-
42
- return devices
43
-
44
-
45
- async def discover_multirooms(bridges: List[LinkPlayBridge]) -> List[LinkPlayMultiroom]:
46
- """Discovers multirooms through the list of provided bridges."""
47
- multirooms: List[LinkPlayMultiroom] = []
48
-
49
- for bridge in bridges:
50
- properties: Dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST)
51
-
52
- if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0:
53
- continue
54
-
55
- followers: List[LinkPlayBridge] = []
56
- for follower in properties[MultiroomAttribute.FOLLOWER_LIST]:
57
- follower_uuid = follower[MultiroomAttribute.UUID]
58
- if follower_bridge := next((b for b in bridges if b.device.uuid == follower_uuid), None):
59
- followers.append(follower_bridge)
60
-
61
- multirooms.append(LinkPlayMultiroom(bridge, followers))
62
-
63
- return multirooms
File without changes