├── .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 AccousticLily & 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 |
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 |
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 |
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 | [](https://travis-ci.org/CharlesBlonde/libsoundtouch) [](https://coveralls.io/github/CharlesBlonde/libsoundtouch?branch=master) [](https://badge.fury.io/py/libsoundtouch) [](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 |
--------------------------------------------------------------------------------