diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8d061e4 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,50 @@ +name: release +run-name: Creating releases/${{ inputs.version }} + +on: + workflow_dispatch: + inputs: + version: + description: 'Version' + required: true + type: string + +jobs: + create_release_branch: + name: Create release branch + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Update version + run: | + echo "${{ github.event.inputs.version }}" > src/linkplay/__version__.py + - name: Create release branch + run: | + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B releases/${{ github.event.inputs.version }} + git commit --allow-empty -am "Create version ${{ github.event.inputs.version }}" + git push --set-upstream origin releases/${{ github.event.inputs.version }} + build: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + + pypi-publish: + runs-on: ubuntu-latest + name: Upload release to PyPI + environment: + name: pypi + url: https://pypi.org/p/python-linkplay + permissions: + id-token: write + steps: + - uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..881bbdd --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,24 @@ +name: test + +on: [push, pull_request] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.11'] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..398785f --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.vscode \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5eb46d2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Velleman Group nv + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 06fc386..dc4c410 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ + +[![PyPI package](https://badge.fury.io/py/python-linkplay.svg)](https://pypi.org/project/python-linkplay/) + +[![Release](https://github.com/velleman/python-linkplay/actions/workflows/release/badge.svg)](https://github.com/velleman/python-linkplay/actions/workflows/release.yaml) + # python-linkplay -LinkPlay library for Python +A Python Library for Seamless LinkPlay Device Control + +## Intro + +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. + +## Key features + +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. + +2. Device Discovery: Easily discover and connect to LinkPlay devices on your network, ensuring a hassle-free setup and integration into your Python applications. + +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. + +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. + +## LinkPlay API documentation + +- https://github.com/n4archive/LinkPlayAPI +- https://github.com/nagyrobi/home-assistant-custom-components-linkplay +- https://github.com/ramikg/linkplay-cli +- https://developer.arylic.com/httpapi/ +- http://airscope-audio.net/core2/pdf/airscope-module-http.pdf +- https://www.wiimhome.com/pdf/HTTP%20API%20for%20WiiM%20Mini.pdf + +## Multiroom + +![Alt text](image.png) \ No newline at end of file diff --git a/image.png b/image.png new file mode 100644 index 0000000..81fb35c Binary files /dev/null and b/image.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3cc3b51 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=42.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = [ + "tests", +] + +log_cli = true +log_cli_level = "INFO" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +[tool.mypy] +mypy_path = "src" +check_untyped_defs = true +disallow_any_generics = true +ignore_missing_imports = true +no_implicit_optional = true +show_error_codes = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +no_implicit_reexport = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1191179 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +# requirements.txt +# +# installs dependencies from ./setup.py, and the package itself, +# in editable mode +-e . + +# (the -e above is optional). you could also just install the package +# normally with just the line below (after uncommenting) +# . \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..c9263bd --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,9 @@ +# requirements_dev.txt +# +# installs dev dependencies from ./setup.py, and the package itself, +# in editable mode +-e ./[testing] + +# (the -e above is optional). you could also just install the package +# normally with just the line below (after uncommenting) +# . \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e5c139f --- /dev/null +++ b/setup.cfg @@ -0,0 +1,41 @@ +[metadata] +name = python_linkplay +description = A Python Library for Seamless LinkPlay Device Control +author = Velleman Group nv +version = attr: linkplay.VERSION +license = MIT +classifiers = + Programming Language :: Python :: 3 + +[options] +packages = find_namespace: +install_requires = + async-timeout==4.0.3 + aiohttp==3.9.1 + async_upnp_client==0.38.0 +python_requires = >=3.8 +package_dir = + =src +zip_safe = no + +[options.package_data] +linkplay = py.typed + +[options.packages.find] +where = src + +[options.extras_require] +testing = + pytest>=7.3.1 + pytest-cov>=4.1.0 + pytest-mock>=3.10.0 + pytest-asyncio>=0.23.3 + mypy>=1.3.0 + flake8>=6.0.0 + tox>=4.6.0 + typing-extensions>=4.6.3 + +[flake8] +max-line-length = 160 +per-file-ignores = + */__init__.py: F401 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..26e08e4 --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +from setuptools import setup + + +if __name__ == "__main__": + setup() diff --git a/src/linkplay/__init__.py b/src/linkplay/__init__.py new file mode 100644 index 0000000..71e183a --- /dev/null +++ b/src/linkplay/__init__.py @@ -0,0 +1 @@ +from linkplay.__version__ import __version__ as VERSION diff --git a/src/linkplay/__main__.py b/src/linkplay/__main__.py new file mode 100644 index 0000000..c30cca3 --- /dev/null +++ b/src/linkplay/__main__.py @@ -0,0 +1,14 @@ +import asyncio +import aiohttp + +from linkplay.discovery import discover_linkplay_bridges, discover_multirooms + + +async def main(): + async with aiohttp.ClientSession() as session: + bridges = await discover_linkplay_bridges(session) + multirooms = await discover_multirooms(bridges) + return bridges, multirooms + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/linkplay/__version__.py b/src/linkplay/__version__.py new file mode 100644 index 0000000..c57bfd5 --- /dev/null +++ b/src/linkplay/__version__.py @@ -0,0 +1 @@ +__version__ = '0.0.0' diff --git a/src/linkplay/bridge.py b/src/linkplay/bridge.py new file mode 100644 index 0000000..b3d755e --- /dev/null +++ b/src/linkplay/bridge.py @@ -0,0 +1,282 @@ +from __future__ import annotations +from typing import Dict, List + +from aiohttp import ClientSession + +from linkplay.consts import ( + ChannelType, + LinkPlayCommand, + DeviceAttribute, + PlayerAttribute, + MuteMode, + EqualizerMode, + LoopMode, + PlaybackMode, + PLAYBACK_MODE_MAP, + PlayingStatus, + PlaymodeSupport, + SpeakerType +) +from linkplay.utils import session_call_api_json, session_call_api_ok, decode_hexstr + + +class LinkPlayDevice(): + """Represents a LinkPlay device.""" + + bridge: LinkPlayBridge + properties: Dict[DeviceAttribute, str] = dict.fromkeys(DeviceAttribute.__members__.values(), "") + + def __init__(self, bridge: LinkPlayBridge): + self.bridge = bridge + + async def update_status(self) -> None: + """Update the device status.""" + self.properties = await self.bridge.json_request(LinkPlayCommand.DEVICE_STATUS) # type: ignore[assignment] + + async def reboot(self): + """Reboot the device.""" + await self.bridge.request(LinkPlayCommand.REBOOT) + + @property + def uuid(self) -> str: + """The UUID of the device.""" + return self.properties[DeviceAttribute.UUID] + + @property + def name(self) -> str: + """The name of the device.""" + return self.properties[DeviceAttribute.DEVICE_NAME] + + @property + def playmode_support(self) -> PlaymodeSupport: + """Returns the player playmode support.""" + return PlaymodeSupport(int(self.properties[DeviceAttribute.PLAYMODE_SUPPORT], base=16)) + + @property + def eth(self) -> str: + """Returns the ethernet address.""" + return self.properties[DeviceAttribute.ETH_DHCP] + + +class LinkPlayPlayer(): + """Represents a LinkPlay player.""" + + bridge: LinkPlayBridge + properties: Dict[PlayerAttribute, str] = dict.fromkeys(PlayerAttribute.__members__.values(), "") + + def __init__(self, bridge: LinkPlayBridge): + self.bridge = bridge + + async def update_status(self): + """Update the player status.""" + self.properties = await self.bridge.json_request(LinkPlayCommand.PLAYER_STATUS) # type: ignore[assignment] + self.properties[PlayerAttribute.TITLE] = decode_hexstr(self.title) + self.properties[PlayerAttribute.ARTIST] = decode_hexstr(self.artist) + self.properties[PlayerAttribute.ALBUM] = decode_hexstr(self.album) + + async def next(self): + """Play the next song in the playlist.""" + await self.bridge.request(LinkPlayCommand.NEXT) + + async def previous(self): + """Play the previous song in the playlist.""" + await self.bridge.request(LinkPlayCommand.PREVIOUS) + + async def play(self, value: str): + """Start playing the selected track.""" + await self.bridge.request(LinkPlayCommand.PLAY.format(value)) # type: ignore[str-format] + + async def resume(self): + """Resume playing the current track.""" + await self.bridge.request(LinkPlayCommand.RESUME) + + async def mute(self): + """Mute the player.""" + await self.bridge.request(LinkPlayCommand.MUTE) + self.properties[PlayerAttribute.MUTED] = MuteMode.MUTED + + async def unmute(self): + """Unmute the player.""" + await self.bridge.request(LinkPlayCommand.UNMUTE) + self.properties[PlayerAttribute.MUTED] = MuteMode.UNMUTED + + async def play_playlist(self, index: int): + """Start playing chosen playlist by index number.""" + await self.bridge.request(LinkPlayCommand.PLAYLIST.format(index)) # type: ignore[str-format] + + async def pause(self): + """Pause the current playing track.""" + await self.bridge.request(LinkPlayCommand.PAUSE) + self.properties[PlayerAttribute.PLAYING_STATUS] = PlayingStatus.PAUSED + + async def toggle(self): + """Start playing if the player is currently not playing. Stops playing if it is.""" + await self.bridge.request(LinkPlayCommand.TOGGLE) + + async def set_volume(self, value: int): + """Set the player volume.""" + if not 0 <= value <= 100: + raise ValueError("Volume must be between 0 and 100.") + + await self.bridge.request(LinkPlayCommand.VOLUME.format(value)) # type: ignore[str-format] + self.properties[PlayerAttribute.VOLUME] = str(value) + + async def set_equalizer_mode(self, mode: EqualizerMode): + """Set the equalizer mode.""" + await self.bridge.request(LinkPlayCommand.EQUALIZER_MODE.format(mode)) # type: ignore[str-format] + self.properties[PlayerAttribute.EQUALIZER_MODE] = mode + + async def set_loop_mode(self, mode: LoopMode): + """Set the loop mode.""" + await self.bridge.request(LinkPlayCommand.LOOP_MODE.format(mode)) # type: ignore[str-format] + self.properties[PlayerAttribute.PLAYLIST_MODE] = mode + + async def set_play_mode(self, mode: PlaybackMode): + """Set the play mode.""" + await self.bridge.request(LinkPlayCommand.SWITCH_MODE.format(PLAYBACK_MODE_MAP[mode])) # type: ignore[str-format] + self.properties[PlayerAttribute.PLAYBACK_MODE] = mode + + @property + def muted(self) -> bool: + """Returns if the player is muted.""" + return self.properties[PlayerAttribute.MUTED] == MuteMode.MUTED + + @property + def title(self) -> str: + """Returns if the currently playing title of the track.""" + return self.properties[PlayerAttribute.TITLE] + + @property + def artist(self) -> str: + """Returns if the currently playing artist.""" + return self.properties[PlayerAttribute.ARTIST] + + @property + def album(self) -> str: + """Returns if the currently playing album.""" + return self.properties[PlayerAttribute.ALBUM] + + @property + def volume(self) -> int: + """Returns the player volume, expressed in %.""" + return int(self.properties[PlayerAttribute.VOLUME]) + + @property + def current_position(self) -> int: + """Returns the current position of the track in milliseconds.""" + return int(self.properties[PlayerAttribute.CURRENT_POSITION]) + + @property + def total_length(self) -> int: + """Returns the total length of the track in milliseconds.""" + return int(self.properties[PlayerAttribute.TOTAL_LENGTH]) + + @property + def status(self) -> PlayingStatus: + """Returns the current playing status.""" + return PlayingStatus(self.properties[PlayerAttribute.PLAYING_STATUS]) + + @property + def equalizer_mode(self) -> EqualizerMode: + """Returns the current equalizer mode.""" + return EqualizerMode(self.properties[PlayerAttribute.EQUALIZER_MODE]) + + @property + def speaker_type(self) -> SpeakerType: + """Returns the current speaker the player is playing on.""" + return SpeakerType(self.properties[PlayerAttribute.SPEAKER_TYPE]) + + @property + def channel_type(self) -> ChannelType: + """Returns the channel the player is playing on.""" + return ChannelType(self.properties[PlayerAttribute.CHANNEL_TYPE]) + + @property + def playback_mode(self) -> PlaybackMode: + """Returns the channel the player is playing on.""" + return PlaybackMode(self.properties[PlayerAttribute.PLAYBACK_MODE]) + + @property + def loop_mode(self) -> LoopMode: + """Returns the current playlist mode.""" + return LoopMode(self.properties[PlayerAttribute.PLAYLIST_MODE]) + + +class LinkPlayBridge(): + """Represents a LinkPlay bridge to control the device and player attached to it.""" + + protocol: str + ip_address: str + session: ClientSession + device: LinkPlayDevice + player: LinkPlayPlayer + + def __init__(self, protocol: str, ip_address: str, session: ClientSession): + self.protocol = protocol + self.ip_address = ip_address + self.session = session + self.device = LinkPlayDevice(self) + self.player = LinkPlayPlayer(self) + + def __repr__(self) -> str: + if self.device.name == "": + return self.endpoint + + return self.device.name + + @property + def endpoint(self) -> str: + """Returns the current player endpoint.""" + return f"{self.protocol}://{self.ip_address}" + + async def json_request(self, command: str) -> Dict[str, str]: + """Performs a GET request on the given command and returns the result as a JSON object.""" + return await session_call_api_json(self.endpoint, self.session, command) + + async def request(self, command: str) -> None: + """Performs a GET request on the given command and verifies the result.""" + await session_call_api_ok(self.endpoint, self.session, command) + + +class LinkPlayMultiroom(): + """Represents a LinkPlay multiroom group. Contains a leader and a list of followers. + The leader is the device that controls the group.""" + + leader: LinkPlayBridge + followers: List[LinkPlayBridge] + + def __init__(self, leader: LinkPlayBridge, followers: List[LinkPlayBridge]): + self.leader = leader + self.followers = followers + + async def ungroup(self): + """Ungroups the multiroom group.""" + await self.leader.request(LinkPlayCommand.MULTIROOM_UNGROUP) + self.followers = [] + + async def add_follower(self, follower: LinkPlayBridge): + """Adds a follower to the multiroom group.""" + await follower.request(LinkPlayCommand.MULTIROOM_JOIN.format(self.leader.device.eth)) # type: ignore[str-format] + self.followers.append(follower) + + async def remove_follower(self, follower: LinkPlayBridge): + """Removes a follower from the multiroom group.""" + await self.leader.request(LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)) # type: ignore[str-format] + self.followers.remove(follower) + + async def set_volume(self, value: int): + """Sets the volume for the multiroom group.""" + assert 0 < value <= 100 + str_vol = str(value) + await self.leader.request(LinkPlayCommand.MULTIROOM_VOL.format(str_vol)) # type: ignore[str-format] + + for bridge in [self.leader] + self.followers: + bridge.player.properties[PlayerAttribute.VOLUME] = str_vol + + async def mute(self): + """Mutes the multiroom group.""" + await self.leader.request(LinkPlayCommand.MULTIROOM_MUTE) + + async def unmute(self): + """Unmutes the multiroom group.""" + await self.leader.request(LinkPlayCommand.MULTIROOM_UNMUTE) diff --git a/src/linkplay/consts.py b/src/linkplay/consts.py new file mode 100644 index 0000000..c729682 --- /dev/null +++ b/src/linkplay/consts.py @@ -0,0 +1,272 @@ +from enum import StrEnum, IntFlag +from typing import Dict + +API_ENDPOINT: str = "{}/httpapi.asp?command={}" +API_TIMEOUT: int = 2 +UNKNOWN_TRACK_PLAYING: str = "Unknown" +UPNP_DEVICE_TYPE = 'urn:schemas-upnp-org:device:MediaRenderer:1' + + +class LinkPlayCommand(StrEnum): + """Defines the LinkPlay commands.""" + DEVICE_STATUS = "getStatus" + SYSLOG = "getsyslog" + UPDATE_SERVER = "GetUpdateServer" + REBOOT = "reboot" + PLAYER_STATUS = "getPlayerStatus" + NEXT = "setPlayerCmd:next" + PREVIOUS = "setPlayerCmd:prev" + UNMUTE = "setPlayerCmd:mute:0" + MUTE = "setPlayerCmd:mute:1" + RESUME = "setPlayerCmd:resume" + PLAY = "setPlayerCmd:play:{}" + SEEK = "setPlayerCmd:seek:{}" + VOLUME = "setPlayerCmd:vol:{}" + PLAYLIST = "setPlayerCmd:playlist:uri:{}" + PAUSE = "setPlayerCmd:pause" + TOGGLE = "setPlayerCmd:onepause" + EQUALIZER_MODE = "setPlayerCmd:equalizer:{}" + LOOP_MODE = "setPlayerCmd:loopmode:{}" + SWITCH_MODE = "setPlayerCmd:switchmode:{}" + M3U_PLAYLIST = "setPlayerCmd:m3u:play:{}" + MULTIROOM_LIST = "multiroom:getSlaveList" + MULTIROOM_UNGROUP = "multiroom:ungroup" + MULTIROOM_KICK = "multiroom:SlaveKickout:{}" + MULTIROOM_VOL = "setPlayerCmd:slave_vol:{}" + MULTIROOM_MUTE = "setPlayerCmd:slave_mute:mute" + MULTIROOM_UNMUTE = "setPlayerCmd:slave_mute:unmute" + MULTIROOM_JOIN = "ConnectMasterAp:JoinGroupMaster:eth{}:wifi0.0.0.0" + + +class SpeakerType(StrEnum): + """Defines the speaker type.""" + MAIN_SPEAKER = "0" + SUB_SPEAKER = "1" + + +class ChannelType(StrEnum): + """Defines the channel type.""" + STEREO = "0" + LEFT_CHANNEL = "1" + RIGHT_CHANNEL = "2" + + +class PlaybackMode(StrEnum): + """Defines the playback mode.""" + IDLE = "-1" + NONE = "0" + AIRPLAY = "1" + DLNA = "2" + QPLAY = "3" + NETWORK = "10" + WIIMU_LOCAL = "11" + WIIMU_STATION = "12" + WIIMU_RADIO = "13" + WIIMU_SONGLIST = "14" + TF_CARD_1 = "16" + WIIMU_MAX = "19" + API = "20" + UDISK = "21" + HTTP_MAX = "29" + ALARM = "30" + SPOTIFY = "31" + LINE_IN = "40" + BLUETOOTH = "41" + EXT_LOCAL = "42" + OPTICAL = "43" + RCA = "44" + CO_AXIAL = "45" + FM = "46" + LINE_IN_2 = "47" + XLR = "48" + HDMI = "49" + MIRROR = "50" + USB_DAC = "51" + TF_CARD_2 = "52" + TALK = "60" + SLAVE = "99" + + +PLAYBACK_MODE_MAP: Dict[PlaybackMode, str] = { + PlaybackMode.IDLE: 'Idle', + PlaybackMode.NONE: 'Idle', + PlaybackMode.AIRPLAY: 'Airplay', + PlaybackMode.DLNA: 'DLNA', + PlaybackMode.QPLAY: 'QPlay', + PlaybackMode.NETWORK: 'wifi', + PlaybackMode.WIIMU_LOCAL: 'udisk', + PlaybackMode.TF_CARD_1: 'TFcard', + PlaybackMode.API: 'API', + PlaybackMode.UDISK: 'udisk', + PlaybackMode.ALARM: 'Alarm', + PlaybackMode.SPOTIFY: 'Spotify', + PlaybackMode.LINE_IN: 'line-in', + PlaybackMode.BLUETOOTH: 'bluetooth', + PlaybackMode.OPTICAL: 'optical', + PlaybackMode.RCA: 'RCA', + PlaybackMode.CO_AXIAL: 'co-axial', + PlaybackMode.FM: 'FM', + PlaybackMode.LINE_IN_2: 'line-in2', + PlaybackMode.XLR: 'XLR', + PlaybackMode.HDMI: 'HDMI', + PlaybackMode.MIRROR: 'cd', + PlaybackMode.USB_DAC: 'USB DAC', + PlaybackMode.TF_CARD_2: 'TFcard', + PlaybackMode.TALK: 'Talk', + PlaybackMode.SLAVE: 'Idle' +} + + +class LoopMode(StrEnum): + """Defines the loop mode.""" + CONTINOUS_PLAY_ONE_SONG = "-1" + PLAY_IN_ORDER = "0" + CONTINUOUS_PLAYBACK = "1" + RANDOM_PLAYBACK = "2" + LIST_CYCLE = "3" + + +class EqualizerMode(StrEnum): + """Defines the equalizer mode.""" + NONE = "0" + CLASSIC = "1" + POP = "2" + JAZZ = "3" + VOCAL = "4" + + +class PlayingStatus(StrEnum): + """Defines the playing status.""" + PLAYING = "play" + LOADING = "load" + STOPPED = "stop" + PAUSED = "pause" + + +class MuteMode(StrEnum): + """Defines the mute mode.""" + UNMUTED = "0" + MUTED = "1" + + +class PlaymodeSupport(IntFlag): + """Defines which modes the player supports.""" + LINE_IN = 2 + BLUETOOTH = 4 + USB = 8 + OPTICAL = 16 + COAXIAL = 64 + LINE_IN_2 = 256 + USBDAC = 32768 + OPTICAL_2 = 262144 + + +class PlayerAttribute(StrEnum): + """Defines the player attributes.""" + SPEAKER_TYPE = "type" + CHANNEL_TYPE = "ch" + PLAYBACK_MODE = "mode" + PLAYLIST_MODE = "loop" + EQUALIZER_MODE = "eq" + PLAYING_STATUS = "status" + CURRENT_POSITION = "curpos" + OFFSET_POSITION = "offset_pts" + TOTAL_LENGTH = "totlen" + TITLE = "Title" + ARTIST = "Artist" + ALBUM = "Album" + ALARM_FLAG = "alarmflag" + PLAYLIST_COUNT = "plicount" + PLAYLIST_INDEX = "plicurr" + VOLUME = "vol" + MUTED = "mute" + + +class DeviceAttribute(StrEnum): + """Defines the device attributes.""" + UUID = "uuid" + DEVICE_NAME = "DeviceName" + GROUP_NAME = "GroupName" + SSID = "ssid" + LANGUAGE = "language" + FIRMWARE = "firmware" + HARDWARE = "hardware" + BUILD = "build" + PROJECT = "project" + PRIV_PRJ = "priv_prj" + PROJECT_BUILD_NAME = "project_build_name" + RELEASE = "Release" + TEMP_UUID = "temp_uuid" + HIDE_SSID = "hideSSID" + SSID_STRATEGY = "SSIDStrategy" + BRANCH = "branch" + GROUP = "group" + WMRM_VERSION = "wmrm_version" + INTERNET = "internet" + MAC_ADDRESS = "MAC" + STA_MAC_ADDRESS = "STA_MAC" + COUNTRY_CODE = "CountryCode" + COUNTRY_REGION = "CountryRegion" + NET_STAT = "netstat" + ESSID = "essid" + APCLI0 = "apcli0" + ETH2 = "eth2" + RA0 = "ra0" + ETH_DHCP = "eth_dhcp" + VERSION_UPDATE = "VersionUpdate" + NEW_VER = "NewVer" + SET_DNS_ENABLE = "set_dns_enable" + MCU_VER = "mcu_ver" + MCU_VER_NEW = "mcu_ver_new" + DSP_VER = "dsp_ver" + DSP_VER_NEW = "dsp_ver_new" + DATE = "date" + TIME = "time" + TIMEZONE = "tz" + DST_ENABLE = "dst_enable" + REGION = "region" + PROMPT_STATUS = "prompt_status" + IOT_VER = "iot_ver" + UPNP_VERSION = "upnp_version" + CAP1 = "cap1" + CAPABILITY = "capability" + LANGUAGES = "languages" + STREAMS_ALL = "streams_all" + STREAMS = "streams" + EXTERNAL = "external" + PLAYMODE_SUPPORT = "plm_support" + PRESET_KEY = "preset_key" + SPOTIFY_ACTIVE = "spotify_active" + LBC_SUPPORT = "lbc_support" + PRIVACY_MODE = "privacy_mode" + WIFI_CHANNEL = "WifiChannel" + RSSI = "RSSI" + BSSID = "BSSID" + BATTERY = "battery" + BATTERY_PERCENT = "battery_percent" + SECURE_MODE = "securemode" + AUTH = "auth" + ENCRYPTION = "encry" + UPNP_UUID = "upnp_uuid" + UART_PASS_PORT = "uart_pass_port" + COMMUNICATION_PORT = "communication_port" + WEB_FIRMWARE_UPDATE_HIDE = "web_firmware_update_hide" + IGNORE_TALKSTART = "ignore_talkstart" + WEB_LOGIN_RESULT = "web_login_result" + SILENCE_OTA_TIME = "silenceOTATime" + IGNORE_SILENCE_OTA_TIME = "ignore_silenceOTATime" + NEW_TUNEIN_PRESET_AND_ALARM = "new_tunein_preset_and_alarm" + IHEARTRADIO_NEW = "iheartradio_new" + NEW_IHEART_PODCAST = "new_iheart_podcast" + TIDAL_VERSION = "tidal_version" + SERVICE_VERSION = "service_version" + ETH_MAC_ADDRESS = "ETH_MAC" + SECURITY = "security" + SECURITY_VERSION = "security_version" + + +class MultiroomAttribute(StrEnum): + """Defines the player attributes.""" + NUM_FOLLOWERS = "slaves" + FOLLOWER_LIST = "slave_list" + UUID = "uuid" diff --git a/src/linkplay/discovery.py b/src/linkplay/discovery.py new file mode 100644 index 0000000..11d7f53 --- /dev/null +++ b/src/linkplay/discovery.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, List + +from aiohttp import ClientSession +from async_upnp_client.search import async_search +from async_upnp_client.utils import CaseInsensitiveDict + +from linkplay.consts import UPNP_DEVICE_TYPE, LinkPlayCommand, MultiroomAttribute +from linkplay.bridge import LinkPlayBridge, LinkPlayMultiroom +from linkplay.exceptions import LinkPlayRequestException + + +async def linkplay_factory_bridge(ip_address: str, session: ClientSession) -> LinkPlayBridge | None: + """Attempts to create a LinkPlayBridge from the given IP address. + Returns None if the device is not an expected LinkPlay device.""" + bridge = LinkPlayBridge("http", ip_address, session) + try: + await bridge.device.update_status() + await bridge.player.update_status() + except LinkPlayRequestException: + return None + return bridge + + +async def discover_linkplay_bridges(session: ClientSession) -> List[LinkPlayBridge]: + """Attempts to discover LinkPlay devices on the local network.""" + devices: List[LinkPlayBridge] = [] + + async def add_linkplay_device_to_list(upnp_device: CaseInsensitiveDict): + ip_address: str | None = upnp_device.get('_host') + + if not ip_address: + return + + if bridge := await linkplay_factory_bridge(ip_address, session): + devices.append(bridge) + + await async_search( + search_target=UPNP_DEVICE_TYPE, + async_callback=add_linkplay_device_to_list + ) + + return devices + + +async def discover_multirooms(bridges: List[LinkPlayBridge]) -> List[LinkPlayMultiroom]: + """Discovers multirooms through the list of provided bridges.""" + multirooms: List[LinkPlayMultiroom] = [] + + for bridge in bridges: + properties: Dict[Any, Any] = await bridge.json_request(LinkPlayCommand.MULTIROOM_LIST) + + if int(properties[MultiroomAttribute.NUM_FOLLOWERS]) == 0: + continue + + followers: List[LinkPlayBridge] = [] + for follower in properties[MultiroomAttribute.FOLLOWER_LIST]: + follower_uuid = follower[MultiroomAttribute.UUID] + if follower_bridge := next((b for b in bridges if b.device.uuid == follower_uuid), None): + followers.append(follower_bridge) + + multirooms.append(LinkPlayMultiroom(bridge, followers)) + + return multirooms diff --git a/src/linkplay/exceptions.py b/src/linkplay/exceptions.py new file mode 100644 index 0000000..3679893 --- /dev/null +++ b/src/linkplay/exceptions.py @@ -0,0 +1,6 @@ +class LinkPlayException(Exception): + pass + + +class LinkPlayRequestException(LinkPlayException): + pass diff --git a/src/linkplay/utils.py b/src/linkplay/utils.py new file mode 100644 index 0000000..e0afe35 --- /dev/null +++ b/src/linkplay/utils.py @@ -0,0 +1,59 @@ +import asyncio +from typing import Dict +import json +from http import HTTPStatus + +import async_timeout +from aiohttp import ClientSession, ClientError + +from linkplay.consts import API_ENDPOINT, API_TIMEOUT +from linkplay.exceptions import LinkPlayRequestException + + +async def session_call_api(endpoint: str, session: ClientSession, command: str): + """Calls the LinkPlay API and returns the result as a string. + + Args: + endpoint (str): The endpoint to use. + session (ClientSession): The client session to use. + command (str): The command to use. + + Raises: + LinkPlayRequestException: Thrown when the request fails or an invalid response is received. + + Returns: + str: The response of the API call. + """ + url = API_ENDPOINT.format(endpoint, command) + + try: + async with async_timeout.timeout(API_TIMEOUT): + response = await session.get(url, ssl=False) + + except (asyncio.TimeoutError, ClientError, asyncio.CancelledError) as error: + raise LinkPlayRequestException(f"Error requesting data from '{url}'") from error + + if response.status != HTTPStatus.OK: + raise LinkPlayRequestException(f"Unexpected HTTPStatus {response.status} received from '{url}'") + + return await response.text() + + +async def session_call_api_json(endpoint: str, session: ClientSession, + command: str) -> Dict[str, str]: + """Calls the LinkPlay API and returns the result as a JSON object.""" + result = await session_call_api(endpoint, session, command) + return json.loads(result) # type: ignore + + +async def session_call_api_ok(endpoint: str, session: ClientSession, command: str): + """Calls the LinkPlay API and checks if the response is OK. Throws exception if not.""" + result = await session_call_api(endpoint, session, command) + + if result != "OK": + raise LinkPlayRequestException(f"Didn't receive expected OK from {endpoint}") + + +def decode_hexstr(hexstr: str) -> str: + """Decode a hex string.""" + return bytes.fromhex(hexstr).decode("utf-8") diff --git a/src/py.typed b/src/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/linkplay/test_bridge.py b/tests/linkplay/test_bridge.py new file mode 100644 index 0000000..0a83628 --- /dev/null +++ b/tests/linkplay/test_bridge.py @@ -0,0 +1,284 @@ +from unittest.mock import AsyncMock + +from linkplay.bridge import ( + LinkPlayBridge, + LinkPlayDevice, + LinkPlayPlayer, + LinkPlayMultiroom +) +from linkplay.consts import ( + EqualizerMode, + LinkPlayCommand, + DeviceAttribute, + LoopMode, + PlaybackMode, + PlayerAttribute, + MuteMode, + PlayingStatus, + PLAYBACK_MODE_MAP +) + + +def test_endpoint(): + """Tests if the endpoint is correctly constructed.""" + bridge = LinkPlayBridge("http", "1.2.3.4", None) + assert "http://1.2.3.4" == bridge.endpoint + + +async def test_device_update_status(): + """Tests if the device update status is correctly called.""" + bridge = AsyncMock() + bridge.json_request.return_value = {DeviceAttribute.UUID: "1234"} + device = LinkPlayDevice(bridge) + + await device.update_status() + + bridge.json_request.assert_called_once_with(LinkPlayCommand.DEVICE_STATUS) + assert device.uuid == "1234" + + +async def test_device_reboot(): + """Tests if the device update is correctly called.""" + bridge = AsyncMock() + device = LinkPlayDevice(bridge) + + await device.reboot() + + bridge.request.assert_called_once_with(LinkPlayCommand.REBOOT) + + +async def test_player_update_status(): + """Tests if the player update status is correctly called.""" + bridge = AsyncMock() + bridge.json_request.return_value = { + PlayerAttribute.TITLE: "556E6B6E6F776E", + PlayerAttribute.ARTIST: "556E6B6E6F776E", + PlayerAttribute.ALBUM: "556E6B6E6F776E" + } + player = LinkPlayPlayer(bridge) + + await player.update_status() + + bridge.json_request.assert_called_once_with(LinkPlayCommand.PLAYER_STATUS) + assert player.title == "Unknown" + + +async def test_player_next(): + """Tests if the player next track is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.next() + + bridge.request.assert_called_once_with(LinkPlayCommand.NEXT) + + +async def test_player_previous(): + """Tests if the player previous track is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.previous() + + bridge.request.assert_called_once_with(LinkPlayCommand.PREVIOUS) + + +async def test_player_play(): + """Tests if the player play is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.play("test") + + bridge.request.assert_called_once_with(LinkPlayCommand.PLAY.format("test")) + + +async def test_player_resume(): + """Tests if the player resume is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.resume() + + bridge.request.assert_called_once_with(LinkPlayCommand.RESUME) + + +async def test_player_mute(): + """Tests if the player mute is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.mute() + + bridge.request.assert_called_once_with(LinkPlayCommand.MUTE) + assert player.muted + + +async def test_player_unmute(): + """Tests if the player mute is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + player.properties[PlayerAttribute.MUTED] = MuteMode.MUTED + + await player.unmute() + + bridge.request.assert_called_once_with(LinkPlayCommand.UNMUTE) + assert not player.muted + + +async def test_player_play_playlist(): + """Tests if the player play_playlist is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.play_playlist(1) + + bridge.request.assert_called_once_with(LinkPlayCommand.PLAYLIST.format(1)) + + +async def test_player_pause(): + """Tests if the player pause is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.pause() + + bridge.request.assert_called_once_with(LinkPlayCommand.PAUSE) + assert player.status == PlayingStatus.PAUSED + + +async def test_player_toggle(): + """Tests if the player pause is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.toggle() + + bridge.request.assert_called_once_with(LinkPlayCommand.TOGGLE) + + +async def test_player_set_volume(): + """Tests if the player set volume is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + + await player.set_volume(100) + + bridge.request.assert_called_once_with(LinkPlayCommand.VOLUME.format(100)) + assert player.volume == 100 + + +async def test_player_set_equalizer_mode(): + """Tests if the player set equalizer mode is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + mode = EqualizerMode.JAZZ + + await player.set_equalizer_mode(mode) + + bridge.request.assert_called_once_with(LinkPlayCommand.EQUALIZER_MODE.format(mode)) + assert mode == player.equalizer_mode + + +async def test_player_set_loop_mode(): + """Tests if the player set loop mode is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + mode = LoopMode.CONTINUOUS_PLAYBACK + + await player.set_loop_mode(mode) + + bridge.request.assert_called_once_with(LinkPlayCommand.LOOP_MODE.format(mode)) + assert mode == player.loop_mode + + +async def test_player_set_play_mode(): + """Tests if the player set play mode is correctly called.""" + bridge = AsyncMock() + player = LinkPlayPlayer(bridge) + mode = PlaybackMode.NETWORK + mode_conv = PLAYBACK_MODE_MAP[mode] + + await player.set_play_mode(mode) + + bridge.request.assert_called_once_with(LinkPlayCommand.SWITCH_MODE.format(mode_conv)) + assert mode == player.playback_mode + + +async def test_multiroom_setup(): + """Tests if multiroom sets up correctly.""" + leader = AsyncMock() + followers = [AsyncMock(), AsyncMock()] + + multiroom = LinkPlayMultiroom(leader, followers) + + assert multiroom.leader == leader + assert multiroom.followers == followers + + +async def test_multiroom_ungroup(): + """Tests if multiroom ungroup is correctly called on the leader.""" + leader = AsyncMock() + followers = [AsyncMock()] + multiroom = LinkPlayMultiroom(leader, followers) + + await multiroom.ungroup() + + leader.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_UNGROUP) + assert multiroom.followers == [] + + +async def test_multiroom_add_follower(): + """Tests if multiroom add follower is correctly called on the follower.""" + leader = AsyncMock() + leader.device.eth = "1.2.3.4" + follower = AsyncMock() + multiroom = LinkPlayMultiroom(leader, []) + + await multiroom.add_follower(follower) + + follower.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_JOIN.format(leader.device.eth)) + assert multiroom.followers == [follower] + + +async def test_multiroom_remove_follower(): + """Tests if multiroom remove folllower is correctly called on the leader.""" + leader = AsyncMock() + follower = AsyncMock() + follower.device.eth = "1.2.3.4" + multiroom = LinkPlayMultiroom(leader, [follower]) + + await multiroom.remove_follower(follower) + + leader.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_KICK.format(follower.device.eth)) + assert multiroom.followers == [] + + +async def test_multiroom_mute(): + """Tests if multiroom mute is correctly called on the leader.""" + leader = AsyncMock() + multiroom = LinkPlayMultiroom(leader, []) + + await multiroom.mute() + + leader.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_MUTE) + + +async def test_multiroom_unmute(): + """Tests if multiroom unmute is correctly called on the leader.""" + leader = AsyncMock() + multiroom = LinkPlayMultiroom(leader, []) + + await multiroom.unmute() + + leader.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_UNMUTE) + + +async def test_multiroom_set_volume(): + """Tests if multiroom set volume is correctly called on the leader.""" + leader = AsyncMock() + multiroom = LinkPlayMultiroom(leader, [AsyncMock()]) + + await multiroom.set_volume(100) + + leader.request.assert_called_once_with(LinkPlayCommand.MULTIROOM_VOL.format(100)) diff --git a/tests/linkplay/test_utils.py b/tests/linkplay/test_utils.py new file mode 100644 index 0000000..e572f8f --- /dev/null +++ b/tests/linkplay/test_utils.py @@ -0,0 +1,6 @@ +from linkplay.utils import decode_hexstr + + +def test_decode_hexstr(): + """Tests the decode_hexstr function.""" + assert "Unknown" == decode_hexstr('556E6B6E6F776E') diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..5de764f --- /dev/null +++ b/tox.ini @@ -0,0 +1,28 @@ +[tox] +minversion = 4.6.0 +envlist = py311, flake8, mypy +isolated_build = true + +[gh-actions] +python = + 3.11: py311, mypy, flake8 + +[testenv] +package = wheel +setenv = + PYTHONPATH = {toxinidir} +deps = + -r{toxinidir}/requirements_dev.txt +commands = + pytest --cov-report html:htmlcov/pytest --basetemp={envtmpdir} + +[testenv:flake8] +basepython = python3.11 +deps = flake8 +commands = flake8 src tests + +[testenv:mypy] +basepython = python3.11 +deps = + -r{toxinidir}/requirements_dev.txt +commands = mypy --install-types --non-interactive src \ No newline at end of file