python-linkplay 0.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Velleman Group nv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.1
2
+ Name: python_linkplay
3
+ Version: 0.0.0
4
+ Summary: A Python Library for Seamless LinkPlay Device Control
5
+ Author: Velleman Group nv
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: async-timeout==4.0.3
12
+ Requires-Dist: aiohttp==3.9.1
13
+ Requires-Dist: async_upnp_client==0.38.0
14
+ Provides-Extra: testing
15
+ Requires-Dist: pytest>=7.3.1; extra == "testing"
16
+ Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
17
+ Requires-Dist: pytest-mock>=3.10.0; extra == "testing"
18
+ Requires-Dist: pytest-asyncio>=0.23.3; extra == "testing"
19
+ Requires-Dist: mypy>=1.3.0; extra == "testing"
20
+ Requires-Dist: flake8>=6.0.0; extra == "testing"
21
+ Requires-Dist: tox>=4.6.0; extra == "testing"
22
+ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
23
+
24
+
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)
28
+
29
+ # python-linkplay
30
+ A Python Library for Seamless LinkPlay Device Control
31
+
32
+ ## Intro
33
+
34
+ Welcome to python-linkplay, a powerful and user-friendly Python library designed to simplify the integration and control of LinkPlay-enabled devices in your projects. LinkPlay technology empowers a wide range of smart audio devices, making them interconnected and easily controllable. With python-linkpaly, you can harness this capability and seamlessly manage your LinkPlay devices from within your Python applications.
35
+
36
+ ## Key features
37
+
38
+ 1. Unified Control: python-linkplay provides a unified interface for controlling various LinkPlay-enabled devices, streamlining the process of interacting with speakers, smart home audio systems, and more.
39
+
40
+ 2. Device Discovery: Easily discover and connect to LinkPlay devices on your network, ensuring a hassle-free setup and integration into your Python applications.
41
+
42
+ 3. Playback Management: Take charge of audio playback on LinkPlay devices with functions to play, pause, skip tracks, adjust volume, and more, offering a comprehensive set of controls for a seamless user experience.
43
+
44
+ 4. Metadata Retrieval: Retrieve essential metadata such as track information, artist details, and album data, enabling you to enhance the user interface and display relevant information in your applications.
45
+
46
+ ## LinkPlay API documentation
47
+
48
+ - https://github.com/n4archive/LinkPlayAPI
49
+ - https://github.com/nagyrobi/home-assistant-custom-components-linkplay
50
+ - https://github.com/ramikg/linkplay-cli
51
+ - https://developer.arylic.com/httpapi/
52
+ - http://airscope-audio.net/core2/pdf/airscope-module-http.pdf
53
+ - https://www.wiimhome.com/pdf/HTTP%20API%20for%20WiiM%20Mini.pdf
54
+
55
+ ## Multiroom
56
+
57
+ ![Alt text](image.png)
@@ -0,0 +1,34 @@
1
+
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)
5
+
6
+ # python-linkplay
7
+ A Python Library for Seamless LinkPlay Device Control
8
+
9
+ ## Intro
10
+
11
+ Welcome to python-linkplay, a powerful and user-friendly Python library designed to simplify the integration and control of LinkPlay-enabled devices in your projects. LinkPlay technology empowers a wide range of smart audio devices, making them interconnected and easily controllable. With python-linkpaly, you can harness this capability and seamlessly manage your LinkPlay devices from within your Python applications.
12
+
13
+ ## Key features
14
+
15
+ 1. Unified Control: python-linkplay provides a unified interface for controlling various LinkPlay-enabled devices, streamlining the process of interacting with speakers, smart home audio systems, and more.
16
+
17
+ 2. Device Discovery: Easily discover and connect to LinkPlay devices on your network, ensuring a hassle-free setup and integration into your Python applications.
18
+
19
+ 3. Playback Management: Take charge of audio playback on LinkPlay devices with functions to play, pause, skip tracks, adjust volume, and more, offering a comprehensive set of controls for a seamless user experience.
20
+
21
+ 4. Metadata Retrieval: Retrieve essential metadata such as track information, artist details, and album data, enabling you to enhance the user interface and display relevant information in your applications.
22
+
23
+ ## LinkPlay API documentation
24
+
25
+ - https://github.com/n4archive/LinkPlayAPI
26
+ - https://github.com/nagyrobi/home-assistant-custom-components-linkplay
27
+ - https://github.com/ramikg/linkplay-cli
28
+ - https://developer.arylic.com/httpapi/
29
+ - http://airscope-audio.net/core2/pdf/airscope-module-http.pdf
30
+ - https://www.wiimhome.com/pdf/HTTP%20API%20for%20WiiM%20Mini.pdf
31
+
32
+ ## Multiroom
33
+
34
+ ![Alt text](image.png)
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [tool.pytest.ini_options]
6
+ asyncio_mode = "auto"
7
+ testpaths = [
8
+ "tests",
9
+ ]
10
+
11
+ log_cli = true
12
+ log_cli_level = "INFO"
13
+ log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
14
+ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
15
+
16
+ [tool.mypy]
17
+ mypy_path = "src"
18
+ check_untyped_defs = true
19
+ disallow_any_generics = true
20
+ ignore_missing_imports = true
21
+ no_implicit_optional = true
22
+ show_error_codes = true
23
+ strict_equality = true
24
+ warn_redundant_casts = true
25
+ warn_return_any = true
26
+ warn_unreachable = true
27
+ warn_unused_configs = true
28
+ no_implicit_reexport = true
@@ -0,0 +1,48 @@
1
+ [metadata]
2
+ name = python_linkplay
3
+ description = A Python Library for Seamless LinkPlay Device Control
4
+ long_description = file: README.md
5
+ long_description_content_type = text/markdown
6
+ author = Velleman Group nv
7
+ version = attr: linkplay.VERSION
8
+ license = MIT
9
+ classifiers =
10
+ Programming Language :: Python :: 3
11
+
12
+ [options]
13
+ packages = find_namespace:
14
+ install_requires =
15
+ async-timeout==4.0.3
16
+ aiohttp==3.9.1
17
+ async_upnp_client==0.38.0
18
+ python_requires = >=3.8
19
+ package_dir =
20
+ =src
21
+ zip_safe = no
22
+
23
+ [options.package_data]
24
+ linkplay = py.typed
25
+
26
+ [options.packages.find]
27
+ where = src
28
+
29
+ [options.extras_require]
30
+ testing =
31
+ pytest>=7.3.1
32
+ pytest-cov>=4.1.0
33
+ pytest-mock>=3.10.0
34
+ pytest-asyncio>=0.23.3
35
+ mypy>=1.3.0
36
+ flake8>=6.0.0
37
+ tox>=4.6.0
38
+ typing-extensions>=4.6.3
39
+
40
+ [flake8]
41
+ max-line-length = 160
42
+ per-file-ignores =
43
+ */__init__.py: F401
44
+
45
+ [egg_info]
46
+ tag_build =
47
+ tag_date = 0
48
+
@@ -0,0 +1,5 @@
1
+ from setuptools import setup
2
+
3
+
4
+ if __name__ == "__main__":
5
+ setup()
@@ -0,0 +1 @@
1
+ from linkplay.__version__ import __version__ as VERSION
@@ -0,0 +1,14 @@
1
+ import asyncio
2
+ import aiohttp
3
+
4
+ from linkplay.discovery import discover_linkplay_bridges, discover_multirooms
5
+
6
+
7
+ async def main():
8
+ async with aiohttp.ClientSession() as session:
9
+ bridges = await discover_linkplay_bridges(session)
10
+ multirooms = await discover_multirooms(bridges)
11
+ return bridges, multirooms
12
+
13
+ if __name__ == "__main__":
14
+ asyncio.run(main())
@@ -0,0 +1 @@
1
+ __version__ = '0.0.0'
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, List
3
+
4
+ from aiohttp import ClientSession
5
+
6
+ from linkplay.consts import (
7
+ ChannelType,
8
+ LinkPlayCommand,
9
+ DeviceAttribute,
10
+ PlayerAttribute,
11
+ MuteMode,
12
+ EqualizerMode,
13
+ LoopMode,
14
+ PlaybackMode,
15
+ PLAYBACK_MODE_MAP,
16
+ PlayingStatus,
17
+ PlaymodeSupport,
18
+ SpeakerType
19
+ )
20
+ from linkplay.utils import session_call_api_json, session_call_api_ok, decode_hexstr
21
+
22
+
23
+ class LinkPlayDevice():
24
+ """Represents a LinkPlay device."""
25
+
26
+ bridge: LinkPlayBridge
27
+ properties: Dict[DeviceAttribute, str] = dict.fromkeys(DeviceAttribute.__members__.values(), "")
28
+
29
+ def __init__(self, bridge: LinkPlayBridge):
30
+ self.bridge = bridge
31
+
32
+ async def update_status(self) -> None:
33
+ """Update the device status."""
34
+ self.properties = await self.bridge.json_request(LinkPlayCommand.DEVICE_STATUS) # type: ignore[assignment]
35
+
36
+ async def reboot(self):
37
+ """Reboot the device."""
38
+ await self.bridge.request(LinkPlayCommand.REBOOT)
39
+
40
+ @property
41
+ def uuid(self) -> str:
42
+ """The UUID of the device."""
43
+ return self.properties[DeviceAttribute.UUID]
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ """The name of the device."""
48
+ return self.properties[DeviceAttribute.DEVICE_NAME]
49
+
50
+ @property
51
+ def playmode_support(self) -> PlaymodeSupport:
52
+ """Returns the player playmode support."""
53
+ return PlaymodeSupport(int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16))
54
+
55
+ @property
56
+ def eth(self) -> str:
57
+ """Returns the ethernet address."""
58
+ return self.properties[DeviceAttribute.ETH_DHCP]
59
+
60
+
61
+ class LinkPlayPlayer():
62
+ """Represents a LinkPlay player."""
63
+
64
+ bridge: LinkPlayBridge
65
+ properties: Dict[PlayerAttribute, str] = dict.fromkeys(PlayerAttribute.__members__.values(), "")
66
+
67
+ def __init__(self, bridge: LinkPlayBridge):
68
+ self.bridge = bridge
69
+
70
+ async def update_status(self):
71
+ """Update the player status."""
72
+ self.properties = await self.bridge.json_request(LinkPlayCommand.PLAYER_STATUS) # type: ignore[assignment]
73
+ self.properties[PlayerAttribute.TITLE] = decode_hexstr(self.title)
74
+ self.properties[PlayerAttribute.ARTIST] = decode_hexstr(self.artist)
75
+ self.properties[PlayerAttribute.ALBUM] = decode_hexstr(self.album)
76
+
77
+ async def next(self):
78
+ """Play the next song in the playlist."""
79
+ await self.bridge.request(LinkPlayCommand.NEXT)
80
+
81
+ async def previous(self):
82
+ """Play the previous song in the playlist."""
83
+ await self.bridge.request(LinkPlayCommand.PREVIOUS)
84
+
85
+ async def play(self, value: str):
86
+ """Start playing the selected track."""
87
+ await self.bridge.request(LinkPlayCommand.PLAY.format(value)) # type: ignore[str-format]
88
+
89
+ async def resume(self):
90
+ """Resume playing the current track."""
91
+ await self.bridge.request(LinkPlayCommand.RESUME)
92
+
93
+ async def mute(self):
94
+ """Mute the player."""
95
+ await self.bridge.request(LinkPlayCommand.MUTE)
96
+ self.properties[PlayerAttribute.MUTED] = MuteMode.MUTED
97
+
98
+ async def unmute(self):
99
+ """Unmute the player."""
100
+ await self.bridge.request(LinkPlayCommand.UNMUTE)
101
+ self.properties[PlayerAttribute.MUTED] = MuteMode.UNMUTED
102
+
103
+ async def play_playlist(self, index: int):
104
+ """Start playing chosen playlist by index number."""
105
+ await self.bridge.request(LinkPlayCommand.PLAYLIST.format(index)) # type: ignore[str-format]
106
+
107
+ async def pause(self):
108
+ """Pause the current playing track."""
109
+ await self.bridge.request(LinkPlayCommand.PAUSE)
110
+ self.properties[PlayerAttribute.PLAYING_STATUS] = PlayingStatus.PAUSED
111
+
112
+ async def toggle(self):
113
+ """Start playing if the player is currently not playing. Stops playing if it is."""
114
+ await self.bridge.request(LinkPlayCommand.TOGGLE)
115
+
116
+ async def set_volume(self, value: int):
117
+ """Set the player volume."""
118
+ if not 0 <= value <= 100:
119
+ raise ValueError("Volume must be between 0 and 100.")
120
+
121
+ await self.bridge.request(LinkPlayCommand.VOLUME.format(value)) # type: ignore[str-format]
122
+ self.properties[PlayerAttribute.VOLUME] = str(value)
123
+
124
+ async def set_equalizer_mode(self, mode: EqualizerMode):
125
+ """Set the equalizer mode."""
126
+ await self.bridge.request(LinkPlayCommand.EQUALIZER_MODE.format(mode)) # type: ignore[str-format]
127
+ self.properties[PlayerAttribute.EQUALIZER_MODE] = mode
128
+
129
+ async def set_loop_mode(self, mode: LoopMode):
130
+ """Set the loop mode."""
131
+ await self.bridge.request(LinkPlayCommand.LOOP_MODE.format(mode)) # type: ignore[str-format]
132
+ self.properties[PlayerAttribute.PLAYLIST_MODE] = mode
133
+
134
+ async def set_play_mode(self, mode: PlaybackMode):
135
+ """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
138
+
139
+ @property
140
+ def muted(self) -> bool:
141
+ """Returns if the player is muted."""
142
+ return self.properties[PlayerAttribute.MUTED] == MuteMode.MUTED
143
+
144
+ @property
145
+ def title(self) -> str:
146
+ """Returns if the currently playing title of the track."""
147
+ return self.properties[PlayerAttribute.TITLE]
148
+
149
+ @property
150
+ def artist(self) -> str:
151
+ """Returns if the currently playing artist."""
152
+ return self.properties[PlayerAttribute.ARTIST]
153
+
154
+ @property
155
+ def album(self) -> str:
156
+ """Returns if the currently playing album."""
157
+ return self.properties[PlayerAttribute.ALBUM]
158
+
159
+ @property
160
+ def volume(self) -> int:
161
+ """Returns the player volume, expressed in %."""
162
+ return int(self.properties[PlayerAttribute.VOLUME])
163
+
164
+ @property
165
+ def current_position(self) -> int:
166
+ """Returns the current position of the track in milliseconds."""
167
+ return int(self.properties[PlayerAttribute.CURRENT_POSITION])
168
+
169
+ @property
170
+ def total_length(self) -> int:
171
+ """Returns the total length of the track in milliseconds."""
172
+ return int(self.properties[PlayerAttribute.TOTAL_LENGTH])
173
+
174
+ @property
175
+ def status(self) -> PlayingStatus:
176
+ """Returns the current playing status."""
177
+ return PlayingStatus(self.properties[PlayerAttribute.PLAYING_STATUS])
178
+
179
+ @property
180
+ def equalizer_mode(self) -> EqualizerMode:
181
+ """Returns the current equalizer mode."""
182
+ return EqualizerMode(self.properties[PlayerAttribute.EQUALIZER_MODE])
183
+
184
+ @property
185
+ def speaker_type(self) -> SpeakerType:
186
+ """Returns the current speaker the player is playing on."""
187
+ return SpeakerType(self.properties[PlayerAttribute.SPEAKER_TYPE])
188
+
189
+ @property
190
+ def channel_type(self) -> ChannelType:
191
+ """Returns the channel the player is playing on."""
192
+ return ChannelType(self.properties[PlayerAttribute.CHANNEL_TYPE])
193
+
194
+ @property
195
+ def playback_mode(self) -> PlaybackMode:
196
+ """Returns the channel the player is playing on."""
197
+ return PlaybackMode(self.properties[PlayerAttribute.PLAYBACK_MODE])
198
+
199
+ @property
200
+ def loop_mode(self) -> LoopMode:
201
+ """Returns the current playlist mode."""
202
+ return LoopMode(self.properties[PlayerAttribute.PLAYLIST_MODE])
203
+
204
+
205
+ class LinkPlayBridge():
206
+ """Represents a LinkPlay bridge to control the device and player attached to it."""
207
+
208
+ protocol: str
209
+ ip_address: str
210
+ session: ClientSession
211
+ device: LinkPlayDevice
212
+ player: LinkPlayPlayer
213
+
214
+ def __init__(self, protocol: str, ip_address: str, session: ClientSession):
215
+ self.protocol = protocol
216
+ self.ip_address = ip_address
217
+ self.session = session
218
+ self.device = LinkPlayDevice(self)
219
+ self.player = LinkPlayPlayer(self)
220
+
221
+ def __repr__(self) -> str:
222
+ if self.device.name == "":
223
+ return self.endpoint
224
+
225
+ return self.device.name
226
+
227
+ @property
228
+ def endpoint(self) -> str:
229
+ """Returns the current player endpoint."""
230
+ return f"{self.protocol}://{self.ip_address}"
231
+
232
+ async def json_request(self, command: str) -> Dict[str, str]:
233
+ """Performs a GET request on the given command and returns the result as a JSON object."""
234
+ return await session_call_api_json(self.endpoint, self.session, command)
235
+
236
+ async def request(self, command: str) -> None:
237
+ """Performs a GET request on the given command and verifies the result."""
238
+ await session_call_api_ok(self.endpoint, self.session, command)
239
+
240
+
241
+ class LinkPlayMultiroom():
242
+ """Represents a LinkPlay multiroom group. Contains a leader and a list of followers.
243
+ The leader is the device that controls the group."""
244
+
245
+ leader: LinkPlayBridge
246
+ followers: List[LinkPlayBridge]
247
+
248
+ def __init__(self, leader: LinkPlayBridge, followers: List[LinkPlayBridge]):
249
+ self.leader = leader
250
+ self.followers = followers
251
+
252
+ async def ungroup(self):
253
+ """Ungroups the multiroom group."""
254
+ await self.leader.request(LinkPlayCommand.MULTIROOM_UNGROUP)
255
+ self.followers = []
256
+
257
+ async def add_follower(self, follower: LinkPlayBridge):
258
+ """Adds a follower to the multiroom group."""
259
+ await follower.request(LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)) # type: ignore[str-format]
260
+ self.followers.append(follower)
261
+
262
+ async def remove_follower(self, follower: LinkPlayBridge):
263
+ """Removes a follower from the multiroom group."""
264
+ await self.leader.request(LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)) # type: ignore[str-format]
265
+ self.followers.remove(follower)
266
+
267
+ async def set_volume(self, value: int):
268
+ """Sets the volume for the multiroom group."""
269
+ assert 0 < value <= 100
270
+ str_vol = str(value)
271
+ await self.leader.request(LinkPlayCommand.MULTIROOM_VOL.format(str_vol)) # type: ignore[str-format]
272
+
273
+ for bridge in [self.leader] + self.followers:
274
+ bridge.player.properties[PlayerAttribute.VOLUME] = str_vol
275
+
276
+ async def mute(self):
277
+ """Mutes the multiroom group."""
278
+ await self.leader.request(LinkPlayCommand.MULTIROOM_MUTE)
279
+
280
+ async def unmute(self):
281
+ """Unmutes the multiroom group."""
282
+ await self.leader.request(LinkPlayCommand.MULTIROOM_UNMUTE)
@@ -0,0 +1,272 @@
1
+ from enum import StrEnum, IntFlag
2
+ from typing import Dict
3
+
4
+ API_ENDPOINT: str = "{}/httpapi.asp?command={}"
5
+ API_TIMEOUT: int = 2
6
+ UNKNOWN_TRACK_PLAYING: str = "Unknown"
7
+ UPNP_DEVICE_TYPE = 'urn:schemas-upnp-org:device:MediaRenderer:1'
8
+
9
+
10
+ class LinkPlayCommand(StrEnum):
11
+ """Defines the LinkPlay commands."""
12
+ DEVICE_STATUS = "getStatus"
13
+ SYSLOG = "getsyslog"
14
+ UPDATE_SERVER = "GetUpdateServer"
15
+ REBOOT = "reboot"
16
+ PLAYER_STATUS = "getPlayerStatus"
17
+ NEXT = "setPlayerCmd:next"
18
+ PREVIOUS = "setPlayerCmd:prev"
19
+ UNMUTE = "setPlayerCmd:mute:0"
20
+ MUTE = "setPlayerCmd:mute:1"
21
+ RESUME = "setPlayerCmd:resume"
22
+ PLAY = "setPlayerCmd:play:{}"
23
+ SEEK = "setPlayerCmd:seek:{}"
24
+ VOLUME = "setPlayerCmd:vol:{}"
25
+ PLAYLIST = "setPlayerCmd:playlist:uri:{}"
26
+ PAUSE = "setPlayerCmd:pause"
27
+ TOGGLE = "setPlayerCmd:onepause"
28
+ EQUALIZER_MODE = "setPlayerCmd:equalizer:{}"
29
+ LOOP_MODE = "setPlayerCmd:loopmode:{}"
30
+ SWITCH_MODE = "setPlayerCmd:switchmode:{}"
31
+ M3U_PLAYLIST = "setPlayerCmd:m3u:play:{}"
32
+ MULTIROOM_LIST = "multiroom:getSlaveList"
33
+ MULTIROOM_UNGROUP = "multiroom:ungroup"
34
+ MULTIROOM_KICK = "multiroom:SlaveKickout:{}"
35
+ MULTIROOM_VOL = "setPlayerCmd:slave_vol:{}"
36
+ MULTIROOM_MUTE = "setPlayerCmd:slave_mute:mute"
37
+ MULTIROOM_UNMUTE = "setPlayerCmd:slave_mute:unmute"
38
+ MULTIROOM_JOIN = "ConnectMasterAp:JoinGroupMaster:eth{}:wifi0.0.0.0"
39
+
40
+
41
+ class SpeakerType(StrEnum):
42
+ """Defines the speaker type."""
43
+ MAIN_SPEAKER = "0"
44
+ SUB_SPEAKER = "1"
45
+
46
+
47
+ class ChannelType(StrEnum):
48
+ """Defines the channel type."""
49
+ STEREO = "0"
50
+ LEFT_CHANNEL = "1"
51
+ RIGHT_CHANNEL = "2"
52
+
53
+
54
+ class PlaybackMode(StrEnum):
55
+ """Defines the playback mode."""
56
+ IDLE = "-1"
57
+ NONE = "0"
58
+ AIRPLAY = "1"
59
+ DLNA = "2"
60
+ QPLAY = "3"
61
+ NETWORK = "10"
62
+ WIIMU_LOCAL = "11"
63
+ WIIMU_STATION = "12"
64
+ WIIMU_RADIO = "13"
65
+ WIIMU_SONGLIST = "14"
66
+ TF_CARD_1 = "16"
67
+ WIIMU_MAX = "19"
68
+ API = "20"
69
+ UDISK = "21"
70
+ HTTP_MAX = "29"
71
+ ALARM = "30"
72
+ SPOTIFY = "31"
73
+ LINE_IN = "40"
74
+ BLUETOOTH = "41"
75
+ EXT_LOCAL = "42"
76
+ OPTICAL = "43"
77
+ RCA = "44"
78
+ CO_AXIAL = "45"
79
+ FM = "46"
80
+ LINE_IN_2 = "47"
81
+ XLR = "48"
82
+ HDMI = "49"
83
+ MIRROR = "50"
84
+ USB_DAC = "51"
85
+ TF_CARD_2 = "52"
86
+ TALK = "60"
87
+ SLAVE = "99"
88
+
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'
117
+ }
118
+
119
+
120
+ class LoopMode(StrEnum):
121
+ """Defines the loop mode."""
122
+ CONTINOUS_PLAY_ONE_SONG = "-1"
123
+ PLAY_IN_ORDER = "0"
124
+ CONTINUOUS_PLAYBACK = "1"
125
+ RANDOM_PLAYBACK = "2"
126
+ LIST_CYCLE = "3"
127
+
128
+
129
+ class EqualizerMode(StrEnum):
130
+ """Defines the equalizer mode."""
131
+ NONE = "0"
132
+ CLASSIC = "1"
133
+ POP = "2"
134
+ JAZZ = "3"
135
+ VOCAL = "4"
136
+
137
+
138
+ class PlayingStatus(StrEnum):
139
+ """Defines the playing status."""
140
+ PLAYING = "play"
141
+ LOADING = "load"
142
+ STOPPED = "stop"
143
+ PAUSED = "pause"
144
+
145
+
146
+ class MuteMode(StrEnum):
147
+ """Defines the mute mode."""
148
+ UNMUTED = "0"
149
+ MUTED = "1"
150
+
151
+
152
+ class PlaymodeSupport(IntFlag):
153
+ """Defines which modes the player supports."""
154
+ LINE_IN = 2
155
+ BLUETOOTH = 4
156
+ USB = 8
157
+ OPTICAL = 16
158
+ COAXIAL = 64
159
+ LINE_IN_2 = 256
160
+ USBDAC = 32768
161
+ OPTICAL_2 = 262144
162
+
163
+
164
+ class PlayerAttribute(StrEnum):
165
+ """Defines the player attributes."""
166
+ SPEAKER_TYPE = "type"
167
+ CHANNEL_TYPE = "ch"
168
+ PLAYBACK_MODE = "mode"
169
+ PLAYLIST_MODE = "loop"
170
+ EQUALIZER_MODE = "eq"
171
+ PLAYING_STATUS = "status"
172
+ CURRENT_POSITION = "curpos"
173
+ OFFSET_POSITION = "offset_pts"
174
+ TOTAL_LENGTH = "totlen"
175
+ TITLE = "Title"
176
+ ARTIST = "Artist"
177
+ ALBUM = "Album"
178
+ ALARM_FLAG = "alarmflag"
179
+ PLAYLIST_COUNT = "plicount"
180
+ PLAYLIST_INDEX = "plicurr"
181
+ VOLUME = "vol"
182
+ MUTED = "mute"
183
+
184
+
185
+ class DeviceAttribute(StrEnum):
186
+ """Defines the device attributes."""
187
+ UUID = "uuid"
188
+ DEVICE_NAME = "DeviceName"
189
+ GROUP_NAME = "GroupName"
190
+ SSID = "ssid"
191
+ LANGUAGE = "language"
192
+ FIRMWARE = "firmware"
193
+ HARDWARE = "hardware"
194
+ BUILD = "build"
195
+ PROJECT = "project"
196
+ PRIV_PRJ = "priv_prj"
197
+ PROJECT_BUILD_NAME = "project_build_name"
198
+ RELEASE = "Release"
199
+ TEMP_UUID = "temp_uuid"
200
+ HIDE_SSID = "hideSSID"
201
+ SSID_STRATEGY = "SSIDStrategy"
202
+ BRANCH = "branch"
203
+ GROUP = "group"
204
+ WMRM_VERSION = "wmrm_version"
205
+ INTERNET = "internet"
206
+ MAC_ADDRESS = "MAC"
207
+ STA_MAC_ADDRESS = "STA_MAC"
208
+ COUNTRY_CODE = "CountryCode"
209
+ COUNTRY_REGION = "CountryRegion"
210
+ NET_STAT = "netstat"
211
+ ESSID = "essid"
212
+ APCLI0 = "apcli0"
213
+ ETH2 = "eth2"
214
+ RA0 = "ra0"
215
+ ETH_DHCP = "eth_dhcp"
216
+ VERSION_UPDATE = "VersionUpdate"
217
+ NEW_VER = "NewVer"
218
+ SET_DNS_ENABLE = "set_dns_enable"
219
+ MCU_VER = "mcu_ver"
220
+ MCU_VER_NEW = "mcu_ver_new"
221
+ DSP_VER = "dsp_ver"
222
+ DSP_VER_NEW = "dsp_ver_new"
223
+ DATE = "date"
224
+ TIME = "time"
225
+ TIMEZONE = "tz"
226
+ DST_ENABLE = "dst_enable"
227
+ REGION = "region"
228
+ PROMPT_STATUS = "prompt_status"
229
+ IOT_VER = "iot_ver"
230
+ UPNP_VERSION = "upnp_version"
231
+ CAP1 = "cap1"
232
+ CAPABILITY = "capability"
233
+ LANGUAGES = "languages"
234
+ STREAMS_ALL = "streams_all"
235
+ STREAMS = "streams"
236
+ EXTERNAL = "external"
237
+ PLAYMODE_SUPPORT = "plm_support"
238
+ PRESET_KEY = "preset_key"
239
+ SPOTIFY_ACTIVE = "spotify_active"
240
+ LBC_SUPPORT = "lbc_support"
241
+ PRIVACY_MODE = "privacy_mode"
242
+ WIFI_CHANNEL = "WifiChannel"
243
+ RSSI = "RSSI"
244
+ BSSID = "BSSID"
245
+ BATTERY = "battery"
246
+ BATTERY_PERCENT = "battery_percent"
247
+ SECURE_MODE = "securemode"
248
+ AUTH = "auth"
249
+ ENCRYPTION = "encry"
250
+ UPNP_UUID = "upnp_uuid"
251
+ UART_PASS_PORT = "uart_pass_port"
252
+ COMMUNICATION_PORT = "communication_port"
253
+ WEB_FIRMWARE_UPDATE_HIDE = "web_firmware_update_hide"
254
+ IGNORE_TALKSTART = "ignore_talkstart"
255
+ WEB_LOGIN_RESULT = "web_login_result"
256
+ SILENCE_OTA_TIME = "silenceOTATime"
257
+ IGNORE_SILENCE_OTA_TIME = "ignore_silenceOTATime"
258
+ NEW_TUNEIN_PRESET_AND_ALARM = "new_tunein_preset_and_alarm"
259
+ IHEARTRADIO_NEW = "iheartradio_new"
260
+ NEW_IHEART_PODCAST = "new_iheart_podcast"
261
+ TIDAL_VERSION = "tidal_version"
262
+ SERVICE_VERSION = "service_version"
263
+ ETH_MAC_ADDRESS = "ETH_MAC"
264
+ SECURITY = "security"
265
+ SECURITY_VERSION = "security_version"
266
+
267
+
268
+ class MultiroomAttribute(StrEnum):
269
+ """Defines the player attributes."""
270
+ NUM_FOLLOWERS = "slaves"
271
+ FOLLOWER_LIST = "slave_list"
272
+ UUID = "uuid"
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,6 @@
1
+ class LinkPlayException(Exception):
2
+ pass
3
+
4
+
5
+ class LinkPlayRequestException(LinkPlayException):
6
+ pass
@@ -0,0 +1,59 @@
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):
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(f"Unexpected HTTPStatus {response.status} received from '{url}'")
38
+
39
+ return await response.text()
40
+
41
+
42
+ async def session_call_api_json(endpoint: str, session: ClientSession,
43
+ command: str) -> Dict[str, str]:
44
+ """Calls the LinkPlay API and returns the result as a JSON object."""
45
+ result = await session_call_api(endpoint, session, command)
46
+ return json.loads(result) # type: ignore
47
+
48
+
49
+ async def session_call_api_ok(endpoint: str, session: ClientSession, command: str):
50
+ """Calls the LinkPlay API and checks if the response is OK. Throws exception if not."""
51
+ result = await session_call_api(endpoint, session, command)
52
+
53
+ if result != "OK":
54
+ raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}")
55
+
56
+
57
+ def decode_hexstr(hexstr: str) -> str:
58
+ """Decode a hex string."""
59
+ return bytes.fromhex(hexstr).decode("utf-8")
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.1
2
+ Name: python_linkplay
3
+ Version: 0.0.0
4
+ Summary: A Python Library for Seamless LinkPlay Device Control
5
+ Author: Velleman Group nv
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: async-timeout==4.0.3
12
+ Requires-Dist: aiohttp==3.9.1
13
+ Requires-Dist: async_upnp_client==0.38.0
14
+ Provides-Extra: testing
15
+ Requires-Dist: pytest>=7.3.1; extra == "testing"
16
+ Requires-Dist: pytest-cov>=4.1.0; extra == "testing"
17
+ Requires-Dist: pytest-mock>=3.10.0; extra == "testing"
18
+ Requires-Dist: pytest-asyncio>=0.23.3; extra == "testing"
19
+ Requires-Dist: mypy>=1.3.0; extra == "testing"
20
+ Requires-Dist: flake8>=6.0.0; extra == "testing"
21
+ Requires-Dist: tox>=4.6.0; extra == "testing"
22
+ Requires-Dist: typing-extensions>=4.6.3; extra == "testing"
23
+
24
+
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)
28
+
29
+ # python-linkplay
30
+ A Python Library for Seamless LinkPlay Device Control
31
+
32
+ ## Intro
33
+
34
+ Welcome to python-linkplay, a powerful and user-friendly Python library designed to simplify the integration and control of LinkPlay-enabled devices in your projects. LinkPlay technology empowers a wide range of smart audio devices, making them interconnected and easily controllable. With python-linkpaly, you can harness this capability and seamlessly manage your LinkPlay devices from within your Python applications.
35
+
36
+ ## Key features
37
+
38
+ 1. Unified Control: python-linkplay provides a unified interface for controlling various LinkPlay-enabled devices, streamlining the process of interacting with speakers, smart home audio systems, and more.
39
+
40
+ 2. Device Discovery: Easily discover and connect to LinkPlay devices on your network, ensuring a hassle-free setup and integration into your Python applications.
41
+
42
+ 3. Playback Management: Take charge of audio playback on LinkPlay devices with functions to play, pause, skip tracks, adjust volume, and more, offering a comprehensive set of controls for a seamless user experience.
43
+
44
+ 4. Metadata Retrieval: Retrieve essential metadata such as track information, artist details, and album data, enabling you to enhance the user interface and display relevant information in your applications.
45
+
46
+ ## LinkPlay API documentation
47
+
48
+ - https://github.com/n4archive/LinkPlayAPI
49
+ - https://github.com/nagyrobi/home-assistant-custom-components-linkplay
50
+ - https://github.com/ramikg/linkplay-cli
51
+ - https://developer.arylic.com/httpapi/
52
+ - http://airscope-audio.net/core2/pdf/airscope-module-http.pdf
53
+ - https://www.wiimhome.com/pdf/HTTP%20API%20for%20WiiM%20Mini.pdf
54
+
55
+ ## Multiroom
56
+
57
+ ![Alt text](image.png)
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ setup.py
6
+ src/linkplay/__init__.py
7
+ src/linkplay/__main__.py
8
+ src/linkplay/__version__.py
9
+ src/linkplay/bridge.py
10
+ src/linkplay/consts.py
11
+ src/linkplay/discovery.py
12
+ src/linkplay/exceptions.py
13
+ src/linkplay/utils.py
14
+ src/python_linkplay.egg-info/PKG-INFO
15
+ src/python_linkplay.egg-info/SOURCES.txt
16
+ src/python_linkplay.egg-info/dependency_links.txt
17
+ src/python_linkplay.egg-info/not-zip-safe
18
+ src/python_linkplay.egg-info/requires.txt
19
+ src/python_linkplay.egg-info/top_level.txt
@@ -0,0 +1,13 @@
1
+ async-timeout==4.0.3
2
+ aiohttp==3.9.1
3
+ async_upnp_client==0.38.0
4
+
5
+ [testing]
6
+ pytest>=7.3.1
7
+ pytest-cov>=4.1.0
8
+ pytest-mock>=3.10.0
9
+ pytest-asyncio>=0.23.3
10
+ mypy>=1.3.0
11
+ flake8>=6.0.0
12
+ tox>=4.6.0
13
+ typing-extensions>=4.6.3