├── .coveragerc ├── MANIFEST.in ├── docs ├── versions.rst ├── _templates │ └── sidebarintro.html ├── api.rst ├── index.rst ├── Makefile └── conf.py ├── requirements.txt ├── tests ├── data │ ├── ws_info.xml │ ├── ws_zone.xml │ ├── ws_volume.xml │ ├── spotify_buffering.xml │ ├── radio.xml │ ├── ws_status.xml │ ├── stored_music.xml │ ├── spotify_utf8.xml │ ├── radio_utf8.xml │ ├── spotify.xml │ ├── ws_presets.xml │ ├── device_info.xml │ └── device_info_utf8.xml └── test_libsoundtouch.py ├── setup.cfg ├── requirements_test_27.txt ├── requirements_test.txt ├── .gitignore ├── .travis.yml ├── libsoundtouch ├── templates │ └── avt_transport_uri.xml ├── __init__.py ├── utils.py └── device.py ├── AUTHORS.rst ├── setup.py ├── tox.ini ├── RELEASES.rst ├── README.md └── LICENSE.md /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = libsoundtouch -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md -------------------------------------------------------------------------------- /docs/versions.rst: -------------------------------------------------------------------------------- 1 | Versions 2 | ======== 3 | 4 | .. include:: ../RELEASES.rst -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | websocket-client 3 | enum-compat 4 | zeroconf -------------------------------------------------------------------------------- /tests/data/ws_info.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/ws_zone.xml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_wheel] 5 | universal=1 6 | -------------------------------------------------------------------------------- /requirements_test_27.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.0.4 2 | pylint>=1.5.6 3 | coveralls>=1.1 4 | pydocstyle>=1.0.0 5 | pytest>=2.9.2 6 | pytest-cov>=2.3.1 7 | mock -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.0.4 2 | pylint>=1.5.6 3 | coveralls>=1.1 4 | pydocstyle>=2.0.0 5 | pytest>=2.9.2 6 | pytest-cov>=2.3.1 7 | mypy>=0.560 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | .coverage 4 | .tox/ 5 | libsoundtouch.egg-info/ 6 | *.pyc 7 | build/ 8 | dist/ 9 | .cache/ 10 | runtime/* 11 | docs/_build 12 | .mypy_cache/ 13 | .pytest_cache/ -------------------------------------------------------------------------------- /tests/data/ws_volume.xml: -------------------------------------------------------------------------------- 1 | 2121false -------------------------------------------------------------------------------- /tests/data/spotify_buffering.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BUFFERING_STATE 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | matrix: 3 | include: 4 | - python: "3.4.2" 5 | env: TOXENV=lint 6 | - python: "2.7.12" 7 | env: TOXENV=py27 8 | - python: "3.4.2" 9 | env: TOXENV=py34 10 | - python: "3.5" 11 | env: TOXENV=py35 12 | - python: "3.6" 13 | env: TOXENV=py36 14 | - python: "3.4.2" 15 | env: TOXENV=typing 16 | install: "pip install -U tox coveralls" 17 | script: tox 18 | after_success: coveralls -------------------------------------------------------------------------------- /libsoundtouch/templates/avt_transport_uri.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 0{0} 6 | 7 | 8 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Thanks to all the wonderful folks who have contributed to Libsoundtouch: 2 | 3 | - jeanregisser (Use enum-compat instead of enum34 directly) 4 | - Tyzer34 (add *play_media* support) 5 | - wanderor (add local computer media support) 6 | - obadz (add Bluetooth source) 7 | - luca-angemi (Fix new firmware error) 8 | - vanto (Fix device names with UTF-8 characters) -------------------------------------------------------------------------------- /docs/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 | 6 |

📰 Useful Links

7 | 12 | -------------------------------------------------------------------------------- /tests/data/radio.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | RMC Info Talk Sport 6 | 7 | 8 | 9 | 10 | RMC Info Talk Sport 11 | 12 | http://item.radio456.com/007452/logo/logo-21630.jpg 13 | 14 | PLAY_STATE 15 | MP3 64 kbps Paris France, Radio du sport 16 | Paris France 17 | -------------------------------------------------------------------------------- /tests/data/ws_status.xml: -------------------------------------------------------------------------------- 1 | Afternoon AccousticDevil We KnowLily & MadeleineLily & Madeleine (Acoustic Sessions)http://i.scdn.co/image/9778636ff8df7a3bee6941df472ef269d6d7fe88PLAY_STATESHUFFLE_ONREPEAT_OFFTRACK_ONDEMANDspotify:track:XXXX -------------------------------------------------------------------------------- /tests/data/stored_music.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | Limp Bizkit 8 | 9 | Break Stuff 10 | Limp Bizkit 11 | Significant Other 12 | 2 13 | 14 | 15 | 16 | PLAY_STATE 17 | SHUFFLE_OFF 18 | REPEAT_OFF 19 | 20 | -------------------------------------------------------------------------------- /tests/data/spotify_utf8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | Capital Inicial 7 | 8 | Música Urbana 9 | Capital Inicial 10 | Millennium - Capital Inicial 11 | http://url 12 | 13 | 14 | PAUSE_STATE 15 | SHUFFLE_ON 16 | REPEAT_OFF 17 | 18 | TRACK_ONDEMAND 19 | spotify:track:XXX 20 | -------------------------------------------------------------------------------- /tests/data/radio_utf8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | France Info 6 | 7 | http://item.radio456.com/007452/logo/logo-1307.jpg 8 | 9 | 10 | 11 | 12 | 13 | France Info 14 | 15 | http://item.radio456.com/007452/logo/logo-1307.jpg 16 | 17 | PLAY_STATE 18 | MP3 64 kbps Paris France, La radio franceinfo vous propose 19 | à tout moment une information complète, des reportages, 20 | et des émissions d'actualité. 21 | 22 | Paris France 23 | -------------------------------------------------------------------------------- /tests/data/spotify.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | Metallica 8 | 9 | Nothing Else Matters (Live) 10 | Metallica 11 | Metallica Through The Never (Music from the Motion Picture) 12 | 13 | http://i.scdn.co/image/1362a06f43 14 | 15 | 16 | 17 | 18 | PLAY_STATE 19 | SHUFFLE_OFF 20 | REPEAT_OFF 21 | 22 | TRACK_ONDEMAND 23 | spotify:track:1HoBsGG0Ss2Wv5Ky8pkCEf 24 | -------------------------------------------------------------------------------- /tests/data/ws_presets.xml: -------------------------------------------------------------------------------- 1 | ZeddAfternoon AccousticRock Balladshttps://mosaic.scdn.co/300/3bed9488d99fcc4b0cd75200e774e8afc54e17642243488087d78eac7c4151553c9844cfb46af7ab5515df20c6ead399b0ef14169679e40b6ed7dbd7900671b1f01757f81e584170142baf21a2723f5a -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | Developer Interface 4 | =================== 5 | 6 | .. module:: libsoundtouch 7 | 8 | This part of the documentation covers all the interfaces of Libsoundtouch. 9 | 10 | Main Interface 11 | -------------- 12 | 13 | .. autofunction:: soundtouch_device 14 | .. autofunction:: discover_devices 15 | 16 | Classes 17 | ------- 18 | 19 | .. automodule:: libsoundtouch.device 20 | 21 | .. autoclass:: SoundTouchDevice 22 | :members: 23 | 24 | .. autoclass:: Config 25 | :members: 26 | 27 | .. autoclass:: Network 28 | :members: 29 | 30 | .. autoclass:: Component 31 | :members: 32 | 33 | .. autoclass:: Status 34 | :members: 35 | 36 | .. autoclass:: ContentItem 37 | :members: 38 | 39 | .. autoclass:: Volume 40 | :members: 41 | 42 | .. autoclass:: Preset 43 | :members: 44 | 45 | .. autoclass:: ZoneStatus 46 | :members: 47 | 48 | .. autoclass:: ZoneSlave 49 | :members: 50 | 51 | Exceptions 52 | ---------- 53 | 54 | .. autoexception:: SoundtouchException 55 | .. autoexception:: NoExistingZoneException 56 | .. autoexception:: NoSlavesException 57 | -------------------------------------------------------------------------------- /tests/data/device_info.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Home 4 | SoundTouch 20 5 | AccountUUIDValue 6 | 7 | 8 | SCM 9 | 10 | 13.0.9.29919.1889959 epdbuild.trunk.cepeswbldXXX 11 | 12 | XXXXX 13 | 14 | 15 | PackagedProduct 16 | YYYYY 17 | 18 | 19 | https://streaming.bose.com 20 | 21 | 00112233445566 22 | 192.168.1.2 23 | 24 | 25 | 66554433221100 26 | 192.168.1.1 27 | 28 | sm2 29 | spotty 30 | normal 31 | GB 32 | GB 33 | -------------------------------------------------------------------------------- /tests/data/device_info_utf8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Küche 4 | SoundTouch 20 5 | AccountUUIDValue 6 | 7 | 8 | SCM 9 | 10 | 13.0.9.29919.1889959 epdbuild.trunk.cepeswbldXXX 11 | 12 | XXXXX 13 | 14 | 15 | PackagedProduct 16 | YYYYY 17 | 18 | 19 | https://streaming.bose.com 20 | 21 | 00112233445566 22 | 192.168.1.2 23 | 24 | 25 | 66554433221100 26 | 192.168.1.1 27 | 28 | sm2 29 | spotty 30 | normal 31 | GB 32 | GB 33 | -------------------------------------------------------------------------------- /libsoundtouch/__init__.py: -------------------------------------------------------------------------------- 1 | """libsoundtouch.""" 2 | 3 | import logging 4 | 5 | try: 6 | from queue import Queue, Empty 7 | except ImportError: 8 | from Queue import Queue, Empty # type: ignore 9 | from zeroconf import Zeroconf, ServiceBrowser 10 | from libsoundtouch.device import SoundTouchDevice 11 | from libsoundtouch.utils import SoundtouchDeviceListener 12 | 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | def soundtouch_device(host, port=8090): 17 | """Create a new Soundtouch device. 18 | 19 | :param host: Host of the device 20 | :param port: Port of the device. Default 8090 21 | 22 | """ 23 | s_device = SoundTouchDevice(host, port) 24 | return s_device 25 | 26 | 27 | def discover_devices(timeout=5): 28 | """Discover devices on the local network. 29 | 30 | :param timeout: Max time to wait in seconds. Default 5 31 | """ 32 | devices = [] 33 | # Using Queue as a timeout timer 34 | add_devices_queue = Queue() 35 | 36 | def add_device_function(name, host, port): 37 | """Add device callback.""" 38 | _LOGGER.info("%s discovered (host: %s, port: %i)", name, host, port) 39 | devices.append(soundtouch_device(host, port)) 40 | 41 | zeroconf = Zeroconf() 42 | listener = SoundtouchDeviceListener(add_device_function) 43 | _LOGGER.debug("Starting discovery...") 44 | ServiceBrowser(zeroconf, "_soundtouch._tcp.local.", listener) 45 | try: 46 | add_devices_queue.get(timeout=timeout) 47 | except Empty: 48 | _LOGGER.debug("End of discovery...") 49 | return devices 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from setuptools import setup, find_packages 3 | import os 4 | 5 | PACKAGES = find_packages(exclude=['tests', 'tests.*']) 6 | 7 | 8 | def gen_data_files(*dirs): 9 | results = [] 10 | 11 | for src_dir in dirs: 12 | for root,dirs,files in os.walk(src_dir): 13 | results.append((root, map(lambda f:root + "/" + f, files))) 14 | return results 15 | 16 | REQUIRES = [ 17 | 'requests>=2,<3', 18 | 'enum-compat>=0.0.2', 19 | 'websocket-client>=0.40.0', 20 | 'zeroconf>=0.19.1' 21 | ] 22 | 23 | PROJECT_CLASSIFIERS = [ 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: Apache Software License', 26 | 'Operating System :: OS Independent', 27 | 'Programming Language :: Python :: 3.6', 28 | 'Programming Language :: Python :: 3.5', 29 | 'Programming Language :: Python :: 3.4', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Topic :: Software Development :: Libraries' 32 | ] 33 | 34 | setup( 35 | name="libsoundtouch", 36 | version="0.8.0", 37 | license="Apache License 2.0", 38 | url="http://libsoundtouch.readthedocs.io", 39 | download_url="https://github.com/CharlesBlonde/libsoundtouch", 40 | author="Charles Blonde", 41 | author_email="charles.blonde@gmail.com", 42 | description="Bose Soundtouch Python library", 43 | packages=PACKAGES, 44 | data_files = gen_data_files("libsoundtouch/templates"), 45 | include_package_data=True, 46 | zip_safe=True, 47 | platforms='any', 48 | install_requires=REQUIRES, 49 | test_suite='tests', 50 | keywords=['bose', 'soundtouch'], 51 | classifiers=PROJECT_CLASSIFIERS, 52 | ) 53 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py35, py36, lint, typing 3 | skip_missing_interpreters = True 4 | 5 | [testenv:py27] 6 | setenv = 7 | LANG=en_US.UTF-8 8 | PYTHONPATH = {toxinidir}:{toxinidir}/libsoundtouch 9 | commands = 10 | py.test -v --duration=10 11 | deps = 12 | -r{toxinidir}/requirements.txt 13 | -r{toxinidir}/requirements_test_27.txt 14 | 15 | [testenv:py34] 16 | setenv = 17 | LANG=en_US.UTF-8 18 | PYTHONPATH = {toxinidir}:{toxinidir}/libsoundtouch 19 | commands = 20 | py.test -v --duration=10 --cov --cov-report= {posargs} 21 | deps = 22 | -r{toxinidir}/requirements.txt 23 | -r{toxinidir}/requirements_test.txt 24 | 25 | [testenv:py35] 26 | setenv = 27 | LANG=en_US.UTF-8 28 | PYTHONPATH = {toxinidir}:{toxinidir}/libsoundtouch 29 | commands = 30 | py.test -v --duration=10 --cov --cov-report= {posargs} 31 | deps = 32 | -r{toxinidir}/requirements.txt 33 | -r{toxinidir}/requirements_test.txt 34 | 35 | [testenv:py36] 36 | setenv = 37 | LANG=en_US.UTF-8 38 | PYTHONPATH = {toxinidir}:{toxinidir}/libsoundtouch 39 | commands = 40 | py.test -v --duration=10 --cov --cov-report= {posargs} 41 | deps = 42 | -r{toxinidir}/requirements.txt 43 | -r{toxinidir}/requirements_test.txt 44 | 45 | [testenv:lint] 46 | basepython = python3 47 | ignore_errors = True 48 | commands = 49 | flake8 libsoundtouch tests 50 | pylint libsoundtouch 51 | pydocstyle libsoundtouch 52 | deps = 53 | -r{toxinidir}/requirements.txt 54 | -r{toxinidir}/requirements_test.txt 55 | 56 | [testenv:typing] 57 | basepython = python3 58 | deps = 59 | -r{toxinidir}/requirements_test.txt 60 | commands = 61 | mypy --silent-imports libsoundtouch 62 | -------------------------------------------------------------------------------- /RELEASES.rst: -------------------------------------------------------------------------------- 1 | Version 0.8.0 2 | ~~~~~~~~~~~~~ 3 | 4 | :Date: 5 | 2018/02/11 6 | 7 | - Fix: New API content with latest firmware 8 | - Fix: Device names with UTF-8 characters 9 | - Allow to select AUX/Bluetooth inputs 10 | - Add snapshotting/restore feature 11 | 12 | Version 0.7.2 13 | ~~~~~~~~~~~~~ 14 | 15 | :Date: 16 | 2017/07/05 17 | 18 | - Fix: Add missing template 19 | 20 | Version 0.7.1 21 | ~~~~~~~~~~~~~ 22 | 23 | :Date: 24 | 2017/07/05 25 | 26 | - Fix: remove debug 27 | 28 | Version 0.7.0 29 | ~~~~~~~~~~~~~ 30 | 31 | :Date: 32 | 2017/07/05 33 | 34 | - Add play_url to play an HTTP URL (not HTTPS) 35 | 36 | Version 0.6.2 37 | ~~~~~~~~~~~~~ 38 | 39 | :Date: 40 | 2017/06/21 41 | 42 | - Fix: websocket source status in messages 43 | 44 | Version 0.6.1 45 | ~~~~~~~~~~~~~ 46 | 47 | :Date: 48 | 2017/06/19 49 | 50 | - Fix: Use enum-compat instead of enum34 directly 51 | 52 | Version 0.6.0 53 | ~~~~~~~~~~~~~ 54 | 55 | :Date: 56 | 2017/06/17 57 | 58 | - Add discovery (mDNS) support 59 | - Official Python 3.6 support 60 | 61 | Version 0.5.0 62 | ~~~~~~~~~~~~~ 63 | 64 | :Date: 65 | 2017/05/28 66 | 67 | - Add Websocket support 68 | 69 | Version 0.4.0 70 | ~~~~~~~~~~~~~ 71 | 72 | :Date: 73 | 2017/05/21 74 | 75 | - Add Bluetooth source 76 | 77 | Version 0.3.0 78 | ~~~~~~~~~~~~~ 79 | 80 | :Date: 81 | 2017/04/09 82 | 83 | - Allow playing local computer media 84 | - Fix issue with non ASCII characters 85 | 86 | Version 0.2.2 87 | ~~~~~~~~~~~~~ 88 | 89 | :Date: 90 | 2017/02/07 91 | 92 | - Fix status with non ascii characters in Python 2.7 93 | 94 | Version 0.2.1 95 | ~~~~~~~~~~~~~ 96 | 97 | :Date: 98 | 2017/02/05 99 | 100 | - Fix dependencies 101 | 102 | Version 0.2.0 103 | ~~~~~~~~~~~~~ 104 | 105 | :Date: 106 | 2017/02/05 107 | 108 | - Add *play_media* support 109 | 110 | Version 0.1.0 111 | ~~~~~~~~~~~~~ 112 | 113 | :Date: 114 | 2016/11/20 115 | 116 | - Initial release 117 | -------------------------------------------------------------------------------- /libsoundtouch/utils.py: -------------------------------------------------------------------------------- 1 | """Utils for the Bose Soundtouch Device.""" 2 | 3 | import logging 4 | import socket 5 | 6 | from enum import Enum 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class Key(Enum): 12 | """Keys of the device.""" 13 | 14 | PLAY = 'PLAY' 15 | PAUSE = 'PAUSE' 16 | PLAY_PAUSE = 'PLAY_PAUSE' 17 | STOP = 'STOP' 18 | PREV_TRACK = 'PREV_TRACK' 19 | NEXT_TRACK = 'NEXT_TRACK' 20 | THUMBS_UP = 'THUMBS_UP' 21 | THUMBS_DOWN = 'THUMBS_DOWN' 22 | BOOKMARK = 'BOOKMARK' 23 | POWER = 'POWER' 24 | MUTE = 'MUTE' 25 | VOLUME_UP = 'VOLUME_UP' 26 | VOLUME_DOWN = 'VOLUME_DOWN' 27 | PRESET_1 = 'PRESET_1' 28 | PRESET_2 = 'PRESET_2' 29 | PRESET_3 = 'PRESET_3' 30 | PRESET_4 = 'PRESET_4' 31 | PRESET_5 = 'PRESET_5' 32 | PRESET_6 = 'PRESET_6' 33 | AUX_INPUT = 'AUX_INPUT' 34 | SHUFFLE_OFF = 'SHUFFLE_OFF' 35 | SHUFFLE_ON = 'SHUFFLE_ON' 36 | REPEAT_OFF = 'REPEAT_OFF' 37 | REPEAT_ONE = 'REPEAT_ONE' 38 | REPEAT_ALL = 'REPEAT_ALL' 39 | ADD_FAVORITE = 'ADD_FAVORITE' 40 | REMOVE_FAVORITE = 'REMOVE_FAVORITE' 41 | 42 | 43 | class Source(Enum): 44 | """Music sources supported by the device.""" 45 | 46 | SLAVE_SOURCE = "SLAVE_SOURCE" 47 | INTERNET_RADIO = "INTERNET_RADIO" 48 | PANDORA = "PANDORA" 49 | AIRPLAY = "AIRPLAY" 50 | STORED_MUSIC = "STORED_MUSIC" 51 | AUX = "AUX" 52 | OFF_SOURCE = "OFF_SOURCE" 53 | CURRATED_RADIO = "CURRATED_RADIO" 54 | STANDBY = "STANDBY" 55 | UPDATE = "UPDATE" 56 | DEEZER = "DEEZER" 57 | SPOTIFY = "SPOTIFY" 58 | IHEART = "IHEART" 59 | LOCAL_MUSIC = "LOCAL_MUSIC" 60 | BLUETOOTH = "BLUETOOTH" 61 | INVALID_SOURCE = "INVALID_SOURCE" 62 | 63 | 64 | class Type(Enum): 65 | """Music types. 66 | 67 | URI for streaming (Spotify, NAS, etc) 68 | TRACK/ALBUM/PLAYLIST for music libraries on computer (Windows media player, 69 | Itunes) 70 | """ 71 | 72 | URI = "uri" 73 | TRACK = "track" 74 | ALBUM = "album" 75 | PLAYLIST = "playlist" 76 | 77 | 78 | class SoundtouchDeviceListener(object): 79 | """Message listener.""" 80 | 81 | def __init__(self, add_device_function): 82 | """Create a new message listener. 83 | 84 | :param add_device_function: Callback function 85 | """ 86 | self.add_device_function = add_device_function 87 | 88 | def remove_service(self, zeroconf, device_type, name): 89 | # pylint: disable=unused-argument,no-self-use 90 | """Remove listener.""" 91 | _LOGGER.info("Service %s removed", name) 92 | 93 | def add_service(self, zeroconf, device_type, name): 94 | """Add device. 95 | 96 | :param zeroconf: MSDNS object 97 | :param device_type: Service type 98 | :param name: Device name 99 | """ 100 | device_name = (name.split(".")[0]) 101 | info = zeroconf.get_service_info(device_type, name) 102 | address = socket.inet_ntoa(info.address) 103 | self.add_device_function(device_name, address, info.port) 104 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Libsoundtouch documentation master file, created by 2 | sphinx-quickstart on Sat Jun 3 11:56:19 2017. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Libsoundtouch's documentation 7 | ========================================= 8 | 9 | .. image:: https://api.travis-ci.org/CharlesBlonde/libsoundtouch.svg?branch=master 10 | :target: https://travis-ci.org/CharlesBlonde/libsoundtouch 11 | 12 | .. image:: https://coveralls.io/repos/github/CharlesBlonde/libsoundtouch/badge.svg?branch=master 13 | :target: https://coveralls.io/github/CharlesBlonde/libsoundtouch?branch=master 14 | 15 | .. image:: https://img.shields.io/pypi/v/libsoundtouch.svg 16 | :target: https://pypi.python.org/pypi/libsoundtouch 17 | 18 | This Python 2.7+/3.4+ library allows you to control `Bose Soundtouch devices 19 | `_ . 20 | 21 | Features 22 | -------- 23 | 24 | - Discovery 25 | - power on/power off 26 | - play/pause 27 | - next/previous track 28 | - volume setting (mute/set volume/volume up/volume down) 29 | - repeat one/all/off 30 | - shuffle on/off 31 | - select AUX/Bluetooth inputs 32 | - select preset (bookmark) 33 | - playback selected music 34 | - allow snapshot and restore playing content 35 | - play HTTP URL (not HTTPS) 36 | - Multi room (zones) 37 | - Websocket notifications 38 | 39 | Usage 40 | ----- 41 | 42 | Installation 43 | ~~~~~~~~~~~~ 44 | 45 | .. code:: shell 46 | 47 | pip install libsoundtouch 48 | 49 | Discovery 50 | ~~~~~~~~~ 51 | 52 | Soundtouch devices support mDNS discovery protocol. 53 | 54 | .. code:: python 55 | 56 | from libsoundtouch import discover_devices 57 | 58 | devices = discover_devices(timeout=2) # Default timeout is 5 seconds 59 | 60 | 61 | for device in devices: 62 | print(device.config.name + " - " + device.config.type) 63 | 64 | Basic Usage 65 | ~~~~~~~~~~~ 66 | 67 | .. code:: python 68 | 69 | from libsoundtouch import soundtouch_device 70 | from libsoundtouch.utils import Source, Type 71 | 72 | device = soundtouch_device('192.168.1.1') # Manual configuration 73 | device.power_on() 74 | 75 | # Config object 76 | print(device.config.name) 77 | 78 | # Status object 79 | # device.status() will do an HTTP request. 80 | # Try to cache this value if needed. 81 | status = device.status() 82 | print(status.source) 83 | print(status.artist+ " - "+ status.track) 84 | device.pause() 85 | device.next_track() 86 | device.play() 87 | 88 | # Media Playback 89 | # device.play_media(source, location, account, media_type) 90 | # account and media_type are optionals 91 | 92 | # Radio 93 | device.play_media(Source.INTERNET_RADIO, '4712') # Studio Brussel 94 | 95 | # Spotify 96 | spot_user_id = '' # Should be filled in with your Spotify userID 97 | # This userID can be found by playing Spotify on the 98 | # connected SoundTouch speaker, and calling 99 | # device.status().content_item.source_account 100 | device.play_media(Source.SPOTIFY, 101 | 'spotify:track:5J59VOgvclrhLDYUoH5OaW', 102 | spot_user_id) # Bazart - Goud 103 | 104 | # Local music (Windows media player, Itunes) 105 | # Account ID can be found by playing local music on the 106 | # connected Soundtouch speaker, and calling 107 | # device.status().content_item.source_account 108 | account_id = device.status().content_item.source_account 109 | device.play_media(Source.LOCAL_MUSIC, 110 | 'album:1', 111 | account_id, 112 | Type.ALBUM) 113 | 114 | # Snapshot current playing 115 | device.snapshot() 116 | 117 | # Select AUX input 118 | device.select_source_aux() 119 | # Select Bluetooth input 120 | device.select_source_bluetooth() 121 | 122 | # Restore previous snapshot 123 | device.restore() 124 | 125 | # Play an HTTP URL (not HTTPS) 126 | device.play_url('http://fqdn/file.mp3') 127 | 128 | # Volume object 129 | # device.volume() will do an HTTP request. 130 | # Try to cache this value if needed. 131 | volume = device.volume() 132 | print(volume.actual) 133 | print(volume.muted) 134 | device.set_volume(30) # 0..100 135 | 136 | # Presets object 137 | # device.presets() will do an HTTP request. 138 | # Try to cache this value if needed. 139 | presets = device.presets() 140 | print(presets[0].name) 141 | print(presets[0].source) 142 | # Play preset 0 143 | device.select_preset(presets[0]) 144 | 145 | # ZoneStatus object 146 | # device.zone_status() will do an HTTP request. 147 | # Try to cache this value if needed. 148 | zone_status = device.zone_status() 149 | print(zone_status.master_id) 150 | print(len(zone_status.slaves)) 151 | 152 | Multi-room 153 | ~~~~~~~~~~ 154 | 155 | Soundtouch devices supports multi-room features called zones. 156 | 157 | .. code:: python 158 | 159 | from libsoundtouch import soundtouch_device 160 | 161 | master = soundtouch_device('192.168.18.1') 162 | slave1 = soundtouch_device('192.168.18.2') 163 | slave2 = soundtouch_device('192.168.18.3') 164 | 165 | # Create a new zone 166 | master.create_zone([slave1, slave2]) 167 | 168 | # Remove a slave 169 | master.remove_zone_slave([slave2]) 170 | 171 | # Add a slave 172 | master.add_zone_slave([slave2]) 173 | 174 | Websocket 175 | ~~~~~~~~~ 176 | 177 | Soundtouch devices support Websocket notifications in order to prevent pulling and to get immediate updates. 178 | 179 | .. code:: python 180 | 181 | from libsoundtouch import soundtouch_device 182 | import time 183 | 184 | # Events listeners 185 | 186 | # Volume updated 187 | def volume_listener(volume): 188 | print(volume.actual) 189 | 190 | # Status updated 191 | def status_listener(status): 192 | print(status.track) 193 | 194 | # Presets updated 195 | def preset_listener(presets): 196 | for preset in presets: 197 | print(preset.name) 198 | 199 | # Zone updated 200 | def zone_status_listener(zone_status): 201 | if zone_status: 202 | print(zone_status.master_id) 203 | else: 204 | print('no Zone') 205 | 206 | device = soundtouch_device('192.168.18.1') 207 | 208 | device.add_volume_listener(volume_listener) 209 | device.add_status_listener(status_listener) 210 | device.add_presets_listener(preset_listener) 211 | device.add_zone_status_listener(zone_status_listener) 212 | 213 | # Start websocket thread. Not started by default 214 | device.start_notification() 215 | 216 | time.sleep(600) # Wait for events 217 | 218 | API Documentation 219 | ----------------- 220 | 221 | If you are looking for information on a specific function, class, or method, 222 | this part of the documentation is for you. 223 | 224 | .. toctree:: 225 | api 226 | 227 | TODO 228 | ---- 229 | 230 | The following features are not yet implemented: 231 | 232 | - Better error management 233 | - Bass configuration 234 | 235 | 236 | Releases 237 | -------- 238 | 239 | .. toctree:: 240 | versions 241 | 242 | About Libsoundtouch 243 | ------------------- 244 | 245 | This library has been created in order to create a component for the `Home Assistant 246 | `_ project but is totally independent. 247 | 248 | 249 | Contributors 250 | ------------ 251 | 252 | .. include:: ../AUTHORS.rst -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Libsoundtouch.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Libsoundtouch.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Libsoundtouch" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Libsoundtouch" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bose Soundtouch Python library 2 | 3 | [![Build Status](https://travis-ci.org/CharlesBlonde/libsoundtouch.svg?branch=master)](https://travis-ci.org/CharlesBlonde/libsoundtouch) [![Coverage Status](https://coveralls.io/repos/github/CharlesBlonde/libsoundtouch/badge.svg?branch=master)](https://coveralls.io/github/CharlesBlonde/libsoundtouch?branch=master) [![PyPI version](https://badge.fury.io/py/libsoundtouch.svg)](https://badge.fury.io/py/libsoundtouch) [![Documentation Status](https://readthedocs.org/projects/libsoundtouch/badge/?version=latest)](http://libsoundtouch.readthedocs.io/en/latest/?badge=latest) 4 | 5 | This Python 2.7+/3.4+ library allows you to control [Bose Soundtouch devices](https://www.soundtouch.com/). 6 | 7 | [http://libsoundtouch.readthedocs.io](http://libsoundtouch.readthedocs.io) 8 | 9 | ## How to use it ? 10 | 11 | 12 | ```shell 13 | pip install libsoundtouch 14 | ``` 15 | 16 | ```python 17 | from libsoundtouch import discover_devices 18 | 19 | devices = discover_devices(timeout=2) 20 | 21 | 22 | for device in devices: 23 | print(device.config.name + " - " + device.config.type) 24 | ``` 25 | 26 | ```python 27 | from libsoundtouch import soundtouch_device 28 | from libsoundtouch.utils import Source, Type 29 | 30 | device = soundtouch_device('192.168.1.1') 31 | device.power_on() 32 | 33 | # Config object 34 | print(device.config.name) 35 | 36 | # Status object 37 | # device.status() will do an HTTP request. Try to cache this value if needed. 38 | status = device.status() 39 | print(status.source) 40 | print(status.artist+ " - "+ status.track) 41 | device.pause() 42 | device.next_track() 43 | device.play() 44 | 45 | # Media Playback 46 | # device.play_media(source, location, account, media_type) #account and media_type are optionals 47 | device.play_media(Source.INTERNET_RADIO, '4712') # Studio Brussel 48 | spot_user_id = '' # Should be filled in with your Spotify userID 49 | # This userID can be found by playing Spotify on the connected SoundTouch speaker, and calling 50 | # device.status().content_item.source_account 51 | device.play_media(Source.SPOTIFY, 'spotify:track:5J59VOgvclrhLDYUoH5OaW', spot_user_id) # Bazart - Goud 52 | # Local music (Windows media player, Itunes) 53 | # Account ID can be found by playing local music on the connected Soundtouch speaker, and calling 54 | # device.status().content_item.source_account 55 | account_id = device.status().content_item.source_account 56 | device.play_media(Source.LOCAL_MUSIC, 'album:1', account_id, Type.ALBUM) 57 | 58 | # Snapshot current playing 59 | device.snapshot() 60 | 61 | # Select AUX input 62 | device.select_source_aux() 63 | # Select Bluetooth input 64 | device.select_source_bluetooth() 65 | 66 | # Restore previous snapshot 67 | device.restore() 68 | 69 | # Play URL 70 | device.play_url('http://fqdn/file.mp3') 71 | 72 | # Volume object 73 | # device.volume() will do an HTTP request. Try to cache this value if needed. 74 | volume = device.volume() 75 | print(volume.actual) 76 | print(volume.muted) 77 | device.set_volume(30) # 0..100 78 | 79 | # Presets object 80 | # device.presets() will do an HTTP request. Try to cache this value if needed. 81 | presets = device.presets() 82 | print(presets[0].name) 83 | print(presets[0].source) 84 | # Play preset 0 85 | device.select_preset(presets[0]) 86 | 87 | # ZoneStatus object 88 | # device.zone_status() will do an HTTP request. Try to cache this value if needed. 89 | zone_status = device.zone_status() 90 | print(zone_status.master_id) 91 | print(len(zone_status.slaves)) 92 | ``` 93 | 94 | ## Supported features 95 | 96 | ### Basics commands 97 | 98 | * Discovery 99 | * power on/power off 100 | * play/pause 101 | * next/previous track 102 | * volume setting (mute/set volume/volume up/volume down) 103 | * repeat one/all/off 104 | * shuffle on/off 105 | * select AUX/Bluetooth inputs 106 | * select preset (bookmark) 107 | * playback selected music 108 | * allow snapshot and restore playing content 109 | * play HTTP URL (HTTPS not supported) 110 | * Websockets notification 111 | 112 | ### Multi-room 113 | 114 | Soundtouch devices supports multi-room features called zones. 115 | 116 | ```python 117 | from libsoundtouch import soundtouch_device 118 | 119 | master = soundtouch_device('192.168.18.1') 120 | slave1 = soundtouch_device('192.168.18.2') 121 | slave2 = soundtouch_device('192.168.18.3') 122 | 123 | # Create a new zone 124 | master.create_zone([slave1, slave2]) 125 | 126 | # Remove a slave 127 | master.remove_zone_slave([slave2]) 128 | 129 | # Add a slave 130 | master.add_zone_slave([slave2]) 131 | ``` 132 | 133 | ### Websocket 134 | 135 | Soundtouch devices support Websocket notifications in order to prevent pulling and to get immediate updates. 136 | 137 | ```python 138 | from libsoundtouch import soundtouch_device 139 | import time 140 | 141 | # Events listeners 142 | 143 | # Volume updated 144 | def volume_listener(volume): 145 | print(volume.actual) 146 | 147 | # Status updated 148 | def status_listener(status): 149 | print(status.track) 150 | 151 | # Presets updated 152 | def preset_listener(presets): 153 | for preset in presets: 154 | print(preset.name) 155 | 156 | # Zone updated 157 | def zone_status_listener(zone_status): 158 | if zone_status: 159 | print(zone_status.master_id) 160 | else: 161 | print('no Zone') 162 | 163 | device = soundtouch_device('192.168.18.1') 164 | 165 | device.add_volume_listener(volume_listener) 166 | device.add_status_listener(status_listener) 167 | device.add_presets_listener(preset_listener) 168 | device.add_zone_status_listener(zone_status_listener) 169 | 170 | # Start websocket thread. Not started by default 171 | device.start_notification() 172 | 173 | time.sleep(600) # Wait for events 174 | 175 | ``` 176 | 177 | ## Full documentation 178 | 179 | [http://libsoundtouch.readthedocs.io](http://libsoundtouch.readthedocs.io) 180 | 181 | ## Incoming features 182 | 183 | The following features are not yet implemented: 184 | 185 | * Better error management 186 | * Bass configuration 187 | 188 | ## Access to the official API documentation 189 | 190 | For an unknown reason, the API documentation is not freely available but you can request to get it: http://developers.bose.com/. 191 | You have to sent an email and you'll received a response in a minute with 2 PDF: 192 | * SoundTouchAPI_Discovery.pdf: How to use SSDP and MDNS discovery protocols 193 | * SoundTouchAPI_WebServices.pdf: REST API documentation. Be careful, the documentation contains errors and is not fully up to date 194 | 195 | ## Changelog 196 | 197 | ### 0.8.0 - 2018/02/11 198 | 199 | * Fix: New API content with latest firmware 200 | * Fix: Device names with UTF-8 characters 201 | * Allow to select AUX/Bluetooth inputs 202 | * Add snapshotting/restore feature 203 | 204 | ### 0.7.2 - 2017/07/05 205 | 206 | * Add missing template 207 | 208 | ### 0.7.1 - 2017/07/05 209 | 210 | * Remove debugging (print) | 211 | 212 | ### 0.7.0 - 2017/07/05 213 | 214 | * Add play_url method to play an HTTP URL (HTTPS not supported) 215 | 216 | ### 0.6.2 - 2017/06/21 217 | 218 | * Fix websocket source status in messages 219 | 220 | ### 0.6.1 - 2017/06/19 221 | 222 | * Use enum-compat instead of enum34 directly 223 | 224 | ### 0.6.0 - 2017/06/17 225 | 226 | * Add discovery (mDNS) support 227 | 228 | ### 0.5.0 - 2017/05/28 229 | 230 | * Add Websocket support 231 | 232 | ### 0.4.0 - 2017/05/21 233 | 234 | * Add Bluetooth source 235 | 236 | ### 0.3.0 - 2017/04/09 237 | 238 | * Fix issue with non ASCII characters 239 | * Allow playing local computer media 240 | 241 | ### 0.2.2 - 2017/02/07 242 | 243 | * Fix status with non ascii characters in Python 2.7 244 | 245 | ### 0.2.1 - 2017/02/05 246 | 247 | * Fix dependencies 248 | 249 | ### 0.2.0 - 2017/02/05 250 | 251 | * Add *play_media* support 252 | 253 | ### 0.1.0 - 2016/11/20 254 | 255 | * Initial release 256 | 257 | ## Contributors 258 | 259 | Thanks to: 260 | 261 | * [jeanregisser](https://github.com/jeanregisser) (Use enum-compat instead of enum34 directly) 262 | * [Tyzer34](https://github.com/Tyzer34) (add *play_media* support) 263 | * [wanderor](https://github.com/wanderor) (add local computer media support) 264 | * [obadz](https://github.com/obadz) (add Bluetooth source) 265 | * [luca-angemi](https://github.com/luca-angemi) (Fix new firmware error) 266 | * [vanto](https://github.com/vanto) (Fix device names with UTF-8 characters) 267 | 268 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Libsoundtouch documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Jun 3 11:56:19 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('../')) 23 | sys.path.insert(0, '..') 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | 'sphinx.ext.autodoc', 35 | 'sphinx.ext.doctest', 36 | 'sphinx.ext.coverage', 37 | 'sphinx.ext.viewcode', 38 | ] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix of source filenames. 44 | source_suffix = '.rst' 45 | 46 | # The encoding of source files. 47 | #source_encoding = 'utf-8-sig' 48 | 49 | # The master toctree document. 50 | master_doc = 'index' 51 | 52 | # General information about the project. 53 | project = 'Libsoundtouch' 54 | copyright = '2017, Charles Blonde' 55 | author = 'Charles Blonde' 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The short X.Y version. 62 | version = '0.5.0' 63 | # The full version, including alpha/beta/rc tags. 64 | release = '0.5.0' 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | #language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | #today_fmt = '%B %d, %Y' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = ['_build'] 79 | 80 | # The reST default role (used for this markup: `text`) to use for all 81 | # documents. 82 | #default_role = None 83 | 84 | # If true, '()' will be appended to :func: etc. cross-reference text. 85 | #add_function_parentheses = True 86 | 87 | # If true, the current module name will be prepended to all description 88 | # unit titles (such as .. function::). 89 | #add_module_names = True 90 | 91 | # If true, sectionauthor and moduleauthor directives will be shown in the 92 | # output. They are ignored by default. 93 | #show_authors = False 94 | 95 | # The name of the Pygments (syntax highlighting) style to use. 96 | pygments_style = 'sphinx' 97 | 98 | # A list of ignored prefixes for module index sorting. 99 | #modindex_common_prefix = [] 100 | 101 | # If true, keep warnings as "system message" paragraphs in the built documents. 102 | #keep_warnings = False 103 | todo_include_todos = True 104 | 105 | 106 | # -- Options for HTML output ---------------------------------------------- 107 | 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'alabaster' 111 | 112 | # Theme options are theme-specific and customize the look and feel of a theme 113 | # further. For a list of options available for each theme, see the 114 | # documentation. 115 | #html_theme_options = {} 116 | html_theme_options = { 117 | 'show_powered_by': False, 118 | 'github_user': 'CharlesBlonde', 119 | 'github_repo': 'libsoundtouch', 120 | 'github_banner': True, 121 | 'show_related': False 122 | } 123 | 124 | # Add any paths that contain custom themes here, relative to this directory. 125 | #html_theme_path = [] 126 | 127 | # The name for this set of Sphinx documents. If None, it defaults to 128 | # " v documentation". 129 | html_title = 'Libsoundtouch' 130 | 131 | # A shorter title for the navigation bar. Default is the same as html_title. 132 | html_short_title = 'Libsoundtouch' 133 | 134 | # The name of an image file (relative to this directory) to place at the top 135 | # of the sidebar. 136 | #html_logo = None 137 | 138 | # The name of an image file (within the static path) to use as favicon of the 139 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 140 | # pixels large. 141 | #html_favicon = None 142 | 143 | # Add any paths that contain custom static files (such as style sheets) here, 144 | # relative to this directory. They are copied after the builtin static files, 145 | # so a file named "default.css" will overwrite the builtin "default.css". 146 | html_static_path = ['_static'] 147 | 148 | # Add any extra paths that contain custom files (such as robots.txt or 149 | # .htaccess) here, relative to this directory. These files are copied 150 | # directly to the root of the documentation. 151 | #html_extra_path = [] 152 | 153 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 154 | # using the given strftime format. 155 | #html_last_updated_fmt = '%b %d, %Y' 156 | 157 | # If true, SmartyPants will be used to convert quotes and dashes to 158 | # typographically correct entities. 159 | # html_use_smartypants = True 160 | 161 | # Custom sidebar templates, maps document names to template names. 162 | #html_sidebars = {} 163 | html_sidebars = { 164 | 'index': ['sidebarintro.html', 'localtoc.html', 'sourcelink.html', 'searchbox.html'], 165 | } 166 | 167 | # Additional templates that should be rendered to pages, maps page names to 168 | # template names. 169 | #html_additional_pages = {} 170 | 171 | # If false, no module index is generated. 172 | #html_domain_indices = True 173 | 174 | # If false, no index is generated. 175 | #html_use_index = True 176 | 177 | # If true, the index is split into individual pages for each letter. 178 | #html_split_index = False 179 | 180 | # If true, links to the reST sources are added to the pages. 181 | html_show_sourcelink = False 182 | 183 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 184 | #html_show_sphinx = True 185 | 186 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 187 | #html_show_copyright = True 188 | 189 | # If true, an OpenSearch description file will be output, and all pages will 190 | # contain a tag referring to it. The value of this option must be the 191 | # base URL from which the finished HTML is served. 192 | #html_use_opensearch = '' 193 | 194 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 195 | #html_file_suffix = None 196 | 197 | # Output file base name for HTML help builder. 198 | htmlhelp_basename = 'Libsoundtouchdoc' 199 | 200 | 201 | # -- Options for LaTeX output --------------------------------------------- 202 | 203 | latex_elements = { 204 | # The paper size ('letterpaper' or 'a4paper'). 205 | #'papersize': 'letterpaper', 206 | 207 | # The font size ('10pt', '11pt' or '12pt'). 208 | #'pointsize': '10pt', 209 | 210 | # Additional stuff for the LaTeX preamble. 211 | #'preamble': '', 212 | } 213 | 214 | # Grouping the document tree into LaTeX files. List of tuples 215 | # (source start file, target name, title, 216 | # author, documentclass [howto, manual, or own class]). 217 | latex_documents = [ 218 | ('index', 'Libsoundtouch.tex', 'Libsoundtouch Documentation', 219 | 'Charles Blonde', 'manual'), 220 | ] 221 | 222 | # The name of an image file (relative to this directory) to place at the top of 223 | # the title page. 224 | #latex_logo = None 225 | 226 | # For "manual" documents, if this is true, then toplevel headings are parts, 227 | # not chapters. 228 | #latex_use_parts = False 229 | 230 | # If true, show page references after internal links. 231 | #latex_show_pagerefs = False 232 | 233 | # If true, show URL addresses after external links. 234 | #latex_show_urls = False 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #latex_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #latex_domain_indices = True 241 | 242 | 243 | # -- Options for manual page output --------------------------------------- 244 | 245 | # One entry per manual page. List of tuples 246 | # (source start file, name, description, authors, manual section). 247 | man_pages = [ 248 | ('index', 'libsoundtouch', 'Libsoundtouch Documentation', 249 | ['Charles Blonde'], 1) 250 | ] 251 | 252 | # If true, show URL addresses after external links. 253 | #man_show_urls = False 254 | 255 | 256 | # -- Options for Texinfo output ------------------------------------------- 257 | 258 | # Grouping the document tree into Texinfo files. List of tuples 259 | # (source start file, target name, title, author, 260 | # dir menu entry, description, category) 261 | texinfo_documents = [ 262 | ('index', 'Libsoundtouch', 'Libsoundtouch Documentation', 263 | 'Charles Blonde', 'Libsoundtouch', 'One line description of project.', 264 | 'Miscellaneous'), 265 | ] 266 | 267 | # Documents to append as an appendix to all manuals. 268 | #texinfo_appendices = [] 269 | 270 | # If false, no module index is generated. 271 | #texinfo_domain_indices = True 272 | 273 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 274 | #texinfo_show_urls = 'footnote' 275 | 276 | # If true, do not generate a @detailmenu in the "Top" node's menu. 277 | #texinfo_no_detailmenu = False 278 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2016 Charles Blonde. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | ``` 16 | ------------------------------------------------------------------------- 17 | Apache License 18 | Version 2.0, January 2004 19 | http://www.apache.org/licenses/ 20 | 21 | 22 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 23 | 24 | 1. Definitions. 25 | 26 | "License" shall mean the terms and conditions for use, reproduction, 27 | and distribution as defined by Sections 1 through 9 of this document. 28 | 29 | "Licensor" shall mean the copyright owner or entity authorized by 30 | the copyright owner that is granting the License. 31 | 32 | "Legal Entity" shall mean the union of the acting entity and all 33 | other entities that control, are controlled by, or are under common 34 | control with that entity. For the purposes of this definition, 35 | "control" means (i) the power, direct or indirect, to cause the 36 | direction or management of such entity, whether by contract or 37 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 38 | outstanding shares, or (iii) beneficial ownership of such entity. 39 | 40 | "You" (or "Your") shall mean an individual or Legal Entity 41 | exercising permissions granted by this License. 42 | 43 | "Source" form shall mean the preferred form for making modifications, 44 | including but not limited to software source code, documentation 45 | source, and configuration files. 46 | 47 | "Object" form shall mean any form resulting from mechanical 48 | transformation or translation of a Source form, including but 49 | not limited to compiled object code, generated documentation, 50 | and conversions to other media types. 51 | 52 | "Work" shall mean the work of authorship, whether in Source or 53 | Object form, made available under the License, as indicated by a 54 | copyright notice that is included in or attached to the work 55 | (an example is provided in the Appendix below). 56 | 57 | "Derivative Works" shall mean any work, whether in Source or Object 58 | form, that is based on (or derived from) the Work and for which the 59 | editorial revisions, annotations, elaborations, or other modifications 60 | represent, as a whole, an original work of authorship. For the purposes 61 | of this License, Derivative Works shall not include works that remain 62 | separable from, or merely link (or bind by name) to the interfaces of, 63 | the Work and Derivative Works thereof. 64 | 65 | "Contribution" shall mean any work of authorship, including 66 | the original version of the Work and any modifications or additions 67 | to that Work or Derivative Works thereof, that is intentionally 68 | submitted to Licensor for inclusion in the Work by the copyright owner 69 | or by an individual or Legal Entity authorized to submit on behalf of 70 | the copyright owner. For the purposes of this definition, "submitted" 71 | means any form of electronic, verbal, or written communication sent 72 | to the Licensor or its representatives, including but not limited to 73 | communication on electronic mailing lists, source code control systems, 74 | and issue tracking systems that are managed by, or on behalf of, the 75 | Licensor for the purpose of discussing and improving the Work, but 76 | excluding communication that is conspicuously marked or otherwise 77 | designated in writing by the copyright owner as "Not a Contribution." 78 | 79 | "Contributor" shall mean Licensor and any individual or Legal Entity 80 | on behalf of whom a Contribution has been received by Licensor and 81 | subsequently incorporated within the Work. 82 | 83 | 2. Grant of Copyright License. Subject to the terms and conditions of 84 | this License, each Contributor hereby grants to You a perpetual, 85 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 86 | copyright license to reproduce, prepare Derivative Works of, 87 | publicly display, publicly perform, sublicense, and distribute the 88 | Work and such Derivative Works in Source or Object form. 89 | 90 | 3. Grant of Patent License. Subject to the terms and conditions of 91 | this License, each Contributor hereby grants to You a perpetual, 92 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 93 | (except as stated in this section) patent license to make, have made, 94 | use, offer to sell, sell, import, and otherwise transfer the Work, 95 | where such license applies only to those patent claims licensable 96 | by such Contributor that are necessarily infringed by their 97 | Contribution(s) alone or by combination of their Contribution(s) 98 | with the Work to which such Contribution(s) was submitted. If You 99 | institute patent litigation against any entity (including a 100 | cross-claim or counterclaim in a lawsuit) alleging that the Work 101 | or a Contribution incorporated within the Work constitutes direct 102 | or contributory patent infringement, then any patent licenses 103 | granted to You under this License for that Work shall terminate 104 | as of the date such litigation is filed. 105 | 106 | 4. Redistribution. You may reproduce and distribute copies of the 107 | Work or Derivative Works thereof in any medium, with or without 108 | modifications, and in Source or Object form, provided that You 109 | meet the following conditions: 110 | 111 | (a) You must give any other recipients of the Work or 112 | Derivative Works a copy of this License; and 113 | 114 | (b) You must cause any modified files to carry prominent notices 115 | stating that You changed the files; and 116 | 117 | (c) You must retain, in the Source form of any Derivative Works 118 | that You distribute, all copyright, patent, trademark, and 119 | attribution notices from the Source form of the Work, 120 | excluding those notices that do not pertain to any part of 121 | the Derivative Works; and 122 | 123 | (d) If the Work includes a "NOTICE" text file as part of its 124 | distribution, then any Derivative Works that You distribute must 125 | include a readable copy of the attribution notices contained 126 | within such NOTICE file, excluding those notices that do not 127 | pertain to any part of the Derivative Works, in at least one 128 | of the following places: within a NOTICE text file distributed 129 | as part of the Derivative Works; within the Source form or 130 | documentation, if provided along with the Derivative Works; or, 131 | within a display generated by the Derivative Works, if and 132 | wherever such third-party notices normally appear. The contents 133 | of the NOTICE file are for informational purposes only and 134 | do not modify the License. You may add Your own attribution 135 | notices within Derivative Works that You distribute, alongside 136 | or as an addendum to the NOTICE text from the Work, provided 137 | that such additional attribution notices cannot be construed 138 | as modifying the License. 139 | 140 | You may add Your own copyright statement to Your modifications and 141 | may provide additional or different license terms and conditions 142 | for use, reproduction, or distribution of Your modifications, or 143 | for any such Derivative Works as a whole, provided Your use, 144 | reproduction, and distribution of the Work otherwise complies with 145 | the conditions stated in this License. 146 | 147 | 5. Submission of Contributions. Unless You explicitly state otherwise, 148 | any Contribution intentionally submitted for inclusion in the Work 149 | by You to the Licensor shall be under the terms and conditions of 150 | this License, without any additional terms or conditions. 151 | Notwithstanding the above, nothing herein shall supersede or modify 152 | the terms of any separate license agreement you may have executed 153 | with Licensor regarding such Contributions. 154 | 155 | 6. Trademarks. This License does not grant permission to use the trade 156 | names, trademarks, service marks, or product names of the Licensor, 157 | except as required for reasonable and customary use in describing the 158 | origin of the Work and reproducing the content of the NOTICE file. 159 | 160 | 7. Disclaimer of Warranty. Unless required by applicable law or 161 | agreed to in writing, Licensor provides the Work (and each 162 | Contributor provides its Contributions) on an "AS IS" BASIS, 163 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 164 | implied, including, without limitation, any warranties or conditions 165 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 166 | PARTICULAR PURPOSE. You are solely responsible for determining the 167 | appropriateness of using or redistributing the Work and assume any 168 | risks associated with Your exercise of permissions under this License. 169 | 170 | 8. Limitation of Liability. In no event and under no legal theory, 171 | whether in tort (including negligence), contract, or otherwise, 172 | unless required by applicable law (such as deliberate and grossly 173 | negligent acts) or agreed to in writing, shall any Contributor be 174 | liable to You for damages, including any direct, indirect, special, 175 | incidental, or consequential damages of any character arising as a 176 | result of this License or out of the use or inability to use the 177 | Work (including but not limited to damages for loss of goodwill, 178 | work stoppage, computer failure or malfunction, or any and all 179 | other commercial damages or losses), even if such Contributor 180 | has been advised of the possibility of such damages. 181 | 182 | 9. Accepting Warranty or Additional Liability. While redistributing 183 | the Work or Derivative Works thereof, You may choose to offer, 184 | and charge a fee for, acceptance of support, warranty, indemnity, 185 | or other liability obligations and/or rights consistent with this 186 | License. However, in accepting such obligations, You may act only 187 | on Your own behalf and on Your sole responsibility, not on behalf 188 | of any other Contributor, and only if You agree to indemnify, 189 | defend, and hold each Contributor harmless for any liability 190 | incurred by, or claims asserted against, such Contributor by reason 191 | of your accepting any such warranty or additional liability. 192 | 193 | END OF TERMS AND CONDITIONS 194 | ``` -------------------------------------------------------------------------------- /libsoundtouch/device.py: -------------------------------------------------------------------------------- 1 | """Bose Soundtouch Device.""" 2 | 3 | # pylint: disable=too-many-public-methods,too-many-instance-attributes, 4 | # pylint: disable=useless-super-delegation,too-many-lines 5 | 6 | import logging 7 | import os 8 | import re 9 | import xml.etree.cElementTree as ET 10 | from xml.dom import minidom 11 | from threading import Thread 12 | 13 | import requests 14 | import websocket 15 | 16 | from libsoundtouch.utils import Source 17 | from .utils import Key, Type 18 | 19 | STATE_STANDBY = 'STANDBY' 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | def _get_dom_attribute(xml_dom, attribute, default_value=None): 25 | if attribute in xml_dom.attributes.keys(): 26 | return xml_dom.attributes[attribute].value 27 | return default_value 28 | 29 | 30 | def _get_dom_element_attribute(xml_dom, element, attribute, 31 | default_value=None): 32 | element = _get_dom_element(xml_dom, element) 33 | if element is not None: 34 | if attribute in element.attributes.keys(): 35 | return element.attributes[attribute].value 36 | return None 37 | else: 38 | return default_value 39 | 40 | 41 | def _get_dom_elements(xml_dom, element): 42 | return xml_dom.getElementsByTagName(element) 43 | 44 | 45 | def _get_dom_element(xml_dom, element): 46 | elements = _get_dom_elements(xml_dom, element) 47 | if elements: 48 | return elements[0] 49 | return None 50 | 51 | 52 | def _get_dom_element_value(xml_dom, element, default_value=None): 53 | element = _get_dom_element(xml_dom, element) 54 | if element is not None and element.firstChild is not None: 55 | return element.firstChild.nodeValue.strip() 56 | return default_value 57 | 58 | 59 | class WebSocketThread(Thread): 60 | """Websocket thread.""" 61 | 62 | def __init__(self, ws): 63 | """Create new Websocket thread.""" 64 | Thread.__init__(self) 65 | self._ws = ws 66 | 67 | def run(self): 68 | """Start Websocket thread.""" 69 | self._ws.run_forever() 70 | 71 | 72 | class SoundTouchDevice: 73 | """Bose SoundTouch Device.""" 74 | 75 | @staticmethod 76 | def __run_listener(listeners, value): 77 | """Run Listener with value.""" 78 | for listener in listeners: 79 | listener(value) 80 | 81 | def _on_message(self, web_socket, message): 82 | # pylint: disable=unused-argument 83 | """Call when web socket is received.""" 84 | dom = minidom.parseString(message.encode('utf-8')) 85 | if dom.firstChild.nodeName == "updates": 86 | action_node = dom.firstChild.firstChild 87 | action = action_node.nodeName 88 | if action == "volumeUpdated": 89 | self._volume = Volume(action_node.firstChild) 90 | self.__run_listener(self._volume_updated_listeners, 91 | self._volume) 92 | if action == "nowPlayingUpdated": 93 | self._status = Status(action_node) 94 | self.__run_listener(self._status_updated_listeners, 95 | self._status) 96 | if action == "presetsUpdated" and action_node.hasChildNodes(): 97 | self._presets = [] 98 | for preset in _get_dom_elements(dom, "preset"): 99 | self._presets.append(Preset(preset)) 100 | self.__run_listener(self._presets_updated_listeners, 101 | self._presets) 102 | if action == "zoneUpdated": 103 | self.__run_listener(self._zone_status_updated_listeners, 104 | self.zone_status(True)) 105 | if action == "infoUpdated": 106 | self.__init_config() 107 | self.__run_listener(self._device_info_updated_listeners, 108 | self._config) 109 | 110 | def __init__(self, host, port=8090, ws_port=8080, dlna_port=8091): 111 | """Create a new Soundtouch device. 112 | 113 | :param host: Host of the device 114 | :param port: Port of the device. Default 8090 115 | :param ws_port: Web socket port. Default 8080 116 | 117 | """ 118 | self._host = host 119 | self._port = port 120 | self._ws_port = ws_port 121 | self._dlna_port = dlna_port 122 | self.__init_config() 123 | self._status = None 124 | self._volume = None 125 | self._zone_status = None 126 | self._presets = None 127 | self._ws_client = None 128 | self._volume_updated_listeners = [] 129 | self._status_updated_listeners = [] 130 | self._presets_updated_listeners = [] 131 | self._zone_status_updated_listeners = [] 132 | self._device_info_updated_listeners = [] 133 | self._snapshot = None 134 | 135 | def __init_config(self): 136 | response = requests.get( 137 | "http://" + self._host + ":" + str(self._port) + "/info") 138 | response.encoding = 'UTF-8' 139 | dom = minidom.parseString(response.text.encode('utf-8')) 140 | self._config = Config(dom) 141 | 142 | def start_notification(self): 143 | """Start Websocket connection.""" 144 | self._ws_client = websocket.WebSocketApp( 145 | "ws://{0}:{1}/".format(self._host, self._ws_port), 146 | on_message=self._on_message, 147 | subprotocols=['gabbo']) 148 | ws_thread = WebSocketThread(self._ws_client) 149 | ws_thread.start() 150 | 151 | def add_volume_listener(self, listener): 152 | """Add a new volume updated listener.""" 153 | self._volume_updated_listeners.append(listener) 154 | 155 | def add_status_listener(self, listener): 156 | """Add a new status updated listener.""" 157 | self._status_updated_listeners.append(listener) 158 | 159 | def add_presets_listener(self, listener): 160 | """Add a new presets updated listener.""" 161 | self._presets_updated_listeners.append(listener) 162 | 163 | def add_zone_status_listener(self, listener): 164 | """Add a new zone status updated listener.""" 165 | self._zone_status_updated_listeners.append(listener) 166 | 167 | def add_device_info_listener(self, listener): 168 | """Add a new device info updated listener.""" 169 | self._device_info_updated_listeners.append(listener) 170 | 171 | def remove_volume_listener(self, listener): 172 | """Remove a new volume updated listener.""" 173 | if listener in self._volume_updated_listeners: 174 | self._volume_updated_listeners.remove(listener) 175 | 176 | def remove_status_listener(self, listener): 177 | """Remove a new status updated listener.""" 178 | if listener in self._status_updated_listeners: 179 | self._status_updated_listeners.remove(listener) 180 | 181 | def remove_presets_listener(self, listener): 182 | """Remove a new presets updated listener.""" 183 | if listener in self._presets_updated_listeners: 184 | self._presets_updated_listeners.remove(listener) 185 | 186 | def remove_zone_status_listener(self, listener): 187 | """Remove a new zone status updated listener.""" 188 | if listener in self._zone_status_updated_listeners: 189 | self._zone_status_updated_listeners.remove(listener) 190 | 191 | def remove_device_info_listener(self, listener): 192 | """Remove a new device info updated listener.""" 193 | if listener in self._device_info_updated_listeners: 194 | self._device_info_updated_listeners.remove(listener) 195 | 196 | def clear_volume_listeners(self): 197 | """Clear volume updated listeners.""" 198 | del self._volume_updated_listeners[:] 199 | 200 | def clear_status_listener(self): 201 | """Clear status updated listeners.""" 202 | del self._status_updated_listeners[:] 203 | 204 | def clear_presets_listeners(self): 205 | """Clear presets updated listeners.""" 206 | del self._presets_updated_listeners[:] 207 | 208 | def clear_zone_status_listeners(self): 209 | """Clear zone status updated listeners.""" 210 | del self._zone_status_updated_listeners[:] 211 | 212 | def clear_device_info_listeners(self): 213 | """Clear device info updated listener..""" 214 | del self._device_info_updated_listeners[:] 215 | 216 | @property 217 | def volume_updated_listeners(self): 218 | """Return Volume Updated listeners.""" 219 | return self._volume_updated_listeners 220 | 221 | @property 222 | def status_updated_listeners(self): 223 | """Return Status Updated listeners.""" 224 | return self._status_updated_listeners 225 | 226 | @property 227 | def presets_updated_listeners(self): 228 | """Return Presets Updated listeners.""" 229 | return self._presets_updated_listeners 230 | 231 | @property 232 | def zone_status_updated_listeners(self): 233 | """Return Zone Status Updated listeners.""" 234 | return self._zone_status_updated_listeners 235 | 236 | @property 237 | def device_info_updated_listeners(self): 238 | """Return Device Info Updated listeners.""" 239 | return self._device_info_updated_listeners 240 | 241 | def refresh_status(self): 242 | """Refresh status state.""" 243 | response = requests.get( 244 | "http://" + self._host + ":" + str(self._port) + "/now_playing") 245 | response.encoding = 'UTF-8' 246 | dom = minidom.parseString(response.text.encode('utf-8')) 247 | self._status = Status(dom) 248 | 249 | def refresh_volume(self): 250 | """Refresh volume state.""" 251 | response = requests.get( 252 | "http://" + self._host + ":" + str(self._port) + "/volume") 253 | dom = minidom.parseString(response.text) 254 | self._volume = Volume(dom) 255 | 256 | def refresh_presets(self): 257 | """Refresh presets.""" 258 | response = requests.get( 259 | "http://" + self._host + ":" + str(self._port) + "/presets") 260 | response.encoding = 'UTF-8' 261 | dom = minidom.parseString(response.text.encode('utf-8')) 262 | self._presets = [] 263 | for preset in _get_dom_elements(dom, "preset"): 264 | self._presets.append(Preset(preset)) 265 | 266 | def refresh_zone_status(self): 267 | """Refresh Zone Status.""" 268 | response = requests.get( 269 | "http://" + self._host + ":" + str(self._port) + "/getZone") 270 | dom = minidom.parseString(response.text) 271 | if _get_dom_elements(dom, "member"): 272 | self._zone_status = ZoneStatus(dom) 273 | else: 274 | self._zone_status = None 275 | 276 | def select_preset(self, preset): 277 | """Play selected preset. 278 | 279 | :param preset Selected preset. 280 | """ 281 | requests.post( 282 | 'http://' + self._host + ":" + str(self._port) + '/select', 283 | preset.source_xml.encode('utf-8')) 284 | 285 | def select_content_item(self, source, source_account=None, location=None, 286 | media_type=None): 287 | """Select specified content. 288 | 289 | :param source The source 290 | :param source_account The source account 291 | :param location The location 292 | :param media_type The media type 293 | """ 294 | attributes = {"source": source.value} 295 | if source_account: 296 | attributes["sourceAccount"] = source_account 297 | if location: 298 | attributes["location"] = location 299 | if media_type: 300 | attributes["type"] = media_type 301 | root = ET.Element("ContentItem", attributes) 302 | 303 | content = ET.tostring(root).decode("UTF-8") 304 | requests.post( 305 | 'http://' + self._host + ":" + str(self._port) + '/select', 306 | content) 307 | 308 | def select_source_aux(self): 309 | """Select AUX source.""" 310 | self.select_content_item(Source.AUX, Source.AUX.value) 311 | 312 | def select_source_bluetooth(self): 313 | """Select BLUETOOTH source.""" 314 | self.select_content_item(Source.BLUETOOTH) 315 | 316 | def _create_zone(self, slaves): 317 | if len(slaves) <= 0: 318 | raise NoSlavesException() 319 | request_body = '' % ( 320 | self.config.device_id, self.config.device_ip 321 | ) 322 | for slave in slaves: 323 | request_body += '%s' % ( 324 | slave.config.device_ip, slave.config.device_id) 325 | request_body += '' 326 | return request_body 327 | 328 | def _get_zone_request_body(self, slaves): 329 | if len(slaves) <= 0: 330 | raise NoSlavesException() 331 | request_body = '' % self.config.device_id 332 | for slave in slaves: 333 | request_body += '%s' % ( 334 | slave.config.device_ip, slave.config.device_id) 335 | request_body += '' 336 | return request_body 337 | 338 | def create_zone(self, slaves): 339 | """Create a zone (multi-room) on a master and play on specified slaves. 340 | 341 | :param slaves: List of slaves. Can not be empty 342 | 343 | """ 344 | request_body = self._create_zone(slaves) 345 | _LOGGER.info("Creating multi-room zone with master device %s", 346 | self.config.name) 347 | requests.post("http://" + self.host + ":" + str( 348 | self.port) + "/setZone", 349 | request_body) 350 | 351 | def add_zone_slave(self, slaves): 352 | """ 353 | Add slave(s) to and existing zone (multi-room). 354 | 355 | Zone must already exist and slaves array can not be empty. 356 | 357 | :param slaves: List of slaves. Can not be empty 358 | """ 359 | if self.zone_status() is None: 360 | raise NoExistingZoneException() 361 | request_body = self._get_zone_request_body(slaves) 362 | _LOGGER.info("Adding slaves to multi-room zone with master device %s", 363 | self.config.name) 364 | requests.post( 365 | "http://" + self.host + ":" + str( 366 | self.port) + "/addZoneSlave", 367 | request_body) 368 | 369 | def remove_zone_slave(self, slaves): 370 | """ 371 | Remove slave(s) from and existing zone (multi-room). 372 | 373 | Zone must already exist and slaves list can not be empty. 374 | Note: If removing last slave, the zone will be deleted and you'll have 375 | to create a new one. You will not be able to add a new slave anymore. 376 | 377 | :param slaves: List of slaves to remove 378 | 379 | """ 380 | if self.zone_status() is None: 381 | raise NoExistingZoneException() 382 | request_body = self._get_zone_request_body(slaves) 383 | _LOGGER.info("Removing slaves from multi-room zone with master " + 384 | "device %s", self.config.name) 385 | requests.post( 386 | "http://" + self.host + ":" + str( 387 | self.port) + "/removeZoneSlave", request_body) 388 | 389 | def _send_key(self, key): 390 | action = '/key' 391 | press = '%s' % key 392 | release = '%s' % key 393 | requests.post('http://' + self._host + ":" + 394 | str(self._port) + action, press) 395 | requests.post('http://' + self._host + ":" + 396 | str(self._port) + action, release) 397 | 398 | def play_media(self, source, location, source_acc=None, 399 | media_type=Type.URI): 400 | """ 401 | Start music playback from a chosen source. 402 | 403 | :param source: Source from which to play. Elements of Source enum. 404 | :param location: A unique uri or identifier. Represents the 405 | requested music from the source. 406 | :param source_acc: Source account. Imperative for some sources. 407 | For Spotify, this can be found by playing Spotify on the connected 408 | SoundTouch speaker, and calling: 409 | device.status().content_item.source_account 410 | :param media_type: Type of the requested music. Typical values are: 411 | "uri", "track", "album", "playlist". This can be found in 412 | device.status().content_item.type 413 | """ 414 | action = "/select" 415 | play = 'Select using API' \ 417 | '' % ( 418 | source.value, media_type.value, 419 | source_acc if source_acc else '', location) 420 | requests.post('http://' + self._host + ":" + 421 | str(self._port) + action, play) 422 | 423 | def play_url(self, url): 424 | """ 425 | Start music playback from an HTTP URL. 426 | 427 | Warning: HTTPS is not supported. 428 | 429 | :param url: HTTP URL to play. 430 | """ 431 | if not re.match(r'http://', url): 432 | raise SoundtouchInvalidUrlException 433 | 434 | action = "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI" 435 | headers = { 436 | "User-Agent": "libsoundtouch", 437 | "Accept": "*/*", 438 | "Content-Type": "text/xml; charset=\"utf-8\"", 439 | "HOST": "{0}:{1}".format(self.host, self.dlna_port), 440 | "SOAPACTION": action 441 | } 442 | template_file = os.path.join(os.path.dirname(__file__), 443 | 'templates/avt_transport_uri.xml') 444 | with open(template_file, 'r') as template: 445 | body = template.read().format(url) 446 | requests.post( 447 | "http://{0}:{1}/AVTransport/Control".format(self.host, 448 | self.dlna_port), 449 | data=body, headers=headers) 450 | 451 | @property 452 | def host(self): 453 | """Host of the device.""" 454 | return self._host 455 | 456 | @property 457 | def port(self): 458 | """Return API port of the device.""" 459 | return self._port 460 | 461 | @property 462 | def dlna_port(self): 463 | """Return DLNA port.""" 464 | return self._dlna_port 465 | 466 | @property 467 | def ws_port(self): 468 | """Return Web Socket port.""" 469 | return self._ws_port 470 | 471 | @property 472 | def config(self): 473 | """Get config object.""" 474 | return self._config 475 | 476 | def status(self, refresh=True): 477 | """Get status object. 478 | 479 | :param refresh: Force refresh, else return old data. 480 | """ 481 | if self._status is None or refresh: 482 | self.refresh_status() 483 | return self._status 484 | 485 | def volume(self, refresh=True): 486 | """Get volume object. 487 | 488 | :param refresh: Force refresh, else return old data. 489 | """ 490 | if self._volume is None or refresh: 491 | self.refresh_volume() 492 | return self._volume 493 | 494 | def zone_status(self, refresh=True): 495 | """Get Zone Status. 496 | 497 | :param refresh: Force refresh, else return old data. 498 | """ 499 | if self._zone_status is None or refresh: 500 | self.refresh_zone_status() 501 | return self._zone_status 502 | 503 | def presets(self, refresh=True): 504 | """Presets. 505 | 506 | :param refresh: Force refresh, else return old data. 507 | """ 508 | if self._presets is None or refresh: 509 | self.refresh_presets() 510 | return self._presets 511 | 512 | def set_volume(self, level): 513 | """Set volume level: from 0 to 100.""" 514 | action = '/volume' 515 | volume = '%s' % level 516 | requests.post('http://' + self._host + ":" + str(self._port) + action, 517 | volume) 518 | 519 | def mute(self): 520 | """Mute/Un-mute volume.""" 521 | self._send_key(Key.MUTE.value) 522 | 523 | def volume_up(self): 524 | """Volume up.""" 525 | self._send_key(Key.VOLUME_UP.value) 526 | 527 | def volume_down(self): 528 | """Volume down.""" 529 | self._send_key(Key.VOLUME_DOWN.value) 530 | 531 | def next_track(self): 532 | """Switch to next track.""" 533 | self._send_key(Key.NEXT_TRACK.value) 534 | 535 | def previous_track(self): 536 | """Switch to previous track.""" 537 | self._send_key(Key.PREV_TRACK.value) 538 | 539 | def pause(self): 540 | """Pause.""" 541 | self._send_key(Key.PAUSE.value) 542 | 543 | def play(self): 544 | """Play.""" 545 | self._send_key(Key.PLAY.value) 546 | 547 | def play_pause(self): 548 | """Toggle play status.""" 549 | self._send_key(Key.PLAY_PAUSE.value) 550 | 551 | def repeat_off(self): 552 | """Turn off repeat.""" 553 | self._send_key(Key.REPEAT_OFF.value) 554 | 555 | def repeat_one(self): 556 | """Repeat one. Doesn't work.""" 557 | self._send_key(Key.REPEAT_ONE.value) 558 | 559 | def repeat_all(self): 560 | """Repeat all.""" 561 | self._send_key(Key.REPEAT_ALL.value) 562 | 563 | def shuffle(self, shuffle): 564 | """Shuffle on/off. 565 | 566 | :param shuffle: Boolean on/off 567 | """ 568 | if shuffle: 569 | self._send_key(Key.SHUFFLE_ON.value) 570 | else: 571 | self._send_key(Key.SHUFFLE_OFF.value) 572 | 573 | def power_on(self): 574 | """Power on device.""" 575 | if self.status().source == STATE_STANDBY: 576 | self._send_key(Key.POWER.value) 577 | 578 | def power_off(self): 579 | """Power off device.""" 580 | if self.status().source != STATE_STANDBY: 581 | self._send_key(Key.POWER.value) 582 | 583 | def snapshot(self): 584 | """Snapshot current playing media.""" 585 | status = self.status(refresh=True) 586 | if status and status.content_item: 587 | self._snapshot = status.content_item 588 | 589 | def restore(self): 590 | """Restore last snapshot.""" 591 | if self._snapshot: 592 | self.select_content_item(Source[self._snapshot.source], 593 | self._snapshot.source_account, 594 | self._snapshot.location, 595 | self._snapshot.type) 596 | 597 | 598 | class Config: 599 | """Soundtouch device configuration.""" 600 | 601 | def __init__(self, xml_dom): 602 | """Create a new configuration. 603 | 604 | :param xml_dom: Configuration XML DOM 605 | """ 606 | self._id = _get_dom_element_attribute(xml_dom, "info", "deviceID") 607 | self._name = _get_dom_element_value(xml_dom, "name") 608 | self._type = _get_dom_element_value(xml_dom, "type") 609 | self._account_uuid = _get_dom_element_value(xml_dom, 610 | "margeAccountUUID") 611 | self._module_type = _get_dom_element_value(xml_dom, "moduleType") 612 | self._variant = _get_dom_element_value(xml_dom, "variant") 613 | self._variant_mode = _get_dom_element_value(xml_dom, "variantMode") 614 | self._country_code = _get_dom_element_value(xml_dom, "countryCode") 615 | self._region_code = _get_dom_element_value(xml_dom, "regionCode") 616 | self._networks = [] 617 | for network in xml_dom.getElementsByTagName("networkInfo"): 618 | self._networks.append(Network(network)) 619 | self._components = [] 620 | for components in _get_dom_elements(xml_dom, "components"): 621 | for component in _get_dom_elements(components, "component"): 622 | self._components.append(Component(component)) 623 | 624 | @property 625 | def device_id(self): 626 | """Device ID.""" 627 | return self._id 628 | 629 | @property 630 | def name(self): 631 | """Device name.""" 632 | return self._name 633 | 634 | @property 635 | def type(self): 636 | """Device type.""" 637 | return self._type 638 | 639 | @property 640 | def networks(self): 641 | """Network.""" 642 | return self._networks 643 | 644 | @property 645 | def components(self): 646 | """Components.""" 647 | return self._components 648 | 649 | @property 650 | def account_uuid(self): 651 | """Account UUID.""" 652 | return self._account_uuid 653 | 654 | @property 655 | def module_type(self): 656 | """Return module type.""" 657 | return self._module_type 658 | 659 | @property 660 | def variant(self): 661 | """Variant.""" 662 | return self._variant 663 | 664 | @property 665 | def variant_mode(self): 666 | """Variant mode.""" 667 | return self._variant_mode 668 | 669 | @property 670 | def country_code(self): 671 | """Country code.""" 672 | return self._country_code 673 | 674 | @property 675 | def region_code(self): 676 | """Region code.""" 677 | return self._region_code 678 | 679 | @property 680 | def device_ip(self): 681 | """Ip.""" 682 | network = next( 683 | (network for network in self._networks if network.type == "SMSC"), 684 | next((network for network in self._networks), None)) 685 | return network.ip_address if network else None 686 | 687 | @property 688 | def mac_address(self): 689 | """Mac address.""" 690 | network = next( 691 | (network for network in self._networks if network.type == "SMSC"), 692 | next((network for network in self._networks), None)) 693 | return network.mac_address if network else None 694 | 695 | 696 | class Network: 697 | """Soundtouch network configuration.""" 698 | 699 | def __init__(self, network_dom): 700 | """Create a new Network. 701 | 702 | :param network_dom: Network configuration XML DOM 703 | """ 704 | self._type = network_dom.attributes["type"].value 705 | self._mac_address = _get_dom_element_value(network_dom, "macAddress") 706 | self._ip_address = _get_dom_element_value(network_dom, "ipAddress") 707 | 708 | @property 709 | def type(self): 710 | """Type.""" 711 | return self._type 712 | 713 | @property 714 | def mac_address(self): 715 | """Mac Address.""" 716 | return self._mac_address 717 | 718 | @property 719 | def ip_address(self): 720 | """IP Address.""" 721 | return self._ip_address 722 | 723 | 724 | class Component: 725 | """Soundtouch component.""" 726 | 727 | def __init__(self, component_dom): 728 | """Create a new Component. 729 | 730 | :param component_dom: Component XML DOM 731 | """ 732 | self._category = _get_dom_element_value(component_dom, 733 | "componentCategory") 734 | self._software_version = _get_dom_element_value(component_dom, 735 | "softwareVersion") 736 | self._serial_number = _get_dom_element_value(component_dom, 737 | "serialNumber") 738 | 739 | @property 740 | def category(self): 741 | """Category.""" 742 | return self._category 743 | 744 | @property 745 | def software_version(self): 746 | """Software version.""" 747 | return self._software_version 748 | 749 | @property 750 | def serial_number(self): 751 | """Return serial number.""" 752 | return self._serial_number 753 | 754 | 755 | class Status: 756 | """Soundtouch device status.""" 757 | 758 | def __init__(self, xml_dom): 759 | """Create a new device status. 760 | 761 | :param xml_dom: Status XML DOM 762 | """ 763 | self._source = _get_dom_element_attribute(xml_dom, "nowPlaying", 764 | "source") 765 | self._content_item = None 766 | content_item = xml_dom.getElementsByTagName("ContentItem") 767 | if content_item: 768 | self._content_item = ContentItem( 769 | _get_dom_element(xml_dom, "ContentItem")) 770 | self._track = _get_dom_element_value(xml_dom, "track") 771 | self._artist = _get_dom_element_value(xml_dom, "artist") 772 | self._album = _get_dom_element_value(xml_dom, "album") 773 | image_status = _get_dom_element_attribute(xml_dom, "art", 774 | "artImageStatus") 775 | if image_status == "IMAGE_PRESENT": 776 | self._image = _get_dom_element_value(xml_dom, "art") 777 | else: 778 | self._image = None 779 | 780 | duration = _get_dom_element_attribute(xml_dom, "time", "total") 781 | self._duration = int(duration) if duration is not None else None 782 | position = _get_dom_element_value(xml_dom, "time") 783 | self._position = int(position) if position is not None else None 784 | self._play_status = _get_dom_element_value(xml_dom, "playStatus") 785 | self._shuffle_setting = _get_dom_element_value(xml_dom, 786 | "shuffleSetting") 787 | self._repeat_setting = _get_dom_element_value(xml_dom, "repeatSetting") 788 | self._stream_type = _get_dom_element_value(xml_dom, "streamType") 789 | self._track_id = _get_dom_element_value(xml_dom, "trackID") 790 | self._station_name = _get_dom_element_value(xml_dom, "stationName") 791 | self._description = _get_dom_element_value(xml_dom, "description") 792 | self._station_location = _get_dom_element_value(xml_dom, 793 | "stationLocation") 794 | 795 | @property 796 | def source(self): 797 | """Source.""" 798 | return self._source 799 | 800 | @property 801 | def content_item(self): 802 | """Content item.""" 803 | return self._content_item 804 | 805 | @property 806 | def track(self): 807 | """Track.""" 808 | return self._track 809 | 810 | @property 811 | def artist(self): 812 | """Artist.""" 813 | return self._artist 814 | 815 | @property 816 | def album(self): 817 | """Album name.""" 818 | return self._album 819 | 820 | @property 821 | def image(self): 822 | """Image URL.""" 823 | return self._image 824 | 825 | @property 826 | def duration(self): 827 | """Duration.""" 828 | return self._duration 829 | 830 | @property 831 | def position(self): 832 | """Position.""" 833 | return self._position 834 | 835 | @property 836 | def play_status(self): 837 | """Status.""" 838 | return self._play_status 839 | 840 | @property 841 | def shuffle_setting(self): 842 | """Shuffle setting.""" 843 | return self._shuffle_setting 844 | 845 | @property 846 | def repeat_setting(self): 847 | """Repeat setting.""" 848 | return self._repeat_setting 849 | 850 | @property 851 | def stream_type(self): 852 | """Stream type.""" 853 | return self._stream_type 854 | 855 | @property 856 | def track_id(self): 857 | """Track id.""" 858 | return self._track_id 859 | 860 | @property 861 | def station_name(self): 862 | """Station name.""" 863 | return self._station_name 864 | 865 | @property 866 | def description(self): 867 | """Description.""" 868 | return self._description 869 | 870 | @property 871 | def station_location(self): 872 | """Station location.""" 873 | return self._station_location 874 | 875 | def __repr__(self): 876 | """Return a String representation.""" 877 | fields = [str(self.source.encode('utf-8')), str(self.content_item), 878 | str(self.track), str(self.artist), str(self.album), 879 | str(self.artist), str(self.image), str(self.duration), 880 | str(self.position), str(self.duration), 881 | str(self.play_status), str(self.shuffle_setting), 882 | str(self.repeat_setting), str(self.stream_type), 883 | str(self.track_id), str(self.station_name), 884 | str(self.station_location)] 885 | return 'Status(' + ",".join(fields) + ')' 886 | 887 | 888 | class ContentItem: 889 | """Content item.""" 890 | 891 | def __init__(self, xml_dom): 892 | """Create a new content item. 893 | 894 | :param xml_dom: Content item XML DOM 895 | """ 896 | self._name = _get_dom_element_value(xml_dom, "itemName") 897 | self._source = _get_dom_attribute(xml_dom, "source") 898 | self._type = _get_dom_attribute(xml_dom, "type") 899 | self._location = _get_dom_attribute(xml_dom, "location") 900 | self._source_account = _get_dom_attribute(xml_dom, "sourceAccount") 901 | self._is_presetable = _get_dom_attribute(xml_dom, 902 | "isPresetable") == 'true' 903 | 904 | @property 905 | def name(self): 906 | """Name.""" 907 | return self._name 908 | 909 | @property 910 | def source(self): 911 | """Source.""" 912 | return self._source 913 | 914 | @property 915 | def type(self): 916 | """Type.""" 917 | return self._type 918 | 919 | @property 920 | def location(self): 921 | """Location.""" 922 | return self._location 923 | 924 | @property 925 | def source_account(self): 926 | """Source account.""" 927 | return self._source_account 928 | 929 | @property 930 | def is_presetable(self): 931 | """Return true if presetable.""" 932 | return self._is_presetable 933 | 934 | def __repr__(self): 935 | """Return a String representation.""" 936 | fields = [self.name.encode('UTF-8') if self.name else self.name, 937 | self.source, self.location, self.source_account, 938 | self.is_presetable] 939 | formated_fields = [str(f) for f in fields] 940 | return 'ContentItem(' + ",".join(formated_fields) + ')' 941 | 942 | 943 | class Volume: 944 | """Volume configuration.""" 945 | 946 | def __init__(self, xml_dom): 947 | """Create a new volume configuration. 948 | 949 | :param xml_dom: Volume configuration XML DOM 950 | """ 951 | self._actual = int(_get_dom_element_value(xml_dom, "actualvolume")) 952 | self._target = int(_get_dom_element_value(xml_dom, "targetvolume")) 953 | self._muted = _get_dom_element_value(xml_dom, "muteenabled") == "true" 954 | 955 | @property 956 | def actual(self): 957 | """Actual volume level.""" 958 | return self._actual 959 | 960 | @property 961 | def target(self): 962 | """Target volume level.""" 963 | return self._target 964 | 965 | @property 966 | def muted(self): 967 | """Return True if volume is muted.""" 968 | return self._muted 969 | 970 | 971 | class Preset: 972 | """Preset.""" 973 | 974 | def __init__(self, preset_dom): 975 | """Create a preset configuration. 976 | 977 | :param preset_dom: Preset configuration XML DOM 978 | """ 979 | self._name = _get_dom_element_value(preset_dom, "itemName") 980 | self._id = _get_dom_attribute(preset_dom, "id") 981 | self._source = _get_dom_element_attribute(preset_dom, "ContentItem", 982 | "source") 983 | self._type = _get_dom_element_attribute(preset_dom, "ContentItem", 984 | "type") 985 | self._location = _get_dom_element_attribute(preset_dom, "ContentItem", 986 | "location") 987 | self._source_account = _get_dom_element_attribute(preset_dom, 988 | "ContentItem", 989 | "sourceAccount") 990 | self._is_presetable = \ 991 | _get_dom_element_attribute(preset_dom, 992 | "ContentItem", 993 | "isPresetable") == "true" 994 | self._source_xml = _get_dom_element(preset_dom, "ContentItem").toxml() 995 | 996 | @property 997 | def name(self): 998 | """Name.""" 999 | return self._name 1000 | 1001 | @property 1002 | def preset_id(self): 1003 | """Id.""" 1004 | return self._id 1005 | 1006 | @property 1007 | def source(self): 1008 | """Source.""" 1009 | return self._source 1010 | 1011 | @property 1012 | def type(self): 1013 | """Type.""" 1014 | return self._type 1015 | 1016 | @property 1017 | def location(self): 1018 | """Location.""" 1019 | return self._location 1020 | 1021 | @property 1022 | def source_account(self): 1023 | """Source account.""" 1024 | return self._source_account 1025 | 1026 | @property 1027 | def is_presetable(self): 1028 | """Return True if is presetable.""" 1029 | return self._is_presetable 1030 | 1031 | @property 1032 | def source_xml(self): 1033 | """XML source.""" 1034 | return self._source_xml 1035 | 1036 | def __repr__(self): 1037 | """Return a String representation.""" 1038 | fields = [self.name if self.name else self.name, 1039 | self.preset_id, self.source, self.type, 1040 | self.location, self.source_account, 1041 | self.source_xml.encode('utf-8')] 1042 | formated_fields = [str(f) for f in fields] 1043 | return 'Preset(' + ",".join(formated_fields) + ')' 1044 | 1045 | 1046 | class ZoneStatus: 1047 | """Zone Status.""" 1048 | 1049 | def __init__(self, zone_dom): 1050 | """Create a new Zone status configuration. 1051 | 1052 | :param zone_dom: Zone status configuration XML DOM 1053 | """ 1054 | self._master_id = _get_dom_element_attribute(zone_dom, "zone", 1055 | "master") 1056 | self._master_ip = _get_dom_element_attribute(zone_dom, "zone", 1057 | "senderIPAddress") 1058 | self._is_master = self._master_ip is None 1059 | members = _get_dom_elements(zone_dom, "member") 1060 | self._slaves = [] 1061 | for member in members: 1062 | self._slaves.append(ZoneSlave(member)) 1063 | 1064 | @property 1065 | def master_id(self): 1066 | """Master id.""" 1067 | return self._master_id 1068 | 1069 | @property 1070 | def is_master(self): 1071 | """Return True if current device is the zone master.""" 1072 | return self._is_master 1073 | 1074 | @property 1075 | def master_ip(self): 1076 | """Master ip.""" 1077 | return self._master_ip 1078 | 1079 | @property 1080 | def slaves(self): 1081 | """Zone slaves.""" 1082 | return self._slaves 1083 | 1084 | 1085 | class ZoneSlave: 1086 | """Zone Slave.""" 1087 | 1088 | def __init__(self, member_dom): 1089 | """Create a new Zone slave configuration. 1090 | 1091 | :param member_dom: Slave XML DOM 1092 | """ 1093 | self._ip = _get_dom_attribute(member_dom, "ipaddress") 1094 | self._role = _get_dom_attribute(member_dom, "role") 1095 | 1096 | @property 1097 | def device_ip(self): 1098 | """Slave ip.""" 1099 | return self._ip 1100 | 1101 | @property 1102 | def role(self): 1103 | """Slave role.""" 1104 | return self._role 1105 | 1106 | 1107 | class SoundtouchException(Exception): 1108 | """Parent Soundtouch Exception.""" 1109 | 1110 | def __init__(self): 1111 | """Soundtouch Exception.""" 1112 | super(SoundtouchException, self).__init__() 1113 | 1114 | 1115 | class NoExistingZoneException(SoundtouchException): 1116 | """Exception while trying to add slave(s) without existing zone.""" 1117 | 1118 | def __init__(self): 1119 | """NoExistingZoneException.""" 1120 | super(NoExistingZoneException, self).__init__() 1121 | 1122 | 1123 | class NoSlavesException(SoundtouchException): 1124 | """Exception while managing multi-room actions without valid slaves.""" 1125 | 1126 | def __init__(self): 1127 | """NoSlavesException.""" 1128 | super(NoSlavesException, self).__init__() 1129 | 1130 | 1131 | class SoundtouchInvalidUrlException(SoundtouchException): 1132 | """Exception while trying to play an invalid URL.""" 1133 | 1134 | def __init__(self): 1135 | """SoundtouchInvalidUrlException.""" 1136 | super(SoundtouchInvalidUrlException, self).__init__() 1137 | -------------------------------------------------------------------------------- /tests/test_libsoundtouch.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | import time 5 | 6 | import libsoundtouch 7 | from libsoundtouch.device import NoSlavesException, NoExistingZoneException, \ 8 | Preset, Config, SoundTouchDevice, SoundtouchInvalidUrlException 9 | from libsoundtouch.utils import Source, Type 10 | import logging 11 | import codecs 12 | 13 | try: 14 | from mock import Mock, mock 15 | except ImportError: 16 | from unittest import mock 17 | from unittest.mock import Mock 18 | 19 | from xml.dom import minidom 20 | from requests.models import Response 21 | import zeroconf 22 | 23 | 24 | class MockResponse(Response): 25 | """Mock Soundtouch XML response.""" 26 | 27 | def __init__(self, text): 28 | """Create new XML response.""" 29 | 30 | self._content = text.encode('utf-8') 31 | self.encoding = None 32 | 33 | 34 | class MockDevice(SoundTouchDevice): 35 | def __init__(self, host, port=8090): 36 | self._host = host 37 | self._port = port 38 | self._zone_status = None 39 | self._config = None 40 | self._status = None 41 | self._volume = None 42 | self._presets = None 43 | self._ws_port = 8080 44 | self._dlna_port = 8091 45 | self._volume_updated_listeners = [] 46 | self._status_updated_listeners = [] 47 | self._presets_updated_listeners = [] 48 | self._zone_status_updated_listeners = [] 49 | self._device_info_updated_listeners = [] 50 | 51 | def set_base_config(self, ip, id): 52 | xml = """ 53 | 54 | 55 | %s 56 | %s 57 | 58 | """ % (id, id, ip) 59 | dom = minidom.parseString(xml) 60 | self._config = Config(dom) 61 | pass 62 | 63 | 64 | class MockPreset(Preset): 65 | def __init__(self, _source_xml): 66 | self._source_xml = _source_xml 67 | 68 | 69 | def _mocked_device_info(*args, **kwargs): 70 | if args[0] == 'http://192.168.1.1:8090/info': 71 | codecs_open = codecs.open("tests/data/device_info.xml", "r", "utf-8") 72 | try: 73 | return MockResponse(codecs_open.read()) 74 | finally: 75 | codecs_open.close() 76 | 77 | 78 | def _mocked_device_info_utf8(*args, **kwargs): 79 | if args[0] == 'http://192.168.1.1:8090/info': 80 | codecs_open = codecs.open("tests/data/device_info_utf8.xml", "r", 81 | "utf-8") 82 | try: 83 | return MockResponse(codecs_open.read()) 84 | finally: 85 | codecs_open.close() 86 | 87 | 88 | def _mocked_device_info_without_values(*args, **kwargs): 89 | if args[0] == 'http://192.168.1.1:8090/info': 90 | return MockResponse(""" 91 | 92 | 93 | 94 | SCM 95 | 96 | 13.0.9.29919.1889959 epdbuild.trunk.cepeswbldXXX 97 | 98 | XXXXX 99 | 100 | 101 | PackagedProduct 102 | XXXXX 103 | 104 | 105 | https://streaming.bose.com 106 | 107 | 00112233445566 108 | 192.168.1.2 109 | 110 | 111 | 66554433221100 112 | 192.168.1.1 113 | 114 | """) 115 | 116 | 117 | def _mocked_status_spotify(*args, **kwargs): 118 | if args[0] == 'http://192.168.1.1:8090/now_playing': 119 | codecs_open = codecs.open("tests/data/spotify.xml", "r", "utf-8") 120 | try: 121 | return MockResponse(codecs_open.read()) 122 | finally: 123 | codecs_open.close() 124 | 125 | 126 | def _mocked_status_spotify_utf8(*args, **kwargs): 127 | if args[0] == 'http://192.168.1.1:8090/now_playing': 128 | codecs_open = codecs.open("tests/data/spotify_utf8.xml", "r", "utf-8") 129 | try: 130 | return MockResponse(codecs_open.read()) 131 | finally: 132 | codecs_open.close() 133 | 134 | 135 | def _mocked_status_radio(*args, **kwargs): 136 | codecs_open = codecs.open("tests/data/radio.xml", "r", "utf-8") 137 | try: 138 | return MockResponse(codecs_open.read()) 139 | finally: 140 | codecs_open.close() 141 | 142 | 143 | def _mocked_status_radio_non_ascii(*args, **kwargs): 144 | codecs_open = codecs.open("tests/data/radio_utf8.xml", "r", "utf-8") 145 | try: 146 | return MockResponse(codecs_open.read()) 147 | finally: 148 | codecs_open.close() 149 | 150 | 151 | def _mocked_status_stored_music(*args, **kwargs): 152 | codecs_open = codecs.open("tests/data/stored_music.xml", "r", "utf-8") 153 | try: 154 | return MockResponse(codecs_open.read()) 155 | finally: 156 | codecs_open.close() 157 | 158 | 159 | def _mocked_status_standby(*args, **kwargs): 160 | if (args[0] == "http://192.168.1.1:8090/now_playing"): 161 | return MockResponse(""" 162 | 163 | 164 | """) 165 | 166 | 167 | def _mocked_status_spotify_buffering(*args, **kwargs): 168 | if args[0] == 'http://192.168.1.1:8090/now_playing': 169 | codecs_open = codecs.open( 170 | "tests/data/spotify_buffering.xml", "r", "utf-8") 171 | try: 172 | return MockResponse(codecs_open.read()) 173 | finally: 174 | codecs_open.close() 175 | 176 | 177 | def _mocked_volume(*args, **kwargs): 178 | if (args[0] == "http://192.168.1.1:8090/volume"): 179 | return MockResponse(""" 180 | 181 | 26 182 | 25 183 | false 184 | """) 185 | 186 | 187 | def _mocked_play(*args, **kwargs): 188 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 189 | 'PLAY', 190 | 'PLAY' 191 | ]: 192 | raise Exception("Unknown call") 193 | 194 | 195 | def _mocked_play_media_without_account(*args, **kwargs): 196 | if args[0] != "http://192.168.1.1:8090/select" or \ 197 | args[1] != '' \ 199 | 'Select using API' \ 200 | '': 201 | raise Exception("Unknown call") 202 | 203 | 204 | def _mocked_play_media_with_account(*args, **kwargs): 205 | if args[0] != "http://192.168.1.1:8090/select" or \ 206 | args[1] != '' \ 209 | 'Select using API': 210 | raise Exception("Unknown call") 211 | 212 | 213 | def _mocked_play_media_with_type(*args, **kwargs): 214 | if args[0] != "http://192.168.1.1:8090/select" or \ 215 | args[1] != '' \ 218 | 'Select using API': 219 | raise Exception("Unknown call") 220 | 221 | 222 | def _mocked_pause(*args, **kwargs): 223 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 224 | 'PAUSE', 225 | 'PAUSE' 226 | ]: 227 | raise Exception("Unknown call") 228 | 229 | 230 | def _mocked_play_pause(*args, **kwargs): 231 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 232 | 'PLAY_PAUSE', 233 | 'PLAY_PAUSE' 234 | ]: 235 | raise Exception("Unknown call") 236 | 237 | 238 | def _mocked_play_url(*args, **kwargs): 239 | assert args[0] == "http://192.168.1.1:8091/AVTransport/Control" 240 | action = "urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI" 241 | assert kwargs['headers']['SOAPACTION'] == action 242 | assert kwargs['headers']['Content-Type'] == 'text/xml; charset="utf-8"' 243 | assert kwargs['headers']['HOST'] == '192.168.1.1:8091' 244 | dom = minidom.parseString(kwargs['data']) 245 | url = dom.getElementsByTagName("CurrentURI")[0].firstChild.nodeValue 246 | assert url == "http://fqdn/file.mp3" 247 | 248 | 249 | def _mocked_power(*args, **kwargs): 250 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 251 | 'POWER', 252 | 'POWER' 253 | ]: 254 | raise Exception("Unknown call") 255 | 256 | 257 | def _mocked_set_volume(*args, **kwargs): 258 | if args[0] != "http://192.168.1.1:8090/volume" or args[1] not in [ 259 | '10', 260 | ]: 261 | raise Exception("Unknown call") 262 | 263 | 264 | def _mocked_volume_up(*args, **kwargs): 265 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 266 | 'VOLUME_UP', 267 | 'VOLUME_UP' 268 | ]: 269 | raise Exception("Unknown call") 270 | 271 | 272 | def _mocked_volume_down(*args, **kwargs): 273 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 274 | 'VOLUME_DOWN', 275 | 'VOLUME_DOWN' 276 | ]: 277 | raise Exception("Unknown call") 278 | 279 | 280 | def _mocked_next_track(*args, **kwargs): 281 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 282 | 'NEXT_TRACK', 283 | 'NEXT_TRACK' 284 | ]: 285 | raise Exception("Unknown call") 286 | 287 | 288 | def _mocked_previous_track(*args, **kwargs): 289 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 290 | 'PREV_TRACK', 291 | 'PREV_TRACK' 292 | ]: 293 | raise Exception("Unknown call") 294 | 295 | 296 | def _mocked_mute(*args, **kwargs): 297 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 298 | 'MUTE', 299 | 'MUTE' 300 | ]: 301 | raise Exception("Unknown call") 302 | 303 | 304 | def _mocked_repeat_one(*args, **kwargs): 305 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 306 | 'REPEAT_ONE', 307 | 'REPEAT_ONE' 308 | ]: 309 | raise Exception("Unknown call") 310 | 311 | 312 | def _mocked_repeat_off(*args, **kwargs): 313 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 314 | 'REPEAT_OFF', 315 | 'REPEAT_OFF' 316 | ]: 317 | raise Exception("Unknown call") 318 | 319 | 320 | def _mocked_repeat_all(*args, **kwargs): 321 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 322 | 'REPEAT_ALL', 323 | 'REPEAT_ALL' 324 | ]: 325 | raise Exception("Unknown call") 326 | 327 | 328 | def _mocked_shuffle_on(*args, **kwargs): 329 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 330 | 'SHUFFLE_ON', 331 | 'SHUFFLE_ON' 332 | ]: 333 | raise Exception("Unknown call") 334 | 335 | 336 | def _mocked_shuffle_off(*args, **kwargs): 337 | if args[0] != "http://192.168.1.1:8090/key" or args[1] not in [ 338 | 'SHUFFLE_OFF', 339 | 'SHUFFLE_OFF' 340 | ]: 341 | raise Exception("Unknown call") 342 | 343 | 344 | def _mocked_zone_status_master(*args, **kwargs): 345 | if (args[0] == "http://192.168.1.1:8090/getZone"): 346 | return MockResponse(""" 347 | 348 | 1111SLAVE 349 | """) 350 | 351 | 352 | def _mocked_zone_status_slave(*args, **kwargs): 353 | if (args[0] == "http://192.168.1.2:8090/getZone"): 354 | return MockResponse(""" 355 | 357 | 1111SLAVE 358 | """) 359 | 360 | 361 | def _mocked_zone_status_none(*args, **kwargs): 362 | if (args[0] == "http://192.168.1.1:8090/getZone"): 363 | return MockResponse(""" 364 | """) 365 | 366 | 367 | def _mocked_presets(*args, **kwargs): 368 | if (args[0] == "http://192.168.1.1:8090/presets"): 369 | return MockResponse(""" 370 | 371 | 372 | 375 | Zedd 376 | 377 | 378 | 379 | 382 | Afternoon Accoustic 383 | 384 | 385 | 386 | 389 | Rock Ballads 390 | 391 | 392 | 393 | 396 | Metallica 397 | 398 | 399 | 400 | 402 | RMC Info Talk Sport 403 | 404 | 405 | 406 | 408 | France Info 409 | 410 | 411 | """) 412 | 413 | 414 | def _mocked_select_preset(*args, **kwargs): 415 | if args[0] != "http://192.168.1.1:8090/select" or args[1] not in [ 416 | 'source'.encode('utf-8') 417 | ]: 418 | raise Exception("Unknown call") 419 | 420 | 421 | def _mocked_create_zone(*args, **kwargs): 422 | if (args[0] != "http://192.168.1.1:8090/setZone" or args[ 423 | 1] != '' 425 | '' 426 | '1111SLAVE'): 427 | raise Exception("Bad argument") 428 | 429 | 430 | def _mocked_remove_slaves(*args, **kwargs): 431 | if (args[0] != 'http://192.168.1.1:8090/removeZoneSlave' or args[ 432 | 1] != '' 433 | '' 434 | '1111SLAVE'): 435 | raise Exception("Bad argument") 436 | 437 | 438 | def _mocked_add_slaves(*args, **kwargs): 439 | if (args[0] != 'http://192.168.1.1:8090/addZoneSlave' or args[ 440 | 1] != '' 441 | '' 442 | '1111SLAVE'): 443 | raise Exception("Bad argument") 444 | 445 | 446 | def _mocked_service_browser(zc, search, listener): 447 | assert isinstance(zc, zeroconf.Zeroconf) 448 | assert search == "_soundtouch._tcp.local." 449 | mock_zeroconf = mock.MagicMock() 450 | service_info = mock.MagicMock() 451 | service_info.port = 8090 452 | mock_zeroconf.get_service_info.return_value = service_info 453 | listener.add_service(mock_zeroconf, '', 'device.tcp') 454 | 455 | 456 | def _mocked_select_bluetooth(*args, **kwargs): 457 | if args[0] != "http://192.168.1.1:8090/select" or args[1] not in [ 458 | '' 459 | ]: 460 | raise Exception("Unknown call") 461 | 462 | 463 | def _mocked_select_aux(*args, **kwargs): 464 | if args[0] != "http://192.168.1.1:8090/select" or args[1] not in [ 465 | '' 466 | ]: 467 | raise Exception("Unknown call") 468 | 469 | 470 | def _mocked_select_content_item(*args, **kwargs): 471 | if args[0] != "http://192.168.1.1:8090/select" or args[1] not in [ 472 | '' 475 | ]: 476 | raise Exception("Unknown call") 477 | 478 | 479 | class TestLibSoundTouch(unittest.TestCase): 480 | def setUp(self): # pylint: disable=invalid-name 481 | """Setup things to be run when tests are started.""" 482 | logging.disable(logging.DEBUG) 483 | 484 | def tearDown(self): # pylint: disable=invalid-name 485 | """Stop everything that was started.""" 486 | logging.disable(logging.NOTSET) 487 | 488 | @mock.patch('requests.get', side_effect=_mocked_device_info) 489 | def test_init_device(self, mocked_device_info): 490 | device = libsoundtouch.soundtouch_device("192.168.1.1") 491 | self.assertEqual(mocked_device_info.call_count, 1) 492 | self.assertEqual(device.host, "192.168.1.1") 493 | self.assertEqual(device.port, 8090) 494 | self.assertEqual(device.ws_port, 8080) 495 | self.assertEqual(device.dlna_port, 8091) 496 | self.assertEqual(device.config.device_id, "00112233445566") 497 | self.assertEqual(device.config.device_ip, "192.168.1.1") 498 | self.assertEqual(device.config.mac_address, "66554433221100") 499 | self.assertEqual(device.config.name, "Home") 500 | self.assertEqual(device.config.type, "SoundTouch 20") 501 | self.assertEqual(device.config.account_uuid, "AccountUUIDValue") 502 | self.assertEqual(device.config.module_type, "sm2") 503 | self.assertEqual(device.config.variant, "spotty") 504 | self.assertEqual(device.config.variant_mode, "normal") 505 | self.assertEqual(device.config.country_code, "GB") 506 | self.assertEqual(device.config.region_code, "GB") 507 | self.assertEqual(len(device.config.networks), 2) 508 | self.assertEqual(len(device.config.components), 2) 509 | self.assertListEqual( 510 | [component.category for component in device.config.components], 511 | ['SCM', 'PackagedProduct']) 512 | self.assertListEqual( 513 | [component.serial_number for component in 514 | device.config.components], 515 | ['XXXXX', 'YYYYY']) 516 | self.assertListEqual( 517 | [component.software_version for component in 518 | device.config.components], 519 | ['13.0.9.29919.1889959 epdbuild.trunk.cepeswbldXXX', None]) 520 | 521 | @mock.patch('requests.get', side_effect=_mocked_device_info_utf8) 522 | def test_init_device_utf8(self, mocked_device_info): 523 | device = libsoundtouch.soundtouch_device("192.168.1.1") 524 | self.assertEqual(mocked_device_info.call_count, 1) 525 | self.assertEqual(device.host, "192.168.1.1") 526 | self.assertEqual(device.port, 8090) 527 | self.assertEqual(device.ws_port, 8080) 528 | self.assertEqual(device.dlna_port, 8091) 529 | self.assertEqual(device.config.device_id, "00112233445566") 530 | self.assertEqual(device.config.device_ip, "192.168.1.1") 531 | self.assertEqual(device.config.mac_address, "66554433221100") 532 | self.assertEqual(device.config.name, u'Küche') 533 | self.assertEqual(device.config.type, "SoundTouch 20") 534 | self.assertEqual(device.config.account_uuid, "AccountUUIDValue") 535 | self.assertEqual(device.config.module_type, "sm2") 536 | self.assertEqual(device.config.variant, "spotty") 537 | self.assertEqual(device.config.variant_mode, "normal") 538 | self.assertEqual(device.config.country_code, "GB") 539 | self.assertEqual(device.config.region_code, "GB") 540 | self.assertEqual(len(device.config.networks), 2) 541 | self.assertEqual(len(device.config.components), 2) 542 | self.assertListEqual( 543 | [component.category for component in device.config.components], 544 | ['SCM', 'PackagedProduct']) 545 | self.assertListEqual( 546 | [component.serial_number for component in 547 | device.config.components], 548 | ['XXXXX', 'YYYYY']) 549 | self.assertListEqual( 550 | [component.software_version for component in 551 | device.config.components], 552 | ['13.0.9.29919.1889959 epdbuild.trunk.cepeswbldXXX', None]) 553 | 554 | @mock.patch('requests.get', side_effect=_mocked_device_info_without_values) 555 | def test_init_device_with_none_values(self, mocked_device_info): 556 | device = libsoundtouch.soundtouch_device("192.168.1.1") 557 | self.assertEqual(mocked_device_info.call_count, 1) 558 | self.assertIsNone(device.config.device_id) 559 | self.assertIsNone(device.config.name) 560 | self.assertIsNone(device.config.type) 561 | self.assertIsNone(device.config.account_uuid) 562 | self.assertIsNone(device.config.module_type) 563 | self.assertIsNone(device.config.variant) 564 | self.assertIsNone(device.config.variant_mode) 565 | self.assertIsNone(device.config.country_code) 566 | self.assertIsNone(device.config.region_code) 567 | 568 | @mock.patch('requests.get', side_effect=_mocked_status_spotify) 569 | def test_status_spotify(self, mocked_device_status): 570 | device = MockDevice("192.168.1.1") 571 | status = device.status() 572 | self.assertEqual(status.source, "SPOTIFY") 573 | self.assertEqual(status.content_item.name, "Metallica") 574 | self.assertEqual(status.content_item.source, "SPOTIFY") 575 | self.assertEqual(status.content_item.type, "uri") 576 | self.assertEqual(status.content_item.location, 577 | "spotify:artist:2ye2Wgw4gimLv2eAKyk1NB") 578 | self.assertEqual(status.content_item.source_account, 579 | "spotify_account") 580 | self.assertEqual(status.content_item.is_presetable, True) 581 | self.assertEqual(status.track, "Nothing Else Matters (Live)") 582 | self.assertEqual(status.artist, "Metallica") 583 | album = "Metallica Through The Never (Music from the Motion Picture)" 584 | self.assertEqual(status.album, album) 585 | image = "http://i.scdn.co/image/1362a06f43" 586 | self.assertEqual(status.image, image) 587 | self.assertEqual(status.duration, 441) 588 | self.assertEqual(status.position, 402) 589 | self.assertEqual(status.play_status, "PLAY_STATE") 590 | self.assertEqual(status.shuffle_setting, "SHUFFLE_OFF") 591 | self.assertEqual(status.repeat_setting, "REPEAT_OFF") 592 | self.assertEqual(status.stream_type, "TRACK_ONDEMAND") 593 | self.assertEqual(status.track_id, 594 | "spotify:track:1HoBsGG0Ss2Wv5Ky8pkCEf") 595 | self.assertEqual(mocked_device_status.call_count, 1) 596 | # Force refresh 597 | self.assertEqual(device.status().source, "SPOTIFY") 598 | self.assertEqual(device.status().content_item.name, "Metallica") 599 | self.assertEqual(mocked_device_status.call_count, 3) 600 | 601 | # Don't refresh 602 | self.assertEqual(device.status(refresh=False).source, "SPOTIFY") 603 | self.assertEqual(device.status(refresh=False).content_item.name, 604 | "Metallica") 605 | self.assertEqual(mocked_device_status.call_count, 3) 606 | 607 | @mock.patch('requests.get', side_effect=_mocked_status_spotify_utf8) 608 | def test_status_spotify_utf8(self, mocked_device_status): 609 | device = MockDevice("192.168.1.1") 610 | status = device.status() 611 | self.assertEqual(status.source, "SPOTIFY") 612 | self.assertEqual(status.track, u'Música Urbana') 613 | self.assertEqual(mocked_device_status.call_count, 1) 614 | 615 | @mock.patch('requests.get', side_effect=_mocked_status_radio) 616 | def test_status_radio(self, mocked_device_status): 617 | device = MockDevice("192.168.1.1") 618 | status = device.status() 619 | self.assertEqual(mocked_device_status.call_count, 1) 620 | self.assertEqual(status.source, "INTERNET_RADIO") 621 | self.assertEqual(status.content_item.source, "INTERNET_RADIO") 622 | self.assertEqual(status.content_item.location, "21630") 623 | self.assertEqual(status.content_item.source_account, "") 624 | self.assertEqual(status.content_item.is_presetable, True) 625 | self.assertIsNone(status.track) 626 | self.assertIsNone(status.artist) 627 | self.assertIsNone(status.album) 628 | self.assertEqual(status.image, 629 | "http://item.radio456.com/007452/logo/logo-21630.jpg") 630 | self.assertEqual(status.station_name, "RMC Info Talk Sport") 631 | self.assertEqual(status.description, 632 | "MP3 64 kbps Paris France, Radio du sport") 633 | self.assertEqual(status.station_location, "Paris France") 634 | 635 | @mock.patch('requests.get', side_effect=_mocked_status_radio_non_ascii) 636 | def test_status_radio_non_ascii(self, mocked_device_status): 637 | device = MockDevice("192.168.1.1") 638 | status = device.status() 639 | self.assertEqual(mocked_device_status.call_count, 1) 640 | self.assertEqual(status.source, "INTERNET_RADIO") 641 | self.assertEqual(status.content_item.source, "INTERNET_RADIO") 642 | self.assertEqual(status.content_item.location, "1307") 643 | self.assertEqual(status.content_item.source_account, "") 644 | self.assertEqual(status.content_item.is_presetable, True) 645 | self.assertIsNone(status.track) 646 | self.assertIsNone(status.artist) 647 | self.assertIsNone(status.album) 648 | self.assertEqual(status.image, 649 | "http://item.radio456.com/007452/logo/logo-1307.jpg") 650 | self.assertEqual(status.station_name, "France Info") 651 | self.assertEqual(status.station_location, "Paris France") 652 | 653 | @mock.patch('requests.get', side_effect=_mocked_status_stored_music) 654 | def test_status_stored_music(self, mocked_device_status): 655 | device = MockDevice("192.168.1.1") 656 | status = device.status() 657 | self.assertEqual(mocked_device_status.call_count, 1) 658 | self.assertEqual(status.source, "STORED_MUSIC") 659 | self.assertEqual(status.content_item.source, "STORED_MUSIC") 660 | self.assertEqual(status.content_item.location, "27$2745") 661 | self.assertIsNone(status.image) 662 | 663 | @mock.patch('requests.get', side_effect=_mocked_status_standby) 664 | def test_status_standby(self, mocked_device_status): 665 | device = MockDevice("192.168.1.1") 666 | status = device.status() 667 | self.assertEqual(mocked_device_status.call_count, 1) 668 | self.assertEqual(status.source, "STANDBY") 669 | self.assertEqual(status.content_item.source, "STANDBY") 670 | 671 | @mock.patch('requests.get', side_effect=_mocked_status_spotify_buffering) 672 | def test_status_buffering(self, mocked_device_status): 673 | device = MockDevice("192.168.1.1") 674 | status = device.status() 675 | self.assertEqual(mocked_device_status.call_count, 1) 676 | self.assertEqual(status.source, "SPOTIFY") 677 | self.assertEqual(status.play_status, "BUFFERING_STATE") 678 | self.assertIsNone(status.content_item) 679 | 680 | @mock.patch('requests.get', side_effect=_mocked_volume) 681 | def test_volume(self, mocked_volume): 682 | device = MockDevice("192.168.1.1") 683 | volume = device.volume() 684 | self.assertEqual(mocked_volume.call_count, 1) 685 | self.assertEqual(volume.actual, 25) 686 | self.assertEqual(volume.target, 26) 687 | self.assertEqual(volume.muted, False) 688 | 689 | @mock.patch('requests.post', side_effect=_mocked_play) 690 | def test_play(self, mocked_play): 691 | device = MockDevice("192.168.1.1") 692 | device.play() 693 | self.assertEqual(mocked_play.call_count, 2) 694 | 695 | @mock.patch('requests.post', 696 | side_effect=_mocked_play_media_without_account) 697 | def test_play_media_without_account(self, mocked_play_media): 698 | device = MockDevice("192.168.1.1") 699 | device.play_media(Source.INTERNET_RADIO, "4712") 700 | self.assertEqual(mocked_play_media.call_count, 1) 701 | 702 | @mock.patch('requests.post', side_effect=_mocked_play_media_with_account) 703 | def test_play_media_with_account(self, mocked_play_media): 704 | device = MockDevice("192.168.1.1") 705 | device.play_media(Source.SPOTIFY, "uri_track", "spot_user_id") 706 | self.assertEqual(mocked_play_media.call_count, 1) 707 | 708 | @mock.patch('requests.post', side_effect=_mocked_play_media_with_type) 709 | def test_play_media_with_type(self, mocked_play_media): 710 | device = MockDevice("192.168.1.1") 711 | device.play_media(Source.LOCAL_MUSIC, "album:1", "account_id", 712 | Type.ALBUM) 713 | self.assertEqual(mocked_play_media.call_count, 1) 714 | 715 | @mock.patch('requests.post', side_effect=_mocked_pause) 716 | def test_pause(self, mocked_pause): 717 | device = MockDevice("192.168.1.1") 718 | device.pause() 719 | self.assertEqual(mocked_pause.call_count, 2) 720 | 721 | @mock.patch('requests.post', side_effect=_mocked_play_pause) 722 | def test_play_plause(self, mocked_play_pause): 723 | device = MockDevice("192.168.1.1") 724 | device.play_pause() 725 | self.assertEqual(mocked_play_pause.call_count, 2) 726 | 727 | @mock.patch('requests.post', side_effect=_mocked_play_url) 728 | def test_play_url(self, mocked_play_url): 729 | device = MockDevice("192.168.1.1") 730 | device.play_url("http://fqdn/file.mp3") 731 | self.assertEqual(mocked_play_url.call_count, 1) 732 | 733 | def test_play_invalid_url(self): 734 | device = MockDevice("192.168.1.1") 735 | self.assertRaises(SoundtouchInvalidUrlException, device.play_url, 736 | "https://fqdn/file.mp3") 737 | 738 | @mock.patch('libsoundtouch.SoundTouchDevice.refresh_status', 739 | side_effect=None) 740 | @mock.patch('requests.post', side_effect=_mocked_power) 741 | def test_power_on(self, mocked_power, refresh): 742 | device = MockDevice("192.168.1.1") 743 | device._status = Mock() 744 | device._status.source = "STANDBY" 745 | device.power_on() 746 | self.assertEqual(mocked_power.call_count, 2) 747 | self.assertEqual(refresh.call_count, 1) 748 | 749 | @mock.patch('libsoundtouch.SoundTouchDevice.refresh_status', 750 | side_effect=None) 751 | @mock.patch('requests.post', side_effect=_mocked_power) 752 | def test_power_on_if_already_on(self, mocked_power, refresh): 753 | device = MockDevice("192.168.1.1") 754 | device._status = Mock() 755 | device._status.source = "SPOTIFY" 756 | device.power_on() 757 | self.assertEqual(mocked_power.call_count, 0) 758 | self.assertEqual(refresh.call_count, 1) 759 | 760 | @mock.patch('libsoundtouch.SoundTouchDevice.refresh_status', 761 | side_effect=None) 762 | @mock.patch('requests.post', side_effect=_mocked_power) 763 | def test_power_off(self, mocked_power, refresh): 764 | device = MockDevice("192.168.1.1") 765 | device._status = Mock() 766 | device._status.source = "SPOTIFY" 767 | device.power_off() 768 | self.assertEqual(mocked_power.call_count, 2) 769 | self.assertEqual(refresh.call_count, 1) 770 | 771 | @mock.patch('libsoundtouch.SoundTouchDevice.refresh_status', 772 | side_effect=None) 773 | @mock.patch('requests.post', side_effect=_mocked_power) 774 | def test_power_off_if_already_off(self, mocked_power, refresh): 775 | device = MockDevice("192.168.1.1") 776 | device._status = Mock() 777 | device._status.source = "STANDBY" 778 | device.power_off() 779 | self.assertEqual(mocked_power.call_count, 0) 780 | self.assertEqual(refresh.call_count, 1) 781 | 782 | @mock.patch('requests.post', side_effect=_mocked_set_volume) 783 | def test_set_volume(self, mocked_set_volume): 784 | device = MockDevice("192.168.1.1") 785 | device.set_volume(10) 786 | self.assertEqual(mocked_set_volume.call_count, 1) 787 | 788 | @mock.patch('requests.post', side_effect=_mocked_volume_up) 789 | def test_volume_up(self, mocked_volume_up): 790 | device = MockDevice("192.168.1.1") 791 | device.volume_up() 792 | self.assertEqual(mocked_volume_up.call_count, 2) 793 | 794 | @mock.patch('requests.post', side_effect=_mocked_volume_down) 795 | def test_volume_down(self, mocked_volume_down): 796 | device = MockDevice("192.168.1.1") 797 | device.volume_down() 798 | self.assertEqual(mocked_volume_down.call_count, 2) 799 | 800 | @mock.patch('requests.post', side_effect=_mocked_next_track) 801 | def test_next_track(self, mocked_next_track): 802 | device = MockDevice("192.168.1.1") 803 | device.next_track() 804 | self.assertEqual(mocked_next_track.call_count, 2) 805 | 806 | @mock.patch('requests.post', side_effect=_mocked_previous_track) 807 | def test_previous_track(self, mocked_previous_track): 808 | device = MockDevice("192.168.1.1") 809 | device.previous_track() 810 | self.assertEqual(mocked_previous_track.call_count, 2) 811 | 812 | @mock.patch('requests.post', side_effect=_mocked_mute) 813 | def test_mute(self, mocked_mute): 814 | device = MockDevice("192.168.1.1") 815 | device.mute() 816 | self.assertEqual(mocked_mute.call_count, 2) 817 | 818 | @mock.patch('requests.post', side_effect=_mocked_repeat_one) 819 | def test_repeat_one(self, mocked_repeat_one): 820 | device = MockDevice("192.168.1.1") 821 | device.repeat_one() 822 | self.assertEqual(mocked_repeat_one.call_count, 2) 823 | 824 | @mock.patch('requests.post', side_effect=_mocked_repeat_all) 825 | def test_repeat_all(self, mocked_repeat_all): 826 | device = MockDevice("192.168.1.1") 827 | device.repeat_all() 828 | self.assertEqual(mocked_repeat_all.call_count, 2) 829 | 830 | @mock.patch('requests.post', side_effect=_mocked_repeat_off) 831 | def test_repeat_off(self, mocked_repeat_off): 832 | device = MockDevice("192.168.1.1") 833 | device.repeat_off() 834 | self.assertEqual(mocked_repeat_off.call_count, 2) 835 | 836 | @mock.patch('requests.post', side_effect=_mocked_shuffle_on) 837 | def test_shuffle_on(self, mocked_shuffle): 838 | device = MockDevice("192.168.1.1") 839 | device.shuffle(True) 840 | self.assertEqual(mocked_shuffle.call_count, 2) 841 | 842 | @mock.patch('requests.post', side_effect=_mocked_shuffle_off) 843 | def test_shuffle_off(self, mocked_shuffle): 844 | device = MockDevice("192.168.1.1") 845 | device.shuffle(False) 846 | self.assertEqual(mocked_shuffle.call_count, 2) 847 | 848 | @mock.patch('requests.get', side_effect=_mocked_presets) 849 | def test_presets(self, mocked_presets): 850 | device = MockDevice("192.168.1.1") 851 | presets = device.presets() 852 | self.assertEqual(mocked_presets.call_count, 1) 853 | self.assertEqual(len(presets), 6) 854 | self.assertEqual(presets[0].name, "Zedd") 855 | self.assertEqual(presets[0].preset_id, "1") 856 | self.assertEqual(presets[0].source, "SPOTIFY") 857 | self.assertEqual(presets[0].type, "uri") 858 | self.assertEqual(presets[0].location, 859 | "spotify:artist:2qxJFvFYMEDqd7ui6kSAcq") 860 | self.assertEqual(presets[0].source_account, "spotify_account") 861 | self.assertEqual(presets[0].is_presetable, True) 862 | self.assertIsNotNone(presets[0].source_xml) 863 | 864 | @mock.patch('requests.post', side_effect=_mocked_select_preset) 865 | def test_select_preset(self, mocked_select_preset): 866 | device = MockDevice("192.168.1.1") 867 | preset = MockPreset("source") 868 | device.select_preset(preset) 869 | self.assertEqual(mocked_select_preset.call_count, 1) 870 | 871 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 872 | def test_zone_status_master(self, mocked_zone_status): 873 | device = MockDevice("192.168.1.1") 874 | zone_status = device.zone_status() 875 | self.assertEqual(mocked_zone_status.call_count, 1) 876 | self.assertTrue(zone_status.is_master) 877 | self.assertEqual(zone_status.master_id, "1111MASTER") 878 | self.assertIsNone(zone_status.master_ip) 879 | self.assertEqual(len(zone_status.slaves), 1) 880 | self.assertEqual(zone_status.slaves[0].device_ip, "192.168.1.2") 881 | self.assertEqual(zone_status.slaves[0].role, "NORMAL") 882 | 883 | @mock.patch('requests.get', side_effect=_mocked_zone_status_slave) 884 | def test_zone_status_slave(self, mocked_zone_status): 885 | device = MockDevice("192.168.1.2") 886 | zone_status = device.zone_status() 887 | self.assertEqual(mocked_zone_status.call_count, 1) 888 | self.assertFalse(zone_status.is_master) 889 | self.assertEqual(zone_status.master_id, "1111MASTER") 890 | self.assertEqual(zone_status.master_ip, "192.168.1.1") 891 | self.assertEqual(len(zone_status.slaves), 1) 892 | self.assertEqual(zone_status.slaves[0].device_ip, "192.168.1.2") 893 | self.assertEqual(zone_status.slaves[0].role, "NORMAL") 894 | 895 | @mock.patch('requests.get', side_effect=_mocked_zone_status_none) 896 | def test_zone_status_none(self, mocked_zone_status): 897 | device = MockDevice("192.168.1.1") 898 | zone_status = device.zone_status() 899 | self.assertEqual(mocked_zone_status.call_count, 1) 900 | self.assertIsNone(zone_status) 901 | 902 | @mock.patch('requests.post', side_effect=_mocked_create_zone) 903 | def test_create_zone(self, mocked_create_zone): 904 | device = MockDevice("192.168.1.1") 905 | device.set_base_config("192.168.1.1", "1111MASTER") 906 | device2 = MockDevice("192.168.1.2") 907 | device2.set_base_config("192.168.1.2", "1111SLAVE") 908 | device.create_zone([device2]) 909 | self.assertEqual(mocked_create_zone.call_count, 1) 910 | 911 | def test_create_zone_without_master(self): 912 | device = MockDevice("192.168.1.1") 913 | self.assertRaises(NoSlavesException, device.create_zone, 914 | []) 915 | 916 | @mock.patch('requests.post', side_effect=_mocked_remove_slaves) 917 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 918 | def test_remove_zone_slaves(self, mocked_remove_slave, mocked_zone_status): 919 | device = MockDevice("192.168.1.1") 920 | device.set_base_config("192.168.1.1", "1111MASTER") 921 | device2 = MockDevice("192.168.1.2") 922 | device2.set_base_config("192.168.1.2", "1111SLAVE") 923 | device.remove_zone_slave([device2]) 924 | self.assertEqual(mocked_zone_status.call_count, 1) 925 | self.assertEqual(mocked_remove_slave.call_count, 1) 926 | 927 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 928 | def test_remove_zone_slave_without_slaves(self, mocked_zone_status): 929 | device = MockDevice("192.168.1.1") 930 | self.assertRaises(NoSlavesException, 931 | device.remove_zone_slave, 932 | []) 933 | self.assertEqual(mocked_zone_status.call_count, 1) 934 | 935 | @mock.patch('requests.get', side_effect=_mocked_zone_status_none) 936 | def test_remove_zone_slave_without_zone(self, mocked_zone_status): 937 | device = MockDevice("192.168.1.1") 938 | self.assertRaises(NoExistingZoneException, 939 | device.remove_zone_slave, 940 | []) 941 | self.assertEqual(mocked_zone_status.call_count, 1) 942 | 943 | @mock.patch('requests.post', side_effect=_mocked_add_slaves) 944 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 945 | def test_add_zone_slaves(self, mocked_add_slaves, mocked_zone_status): 946 | device = MockDevice("192.168.1.1") 947 | device.set_base_config("192.168.1.1", "1111MASTER") 948 | device2 = MockDevice("192.168.1.2") 949 | device2.set_base_config("192.168.1.2", "1111SLAVE") 950 | device.add_zone_slave([device2]) 951 | self.assertEqual(mocked_zone_status.call_count, 1) 952 | self.assertEqual(mocked_add_slaves.call_count, 1) 953 | 954 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 955 | def test_add_zone_slaves_without_master(self, mocked_zone_status): 956 | device = MockDevice("192.168.1.1") 957 | self.assertRaises(NoSlavesException, 958 | device.add_zone_slave, []) 959 | self.assertEqual(mocked_zone_status.call_count, 1) 960 | 961 | @mock.patch('requests.get', side_effect=_mocked_zone_status_none) 962 | def test_add_zone_slaves_without_zone(self, mocked_zone_status): 963 | device = MockDevice("192.168.1.1") 964 | self.assertRaises(NoExistingZoneException, 965 | device.add_zone_slave, []) 966 | self.assertEqual(mocked_zone_status.call_count, 1) 967 | 968 | @mock.patch('websocket.WebSocketApp.run_forever') 969 | def test_ws_start(self, ws_run_forever): 970 | device = MockDevice("192.168.1.1") 971 | device.start_notification() 972 | time.sleep(1) # Wait thread start 973 | self.assertEqual(ws_run_forever.call_count, 1) 974 | 975 | def test_ws_listeners(self): 976 | device = MockDevice("192.168.1.1") 977 | 978 | def listener_1(): 979 | pass 980 | 981 | def listener_2(): 982 | pass 983 | 984 | device.add_volume_listener(listener_1) 985 | device.add_volume_listener(listener_2) 986 | self.assertEqual(len(device.volume_updated_listeners), 2) 987 | device.remove_volume_listener(listener_2) 988 | self.assertEqual(len(device.volume_updated_listeners), 1) 989 | device.clear_volume_listeners() 990 | self.assertEqual(len(device.volume_updated_listeners), 0) 991 | 992 | device.add_status_listener(listener_1) 993 | device.add_status_listener(listener_2) 994 | self.assertEqual(len(device.status_updated_listeners), 2) 995 | device.remove_status_listener(listener_2) 996 | self.assertEqual(len(device.status_updated_listeners), 1) 997 | device.clear_status_listener() 998 | self.assertEqual(len(device.status_updated_listeners), 0) 999 | 1000 | device.add_presets_listener(listener_1) 1001 | device.add_presets_listener(listener_2) 1002 | self.assertEqual(len(device.presets_updated_listeners), 2) 1003 | device.remove_presets_listener(listener_2) 1004 | self.assertEqual(len(device.presets_updated_listeners), 1) 1005 | device.clear_presets_listeners() 1006 | self.assertEqual(len(device.presets_updated_listeners), 0) 1007 | 1008 | device.add_zone_status_listener(listener_1) 1009 | device.add_zone_status_listener(listener_2) 1010 | self.assertEqual(len(device.zone_status_updated_listeners), 2) 1011 | device.remove_zone_status_listener(listener_2) 1012 | self.assertEqual(len(device.zone_status_updated_listeners), 1) 1013 | device.clear_zone_status_listeners() 1014 | self.assertEqual(len(device.zone_status_updated_listeners), 0) 1015 | 1016 | device.add_device_info_listener(listener_1) 1017 | device.add_device_info_listener(listener_2) 1018 | self.assertEqual(len(device.device_info_updated_listeners), 2) 1019 | device.remove_device_info_listener(listener_2) 1020 | self.assertEqual(len(device.device_info_updated_listeners), 1) 1021 | device.clear_device_info_listeners() 1022 | self.assertEqual(len(device.device_info_updated_listeners), 0) 1023 | 1024 | def test_ws_status_notification(self): 1025 | device = MockDevice("192.168.1.1") 1026 | self.listener_called = False 1027 | self.status = None 1028 | 1029 | def listener(status_msg): 1030 | self.listener_called = True 1031 | self.status = status_msg 1032 | 1033 | device.add_status_listener(listener) 1034 | codecs_open = codecs.open("tests/data/ws_status.xml", "r", "utf-8") 1035 | try: 1036 | content = codecs_open.read() 1037 | device._on_message(None, content) 1038 | self.assertTrue(self.listener_called) 1039 | self.assertEqual(self.status.source, "SPOTIFY") 1040 | self.assertEqual(self.status.track, "Devil We Know") 1041 | finally: 1042 | codecs_open.close() 1043 | 1044 | def test_ws_volume_notification(self): 1045 | device = MockDevice("192.168.1.1") 1046 | self.listener_called = False 1047 | self.volume = None 1048 | 1049 | def listener(status_msg): 1050 | self.listener_called = True 1051 | self.volume = status_msg 1052 | 1053 | device.add_volume_listener(listener) 1054 | codecs_open = codecs.open("tests/data/ws_volume.xml", "r", "utf-8") 1055 | try: 1056 | content = codecs_open.read() 1057 | device._on_message(None, content) 1058 | self.assertTrue(self.listener_called) 1059 | self.assertEqual(self.volume.actual, 21) 1060 | finally: 1061 | codecs_open.close() 1062 | 1063 | def test_ws_presets_notification(self): 1064 | device = MockDevice("192.168.1.1") 1065 | self.listener_called = False 1066 | self.presets = None 1067 | 1068 | def listener(status_msg): 1069 | self.listener_called = True 1070 | self.presets = status_msg 1071 | 1072 | device.add_presets_listener(listener) 1073 | codecs_open = codecs.open("tests/data/ws_presets.xml", "r", "utf-8") 1074 | try: 1075 | content = codecs_open.read() 1076 | device._on_message(None, content) 1077 | self.assertTrue(self.listener_called) 1078 | self.assertEqual(len(self.presets), 3) 1079 | self.assertEqual(self.presets[0].name, "Zedd") 1080 | finally: 1081 | codecs_open.close() 1082 | 1083 | @mock.patch('requests.get', side_effect=_mocked_zone_status_master) 1084 | def test_ws_zone_notification(self, mocked_zone_status): 1085 | device = MockDevice("192.168.1.1") 1086 | self.listener_called = False 1087 | self.zone = None 1088 | 1089 | def listener(status_msg): 1090 | self.listener_called = True 1091 | self.zone = status_msg 1092 | 1093 | device.add_zone_status_listener(listener) 1094 | codecs_open = codecs.open("tests/data/ws_zone.xml", "r", "utf-8") 1095 | try: 1096 | content = codecs_open.read() 1097 | device._on_message(None, content) 1098 | self.assertTrue(self.listener_called) 1099 | self.assertEqual(mocked_zone_status.call_count, 1) 1100 | self.assertTrue(self.zone.is_master) 1101 | self.assertEqual(self.zone.master_id, "1111MASTER") 1102 | finally: 1103 | codecs_open.close() 1104 | 1105 | @mock.patch('requests.get', side_effect=_mocked_device_info) 1106 | def test_ws_info_notification(self, mocked_device_info): 1107 | device = MockDevice("192.168.1.1") 1108 | self.listener_called = False 1109 | self.info = None 1110 | 1111 | def listener(status_msg): 1112 | self.listener_called = True 1113 | self.info = status_msg 1114 | 1115 | device.add_device_info_listener(listener) 1116 | codecs_open = codecs.open("tests/data/ws_info.xml", "r", "utf-8") 1117 | try: 1118 | content = codecs_open.read() 1119 | device._on_message(None, content) 1120 | self.assertTrue(self.listener_called) 1121 | self.assertEqual(mocked_device_info.call_count, 1) 1122 | self.assertEqual(self.info.name, "Home") 1123 | finally: 1124 | codecs_open.close() 1125 | 1126 | @mock.patch('requests.get', side_effect=_mocked_device_info) 1127 | @mock.patch('socket.inet_ntoa', return_value='192.168.1.1') 1128 | @mock.patch('zeroconf.ServiceBrowser.__init__', return_value=None, 1129 | side_effect=_mocked_service_browser) 1130 | def test_discover_devices(self, mocked_service_browser, mocked_inet_ntoa, 1131 | mocked_request_get): 1132 | devices = libsoundtouch.discover_devices(timeout=1) 1133 | self.assertEqual(mocked_service_browser.call_count, 1) 1134 | self.assertEqual(mocked_inet_ntoa.call_count, 1) 1135 | self.assertEqual(mocked_request_get.call_count, 1) 1136 | self.assertEqual(len(devices), 1) 1137 | self.assertEqual(devices[0].host, "192.168.1.1") 1138 | self.assertEqual(devices[0].port, 8090) 1139 | 1140 | @mock.patch('requests.post', side_effect=_mocked_select_bluetooth) 1141 | def test_select_bluetooth(self, mocked_select_bluetooth): 1142 | device = MockDevice("192.168.1.1") 1143 | device.select_source_bluetooth() 1144 | self.assertEqual(mocked_select_bluetooth.call_count, 1) 1145 | 1146 | @mock.patch('requests.post', side_effect=_mocked_select_aux) 1147 | def test_select_aux(self, mocked_select_aux): 1148 | device = MockDevice("192.168.1.1") 1149 | device.select_source_aux() 1150 | self.assertEqual(mocked_select_aux.call_count, 1) 1151 | 1152 | @mock.patch('requests.post', side_effect=_mocked_select_content_item) 1153 | def test_select_content_item(self, mocked_select_content_item): 1154 | device = MockDevice("192.168.1.1") 1155 | device.select_content_item(Source.SPOTIFY, "spotify_account", 1156 | "spotify:artist:2ye2Wgw4gimLv2eAKyk1NB", 1157 | "uri") 1158 | self.assertEqual(mocked_select_content_item.call_count, 1) 1159 | 1160 | @mock.patch('requests.get', side_effect=_mocked_status_spotify) 1161 | @mock.patch('requests.post', side_effect=_mocked_select_content_item) 1162 | def test_snapshot_restore(self, mocked_device_status, 1163 | mocked_select_content_item): 1164 | device = MockDevice("192.168.1.1") 1165 | device.snapshot() 1166 | device.restore() 1167 | self.assertEqual(mocked_device_status.call_count, 1) 1168 | self.assertEqual(mocked_select_content_item.call_count, 1) 1169 | --------------------------------------------------------------------------------