├── tests
├── __init__.py
└── samsung_multiroom
│ ├── clock
│ ├── __init__.py
│ ├── test_timer.py
│ ├── test_group.py
│ ├── test_clock.py
│ └── test_alarm.py
│ ├── event
│ ├── __init__.py
│ ├── type
│ │ ├── __init__.py
│ │ ├── test_speaker_mute_changed.py
│ │ ├── test_speaker_volume_changed.py
│ │ ├── test_speaker_player_shuffle_changed.py
│ │ ├── test_speaker_player_repeat_changed.py
│ │ ├── test_speaker_service.py
│ │ └── test_speaker_player.py
│ ├── test_event.py
│ └── test_event_loop.py
│ ├── service
│ ├── __init__.py
│ ├── app
│ │ ├── __init__.py
│ │ ├── test_service_appservice.py
│ │ ├── test_player_appplayer.py
│ │ └── test_browser_appbrowser.py
│ ├── dlna
│ │ ├── __init__.py
│ │ ├── test_service_dlnaservice.py
│ │ └── test_player_dlnaplayer.py
│ ├── tunein
│ │ ├── __init__.py
│ │ ├── test_service_tuneinservice.py
│ │ ├── test_browser_tuneinbrowser.py
│ │ └── test_player_tuneinplayer.py
│ ├── test_path_to_folders.py
│ ├── test_player.py
│ ├── test_nullplayer.py
│ ├── test_service_registry.py
│ └── test_playeroperator.py
│ ├── __init__.py
│ ├── test_discovery.py
│ ├── equalizer
│ ├── test_group.py
│ └── test_equalizer.py
│ ├── api
│ ├── test_api_response.py
│ └── test_api_stream.py
│ └── test_speaker.py
├── samsung_multiroom
├── event
│ ├── type
│ │ ├── __init__.py
│ │ ├── speaker_mute_changed.py
│ │ ├── speaker_volume_changed.py
│ │ ├── speaker_player_shuffle_changed.py
│ │ ├── speaker_player.py
│ │ ├── speaker_service.py
│ │ └── speaker_player_repeat_changed.py
│ ├── __init__.py
│ ├── event.py
│ └── event_loop.py
├── equalizer
│ ├── __init__.py
│ ├── group.py
│ └── equalizer.py
├── service
│ ├── app
│ │ ├── __init__.py
│ │ ├── service.py
│ │ ├── browser.py
│ │ └── player.py
│ ├── dlna
│ │ ├── __init__.py
│ │ ├── service.py
│ │ ├── browser.py
│ │ └── player.py
│ ├── tunein
│ │ ├── __init__.py
│ │ ├── service.py
│ │ ├── browser.py
│ │ └── player.py
│ ├── __init__.py
│ ├── service.py
│ ├── service_registry.py
│ ├── browser.py
│ ├── player_operator.py
│ └── player.py
├── clock
│ ├── __init__.py
│ ├── timer.py
│ ├── group.py
│ └── clock.py
├── api
│ ├── __init__.py
│ ├── api_response.py
│ └── api_stream.py
├── __init__.py
├── factory.py
├── discovery.py
├── base.py
├── speaker.py
└── group.py
├── setup.py
├── .flake8
├── .gitignore
├── Pipfile
├── .coveragerc
├── .isort.cfg
├── .yapf
├── requirements.txt
├── setup.cfg
├── LICENSE
├── .travis.yml
├── development.txt
├── Makefile
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/clock/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/app/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/dlna/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/tunein/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/__init__.py:
--------------------------------------------------------------------------------
1 | """Speaker events."""
2 | from .event_loop import EventLoop
3 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 |
3 | setuptools.setup(
4 | setup_requires=['pbr'],
5 | pbr=True
6 | )
7 |
--------------------------------------------------------------------------------
/samsung_multiroom/equalizer/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .equalizer import Equalizer
5 | from .group import EqualizerGroup
6 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = E501,E731,F401,F821,E901,E902
3 | max-line-length = 120
4 | exclude=patterns = .git,__pycache__,.tox,.eggs,*.egg
5 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/app/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .browser import AppBrowser
5 | from .player import AppPlayer
6 | from .service import AppService
7 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/dlna/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .browser import DlnaBrowser
5 | from .player import DlnaPlayer
6 | from .service import DlnaService
7 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/tunein/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .browser import TuneInBrowser
5 | from .player import TuneInPlayer
6 | from .service import TuneInService
7 |
--------------------------------------------------------------------------------
/samsung_multiroom/clock/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .alarm import Alarm
5 | from .alarm import AlarmSlot
6 | from .clock import Clock
7 | from .group import ClockGroup
8 | from .timer import Timer
9 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import sys
3 |
4 | logger = logging.getLogger()
5 | logger.level = logging.DEBUG
6 | stream_handler = logging.StreamHandler(sys.stdout)
7 | logger.addHandler(stream_handler)
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build
2 | *.pyc
3 | __pycache__
4 | *.egg*
5 | *.egg-info
6 | build/
7 | dist/
8 |
9 |
10 | # test
11 | .coverage
12 | htmlcov
13 | coverage.xml
14 | cc-test-reporter
15 |
16 |
17 | # pbr
18 | ChangeLog
19 | AUTHORS
20 |
--------------------------------------------------------------------------------
/samsung_multiroom/api/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .api import COMMAND_CPM
5 | from .api import COMMAND_UIC
6 | from .api import METHOD_GET
7 | from .api import SamsungMultiroomApi
8 | from .api import SamsungMultiroomApiException
9 | from .api import paginator
10 | from .api_response import ApiResponse
11 | from .api_stream import ApiStream
12 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/test_event.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.event import Event
5 |
6 |
7 | class TestEvent(unittest.TestCase):
8 |
9 | def test_name(self):
10 | event = Event('speaker.volume.changed')
11 |
12 | self.assertEqual(event.name, 'speaker.volume.changed')
13 |
--------------------------------------------------------------------------------
/samsung_multiroom/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | # pylint: disable=C0103
5 | from . import discovery
6 | from . import factory
7 | from .service import REPEAT_ALL
8 | from .service import REPEAT_OFF
9 | from .service import REPEAT_ONE
10 |
11 | # aliases
12 | SamsungMultiroomSpeaker = factory.speaker_factory
13 | SamsungSpeakerDiscovery = discovery.SpeakerDiscovery
14 |
--------------------------------------------------------------------------------
/Pipfile:
--------------------------------------------------------------------------------
1 | [[source]]
2 | name = "pypi"
3 | url = "https://pypi.org/simple"
4 | verify_ssl = true
5 |
6 | [dev-packages]
7 | httpretty = "*"
8 | pytest = "*"
9 | pytest-cov = "*"
10 | flake8 = "*"
11 | pylint = "*"
12 | autopep8 = "*"
13 | yapf = "*"
14 | pytest-asyncio = "*"
15 |
16 | [packages]
17 | xmltodict = "*"
18 | requests = "*"
19 | upnpclient = "*"
20 | http-parser = "*"
21 |
22 | [requires]
23 | python_version = "3.8"
24 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/event.py:
--------------------------------------------------------------------------------
1 | """Generic event."""
2 |
3 |
4 | class Event:
5 | """Generic event."""
6 |
7 | def __init__(self, name):
8 | """
9 | :param name: Name of event as defined in Events class
10 | """
11 | self._name = name
12 |
13 | @property
14 | def name(self):
15 | """
16 | :returns: Name of event
17 | """
18 | return self._name
19 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/dlna/service.py:
--------------------------------------------------------------------------------
1 | """DLNA service."""
2 | from ..service import Service
3 | from .browser import DlnaBrowser
4 |
5 |
6 | class DlnaService(Service):
7 | """
8 | Dlna service.
9 | """
10 |
11 | def __init__(self, api):
12 | super().__init__(DlnaBrowser(api))
13 |
14 | @property
15 | def name(self):
16 | """
17 | :returns: Name of the service
18 | """
19 | return 'dlna'
20 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Init.
3 | """
4 | from .browser import Browser
5 | from .browser import Item
6 | from .browser import path_to_folders
7 | from .player import REPEAT_ALL
8 | from .player import REPEAT_OFF
9 | from .player import REPEAT_ONE
10 | from .player import Player
11 | from .player import Track
12 | from .player_operator import PlayerOperator
13 | from .service import Service
14 | from .service_registry import ServiceRegistry
15 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [report]
2 | exclude_lines =
3 | # Have to re-enable the standard pragma
4 | pragma: no cover
5 |
6 | # Don't complain about missing debug-only code:
7 | def __repr__
8 | if self\.debug
9 |
10 | # Don't complain if tests don't hit defensive assertion code:
11 | raise AssertionError
12 | raise NotImplementedError
13 |
14 | # Don't complain if non-runnable code isn't run:
15 | if 0:
16 | if __name__ == .__main__.:
17 |
--------------------------------------------------------------------------------
/.isort.cfg:
--------------------------------------------------------------------------------
1 | [isort]
2 | line_length = 120
3 | known_future_library = future
4 | known_twisted =
5 | twisted,
6 | klein
7 | known_mock = mock
8 | known_third_party =
9 | coverage,
10 | setuptools,
11 | flask,
12 | requests
13 | known_first_party = dopplerr
14 | force_single_line = 1
15 | sections =
16 | FUTURE,
17 | FUTURE_LIBRARY,
18 | STDLIB,
19 | THIRDPARTY,
20 | MOCK,
21 | TWISTED,
22 | FIRSTPARTY,
23 | LOCALFOLDER
24 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/tunein/service.py:
--------------------------------------------------------------------------------
1 | """TuneIn service."""
2 | from ..service import Service
3 | from .browser import TuneInBrowser
4 |
5 |
6 | class TuneInService(Service):
7 | """
8 | TuneIn radio service.
9 | """
10 |
11 | def __init__(self, api):
12 | super().__init__(TuneInBrowser(api))
13 |
14 | @property
15 | def name(self):
16 | """
17 | :returns: Name of the service
18 | """
19 | return 'tunein'
20 |
--------------------------------------------------------------------------------
/.yapf:
--------------------------------------------------------------------------------
1 | [style]
2 | based_on_style = pep8
3 | column_limit = 120
4 | indent_dictionary_value = true
5 | split_before_first_argument= false
6 | split_penalty_after_opening_bracket = 10
7 | blank_line_before_nested_class_or_def = true
8 | ; align_closing_bracket_with_visual_indent = true
9 | ; dedent_closing_brackets = false
10 | ; spaces_around_power_operator = false
11 | ; spaces_before_comment = 2
12 | ; split_before_first_argument = false
13 | ; split_before_logical_operator = false
14 | ; split_arguments_when_comma_terminated = true
15 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/test_path_to_folders.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from samsung_multiroom.service import path_to_folders
4 |
5 |
6 | class TestPathToFolders(unittest.TestCase):
7 |
8 | def test_path_to_folders(self):
9 | self.assertEqual(path_to_folders(None), [None])
10 | self.assertEqual(path_to_folders(''), [None])
11 | self.assertEqual(path_to_folders('/'), [None])
12 | self.assertEqual(path_to_folders('/Folder1'), [None, 'Folder1'])
13 | self.assertEqual(path_to_folders('/Folder1/'), [None, 'Folder1'])
14 | self.assertEqual(path_to_folders('/Folder1/Folder2/Folder3/'), [None, 'Folder1', 'Folder2', 'Folder3'])
15 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -i https://pypi.org/simple
2 | certifi==2020.6.20
3 | chardet==3.0.4
4 | http-parser==0.9.0
5 | idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
6 | ifaddr==0.1.7
7 | lxml==4.5.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
8 | netdisco==2.8.2; python_version >= '3'
9 | python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
10 | requests==2.24.0
11 | six==1.15.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
12 | upnpclient==0.0.8
13 | urllib3==1.25.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
14 | xmltodict==0.12.0
15 | zeroconf==0.28.6
16 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = samsung_multiroom
3 | summary = Samsung multiroom speaker support
4 | description-file =
5 | README.rst
6 | author = Krystian Galutowski
7 | author-email = k.galutowski@gmail.com
8 | license = MIT
9 | home-page = https://github.com/krygal/samsung_multiroom
10 | classifier =
11 | Intended Audience :: Information Technology
12 | Intended Audience :: System Administrators
13 | License :: OSI Approved :: MIT License
14 | Operating System :: POSIX :: Linux
15 | Programming Language :: Python
16 | Programming Language :: Python :: 3
17 | Programming Language :: Python :: 3.8
18 | Topic :: Home Automation
19 |
20 | [files]
21 | packages =
22 | samsung_multiroom
23 |
24 | [pbr]
25 | warnerrors = True
26 |
27 | [wheel]
28 | universal = 0
29 |
30 | [bdist_wheel]
31 | universal = 0
32 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_mute_changed.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_mute_changed import SpeakerMuteChangedEvent
5 |
6 |
7 | class TestSpeakerMuteChangedEvent(unittest.TestCase):
8 |
9 | def test_factory(self):
10 | response = MagicMock()
11 | response.name = 'MuteStatus'
12 | response.data = {'mute': 'on'}
13 |
14 | event = SpeakerMuteChangedEvent.factory(response)
15 |
16 | self.assertIsInstance(event, SpeakerMuteChangedEvent)
17 | self.assertEqual(event.muted, True)
18 |
19 | def test_factory_returns_none(self):
20 | response = MagicMock()
21 | response.name = 'OtherResponse'
22 |
23 | event = SpeakerMuteChangedEvent.factory(response)
24 |
25 | self.assertEqual(event, None)
26 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_volume_changed.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_volume_changed import SpeakerVolumeChangedEvent
5 |
6 |
7 | class TestSpeakerVolumeChangedEvent(unittest.TestCase):
8 |
9 | def test_factory(self):
10 | response = MagicMock()
11 | response.name = 'VolumeLevel'
12 | response.data = {'volume': '25'}
13 |
14 | event = SpeakerVolumeChangedEvent.factory(response)
15 |
16 | self.assertIsInstance(event, SpeakerVolumeChangedEvent)
17 | self.assertEqual(event.volume, 25)
18 |
19 | def test_factory_returns_none(self):
20 | response = MagicMock()
21 | response.name = 'OtherResponse'
22 |
23 | event = SpeakerVolumeChangedEvent.factory(response)
24 |
25 | self.assertEqual(event, None)
26 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_mute_changed.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ..event import Event
3 |
4 |
5 | class SpeakerMuteChangedEvent(Event):
6 | """Event when speaker mute state changes."""
7 |
8 | def __init__(self, muted):
9 | """
10 | :param muted: Boolean
11 | """
12 | super().__init__('speaker.mute.changed')
13 |
14 | self._muted = muted
15 |
16 | @property
17 | def muted(self):
18 | """
19 | :returns: Mute state, True if muted
20 | """
21 | return self._muted
22 |
23 | @classmethod
24 | def factory(cls, response):
25 | """
26 | Factory event from response.
27 |
28 | :returns: SpeakerMuteChangedEvent instance or None if response is unsupported
29 | """
30 | if response.name != 'MuteStatus':
31 | return None
32 |
33 | return cls(response.data['mute'] == 'on')
34 |
--------------------------------------------------------------------------------
/samsung_multiroom/clock/timer.py:
--------------------------------------------------------------------------------
1 | """Control timer to put speaker into sleep mode."""
2 |
3 |
4 | class Timer:
5 | """
6 | Control timer to put speaker into sleep mode.
7 | """
8 |
9 | def __init__(self, api):
10 | """
11 | :param api: SamsungMultiroomApi instance
12 | """
13 | self._api = api
14 |
15 | def start(self, delay):
16 | """
17 | Set timer to put speaker into sleep mode after delay.
18 |
19 | :param delay: Delay in seconds
20 | """
21 | self._api.set_sleep_timer('start', delay)
22 |
23 | def stop(self):
24 | """
25 | Stop the timer.
26 | """
27 | self._api.set_sleep_timer('off', 0)
28 |
29 | def get_remaining_time(self):
30 | """
31 | :returns: Remaining timer seconds, or 0 if not enabled.
32 | """
33 | return int(self._api.get_sleep_timer()['sleeptime'])
34 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_volume_changed.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ..event import Event
3 |
4 |
5 | class SpeakerVolumeChangedEvent(Event):
6 | """Event when speaker volume is adjusted."""
7 |
8 | def __init__(self, volume):
9 | """
10 | :param volume: int new volume
11 | """
12 | super().__init__('speaker.volume.changed')
13 |
14 | self._volume = volume
15 |
16 | @property
17 | def volume(self):
18 | """
19 | :returns: new volume
20 | """
21 | return self._volume
22 |
23 | @classmethod
24 | def factory(cls, response):
25 | """
26 | Factory event from response.
27 |
28 | :returns: SpeakerVolumeChangedEvent instance or None if response is unsupported
29 | """
30 | if response.name != 'VolumeLevel':
31 | return None
32 |
33 | return cls(int(response.data['volume']))
34 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_player_shuffle_changed.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_player_shuffle_changed import SpeakerPlayerShuffleChangedEvent
5 |
6 |
7 | class TestSpeakershuffleChangedEvent(unittest.TestCase):
8 |
9 | def test_factory(self):
10 | response = MagicMock()
11 | response.name = 'ShuffleMode'
12 | response.data = {'shuffle': 'on'}
13 |
14 | event = SpeakerPlayerShuffleChangedEvent.factory(response)
15 |
16 | self.assertIsInstance(event, SpeakerPlayerShuffleChangedEvent)
17 | self.assertEqual(event.shuffle, True)
18 |
19 | def test_factory_returns_none(self):
20 | response = MagicMock()
21 | response.name = 'OtherResponse'
22 |
23 | event = SpeakerPlayerShuffleChangedEvent.factory(response)
24 |
25 | self.assertEqual(event, None)
26 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_player_repeat_changed.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_player_repeat_changed import SpeakerPlayerRepeatChangedEvent
5 | from samsung_multiroom.service.player import REPEAT_ALL
6 |
7 |
8 | class TestSpeakerrepeatChangedEvent(unittest.TestCase):
9 |
10 | def test_factory(self):
11 | response = MagicMock()
12 | response.name = 'RepeatMode'
13 | response.data = {'repeat': 'all'}
14 |
15 | event = SpeakerPlayerRepeatChangedEvent.factory(response)
16 |
17 | self.assertIsInstance(event, SpeakerPlayerRepeatChangedEvent)
18 | self.assertEqual(event.repeat, REPEAT_ALL)
19 |
20 | def test_factory_returns_none(self):
21 | response = MagicMock()
22 | response.name = 'OtherResponse'
23 |
24 | event = SpeakerPlayerRepeatChangedEvent.factory(response)
25 |
26 | self.assertEqual(event, None)
27 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_player_shuffle_changed.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ..event import Event
3 |
4 |
5 | class SpeakerPlayerShuffleChangedEvent(Event):
6 | """Event when player's shuffle state changes."""
7 |
8 | def __init__(self, shuffle):
9 | """
10 | :param shuffle: Boolean
11 | """
12 | super().__init__('speaker.player.shuffle.changed')
13 |
14 | self._shuffle = shuffle
15 |
16 | @property
17 | def shuffle(self):
18 | """
19 | :returns: Shuffle state, True if shuffle is on
20 | """
21 | return self._shuffle
22 |
23 | @classmethod
24 | def factory(cls, response):
25 | """
26 | Factory event from response.
27 |
28 | :returns: SpeakerPlayerShuffleChangedEvent instance or None if response is unsupported
29 | """
30 | if response.name != 'ShuffleMode':
31 | return None
32 |
33 | return cls(response.data['shuffle'] == 'on')
34 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/clock/test_timer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.clock import Timer
5 |
6 |
7 | def get_timer():
8 | api = MagicMock()
9 |
10 | timer = Timer(api)
11 |
12 | return (timer, api)
13 |
14 |
15 | class TestTimer(unittest.TestCase):
16 |
17 | def test_start(self):
18 | timer, api = get_timer()
19 |
20 | timer.start(300)
21 |
22 | api.set_sleep_timer.assert_called_once_with('start', 300)
23 |
24 | def test_stop(self):
25 | timer, api = get_timer()
26 |
27 | timer.stop()
28 |
29 | api.set_sleep_timer.assert_called_once_with('off', 0)
30 |
31 | def test_get_remaining_time(self):
32 | timer, api = get_timer()
33 |
34 | api.get_sleep_timer.return_value = {
35 | 'sleepoption': 'start',
36 | 'sleeptime': '270',
37 | }
38 |
39 | remaining_time = timer.get_remaining_time()
40 |
41 | self.assertEqual(remaining_time, 270)
42 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_player.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ..event import Event
3 |
4 |
5 | class SpeakerPlayerEvent(Event):
6 | """Event when player status changes."""
7 |
8 | @classmethod
9 | def factory(cls, response):
10 | """
11 | Factory event from response.
12 |
13 | :returns: SpeakerPlayerEvent instance or None if response is unsupported
14 | """
15 | valid_responses_map = {
16 | 'StartPlaybackEvent': 'speaker.player.playback_started',
17 | 'StopPlaybackEvent': 'speaker.player.playback_ended',
18 | 'EndPlaybackEvent': 'speaker.player.playback_ended',
19 | 'PausePlaybackEvent': 'speaker.player.playback_paused',
20 | 'MediaBufferStartEvent': 'speaker.player.buffering_started',
21 | 'MediaBufferEndEvent': 'speaker.player.buffering_ended',
22 | }
23 |
24 | if response.name not in valid_responses_map:
25 | return None
26 |
27 | return cls(valid_responses_map[response.name])
28 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/service.py:
--------------------------------------------------------------------------------
1 | """Speaker music service integrations."""
2 | import abc
3 |
4 |
5 | class Service(metaclass=abc.ABCMeta):
6 | """
7 | Abstract service
8 | """
9 |
10 | def __init__(self, browser):
11 | """
12 | :param browser: Browser instance specific to implementing service
13 | """
14 | self._browser = browser
15 |
16 | @property
17 | @abc.abstractmethod
18 | def name(self):
19 | """
20 | :returns: Name of the service
21 | """
22 | raise NotImplementedError()
23 |
24 | @property
25 | def browser(self):
26 | """
27 | :returns: Compatible Browser instance for this service
28 | """
29 | return self._browser
30 |
31 | def login(self, username, password):
32 | """
33 | Authenticate with username/password.
34 |
35 | :param username:
36 | :param password:
37 | """
38 |
39 | def logout(self):
40 | """
41 | Reset authentication state.
42 | """
43 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/dlna/test_service_dlnaservice.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service.dlna import DlnaBrowser
5 | from samsung_multiroom.service.dlna import DlnaService
6 |
7 |
8 | def _get_service():
9 | api = MagicMock()
10 |
11 | service = DlnaService(api)
12 |
13 | return (service, api)
14 |
15 |
16 | class TestDlnaService(unittest.TestCase):
17 |
18 | def test_name(self):
19 | service, api = _get_service()
20 |
21 | self.assertEqual(service.name, 'dlna')
22 |
23 | def test_browser(self):
24 | service, api = _get_service()
25 |
26 | browser = service.browser
27 |
28 | self.assertIsInstance(browser, DlnaBrowser)
29 |
30 | def test_login(self):
31 | service, api = _get_service()
32 |
33 | service.login('test_username', 'test_password')
34 |
35 | api.set_sign_in.assert_not_called()
36 |
37 | def test_logout(self):
38 | service, api = _get_service()
39 |
40 | service.logout()
41 |
42 | api.set_sign_out.assert_not_called()
43 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_service.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_service import SpeakerServiceEvent
5 |
6 |
7 | class TestSpeakerPlayerEvent(unittest.TestCase):
8 |
9 | def test_factory(self):
10 | responses = {
11 | 'CpChanged': 'speaker.service.changed',
12 | 'SignInStatus': 'speaker.service.logged_in',
13 | 'SignOutStatus': 'speaker.service.logged_out',
14 | 'OtherResponse': None,
15 | }
16 |
17 | for response_name, event_name in responses.items():
18 | response = MagicMock()
19 | response.name = response_name
20 | response.data = {'cpname': 'MyService'}
21 |
22 | event = SpeakerServiceEvent.factory(response)
23 |
24 | if event_name:
25 | self.assertIsInstance(event, SpeakerServiceEvent)
26 | self.assertEqual(event.name, event_name)
27 | self.assertEqual(event.service_name, 'MyService')
28 | else:
29 | self.assertIsNone(event)
30 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/tunein/test_service_tuneinservice.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service.tunein import TuneInBrowser
5 | from samsung_multiroom.service.tunein import TuneInService
6 |
7 |
8 | def _get_service():
9 | api = MagicMock()
10 |
11 | service = TuneInService(api)
12 |
13 | return (service, api)
14 |
15 |
16 | class TestTuneInService(unittest.TestCase):
17 |
18 | def test_name(self):
19 | service, api = _get_service()
20 |
21 | self.assertEqual(service.name, 'tunein')
22 |
23 | def test_browser(self):
24 | service, api = _get_service()
25 |
26 | browser = service.browser
27 |
28 | self.assertIsInstance(browser, TuneInBrowser)
29 |
30 | def test_login(self):
31 | service, api = _get_service()
32 |
33 | service.login('test_username', 'test_password')
34 |
35 | api.set_sign_in.assert_not_called()
36 |
37 | def test_logout(self):
38 | service, api = _get_service()
39 |
40 | service.logout()
41 |
42 | api.set_sign_out.assert_not_called()
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Krystian Galutowski
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/type/test_speaker_player.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.event.type.speaker_player import SpeakerPlayerEvent
5 |
6 |
7 | class TestSpeakerPlayerEvent(unittest.TestCase):
8 |
9 | def test_factory(self):
10 | responses = {
11 | 'StartPlaybackEvent': 'speaker.player.playback_started',
12 | 'StopPlaybackEvent': 'speaker.player.playback_ended',
13 | 'EndPlaybackEvent': 'speaker.player.playback_ended',
14 | 'PausePlaybackEvent': 'speaker.player.playback_paused',
15 | 'MediaBufferStartEvent': 'speaker.player.buffering_started',
16 | 'MediaBufferEndEvent': 'speaker.player.buffering_ended',
17 | 'OtherResponse': None,
18 | }
19 |
20 | for response_name, event_name in responses.items():
21 | response = MagicMock()
22 | response.name = response_name
23 |
24 | event = SpeakerPlayerEvent.factory(response)
25 |
26 | if event_name:
27 | self.assertIsInstance(event, SpeakerPlayerEvent)
28 | self.assertEqual(event.name, event_name)
29 | else:
30 | self.assertIsNone(event)
31 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_service.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ..event import Event
3 |
4 |
5 | class SpeakerServiceEvent(Event):
6 | """Event when service is changed."""
7 |
8 | def __init__(self, name, service_name):
9 | """
10 | :param name: Event name
11 | :param service_name: Name of the service selected
12 | """
13 | super().__init__(name)
14 |
15 | self._service_name = service_name
16 |
17 | @property
18 | def service_name(self):
19 | """
20 | :returns: Name of the service selected
21 | """
22 | return self._service_name
23 |
24 | @classmethod
25 | def factory(cls, response):
26 | """
27 | Factory event from response.
28 |
29 | :returns: SpeakerServiceEvent instance or None if response is unsupported
30 | """
31 | valid_responses_map = {
32 | 'CpChanged': 'speaker.service.changed',
33 | 'SignInStatus': 'speaker.service.logged_in',
34 | 'SignOutStatus': 'speaker.service.logged_out',
35 | }
36 |
37 | if response.name not in valid_responses_map:
38 | return None
39 |
40 | return cls(valid_responses_map[response.name], response.data['cpname'])
41 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/clock/test_group.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import unittest
3 | from unittest.mock import MagicMock
4 |
5 | from samsung_multiroom.clock import ClockGroup
6 |
7 |
8 | def _get_clock_group():
9 | clocks = [
10 | MagicMock(),
11 | MagicMock(),
12 | MagicMock(),
13 | ]
14 |
15 | clock_group = ClockGroup(clocks)
16 |
17 | return (clock_group, clocks)
18 |
19 |
20 | class TestClockGroup(unittest.TestCase):
21 |
22 | def test_set_time_sets_all_clocks(self):
23 | clock_group, clocks = _get_clock_group()
24 |
25 | dt = datetime.datetime.now()
26 |
27 | clock_group.set_time(dt)
28 |
29 | for clock in clocks:
30 | clock.set_time.assert_called_once_with(dt)
31 |
32 | def test_alarm_returns_first_clock_alarm(self):
33 | clock_group, clocks = _get_clock_group()
34 | clocks[0].alarm = MagicMock()
35 |
36 | alarm = clock_group.alarm
37 |
38 | self.assertEqual(alarm, clocks[0].alarm)
39 |
40 | def test_timer_returns_first_clock_timer(self):
41 | clock_group, clocks = _get_clock_group()
42 | clocks[0].timer = MagicMock()
43 |
44 | timer = clock_group.timer
45 |
46 | self.assertEqual(timer, clocks[0].timer)
47 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/type/speaker_player_repeat_changed.py:
--------------------------------------------------------------------------------
1 | """Event."""
2 | from ...service.player import REPEAT_ALL
3 | from ...service.player import REPEAT_OFF
4 | from ...service.player import REPEAT_ONE
5 | from ..event import Event
6 |
7 |
8 | class SpeakerPlayerRepeatChangedEvent(Event):
9 | """Event when player's repeat state changes."""
10 |
11 | def __init__(self, repeat):
12 | """
13 | :param repeat: one of REPEAT_ constants
14 | """
15 | super().__init__('speaker.player.repeat.changed')
16 |
17 | self._repeat = repeat
18 |
19 | @property
20 | def repeat(self):
21 | """
22 | :returns: repeat state, True if repeat is on
23 | """
24 | return self._repeat
25 |
26 | @classmethod
27 | def factory(cls, response):
28 | """
29 | Factory event from response.
30 |
31 | :returns: SpeakerPlayerRepeatChangedEvent instance or None if response is unsupported
32 | """
33 | if response.name != 'RepeatMode':
34 | return None
35 |
36 | mode_map = {
37 | 'all': REPEAT_ALL,
38 | 'one': REPEAT_ONE,
39 | 'off': REPEAT_OFF,
40 | }
41 |
42 | return cls(mode_map[response.data['repeat']])
43 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | python:
4 | - 3.8
5 |
6 | before_script:
7 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
8 | - chmod +x ./cc-test-reporter
9 | - ./cc-test-reporter before-build
10 |
11 | install:
12 | - make ensure-pip-ci
13 | - make dev-ci
14 |
15 | script:
16 | - make checks
17 | - make requirements
18 | - make test-coverage
19 |
20 | after_script:
21 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
22 |
23 | deploy:
24 | provider: pypi
25 | user: krygal
26 | password:
27 | secure: "jG506FBgrMX2M55oOkv1zTzsZp3MIDx0dB4zKyVMsbE0hUrNpWGc8LL+SOPzMovrPQ51dtLe0Tq8mMetW52Ni/PtwwQZaW24J7KeTsCC4hLlnifJmMsarym1evUjGLzCCHxm9ls0RntCTZqS1CRKNQba3D9I/RGAn5Q6Cns8w3HFQND23lmHLIAQSyvVDOBGNofZ72HS8VFxIMsuaCiLrr2tvi90FqOSLjeINfNSjQlsDp6ScfgQOnaWw0fAUE+vz8UZbqzgCVaoXrCetew6/RYWhAmTFrCunaSmwDXrDBenGh4FQ5F4czxm+Y/AM1YrW7hiEXkqdx0OsvD7519XB35D49+MaY5KaF26d1KZxOXsQasCP9Fm+6Hwf6CNsyMk6bIKILDGu4VOcYIfDEsVtqDz5xbSvyY+va5m7NOA6a/t5NQPdLH8rp08UNhgxhLmbliqzCe4/ASwoT3WgMj3x2298aXk+I4nyK7dyxCYABAIqR0GK3LXWq18PS1QB9v+HGeYFMkIHtLOcgbW3PxlqspcphI1cncv6/wleWTvF840DblwGESUCa5GglicTkqa+rNXdmMANjeb2Lrs0RT3+tqbDrsytWLdT/pZ6Vh4w4ThAtnw2f2zfFrkEXPAUsuEmocQBWcSVo/ZE+xbRacebDyL28R7PvCinrfOgq7VZjI="
28 | on:
29 | tags: true
30 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/clock/test_clock.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import unittest
3 | from unittest.mock import MagicMock
4 |
5 | from samsung_multiroom.clock import Clock
6 |
7 |
8 | def get_clock():
9 | api = MagicMock()
10 | alarm = MagicMock()
11 | timer = MagicMock()
12 |
13 | clock = Clock(api, timer, alarm)
14 |
15 | return (clock, api, alarm, timer)
16 |
17 |
18 | NOW = datetime.datetime(2018, 1, 7, 15, 45, 32)
19 |
20 |
21 | class TestClock(unittest.TestCase):
22 |
23 | def test_set_time(self):
24 | clock, api, alarm, timer = get_clock()
25 |
26 | spk_datetime = datetime.datetime.now()
27 |
28 | clock.set_time(spk_datetime)
29 |
30 | api.set_speaker_time.assert_called_once_with(spk_datetime)
31 |
32 | @unittest.mock.patch('datetime.datetime')
33 | def test_set_time_now(self, dt):
34 | clock, api, alarm, timer = get_clock()
35 |
36 | dt.now.return_value = NOW
37 |
38 | clock.set_time()
39 |
40 | api.set_speaker_time.assert_called_once_with(NOW)
41 |
42 | def test_timer(self):
43 | clock, api, alarm, timer = get_clock()
44 |
45 | self.assertEqual(clock.timer, timer)
46 |
47 | def test_alarm(self):
48 | clock, api, alarm, timer = get_clock()
49 |
50 | self.assertEqual(clock.alarm, alarm)
51 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/app/test_service_appservice.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service.app import AppBrowser
5 | from samsung_multiroom.service.app import AppService
6 |
7 |
8 | def _get_service():
9 | api = MagicMock()
10 |
11 | service = AppService(api, 12, 'Service 2')
12 |
13 | return (service, api)
14 |
15 |
16 | class TestAppService(unittest.TestCase):
17 |
18 | def test_name(self):
19 | service, api = _get_service()
20 |
21 | self.assertEqual(service.name, 'Service 2')
22 |
23 | def test_browser(self):
24 | service, api = _get_service()
25 |
26 | browser = service.browser
27 |
28 | api.set_cp_service.assert_called_once_with(12)
29 | self.assertIsInstance(browser, AppBrowser)
30 |
31 | def test_login(self):
32 | service, api = _get_service()
33 |
34 | service.login('test_username', 'test_password')
35 |
36 | api.set_cp_service.assert_called_once_with(12)
37 | api.set_sign_in.assert_called_once_with('test_username', 'test_password')
38 |
39 | def test_logout(self):
40 | service, api = _get_service()
41 |
42 | service.logout()
43 |
44 | api.set_cp_service.assert_called_once_with(12)
45 | api.set_sign_out.assert_called_once()
46 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/app/service.py:
--------------------------------------------------------------------------------
1 | """Generic music streaming app service."""
2 | from ..service import Service
3 | from .browser import AppBrowser
4 |
5 |
6 | class AppService(Service):
7 | """
8 | Generic music streaming app service.
9 | """
10 |
11 | def __init__(self, api, app_id, app_name):
12 | super().__init__(AppBrowser(api, app_id, app_name))
13 |
14 | self._api = api
15 | self._id = app_id
16 | self._name = app_name
17 |
18 | @property
19 | def name(self):
20 | """
21 | :returns: Name of the service
22 | """
23 | return self._name
24 |
25 | @property
26 | def browser(self):
27 | """
28 | :returns: Compatible Browser instance for this service
29 | """
30 | self._api.set_cp_service(self._id)
31 | return super().browser
32 |
33 | def login(self, username, password):
34 | """
35 | Authenticate with username/password.
36 |
37 | :param username:
38 | :param password:
39 | """
40 | self._api.set_cp_service(self._id)
41 | self._api.set_sign_in(username, password)
42 |
43 | def logout(self):
44 | """
45 | Reset authentication state.
46 | """
47 | self._api.set_cp_service(self._id)
48 | self._api.set_sign_out()
49 |
--------------------------------------------------------------------------------
/samsung_multiroom/factory.py:
--------------------------------------------------------------------------------
1 | """Factory for Speaker."""
2 | import uuid
3 |
4 | from .api import ApiStream
5 | from .api import SamsungMultiroomApi
6 | from .clock import Alarm
7 | from .clock import Clock
8 | from .clock import Timer
9 | from .equalizer import Equalizer
10 | from .event import EventLoop
11 | from .service import PlayerOperator
12 | from .service import ServiceRegistry
13 | from .service.app import AppPlayer
14 | from .service.dlna import DlnaPlayer
15 | from .service.tunein import TuneInPlayer
16 | from .speaker import Speaker
17 |
18 |
19 | def speaker_factory(ip_address, port=55001):
20 | """
21 | Factory for Speaker.
22 |
23 | :param ip_address: IP address of the speaker.
24 | """
25 | user = str(uuid.uuid1())
26 | api = SamsungMultiroomApi(user, ip_address, port=port)
27 | api_stream = ApiStream(user, ip_address)
28 |
29 | timer = Timer(api)
30 | alarm = Alarm(api)
31 | clock = Clock(api, timer, alarm)
32 |
33 | equalizer = Equalizer(api)
34 |
35 | players = [
36 | DlnaPlayer(api),
37 | TuneInPlayer(api),
38 | AppPlayer(api),
39 | ]
40 | player_operator = PlayerOperator(api, players)
41 |
42 | service_registry = ServiceRegistry(api)
43 |
44 | event_loop = EventLoop(api_stream)
45 |
46 | return Speaker(api, event_loop, clock, equalizer, player_operator, service_registry)
47 |
--------------------------------------------------------------------------------
/samsung_multiroom/clock/group.py:
--------------------------------------------------------------------------------
1 | """Control time functions for the group of the speakers."""
2 | from .clock import ClockBase
3 |
4 |
5 | class ClockGroup(ClockBase):
6 | """
7 | Control time functions for the group of the speakers.
8 |
9 | Due to the nature of alarms and timers, it is only required to use those on the main speaker of the group.
10 | """
11 |
12 | def __init__(self, clocks):
13 | """
14 | :param clocks: List of Clock instances
15 | """
16 | self._clocks = clocks
17 |
18 | @property
19 | def clocks(self):
20 | """
21 | :returns: List of Clock instances in group
22 | """
23 | return self._clocks
24 |
25 | def set_time(self, speaker_datetime=None):
26 | """
27 | Set current time for all speakers in group.
28 |
29 | :param speaker_datetime: Datetime object
30 | """
31 | for clock in self._clocks:
32 | clock.set_time(speaker_datetime)
33 |
34 | @property
35 | def alarm(self):
36 | """
37 | It is not possible to update alarm functions while the speaker is part of a group.
38 |
39 | :returns: Alarm instance of the first clock
40 | """
41 | return self._clocks[0].alarm
42 |
43 | @property
44 | def timer(self):
45 | """
46 | :returns: Timer instance of the first clock
47 | """
48 | return self._clocks[0].timer
49 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/service_registry.py:
--------------------------------------------------------------------------------
1 | """Entry point for service initialisation."""
2 | from ..api import paginator
3 | from .app import AppService
4 | from .dlna import DlnaService
5 | from .tunein import TuneInService
6 |
7 |
8 | class ServiceRegistry:
9 | """
10 | Entry point for service initialisation.
11 | """
12 |
13 | def __init__(self, api):
14 | """
15 | :param api:
16 | """
17 | self._api = api
18 |
19 | def get_services_names(self):
20 | """
21 | Get list of supported services names.
22 |
23 | :returns: List
24 | """
25 | base_names = [
26 | 'dlna',
27 | 'tunein',
28 | ]
29 |
30 | app_list = self._get_app_list()
31 | app_names = [s['cpname'] for s in app_list]
32 |
33 | return base_names + app_names
34 |
35 | def service(self, name):
36 | """
37 | Get service by name.
38 |
39 | :returns: Service instance
40 | """
41 | base_services = {
42 | 'dlna': DlnaService(self._api),
43 | 'tunein': TuneInService(self._api),
44 | }
45 |
46 | app_list = self._get_app_list()
47 | app_services = {s['cpname']: AppService(self._api, s['cpid'], s['cpname']) for s in app_list}
48 |
49 | return {**base_services, **app_services}[name]
50 |
51 | def _get_app_list(self):
52 | return paginator(self._api.get_cp_list, 0, 30)
53 |
--------------------------------------------------------------------------------
/samsung_multiroom/discovery.py:
--------------------------------------------------------------------------------
1 | """Discover speakers on your local network."""
2 | from urllib.parse import urlparse
3 |
4 | import upnpclient
5 |
6 | from .factory import speaker_factory
7 |
8 |
9 | class SpeakerDiscovery:
10 | """
11 | Discover speakers on your local network.
12 | """
13 |
14 | def __init__(self):
15 | """Init."""
16 | self._speakers = {}
17 |
18 | def discover(self):
19 | """
20 | Discover speakers.
21 |
22 | :returns: List of Speaker instances
23 | """
24 |
25 | devices = upnpclient.discover()
26 | for device in devices:
27 | if not self._is_compatible_device(device):
28 | continue
29 |
30 | url = urlparse(device.location)
31 | hostname = url.hostname
32 |
33 | if hostname in self._speakers:
34 | continue
35 |
36 | self._speakers[hostname] = speaker_factory(hostname)
37 |
38 | return list(self._speakers.values())
39 |
40 | def _is_compatible_device(self, device):
41 | """
42 | Samsung speakers report on /smp_3_ and /smp_7_
43 | Samsung TVs report on /smp_2_ /smp_7_ /smp_15_ /smp_25_
44 |
45 | Therefore /smp_3_ seems to be specific to Samsung speakers.
46 | """
47 | valid_services = [s for s in device.services if s.service_id == 'urn:samsung.com:serviceId:MultiScreenService']
48 | valid_location = '/smp_3_' in device.location
49 |
50 | return valid_services and valid_location
51 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/test_discovery.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom import SamsungSpeakerDiscovery
5 |
6 |
7 | class TestSpeakerDiscovery(unittest.TestCase):
8 |
9 | @unittest.mock.patch('upnpclient.discover')
10 | def test_discover(self, upnpclient):
11 | upnp_devices = [
12 | MagicMock(),
13 | MagicMock(),
14 | MagicMock(),
15 | MagicMock(),
16 | ]
17 | upnpclient.return_value = upnp_devices
18 |
19 | upnp_devices[0].location = 'http://192.168.1.129:7676/smp_3_'
20 | upnp_devices[1].location = 'http://192.168.1.165:7676/smp_3_'
21 | upnp_devices[2].location = 'http://192.168.1.216:7676/smp_3_'
22 | upnp_devices[3].location = 'http://192.168.1.60:7676/smp_7_'
23 |
24 | upnp_devices[0].services = [MagicMock(service_id='urn:samsung.com:serviceId:MultiScreenService')]
25 | upnp_devices[1].services = [MagicMock(service_id='invalid service')]
26 | upnp_devices[2].services = [MagicMock(service_id='urn:samsung.com:serviceId:MultiScreenService')]
27 | upnp_devices[3].services = [MagicMock(service_id='urn:samsung.com:serviceId:MultiScreenService')]
28 |
29 | speaker_discovery = SamsungSpeakerDiscovery()
30 | speakers = speaker_discovery.discover()
31 |
32 | self.assertEqual(len(speakers), 2)
33 | self.assertEqual(speakers[0].ip_address, '192.168.1.129')
34 | self.assertEqual(speakers[1].ip_address, '192.168.1.216')
35 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/test_player.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from samsung_multiroom.service import Player
4 | from samsung_multiroom.service.player import unsupported
5 |
6 |
7 | class FakePlayer(Player):
8 |
9 | def play(self, playlist):
10 | """Fake."""
11 | return False
12 |
13 | def jump(self, time):
14 | """Fake."""
15 |
16 | def resume(self):
17 | """Fake."""
18 |
19 | def stop(self):
20 | """Fake."""
21 |
22 | def pause(self):
23 | """Fake."""
24 |
25 | def next(self):
26 | """Fake."""
27 |
28 | def previous(self):
29 | """Fake."""
30 |
31 | @unsupported
32 | def repeat(self, mode):
33 | """Fake."""
34 |
35 | def shuffle(self, enabled):
36 | """Fake."""
37 |
38 | def get_repeat(self):
39 | """Fake."""
40 | return REPEAT_OFF
41 |
42 | def get_shuffle(self):
43 | """Fake."""
44 | return False
45 |
46 | def get_current_track(self):
47 | """Fake."""
48 | return None
49 |
50 | def is_active(self, function, submode=None):
51 | """Fake."""
52 | return True
53 |
54 |
55 |
56 | class TestPlayer(unittest.TestCase):
57 |
58 | def test_unsupported(self):
59 | player = FakePlayer()
60 | self.assertTrue(player.is_play_supported())
61 | self.assertFalse(player.is_repeat_supported())
62 |
63 | self.assertTrue(hasattr(player, 'is_play_supported'))
64 | self.assertTrue(hasattr(player, 'is_repeat_supported'))
65 | self.assertFalse(hasattr(player, 'is_nonsense_supported'))
66 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/event/test_event_loop.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | import pytest
5 |
6 | from samsung_multiroom.api import ApiResponse
7 | from samsung_multiroom.event import EventLoop
8 | from samsung_multiroom.event.event import Event
9 |
10 |
11 | def _get_event_loop():
12 | api_stream = MagicMock()
13 |
14 | event_loop = EventLoop(api_stream)
15 |
16 | return (event_loop, api_stream)
17 |
18 |
19 | def _fake_event_factory(response):
20 | event = MagicMock(spec=Event)
21 | event.name = 'fake.event'
22 | return event
23 |
24 |
25 | def _get_api_response(name):
26 | event = MagicMock(spec=ApiResponse)
27 | event.name = name
28 | return event
29 |
30 |
31 | class TestEventLoop(): # pytest-asyncio doesn't play well with unittest.TestCase
32 |
33 | @pytest.mark.asyncio
34 | async def test_loop(self):
35 | listener = MagicMock()
36 |
37 | event_loop, api_stream = _get_event_loop()
38 |
39 | api_stream.open.return_value = iter([
40 | _get_api_response('FakeEvent'),
41 | ])
42 |
43 | event_loop.register_factory(_fake_event_factory)
44 | event_loop.add_listener('fake.event', listener)
45 |
46 | await event_loop.loop()
47 |
48 | listener.assert_called_once()
49 |
50 | @pytest.mark.asyncio
51 | async def test_loop_no_events(self):
52 | listener = MagicMock()
53 |
54 | event_loop, api_stream = _get_event_loop()
55 |
56 | api_stream.open.return_value = iter([
57 | _get_api_response('FakeEvent'),
58 | ])
59 |
60 | event_loop.add_listener('*', listener)
61 |
62 | await event_loop.loop()
63 |
64 | listener.assert_not_called()
65 |
--------------------------------------------------------------------------------
/samsung_multiroom/clock/clock.py:
--------------------------------------------------------------------------------
1 | """Control speaker's clock functions."""
2 | import abc
3 | import datetime
4 |
5 |
6 | class ClockBase(metaclass=abc.ABCMeta):
7 | """
8 | Clock interface to control time functions of the speaker.
9 | """
10 |
11 | def set_time(self, speaker_datetime=None):
12 | """
13 | Set speaker's current time.
14 |
15 | :param speaker_datetime: Datetime object
16 | """
17 | raise NotImplementedError()
18 |
19 | @property
20 | def alarm(self):
21 | """
22 | :returns: Alarm instance
23 | """
24 | raise NotImplementedError()
25 |
26 | @property
27 | def timer(self):
28 | """
29 | :returns: Timer instance
30 | """
31 | raise NotImplementedError()
32 |
33 |
34 | class Clock(ClockBase):
35 | """
36 | Control time functions of the speaker.
37 | """
38 |
39 | def __init__(self, api, timer, alarm):
40 | """
41 | :param api: SamsungMultiroomApi instance
42 | """
43 | self._api = api
44 | self._timer = timer
45 | self._alarm = alarm
46 |
47 | def set_time(self, speaker_datetime=None):
48 | """
49 | Set speaker's current time.
50 |
51 | :param speaker_datetime: Datetime object
52 | """
53 | if speaker_datetime is None:
54 | speaker_datetime = datetime.datetime.now()
55 |
56 | self._api.set_speaker_time(speaker_datetime)
57 |
58 | @property
59 | def alarm(self):
60 | """
61 | :returns: Alarm instance
62 | """
63 | return self._alarm
64 |
65 | @property
66 | def timer(self):
67 | """
68 | :returns: Timer instance
69 | """
70 | return self._timer
71 |
--------------------------------------------------------------------------------
/development.txt:
--------------------------------------------------------------------------------
1 | -i https://pypi.org/simple
2 | astroid==2.4.2; python_version >= '3.5'
3 | attrs==20.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
4 | autopep8==1.5.4
5 | certifi==2020.6.20
6 | chardet==3.0.4
7 | coverage==5.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
8 | flake8==3.8.4
9 | http-parser==0.9.0
10 | httpretty==1.0.2
11 | idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
12 | ifaddr==0.1.7
13 | iniconfig==1.1.1
14 | isort==5.6.4; python_version >= '3.6' and python_version < '4.0'
15 | lazy-object-proxy==1.4.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
16 | lxml==4.5.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
17 | mccabe==0.6.1
18 | netdisco==2.8.2; python_version >= '3'
19 | packaging==20.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
20 | pluggy==0.13.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
21 | py==1.9.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
22 | pycodestyle==2.6.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
23 | pyflakes==2.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
24 | pylint==2.6.0
25 | pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
26 | pytest-asyncio==0.14.0
27 | pytest-cov==2.10.1
28 | pytest==6.1.1
29 | python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
30 | requests==2.24.0
31 | six==1.15.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
32 | toml==0.10.1
33 | upnpclient==0.0.8
34 | urllib3==1.25.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'
35 | wrapt==1.12.1
36 | xmltodict==0.12.0
37 | yapf==0.30.0
38 | zeroconf==0.28.6
39 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/tunein/browser.py:
--------------------------------------------------------------------------------
1 | """TuneIn service browser."""
2 | from ...api import paginator
3 | from ..browser import Browser
4 | from ..browser import Item
5 | from ..browser import path_to_folders
6 |
7 |
8 | class TuneInBrowser(Browser):
9 | """
10 | TuneIn radio browser.
11 | """
12 |
13 | def __init__(self, api, path=None, items=None):
14 | super().__init__(path, items)
15 |
16 | self._api = api
17 |
18 | def get_name(self):
19 | return 'tunein'
20 |
21 | def browse(self, path=None):
22 | folders = path_to_folders(path)
23 |
24 | items = self._get_initial_items(folders)
25 |
26 | for folder in folders:
27 | # locate prepopulated items matching folder
28 | parent_id = next(iter([i.object_id for i in items if i.name == folder]), None)
29 |
30 | if parent_id is None:
31 | data_list = paginator(self._api.browse_main, 0, 30)
32 | else:
33 | data_list = paginator(self._api.get_select_radio_list, self._api.get_current_radio_list, parent_id, 0,
34 | 30)
35 |
36 | items = [self._factory_item(data) for data in data_list]
37 |
38 | return TuneInBrowser(self._api, self._merge_path(path), items)
39 |
40 | def _factory_item(self, data):
41 | kwargs = {'metadata': {}}
42 |
43 | # mandatory
44 | if 'contentid' in data:
45 | kwargs['object_id'] = data['contentid']
46 |
47 | if '@type' in data and data['@type'] == '2':
48 | kwargs['object_type'] = 'tunein_radio'
49 | else:
50 | kwargs['object_type'] = 'container'
51 |
52 | if 'title' in data:
53 | kwargs['name'] = data['title']
54 |
55 | # metadata
56 | if 'thumbnail' in data:
57 | kwargs['metadata']['thumbnail_url'] = data['thumbnail']
58 |
59 | return Item(**kwargs)
60 |
--------------------------------------------------------------------------------
/samsung_multiroom/api/api_response.py:
--------------------------------------------------------------------------------
1 | """
2 | Parse API XML response.
3 | """
4 | import xmltodict
5 |
6 |
7 | class ApiResponse:
8 | """
9 | Extract key information from api response body text.
10 | """
11 |
12 | def __init__(self, response_text):
13 | self._name = None
14 | self._user = None
15 | self._success = None
16 | self._data = None
17 | self._raw = None
18 |
19 | self._parse(response_text)
20 |
21 | @property
22 | def name(self):
23 | """
24 | :returns: resopnse type/name
25 | """
26 | return self._name
27 |
28 | @property
29 | def user(self):
30 | """
31 | :returns: resopnse user identifier
32 | """
33 | return self._user
34 |
35 | @property
36 | def success(self):
37 | """
38 | :returns: True if response was successful
39 | """
40 | return self._success
41 |
42 | @property
43 | def data(self):
44 | """
45 | :returns: response data
46 | """
47 | return self._data
48 |
49 | @property
50 | def raw(self):
51 | """
52 | :returns: Raw response text
53 | """
54 | return self._raw
55 |
56 | def _parse(self, response_text):
57 | self._success = False
58 | self._raw = response_text
59 |
60 | try:
61 | response_dict = xmltodict.parse(response_text)
62 | except xmltodict.expat.ExpatError:
63 | return
64 |
65 | # for some requests speaker returns command in response that does not match request command
66 | try:
67 | response_command = next(iter(response_dict))
68 |
69 | self._name = response_dict[response_command]['method']
70 | self._data = response_dict[response_command]['response']
71 | self._user = response_dict[response_command]['user_identifier'] or None
72 | self._success = (self._data['@result'] == 'ok')
73 |
74 | # redundant
75 | del self._data['@result']
76 |
77 | return
78 | except KeyError:
79 | pass
80 |
--------------------------------------------------------------------------------
/samsung_multiroom/equalizer/group.py:
--------------------------------------------------------------------------------
1 | """Group multiple equalizers together to act on all simultaneously."""
2 | from .equalizer import EqualizerBase
3 |
4 |
5 | class EqualizerGroup(EqualizerBase):
6 | """
7 | Group multiple equalizers together to act on all simultaneously.
8 | """
9 |
10 | def __init__(self, equalizers):
11 | """
12 | :param equalizers: List of EqualizerBase implementations
13 | """
14 | self._equalizers = equalizers
15 |
16 | @property
17 | def equalizers(self):
18 | """
19 | :returns: List of equalizers
20 | """
21 | return self._equalizers
22 |
23 | def get_presets_names(self):
24 | """
25 | :returns: List of preset names shared by all equalizers.
26 | """
27 | presets_names = self._equalizers[0].get_presets_names()
28 |
29 | for equalizer in self._equalizers[1:]:
30 | eq_presets_names = equalizer.get_presets_names()
31 | presets_names = [p for p in presets_names if p in eq_presets_names]
32 |
33 | return presets_names
34 |
35 | def set(self, *args):
36 | """
37 | Set equalizer values by preset name or list of band values for all equalizers.
38 |
39 | :param: Name of the preset to set or list of 7 band values to set
40 | """
41 | for equalizer in self._equalizers:
42 | equalizer.set(*args)
43 |
44 | def save(self, name=None):
45 | """
46 | Create a new or overwrite existing preset for all equalizers.
47 |
48 | :param name: If empty, current preset will be saved, if provided name exist, that preset will overwrite existing
49 | one, otherwise a new preset is created with that name. In either case band values used will be ones
50 | currently set on a speaker e.g. via set() method.
51 | """
52 | for equalizer in self._equalizers:
53 | equalizer.save(name)
54 |
55 | def delete(self, name):
56 | """
57 | Delete preset from all equalizers.
58 |
59 | :param name: Name of a preset to delete
60 | """
61 | for equalizer in self._equalizers:
62 | equalizer.delete(name)
63 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/equalizer/test_group.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.equalizer import EqualizerGroup
5 |
6 |
7 | def _get_equalizer_group():
8 | equalizers = [
9 | MagicMock(),
10 | MagicMock(),
11 | MagicMock(),
12 | ]
13 |
14 | equalizer_group = EqualizerGroup(equalizers)
15 |
16 | return (equalizer_group, equalizers)
17 |
18 |
19 | class TestEqualizerGroup(unittest.TestCase):
20 |
21 | def test_get_presets_names_returns_shared(self):
22 | equalizer_group, equalizers = _get_equalizer_group()
23 |
24 | equalizers[0].get_presets_names.return_value = ['None', 'Pop', 'Jazz', 'Classic', 'Custom 1', 'Custom 2']
25 | equalizers[1].get_presets_names.return_value = ['None', 'Pop', 'Jazz', 'Classic', 'Custom 2', 'Custom 3']
26 | equalizers[2].get_presets_names.return_value = ['None', 'Pop', 'Jazz', 'Classic', 'Custom 2', 'Custom 4']
27 |
28 | presets_names = equalizer_group.get_presets_names()
29 |
30 | self.assertEqual(presets_names, ['None', 'Pop', 'Jazz', 'Classic', 'Custom 2'])
31 |
32 | def test_set_sets_all_equalizers(self):
33 | equalizer_group, equalizers = _get_equalizer_group()
34 |
35 | equalizer_group.set('Jazz')
36 |
37 | equalizers[0].set.assert_called_once_with('Jazz')
38 | equalizers[1].set.assert_called_once_with('Jazz')
39 | equalizers[2].set.assert_called_once_with('Jazz')
40 |
41 | def test_save_saves_all_equalizers(self):
42 | equalizer_group, equalizers = _get_equalizer_group()
43 |
44 | equalizer_group.save('Custom 7')
45 |
46 | equalizers[0].save.assert_called_once_with('Custom 7')
47 | equalizers[1].save.assert_called_once_with('Custom 7')
48 | equalizers[2].save.assert_called_once_with('Custom 7')
49 |
50 | def test_delete_deletes_all_equalizers(self):
51 | equalizer_group, equalizers = _get_equalizer_group()
52 |
53 | equalizer_group.delete('Custom 7')
54 |
55 | equalizers[0].delete.assert_called_once_with('Custom 7')
56 | equalizers[1].delete.assert_called_once_with('Custom 7')
57 | equalizers[2].delete.assert_called_once_with('Custom 7')
58 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build
2 |
3 | MODULE:=samsung_multiroom
4 |
5 | all: ensure-pip dev style checks dists test
6 |
7 | ensure-pip:
8 | pip install --user --upgrade pipenv pip
9 | pip --version
10 | pipenv --version
11 |
12 | ensure-pip-ci:
13 | pip install --upgrade pipenv pip
14 | pip --version
15 | pipenv --version
16 |
17 | dev:
18 | pipenv install --dev
19 | pipenv run pip install -e .
20 |
21 | dev-ci:
22 | pipenv install --dev --deploy
23 | pipenv run pip install -e .
24 |
25 | style: isort autopep8 yapf
26 |
27 | isort:
28 | pipenv run isort .
29 |
30 | autopep8:
31 | pipenv run autopep8 --in-place --recursive setup.py $(MODULE)
32 |
33 | yapf:
34 | pipenv run yapf --style .yapf --recursive -i $(MODULE)
35 |
36 | checks: flake8 pylint
37 |
38 | flake8:
39 | pipenv run python setup.py flake8
40 |
41 | pylint:
42 | pipenv run pylint --rcfile=.pylintrc --output-format=colorized $(MODULE)
43 |
44 | sc: style checks
45 | sct: style checks test
46 |
47 | build: dists
48 |
49 | test:
50 | pipenv run pytest
51 |
52 | test-verbose:
53 | pipenv run pytest -s
54 |
55 | test-coverage:
56 | pipenv run py.test -v --cov $(MODULE) --cov-report term-missing --cov-report html --cov-report xml:coverage.xml
57 |
58 | dists: requirements sdist bdist wheels
59 |
60 | requirements:
61 | pipenv lock -r > requirements.txt
62 | pipenv lock -r -d > development.txt
63 |
64 | release: requirements
65 |
66 | sdist: requirements
67 | pipenv run python setup.py sdist
68 |
69 | bdist: requirements
70 | pipenv run python setup.py bdist
71 |
72 | wheels: requirements
73 | pipenv run python setup.py bdist_wheel
74 |
75 | pypi-publish: build release
76 | pipenv run twine upload --repository-url=https://upload.pypi.org/legacy/ dist/*.whl
77 |
78 | update:
79 | pipenv update --clear
80 |
81 | githook: style
82 |
83 | push: githook
84 | @git push origin --tags
85 |
86 | clean:
87 | pipenv --rm
88 |
89 | prepare-release: requirements
90 |
91 | # aliases to gracefully handle typos on poor dev's terminal
92 | check: checks
93 | devel: dev
94 | develop: dev
95 | dist: dists
96 | install: install-system
97 | pypi: pypi-publish
98 | styles: style
99 | wheel: wheels
100 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/test_nullplayer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service import REPEAT_OFF
5 | from samsung_multiroom.service import REPEAT_ONE
6 | from samsung_multiroom.service.player_operator import NullPlayer
7 |
8 |
9 | class TestNullPlayer(unittest.TestCase):
10 |
11 | def test_is_supported(self):
12 | player = NullPlayer()
13 |
14 | self.assertFalse(player.is_play_supported())
15 | self.assertFalse(player.is_jump_supported())
16 | self.assertFalse(player.is_resume_supported())
17 | self.assertFalse(player.is_stop_supported())
18 | self.assertFalse(player.is_pause_supported())
19 | self.assertFalse(player.is_next_supported())
20 | self.assertFalse(player.is_previous_supported())
21 | self.assertFalse(player.is_repeat_supported())
22 | self.assertFalse(player.is_shuffle_supported())
23 |
24 | def test_play(self):
25 | player = NullPlayer()
26 | self.assertFalse(player.play(MagicMock()))
27 |
28 | def test_jump(self):
29 | player = NullPlayer()
30 | player.jump(50)
31 |
32 | def test_resume(self):
33 | player = NullPlayer()
34 | player.resume()
35 |
36 | def test_stop(self):
37 | player = NullPlayer()
38 | player.stop()
39 |
40 | def test_pause(self):
41 | player = NullPlayer()
42 | player.pause()
43 |
44 | def test_next(self):
45 | player = NullPlayer()
46 | player.next()
47 |
48 | def test_previous(self):
49 | player = NullPlayer()
50 | player.previous()
51 |
52 | def test_get_current_track(self):
53 | player = NullPlayer()
54 | track = player.get_current_track()
55 |
56 | def test_repeat(self):
57 | player = NullPlayer()
58 | player.repeat(REPEAT_ONE)
59 |
60 | def test_shuffle(self):
61 | player = NullPlayer()
62 | player.shuffle(True)
63 |
64 | def test_get_repeat(self):
65 | player = NullPlayer()
66 | repeat = player.get_repeat()
67 |
68 | self.assertEqual(repeat, REPEAT_OFF)
69 |
70 | def test_get_shuffle(self):
71 | player = NullPlayer()
72 | shuffle = player.get_shuffle()
73 |
74 | self.assertFalse(shuffle)
75 |
76 | def test_is_active(self):
77 | player = NullPlayer()
78 |
79 | self.assertTrue(player.is_active('wifi', 'cp'))
80 | self.assertTrue(player.is_active('wifi', 'dlna'))
81 | self.assertTrue(player.is_active('bt'))
82 | self.assertTrue(player.is_active(None))
83 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/app/browser.py:
--------------------------------------------------------------------------------
1 | """Generic music streaming app service browser."""
2 | from ...api import paginator
3 | from ..browser import Browser
4 | from ..browser import Item
5 | from ..browser import path_to_folders
6 |
7 |
8 | class AppBrowser(Browser):
9 | """
10 | Generic music streaming app service browser.
11 | """
12 |
13 | def __init__(self, api, app_id, app_name, path=None, items=None):
14 | super().__init__(path, items)
15 |
16 | self._api = api
17 | self._id = app_id
18 | self._name = app_name
19 |
20 | def get_name(self):
21 | return self._name
22 |
23 | def browse(self, path=None):
24 | folders = path_to_folders(path)
25 |
26 | items = self._get_initial_items(folders)
27 | depth = self._get_initial_depth(folders)
28 |
29 | for folder in folders:
30 | depth += 1
31 |
32 | # locate prepopulated items matching folder
33 | parent_id = next(iter([i.object_id for i in items if i.name == folder]), None)
34 |
35 | if parent_id is None:
36 | data_list = self._api.get_cp_submenu()
37 | elif depth <= 2:
38 | data_list = paginator(self._api.set_select_cp_submenu, parent_id, 0, 30)
39 | else:
40 | data_list = paginator(self._api.get_select_radio_list, self._api.get_current_radio_list, parent_id, 0,
41 | 30)
42 |
43 | items = [self._factory_item(data) for data in data_list]
44 |
45 | return AppBrowser(self._api, self._id, self._name, self._merge_path(path), items)
46 |
47 | def _factory_item(self, data):
48 | kwargs = {'metadata': {}}
49 |
50 | # mandatory
51 | if '@id' in data:
52 | kwargs['object_id'] = data['@id']
53 | if 'contentid' in data:
54 | kwargs['object_id'] = data['contentid']
55 |
56 | if '@type' in data and data['@type'] in ('1', '2'):
57 | kwargs['object_type'] = 'app_audio'
58 | else:
59 | kwargs['object_type'] = 'container'
60 |
61 | if 'submenuitem_localized' in data:
62 | kwargs['name'] = data['submenuitem_localized']
63 | if 'title' in data:
64 | kwargs['name'] = data['title']
65 |
66 | # metadata
67 | if 'artist' in data:
68 | kwargs['metadata']['artist'] = data['artist']
69 | if 'album' in data:
70 | kwargs['metadata']['album'] = data['album']
71 | if 'tracklength' in data:
72 | kwargs['metadata']['duration'] = int(data['tracklength'])
73 | if 'thumbnail' in data:
74 | kwargs['metadata']['thumbnail_url'] = data['thumbnail']
75 |
76 | return Item(**kwargs)
77 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/api/test_api_response.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from samsung_multiroom.api import ApiResponse
4 |
5 |
6 | class TestApiResponse(unittest.TestCase):
7 |
8 | def test_parse_successful_response(self):
9 | response_text = """
10 |
11 | VolumeLevel
12 | 1.0
13 | 192.168.1.129
14 | de890e34-2347-11e9-ab14-d663bd873d93
15 |
16 | 10
17 |
18 | """
19 |
20 | response = ApiResponse(response_text)
21 |
22 | self.assertTrue(response.success)
23 | self.assertEqual(response.raw, response_text)
24 | self.assertEqual(response.name, 'VolumeLevel')
25 | self.assertEqual(response.user, 'de890e34-2347-11e9-ab14-d663bd873d93')
26 | self.assertEqual(response.data, {
27 | 'volume': '10'
28 | })
29 |
30 | def test_parse_fails_nonxml(self):
31 | response_text = """Hello there"""
32 |
33 | response = ApiResponse(response_text)
34 |
35 | self.assertFalse(response.success)
36 | self.assertEqual(response.raw, response_text)
37 | self.assertEqual(response.name, None)
38 | self.assertEqual(response.user, None)
39 | self.assertEqual(response.data, None)
40 |
41 | def test_parse_fails_response_notok(self):
42 | response_text = """
43 |
44 | VolumeLevel
45 | 1.0
46 | 192.168.1.129
47 |
48 |
49 | """
50 |
51 | response = ApiResponse(response_text)
52 |
53 | self.assertFalse(response.success)
54 | self.assertEqual(response.raw, response_text)
55 | self.assertEqual(response.name, 'VolumeLevel')
56 | self.assertEqual(response.user, None)
57 | self.assertEqual(response.data, {})
58 |
59 | def test_parse_fails_wrong_xml(self):
60 | response_text = """
61 |
62 | There
63 |
64 | """
65 |
66 | response = ApiResponse(response_text)
67 |
68 | self.assertFalse(response.success)
69 | self.assertEqual(response.raw, response_text)
70 | self.assertEqual(response.name, None)
71 | self.assertEqual(response.user, None)
72 | self.assertEqual(response.data, None)
73 |
--------------------------------------------------------------------------------
/samsung_multiroom/event/event_loop.py:
--------------------------------------------------------------------------------
1 | """Event dispatching."""
2 | import fnmatch
3 |
4 |
5 | class EventLoop:
6 | """
7 | Listen to speaker events.
8 |
9 | Use add_listener to subscribe to events of particular type.
10 | """
11 |
12 | def __init__(self, api_stream):
13 | """
14 | :param api_stream: ApiStream instance
15 | """
16 | self._api_stream = api_stream
17 | self._listeners = []
18 | self._factories = _get_default_factories()
19 |
20 | def register_factory(self, factory):
21 | """
22 | Register event factory function.
23 |
24 | :param factory: Callable accepting ApiResponse instance
25 | """
26 | if not callable(factory):
27 | raise ValueError('factory must be callable')
28 |
29 | self._factories.append(factory)
30 |
31 | def add_listener(self, event_name, listener):
32 | """
33 | :param event_name: Event name. See Events for all supported events
34 | :param listener: Callable. Will be called on a matching event and passed matching Event object
35 | """
36 | if not callable(listener):
37 | raise ValueError('listener must be a callable')
38 |
39 | self._listeners.append((event_name, listener))
40 |
41 | async def loop(self):
42 | """
43 | Start emitting speaker events.
44 | """
45 | for response in self._api_stream.open('/UIC?cmd=%3Cname%3EGetMainInfo%3C/name%3E'):
46 | event = self._factory(response)
47 | if event:
48 | self._dispatch_event(event)
49 |
50 | def _dispatch_event(self, event):
51 | for event_name, listener in self._listeners:
52 | if fnmatch.fnmatch(event.name, event_name):
53 | listener(event)
54 |
55 | def _factory(self, response):
56 | """
57 | Factory event from response.
58 |
59 | :param response: ApiResponse instance
60 | """
61 | for factory in reversed(self._factories):
62 | event = factory(response)
63 |
64 | if event:
65 | return event
66 |
67 | return None
68 |
69 |
70 | def _get_default_factories():
71 | event_classes = [
72 | ('samsung_multiroom.event.type.speaker_mute_changed', 'SpeakerMuteChangedEvent'),
73 | ('samsung_multiroom.event.type.speaker_player', 'SpeakerPlayerEvent'),
74 | ('samsung_multiroom.event.type.speaker_player_repeat_changed', 'SpeakerPlayerRepeatChangedEvent'),
75 | ('samsung_multiroom.event.type.speaker_player_shuffle_changed', 'SpeakerPlayerShuffleChangedEvent'),
76 | ('samsung_multiroom.event.type.speaker_service', 'SpeakerServiceEvent'),
77 | ('samsung_multiroom.event.type.speaker_volume_changed', 'SpeakerVolumeChangedEvent'),
78 | ]
79 |
80 | factories = []
81 |
82 | for module_name, class_name in event_classes:
83 | mod = __import__(module_name, fromlist=[class_name])
84 | klass = getattr(mod, class_name)
85 |
86 | factories.append(klass.factory)
87 |
88 | return factories
89 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/test_service_registry.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service import ServiceRegistry
5 | from samsung_multiroom.service.app import AppService
6 | from samsung_multiroom.service.dlna import DlnaService
7 | from samsung_multiroom.service.tunein import TuneInService
8 |
9 |
10 | def _get_service_registry():
11 | api = MagicMock()
12 | api.get_cp_list.return_value = [
13 | {
14 | 'cpid': '0',
15 | 'cpname': 'Signed out service',
16 | 'signinstatus': '0',
17 | },
18 | {
19 | 'cpid': '1',
20 | 'cpname': 'Signed in service',
21 | 'signinstatus': '1',
22 | 'username': 'service_username',
23 | },
24 | {
25 | 'cpid': '2',
26 | 'cpname': 'Service 2',
27 | 'signinstatus': '0',
28 | },
29 | {
30 | 'cpid': '3',
31 | 'cpname': 'Service 3',
32 | 'signinstatus': '0',
33 | }
34 | ]
35 |
36 | service_registry = ServiceRegistry(api)
37 |
38 | return (service_registry, api)
39 |
40 |
41 | class TestServiceRegistry(unittest.TestCase):
42 |
43 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
44 | def test_get_services_names(self, signature):
45 | signature.side_effect = [['start_index', 'list_count']] * 2
46 |
47 | service_registry, api = _get_service_registry()
48 |
49 | services_names = service_registry.get_services_names()
50 |
51 | self.assertEqual(services_names, ['dlna', 'tunein', 'Signed out service', 'Signed in service', 'Service 2', 'Service 3'])
52 |
53 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
54 | def test_service_dlna(self, signature):
55 | signature.side_effect = [['start_index', 'list_count']] * 2
56 |
57 | service_registry, api = _get_service_registry()
58 |
59 | service = service_registry.service('dlna')
60 |
61 | self.assertIsInstance(service, DlnaService)
62 | self.assertEqual(service.name, 'dlna')
63 |
64 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
65 | def test_service_tunein(self, signature):
66 | signature.side_effect = [['start_index', 'list_count']] * 2
67 |
68 | service_registry, api = _get_service_registry()
69 |
70 | service = service_registry.service('tunein')
71 |
72 | self.assertIsInstance(service, TuneInService)
73 | self.assertEqual(service.name, 'tunein')
74 |
75 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
76 | def test_service_app(self, signature):
77 | signature.side_effect = [
78 | ['start_index', 'list_count'],
79 | ['start_index', 'list_count'],
80 | ]
81 |
82 | service_registry, api = _get_service_registry()
83 |
84 | service = service_registry.service('Service 2')
85 |
86 | self.assertIsInstance(service, AppService)
87 | self.assertEqual(service.name, 'Service 2')
88 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/dlna/browser.py:
--------------------------------------------------------------------------------
1 | """DLNA service browser."""
2 | from ...api import paginator
3 | from ..browser import Browser
4 | from ..browser import Item
5 | from ..browser import path_to_folders
6 |
7 |
8 | class DlnaBrowser(Browser):
9 | """
10 | DLNA DMS device browser.
11 | """
12 |
13 | def __init__(self, api, path=None, items=None):
14 | super().__init__(path, items)
15 |
16 | self._api = api
17 |
18 | def get_name(self):
19 | return 'dlna'
20 |
21 | def browse(self, path=None):
22 | folders = path_to_folders(path)
23 |
24 | items = self._get_initial_items(folders)
25 |
26 | for folder in folders:
27 | # locate prepopulated items matching folder
28 | device_udn, parent_id = next(iter([(i.device_udn, i.object_id) for i in items if i.name == folder]),
29 | (None, None))
30 |
31 | # if we don't have device udn we search through devices
32 | if device_udn is None:
33 | data_list = paginator(self._api.get_dms_list, 0, 20)
34 | elif parent_id is None:
35 | data_list = paginator(self._api.pc_get_music_list_by_category, device_udn, 0, 20)
36 | else:
37 | data_list = paginator(self._api.pc_get_music_list_by_id, device_udn, parent_id, 0, 20)
38 |
39 | items = [self._factory_item(data) for data in data_list]
40 |
41 | return DlnaBrowser(self._api, self._merge_path(path), items)
42 |
43 | def _factory_item(self, data):
44 | kwargs = {'metadata': {}}
45 |
46 | # mandatory
47 | if 'dmsid' in data:
48 | kwargs['object_id'] = None
49 | if '@object_id' in data:
50 | kwargs['object_id'] = data['@object_id']
51 |
52 | if 'type' in data and data['type'] == 'AUDIO':
53 | kwargs['object_type'] = 'dlna_audio'
54 | else:
55 | kwargs['object_type'] = 'container'
56 |
57 | if 'dmsname' in data:
58 | kwargs['name'] = data['dmsname']
59 | if 'title' in data:
60 | kwargs['name'] = data['title']
61 |
62 | # metadata
63 | if 'artist' in data:
64 | kwargs['metadata']['artist'] = data['artist']
65 | if 'album' in data:
66 | kwargs['metadata']['album'] = data['album']
67 | if 'timelength' in data and data['timelength']:
68 | (hours, minutes, seconds) = data['timelength'].split(':')
69 | kwargs['metadata']['duration'] = int(hours) * 3600 + int(minutes) * 60 + int(float(seconds))
70 | if 'thumbnail' in data:
71 | kwargs['metadata']['thumbnail_url'] = data['thumbnail']
72 | if 'thumbnail_JPG_LRG' in data:
73 | kwargs['metadata']['thumbnail_url'] = data['thumbnail_JPG_LRG']
74 | if 'devicetype' in data:
75 | kwargs['metadata']['device_type'] = data['devicetype']
76 | if 'dmsid' in data:
77 | kwargs['metadata']['device_udn'] = data['dmsid']
78 | if 'device_udn' in data:
79 | kwargs['metadata']['device_udn'] = data['device_udn']
80 |
81 | return Item(**kwargs)
82 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/equalizer/test_equalizer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.equalizer import Equalizer
5 |
6 |
7 | def get_equalizer():
8 | api = MagicMock()
9 | api.get_7band_eq_list.return_value = [
10 | {
11 | '@index': '0',
12 | 'presetindex': '0',
13 | 'presetname': 'Pop',
14 | },
15 | {
16 | '@index': '1',
17 | 'presetindex': '1',
18 | 'presetname': 'Rock',
19 | },
20 | {
21 | '@index': '2',
22 | 'presetindex': '2',
23 | 'presetname': 'Custom 1',
24 | }
25 | ]
26 | api.get_current_eq_mode.return_value = {
27 | 'presetindex': '1',
28 | 'presetname': 'Rock',
29 | 'eqvalue1': '4',
30 | 'eqvalue2': '2',
31 | 'eqvalue3': '0',
32 | 'eqvalue4': '0',
33 | 'eqvalue5': '1',
34 | 'eqvalue6': '2',
35 | 'eqvalue7': '1',
36 | }
37 |
38 | equalizer = Equalizer(api)
39 |
40 | return (equalizer, api)
41 |
42 |
43 | class TestEqualizer(unittest.TestCase):
44 |
45 | def test_get_presets_names(self):
46 | equalizer, api = get_equalizer()
47 |
48 | presets_names = equalizer.get_presets_names()
49 |
50 | self.assertEqual(presets_names, ['Pop', 'Rock', 'Custom 1'])
51 |
52 | def test_set_by_preset_name(self):
53 | equalizer, api = get_equalizer()
54 |
55 | equalizer.set('Rock')
56 |
57 | api.set_7band_eq_mode.assert_called_once_with(1)
58 |
59 | def test_set_by_preset_name_exception(self):
60 | equalizer, api = get_equalizer()
61 |
62 | self.assertRaises(ValueError, equalizer.set, 'Invalid preset')
63 |
64 | api.set_7band_eq_mode.assert_not_called()
65 |
66 | def test_set_by_bands(self):
67 | equalizer, api = get_equalizer()
68 |
69 | equalizer.set([1,2,3,4,5,6,-6])
70 |
71 | api.set_7band_eq_value.assert_called_once_with(1, [1,2,3,4,5,6,-6])
72 |
73 | def test_set_by_bands_incorrect(self):
74 | equalizer, api = get_equalizer()
75 |
76 | self.assertRaises(ValueError, equalizer.set, 1,2,3,4,5,6,6)
77 | self.assertRaises(ValueError, equalizer.set, [1,2,3])
78 | self.assertRaises(ValueError, equalizer.set, [1,2,3,4,5,6,6,6,6])
79 | self.assertRaises(ValueError, equalizer.set, [1,2,3,4,5,6,7])
80 | self.assertRaises(ValueError, equalizer.set, [1,2,3,4,5,6,-7])
81 | self.assertRaises(ValueError, equalizer.set, [1,2,3,4,5,6,'Hello there'])
82 |
83 | api.set_7band_eq_value.assert_not_called()
84 |
85 | def test_save_create_new(self):
86 | equalizer, api = get_equalizer()
87 |
88 | equalizer.save('My new preset')
89 |
90 | api.add_custom_eq_mode.assert_called_once_with(3, 'My new preset')
91 |
92 | def test_save_overwrite_existing(self):
93 | equalizer, api = get_equalizer()
94 |
95 | equalizer.save('Custom 1')
96 |
97 | api.reset_7band_eq_value.assert_called_once_with(2, [4,2,0,0,1,2,1])
98 |
99 | def test_save_overwrite_current(self):
100 | equalizer, api = get_equalizer()
101 |
102 | equalizer.save()
103 |
104 | api.reset_7band_eq_value.assert_called_once_with(1, [4,2,0,0,1,2,1])
105 |
106 | def test_delete(self):
107 | equalizer, api = get_equalizer()
108 |
109 | equalizer.delete('Custom 1')
110 |
111 | api.del_custom_eq_mode.assert_called_once_with(2)
112 |
113 | def test_delete_missing(self):
114 | equalizer, api = get_equalizer()
115 |
116 | self.assertRaises(ValueError, equalizer.delete, 'Invalid preset')
117 |
118 | api.del_custom_eq_mode.assert_not_called()
119 |
--------------------------------------------------------------------------------
/samsung_multiroom/api/api_stream.py:
--------------------------------------------------------------------------------
1 | """
2 | Stream messages from the speaker.
3 | """
4 | import logging
5 | import socket
6 |
7 | from .api_response import ApiResponse
8 |
9 | try:
10 | from http_parser.parser import HttpParser
11 | except ImportError:
12 | from http_parser.pyparser import HttpParser
13 |
14 | _LOGGER = logging.getLogger(__name__)
15 |
16 |
17 | class ApiStream:
18 | """
19 | Speaker's api stream.
20 |
21 | It is possible to listen to all events/responses a speaker generates. It is useful in situations where there are
22 | multiple clients operating the speaker. In such case you can maintain internal state without polling.
23 |
24 | Once opened it will listen to messages in definitely, until interrupted using close() method.
25 |
26 | Example:
27 | stream = ApiStream('unique-id', '129.168.1.129')
28 |
29 | for response in stream.open('/UIC?cmd=%3Cname%3EGetMainInfo%3C/name%3E'):
30 | print(response.data)
31 | """
32 |
33 | def __init__(self, user, ip_address, port=55001, timeout=None):
34 | """
35 | Initialise stream.
36 |
37 | :param user: User identifier to pass along with request
38 | :param ip_address: IP address of the speaker to connect to
39 | :param port: Port to use, defaults to 55001
40 | :param timeout: Timeout in seconds
41 | """
42 | self._user = user
43 | self._ip_address = ip_address
44 | self._port = port
45 | self._timeout = timeout
46 | self._continue_stream = False
47 |
48 | def open(self, uri):
49 | """
50 | Generator consuming events from speaker's main info stream.
51 |
52 | Yields ApiResponse instance.
53 |
54 | :param uri: URI to open for the stream e.g. /UIC?cmd=%3Cname%3EGetMainInfo%3C/name%3E
55 | """
56 | self._continue_stream = True
57 |
58 | headers = {
59 | 'mobileUUID': self._user,
60 | 'mobileName': 'Wireless Audio',
61 | 'mobileVersion': '1.0',
62 | }
63 |
64 | while self._continue_stream:
65 | _LOGGER.debug('Opening new stream')
66 | try:
67 | parser = HttpParser()
68 | body = []
69 |
70 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
71 | sock.connect((self._ip_address, self._port))
72 | sock.settimeout(self._timeout)
73 | sock.send('GET {0} HTTP/1.1\r\n'.format(uri).encode())
74 | sock.send('Host: {0}:{1}\r\n'.format(self._ip_address, self._port).encode())
75 | for header, value in headers.items():
76 | sock.send('{0}: {1}\r\n'.format(header, value).encode())
77 | sock.send('\r\n\r\n'.encode())
78 |
79 | while self._continue_stream:
80 | _LOGGER.debug('Receiving from stream')
81 | data = sock.recv(1024)
82 |
83 | while data:
84 | _LOGGER.debug('Received data: %s', data.decode())
85 | received_length = len(data)
86 | parsed_length = parser.execute(data, received_length)
87 |
88 | if parser.is_partial_body():
89 | body.append(parser.recv_body().decode())
90 |
91 | if parser.is_message_complete():
92 | full_body = ''.join(body)
93 | parser = HttpParser()
94 | body = []
95 |
96 | _LOGGER.debug('Stream response: %s', full_body)
97 |
98 | yield ApiResponse(full_body)
99 |
100 | data = data[parsed_length:]
101 | except socket.error:
102 | _LOGGER.error('Socket exception', exc_info=1)
103 | except StopIteration:
104 | break
105 | finally:
106 | _LOGGER.debug('Closing the stream')
107 | sock.shutdown(socket.SHUT_RDWR)
108 | sock.close()
109 |
110 | def close(self):
111 | """
112 | Attempt to interrupt currently open stream.
113 | """
114 | _LOGGER.debug('Requested to close the stream')
115 | self._continue_stream = False
116 |
--------------------------------------------------------------------------------
/samsung_multiroom/base.py:
--------------------------------------------------------------------------------
1 | """Speaker interface to control speaker operation."""
2 | import abc
3 |
4 |
5 | class SpeakerBase(metaclass=abc.ABCMeta):
6 | """Speaker interface to control speaker operation."""
7 |
8 | @property
9 | def ip_address(self):
10 | """
11 | :returns: Speaker's ip address
12 | """
13 | raise NotImplementedError()
14 |
15 | @property
16 | def mac_address(self):
17 | """
18 | :returns: Speaker's mac address
19 | """
20 | raise NotImplementedError()
21 |
22 | def get_name(self):
23 | """
24 | Retrieve speaker's name.
25 |
26 | :returns: Speaker name string
27 | """
28 | raise NotImplementedError()
29 |
30 | def set_name(self, name):
31 | """
32 | Set speaker's name.
33 |
34 | :param name: Speaker name string
35 | """
36 | raise NotImplementedError()
37 |
38 | def get_volume(self):
39 | """
40 | Get current speaker volume.
41 |
42 | :returns: int current volume
43 | """
44 | raise NotImplementedError()
45 |
46 | def set_volume(self, volume):
47 | """
48 | Set speaker volume.
49 |
50 | :param volume: speaker volume, integer between 0 and 100
51 | """
52 | raise NotImplementedError()
53 |
54 | def get_sources(self):
55 | """
56 | Get all supported sources.
57 |
58 | :returns: List of supported sources.
59 | """
60 | raise NotImplementedError()
61 |
62 | def get_source(self):
63 | """
64 | Get currently selected source.
65 |
66 | :returns: selected source string.
67 | """
68 | raise NotImplementedError()
69 |
70 | def set_source(self, source):
71 | """
72 | Set speaker source.
73 |
74 | :param source: Speaker source, one of returned by get_sources()
75 | """
76 | raise NotImplementedError()
77 |
78 | def is_muted(self):
79 | """
80 | Check if speaker is muted.
81 |
82 | :returns: True if muted, False otherwise
83 | """
84 | raise NotImplementedError()
85 |
86 | def mute(self):
87 | """Mute the speaker."""
88 | raise NotImplementedError()
89 |
90 | def unmute(self):
91 | """Unmute the speaker."""
92 | raise NotImplementedError()
93 |
94 | @property
95 | def event_loop(self):
96 | """
97 | Get event loop
98 |
99 | :returns: EventLoop instance
100 | """
101 | raise NotImplementedError()
102 |
103 | def get_services_names(self):
104 | """
105 | Get all supported services names.
106 |
107 | :returns: List of strings
108 | """
109 | raise NotImplementedError()
110 |
111 | def service(self, name):
112 | """
113 | Get service by type
114 |
115 | :returns: Service instance
116 | """
117 | raise NotImplementedError()
118 |
119 | @property
120 | def clock(self):
121 | """
122 | Get clock to control time functions.
123 |
124 | :returns: Clock instance
125 | """
126 | raise NotImplementedError()
127 |
128 | @property
129 | def equalizer(self):
130 | """
131 | Get equalizer to control sound adjustments.
132 |
133 | :returns: Equalizer instance
134 | """
135 | raise NotImplementedError()
136 |
137 | @property
138 | def player(self):
139 | """
140 | Get currently active player.
141 |
142 | Use player to control playback e.g. pause, resume, get track info etc.
143 |
144 | :returns: Player instance
145 | """
146 | raise NotImplementedError()
147 |
148 | def browser(self, name):
149 | """
150 | Get media browser by type
151 |
152 | :returns: Browser instance
153 | """
154 | raise NotImplementedError()
155 |
156 | def group(self, name, speakers):
157 | """
158 | Group this speaker with another ones.
159 |
160 | This speaker will be the main speaker controlling the playback.
161 |
162 | :param speaker: List of Speaker instances
163 | :returns: SpeakerGroup instance
164 | """
165 | raise NotImplementedError()
166 |
167 | def ungroup(self):
168 | """
169 | Remove this speaker from its current group.
170 | """
171 | raise NotImplementedError()
172 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/tunein/player.py:
--------------------------------------------------------------------------------
1 | """TuneIn service player."""
2 | from ...api import paginator
3 | from ..player import REPEAT_OFF
4 | from ..player import Player
5 | from ..player import Track
6 | from ..player import init_track_kwargs
7 | from ..player import unsupported
8 |
9 |
10 | class TuneInPlayer(Player):
11 | """Controls player in WIFI+ TuneIn radio mode."""
12 |
13 | def __init__(self, api):
14 | self._api = api
15 |
16 | def play(self, playlist):
17 | """
18 | Play first radio from a playlist.
19 |
20 | Playlist items must be an object with following attributes:
21 | - object_id - object id
22 | - object_type - must be 'tunein_radio'
23 | - title - radio name
24 |
25 | :param playlist: Iterable returning player combatible objects
26 | :returns: True if playlist was accepted, False otherwise
27 | """
28 | for radio in playlist:
29 | if radio.object_type not in ['tunein_radio']:
30 | continue
31 |
32 | self._api.set_play_select(radio.object_id)
33 | return True
34 |
35 | return False
36 |
37 | @unsupported
38 | def jump(self, time):
39 | """
40 | Not supported for radios.
41 |
42 | :param time: Time from the beginning of the track in seconds
43 | """
44 |
45 | def resume(self):
46 | """Play/resume current track."""
47 | self._api.set_select_radio()
48 |
49 | @unsupported
50 | def stop(self):
51 | """Stop current radio."""
52 | raise NotImplementedError()
53 |
54 | def pause(self):
55 | """Pause current radio."""
56 | self._api.set_playback_control('pause')
57 |
58 | def next(self):
59 | """Play next radio from the preset list."""
60 | self._previous_next_preset(1)
61 |
62 | def previous(self):
63 | """Play previous radio from the preset list."""
64 | self._previous_next_preset(-1)
65 |
66 | @unsupported
67 | def repeat(self, mode):
68 | """Not supported for radio."""
69 |
70 | @unsupported
71 | def shuffle(self, enabled):
72 | """Not supported for radio."""
73 |
74 | def get_repeat(self):
75 | """Not supported for radio."""
76 | return REPEAT_OFF
77 |
78 | def get_shuffle(self):
79 | """Not supported for radio."""
80 | return False
81 |
82 | def get_current_track(self):
83 | """
84 | Get current radio info.
85 |
86 | :returns: Track instance, or None if unavailable
87 | """
88 | radio_info = self._api.get_radio_info()
89 |
90 | track_kwargs = init_track_kwargs('tunein_radio')
91 |
92 | if 'title' in radio_info:
93 | track_kwargs['artist'] = radio_info['title']
94 | if 'description' in radio_info:
95 | track_kwargs['title'] = radio_info['description']
96 | if 'thumbnail' in radio_info and 'http' in radio_info['thumbnail']:
97 | track_kwargs['thumbnail_url'] = radio_info['thumbnail']
98 |
99 | return Track(**track_kwargs)
100 |
101 | def is_active(self, function, submode=None):
102 | """
103 | Check if this player is active based on current function/submode.
104 |
105 | :returns: Boolean True if function/submode is supported
106 | """
107 | return function == 'wifi' and submode == 'cp'
108 |
109 | def _previous_next_preset(self, direction):
110 | if direction not in [-1, 1]:
111 | raise ValueError('Direction must be either 1 or -1')
112 |
113 | presets = list(paginator(self._api.get_preset_list, 0, 30))
114 | presets_count = len(presets)
115 |
116 | if presets_count <= 1:
117 | return
118 |
119 | # locate current mediaid on the preset list
120 | radio_info = self._api.get_radio_info()
121 |
122 | current_preset_index = -direction
123 | if 'presetindex' in radio_info and radio_info['presetindex'] is not None:
124 | current_preset_index = int(radio_info['presetindex'])
125 |
126 | kind_preset_type = {
127 | 'speaker': 1,
128 | 'my': 0,
129 | }
130 |
131 | preset_index = (presets_count + max(0, current_preset_index) + direction) % presets_count
132 | preset = presets[preset_index]
133 | preset_type = kind_preset_type[preset['kind']]
134 |
135 | self._api.set_play_preset(preset_type, preset_index)
136 | self._api.set_select_radio()
137 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/app/player.py:
--------------------------------------------------------------------------------
1 | """Generic music streaming app service player."""
2 | from ..player import REPEAT_OFF
3 | from ..player import Player
4 | from ..player import Track
5 | from ..player import init_track_kwargs
6 | from ..player import unsupported
7 |
8 |
9 | class AppPlayer(Player):
10 | """Controls player in WIFI+cp mode."""
11 |
12 | def __init__(self, api):
13 | self._api = api
14 |
15 | def play(self, playlist):
16 | """
17 | Enqueue and play a playlist.
18 |
19 | Playlist items must be an object with following attributes:
20 | - object_id - object id
21 | - object_type - must be 'app_audio'
22 |
23 | :param playlist: Iterable returning player combatible objects
24 | :returns: True if playlist was accepted, False otherwise
25 | """
26 | item_ids = []
27 | for track in playlist:
28 | if track.object_type not in ['app_audio']:
29 | continue
30 |
31 | item_ids.append(track.object_id)
32 |
33 | if item_ids:
34 | self._api.set_play_select(item_ids)
35 | return True
36 |
37 | return False
38 |
39 | @unsupported
40 | def jump(self, time):
41 | """
42 | Not supported for app.
43 |
44 | :param time: Time from the beginning of the track in seconds
45 | """
46 |
47 | def resume(self):
48 | """Play/resume current track."""
49 | self._api.set_playback_control('play')
50 |
51 | @unsupported
52 | def stop(self):
53 | """Stop current track and reset position to the beginning."""
54 | raise NotImplementedError()
55 |
56 | def pause(self):
57 | """Pause current track and retain position."""
58 | self._api.set_playback_control('pause')
59 |
60 | def next(self):
61 | """Play next track in the queue."""
62 | self._api.set_skip_current_track()
63 |
64 | def previous(self):
65 | """Play previous track in the queue."""
66 | playlist = self._api.get_cp_player_playlist()
67 |
68 | for i, playlist_item in enumerate(playlist):
69 | if '@currentplaying' in playlist_item and playlist_item['@currentplaying'] == '1':
70 | previous_index = max(0, i - 1)
71 |
72 | self._api.set_play_cp_playlist_track(playlist[previous_index]['contentid'])
73 |
74 | @unsupported
75 | def repeat(self, mode):
76 | """Not supported for apps."""
77 |
78 | @unsupported
79 | def shuffle(self, enabled):
80 | """Not supported for apps."""
81 |
82 | def get_repeat(self):
83 | """Not supported for apps."""
84 | return REPEAT_OFF
85 |
86 | def get_shuffle(self):
87 | """Not supported for apps."""
88 | return False
89 |
90 | def get_current_track(self):
91 | """
92 | Get current track info.
93 |
94 | :returns: Track instance, or None if unavailable
95 | """
96 | playlist = self._api.get_cp_player_playlist()
97 | try:
98 | playlist_item = [p for p in playlist if '@currentplaying' in p and p['@currentplaying'] == '1'][0]
99 | except KeyError:
100 | return None
101 |
102 | track_kwargs = init_track_kwargs('app_audio')
103 |
104 | if 'title' in playlist_item:
105 | track_kwargs['title'] = playlist_item['title']
106 | if 'artist' in playlist_item:
107 | track_kwargs['artist'] = playlist_item['artist']
108 | if 'album' in playlist_item:
109 | track_kwargs['album'] = playlist_item['album']
110 | if 'thumbnail' in playlist_item and 'http' in playlist_item['thumbnail']:
111 | track_kwargs['thumbnail_url'] = playlist_item['thumbnail']
112 | if 'mediaid' in playlist_item:
113 | track_kwargs['metadata']['object_id'] = playlist_item['mediaid']
114 |
115 | play_time = self._api.get_current_play_time()
116 |
117 | if 'timelength' in play_time and play_time['timelength'] is not None:
118 | track_kwargs['duration'] = int(play_time['timelength'])
119 | if 'playtime' in play_time:
120 | track_kwargs['position'] = int(play_time['playtime'])
121 |
122 | return Track(**track_kwargs)
123 |
124 | def is_active(self, function, submode=None):
125 | """
126 | Check if this player is active based on current function/submode.
127 |
128 | :returns: Boolean True if function/submode is supported
129 | """
130 | return function == 'wifi' and submode == 'cp'
131 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/browser.py:
--------------------------------------------------------------------------------
1 | """Abstract media browser and item."""
2 | import abc
3 |
4 |
5 | class Browser(metaclass=abc.ABCMeta):
6 | """
7 | Abstract media browser.
8 |
9 | Implementations should be immutable.
10 | """
11 |
12 | def __init__(self, path=None, items=None):
13 | """
14 | :param path: Path used to list items
15 | :param items: Items listed
16 | """
17 | self._path = path
18 | self._items = items.copy() if items else []
19 |
20 | @abc.abstractmethod
21 | def get_name(self):
22 | """
23 | :returns: Name of the browser
24 | """
25 | raise NotImplementedError()
26 |
27 | def get_path(self):
28 | """
29 | :returns: Path used to list items
30 | """
31 | return self._path
32 |
33 | @abc.abstractmethod
34 | def browse(self, path=None):
35 | """
36 | List items based on path.
37 |
38 | :param path: Forward slash separated string of folders e.g. /NAS/Music/By Folder/Air/Moon Safari/CD1
39 | :returns: Browser instance with listed items
40 | """
41 | raise NotImplementedError()
42 |
43 | def _merge_path(self, new_path):
44 | folders = path_to_folders(new_path)
45 |
46 | if folders[0] is not None:
47 | folders = path_to_folders(self.get_path()) + folders
48 |
49 | return folders_to_path(folders)
50 |
51 | def _get_items(self):
52 | return self._items
53 |
54 | def _get_initial_items(self, folders):
55 | """
56 | :returns: initial items to use, for root search return empty list
57 | """
58 | if folders[0] is None:
59 | return []
60 |
61 | return self._get_items()
62 |
63 | def _get_initial_depth(self, folders):
64 | """
65 | :returns: depth of the current browse, taking into account relative/absolute browsing
66 | """
67 | if folders[0] is None:
68 | return 0
69 |
70 | return len(path_to_folders(self._path))
71 |
72 | def __getitem__(self, i):
73 | return self._items[i]
74 |
75 | def __len__(self):
76 | return len(self._items)
77 |
78 | def __iter__(self):
79 | return iter(self._items)
80 |
81 |
82 | class Item:
83 | """
84 | Immutable browser item.
85 | """
86 |
87 | def __init__(self, object_id, object_type, name, metadata=None):
88 | self._object_id = object_id
89 | self._object_type = object_type
90 | self._name = name
91 | self._metadata = metadata or {}
92 |
93 | @property
94 | def object_id(self):
95 | """
96 | :returns: Object id
97 | """
98 | return self._object_id
99 |
100 | @property
101 | def object_type(self):
102 | """
103 | :returns: Object type
104 | """
105 | return self._object_type
106 |
107 | @property
108 | def name(self):
109 | """
110 | :returns: Item name
111 | """
112 | return self._name
113 |
114 | @property
115 | def title(self):
116 | """
117 | :returns: Item title, equivallent of name
118 | """
119 | if 'title' in self._metadata:
120 | return self._metadata['title']
121 |
122 | return self.name
123 |
124 | @property
125 | def metadata(self):
126 | """
127 | :returns: Item metadata specific to item type
128 | """
129 | return self._metadata
130 |
131 | def __getattr__(self, name):
132 | """
133 | Look up name in metadata.
134 |
135 | :returns: Metadata value
136 | """
137 | if name in self._metadata:
138 | return self._metadata[name]
139 |
140 | return None
141 |
142 |
143 | def path_to_folders(path):
144 | """
145 | Convert path to a standarised list of folder.
146 |
147 | If path is prepended with /, first element on the list will be special folder None denoting root.
148 |
149 | :param path: None or path string with folders '/' separated, e.g. /NAS/Music/By Folder/folder1/folder2
150 | :returns: List of folders e.g. [None, 'NAS', 'Music', 'By Folder', 'folder1', 'folder2']
151 | """
152 | if path in [None, '', '/']:
153 | path = '/'
154 |
155 | is_from_root = (path[0] == '/')
156 |
157 | path = path.strip(' /').split('/')
158 | path = [p for p in path if p != '']
159 |
160 | if is_from_root:
161 | path.insert(0, None)
162 |
163 | return path
164 |
165 |
166 | def folders_to_path(folders):
167 | """
168 | Convert list of folders to a path.
169 |
170 | :param folders: List of folders, None denotes a root folder e.g. [None, 'NAS', 'Music', 'By Folder', 'folder1']
171 | :returns: path string e.g. /NAS/Music/By Folder/folder1
172 | """
173 | if not folders:
174 | return '/'
175 |
176 | if folders[0] is None:
177 | folders[0] = ''
178 |
179 | return '/'.join(folders) or '/'
180 |
--------------------------------------------------------------------------------
/samsung_multiroom/equalizer/equalizer.py:
--------------------------------------------------------------------------------
1 | """
2 | Control speaker's sound adjustments.
3 | """
4 | import abc
5 |
6 |
7 | class EqualizerBase(metaclass=abc.ABCMeta):
8 | """
9 | Abstract base class for equalizers.
10 | """
11 |
12 | @abc.abstractmethod
13 | def get_presets_names(self):
14 | """
15 | :returns: List of preset names
16 | """
17 | raise NotImplementedError()
18 |
19 | @abc.abstractmethod
20 | def set(self, *args):
21 | """
22 | Set equalizer values by preset name or list of band values
23 |
24 | :param: Name of the preset to set or list of 7 band values to set
25 | """
26 | raise NotImplementedError()
27 |
28 | @abc.abstractmethod
29 | def save(self, name=None):
30 | """
31 | Create a new or overwrite existing preset.
32 |
33 | :param name: If empty, current preset will be saved, if provided name exist, that preset will overwrite existing
34 | one, otherwise a new preset is created with that name. In either case band values used will be ones
35 | currently set on a speaker e.g. via set() method.
36 | """
37 | raise NotImplementedError()
38 |
39 | @abc.abstractmethod
40 | def delete(self, name):
41 | """
42 | Delete preset.
43 |
44 | :param name: Name of a preset to delete
45 | """
46 | raise NotImplementedError()
47 |
48 |
49 | class Equalizer(EqualizerBase):
50 | """
51 | Control speaker's sound adjustments.
52 |
53 | Allows adjustments for bands 150, 300, 600, 1.2k, 2.5k, 5.0k, 10.0k, each value must be integer between -6 and 6.
54 | """
55 |
56 | def __init__(self, api):
57 | self._api = api
58 |
59 | def get_presets_names(self):
60 | """
61 | :returns: List of preset names
62 | """
63 | presets = self._api.get_7band_eq_list()
64 |
65 | return [p['presetname'] for p in presets]
66 |
67 | def set(self, *args):
68 | """
69 | Set equalizer values by preset name or list of band values
70 |
71 | :param: Name of the preset to set or list of 7 band values to set
72 | """
73 | # set by preset name
74 | if isinstance(args[0], str):
75 | preset_index = self._get_preset_index_by_name(args[0])
76 | if not preset_index:
77 | raise ValueError('Preset {0} does not exists'.format(args[0]))
78 |
79 | self._api.set_7band_eq_mode(preset_index)
80 | # set band values
81 | else:
82 | if not isinstance(args[0], list) or len(args[0]) != 7:
83 | raise ValueError('Pass all 7 band values as a list')
84 |
85 | if [value for value in args[0] if not isinstance(value, int) or abs(value) > 6]:
86 | raise ValueError('Band values must be integers between -6 and 6')
87 |
88 | preset_index = self._get_current_preset()['id']
89 | self._api.set_7band_eq_value(preset_index, args[0])
90 |
91 | def save(self, name=None):
92 | """
93 | Create a new or overwrite existing preset.
94 |
95 | :param name: If empty, current preset will be saved, if provided name exist, that preset will overwrite existing
96 | one, otherwise a new preset is created with that name. In either case band values used will be ones
97 | currently set on a speaker e.g. via set() method.
98 | """
99 | current_preset = self._get_current_preset()
100 |
101 | # overwrite current one
102 | if name is None:
103 | self._api.reset_7band_eq_value(current_preset['id'], current_preset['band_values'])
104 | else:
105 | preset_index = self._get_preset_index_by_name(name)
106 |
107 | # overwrite preset with the same name
108 | if preset_index:
109 | self._api.reset_7band_eq_value(preset_index, current_preset['band_values'])
110 | # create a new preset
111 | else:
112 | presets = self._api.get_7band_eq_list()
113 | presets_ids = [int(p['presetindex']) for p in presets]
114 |
115 | self._api.add_custom_eq_mode(max(presets_ids) + 1, name)
116 |
117 | def delete(self, name):
118 | """
119 | Delete preset.
120 |
121 | :param name: Name of a preset to delete
122 | """
123 | preset_index = self._get_preset_index_by_name(name)
124 |
125 | if not preset_index:
126 | raise ValueError('Preset {0} does not exists'.format(name))
127 |
128 | self._api.del_custom_eq_mode(preset_index)
129 |
130 | def _get_preset_index_by_name(self, name):
131 | presets = self._api.get_7band_eq_list()
132 | matching_presets = [int(p['presetindex']) for p in presets if p['presetname'] == name]
133 |
134 | if matching_presets:
135 | return matching_presets[0]
136 |
137 | return None
138 |
139 | def _get_current_preset(self):
140 | preset = self._api.get_current_eq_mode()
141 |
142 | return {
143 | 'id': int(preset['presetindex']),
144 | 'name': preset['presetname'],
145 | 'band_values': [int(preset['eqvalue' + str(i)]) for i in range(1, 8)]
146 | }
147 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/dlna/player.py:
--------------------------------------------------------------------------------
1 | """DLNA service player."""
2 | from ..player import REPEAT_ALL
3 | from ..player import REPEAT_OFF
4 | from ..player import REPEAT_ONE
5 | from ..player import Player
6 | from ..player import Track
7 | from ..player import init_track_kwargs
8 | from ..player import unsupported
9 |
10 |
11 | class DlnaPlayer(Player):
12 | """Controls player in WIFI+DLNA mode."""
13 |
14 | def __init__(self, api):
15 | self._api = api
16 |
17 | def play(self, playlist):
18 | """
19 | Enqueue and play a playlist.
20 |
21 | Playlist items must be an object with following attributes:
22 | - object_id - object id
23 | - object_type - must be 'dlna_audio'
24 | - title - track title
25 | - artist - track artist
26 | - thumbnail_url - thumbnail URL
27 | - device_udn - DLNA device UDN
28 |
29 | :param playlist: Iterable returning player combatible objects
30 | :returns: True if playlist was accepted, False otherwise
31 | """
32 | items = []
33 | for track in playlist:
34 | if track.object_type not in ['dlna_audio']:
35 | continue
36 |
37 | items.append({
38 | 'object_id': track.object_id,
39 | 'title': track.title,
40 | 'artist': track.artist,
41 | 'thumbnail': track.thumbnail_url,
42 | 'device_udn': track.device_udn,
43 | })
44 |
45 | if items:
46 | self._api.set_playlist_playback_control(items)
47 | return True
48 |
49 | return False
50 |
51 | def jump(self, time):
52 | """
53 | Advance current playback to specific time.
54 |
55 | :param time: Time from the beginning of the track in seconds
56 | """
57 | self._api.set_search_time(time)
58 |
59 | def resume(self):
60 | """Play/resume current track."""
61 | self._api.set_playback_control('resume')
62 |
63 | @unsupported
64 | def stop(self):
65 | """Stop current track and reset position to the beginning."""
66 | raise NotImplementedError()
67 |
68 | def pause(self):
69 | """Pause current track and retain position."""
70 | self._api.set_playback_control('pause')
71 |
72 | def next(self):
73 | """Play next track in the queue."""
74 | self._api.set_trick_mode('next')
75 |
76 | def previous(self):
77 | """Play previous track in the queue."""
78 | self._api.set_trick_mode('previous')
79 |
80 | def repeat(self, mode):
81 | """
82 | Set playback repeat mode.
83 |
84 | :param mode: one of REPEAT_* constants
85 | """
86 | mode_map = {
87 | REPEAT_ALL: 'all',
88 | REPEAT_ONE: 'one',
89 | REPEAT_OFF: 'off',
90 | }
91 |
92 | if mode not in mode_map:
93 | raise ValueError('mode must be one of REPEAT_* constants')
94 |
95 | self._api.set_repeat_mode(mode_map[mode])
96 |
97 | def shuffle(self, enabled):
98 | """
99 | Enable/disable playback shuffle mode.
100 |
101 | :param enabled: True to enable, False to disable
102 | """
103 | self._api.set_shuffle_mode(enabled)
104 |
105 | def get_repeat(self):
106 | """
107 | Get playback repeat mode.
108 |
109 | :returns: one of REPEAT_* constants
110 | """
111 | mode_map = {
112 | 'all': REPEAT_ALL,
113 | 'one': REPEAT_ONE,
114 | 'off': REPEAT_OFF,
115 | }
116 |
117 | mode = self._api.get_repeat_mode()
118 |
119 | return mode_map[mode]
120 |
121 | def get_shuffle(self):
122 | """
123 | Get playback shuffle mode.
124 |
125 | :returns: boolean, True if enabled, False otherwise
126 | """
127 | return self._api.get_shuffle_mode()
128 |
129 | def get_current_track(self):
130 | """
131 | Get current track info.
132 |
133 | :returns: Track instance, or None if unavailable
134 | """
135 | music_info = self._api.get_music_info()
136 |
137 | track_kwargs = init_track_kwargs('dlna_audio')
138 |
139 | if 'title' in music_info:
140 | track_kwargs['title'] = music_info['title']
141 | if 'artist' in music_info:
142 | track_kwargs['artist'] = music_info['artist']
143 | if 'album' in music_info:
144 | track_kwargs['album'] = music_info['album']
145 | if 'thumbnail' in music_info and 'http' in music_info['thumbnail']:
146 | track_kwargs['thumbnail_url'] = music_info['thumbnail']
147 | if 'timelength' in music_info and music_info['timelength'] is not None:
148 | (hours, minutes, seconds) = music_info['timelength'].split(':')
149 | track_kwargs['duration'] = int(hours) * 3600 + int(minutes) * 60 + int(float(seconds))
150 | if 'playtime' in music_info:
151 | track_kwargs['position'] = int(int(music_info['playtime']) / 1000)
152 | if 'device_udn' in music_info:
153 | track_kwargs['metadata']['device_udn'] = music_info['device_udn']
154 | if 'objectid' in music_info:
155 | track_kwargs['metadata']['object_id'] = music_info['objectid']
156 |
157 | return Track(**track_kwargs)
158 |
159 | def is_active(self, function, submode=None):
160 | """
161 | Check if this player is active based on current function/submode.
162 |
163 | :returns: Boolean True if function/submode is supported
164 | """
165 | return function == 'wifi' and submode == 'dlna'
166 |
--------------------------------------------------------------------------------
/samsung_multiroom/speaker.py:
--------------------------------------------------------------------------------
1 | """Entry control for speaker operation."""
2 | from .base import SpeakerBase
3 | from .group import SpeakerGroup
4 |
5 |
6 | class Speaker(SpeakerBase):
7 | """Entry control for speaker operation."""
8 |
9 | def __init__(self, api, event_loop, clock, equalizer, player_operator, service_registry):
10 | """
11 | Initialise the speaker.
12 |
13 | :param api: SamsungMultiroomApi instance
14 | :param event_loop: EventLoop instance
15 | :param clock: Clock instance
16 | :param equalizer: Equalizer instance
17 | :param player_operator: PlayerOperator instance
18 | :param service_registry: ServiceRegistry instance
19 | """
20 | self._api = api
21 | self._event_loop = event_loop
22 | self._clock = clock
23 | self._equalizer = equalizer
24 | self._player_operator = player_operator
25 | self._service_registry = service_registry
26 |
27 | @property
28 | def ip_address(self):
29 | """
30 | :returns: Speaker's ip address
31 | """
32 | return self._api.ip_address
33 |
34 | @property
35 | def mac_address(self):
36 | """
37 | :returns: Speaker's mac address
38 | """
39 | main_info = self._api.get_main_info()
40 | return main_info['spkmacaddr']
41 |
42 | def get_name(self):
43 | """
44 | Retrieve speaker's name.
45 |
46 | :returns: Speaker name string
47 | """
48 | return self._api.get_speaker_name()
49 |
50 | def set_name(self, name):
51 | """
52 | Set speaker's name.
53 |
54 | :param name: Speaker name string
55 | """
56 | self._api.set_speaker_name(name)
57 |
58 | def get_volume(self):
59 | """
60 | Get current speaker volume.
61 |
62 | :returns: int current volume
63 | """
64 | return self._api.get_volume()
65 |
66 | def set_volume(self, volume):
67 | """
68 | Set speaker volume.
69 |
70 | :param volume: speaker volume, integer between 0 and 100
71 | """
72 | if not isinstance(volume, int) or int(volume) < 0 or int(volume) > 100:
73 | raise ValueError('Volume must be integer between 0 and 100')
74 |
75 | self._api.set_volume(volume)
76 |
77 | def get_sources(self):
78 | """
79 | Get all supported sources.
80 |
81 | :returns: List of supported sources.
82 | """
83 | return ['aux', 'bt', 'hdmi', 'optical', 'soundshare', 'wifi']
84 |
85 | def get_source(self):
86 | """
87 | Get currently selected source.
88 |
89 | :returns: selected source string.
90 | """
91 | function = self._api.get_func()
92 | return function['function']
93 |
94 | def set_source(self, source):
95 | """
96 | Set speaker source.
97 |
98 | :param source: Speaker source, one of returned by get_sources()
99 | """
100 | if source not in self.get_sources():
101 | raise ValueError('Invalid source {0}'.format(source))
102 |
103 | self._api.set_func(source)
104 |
105 | def is_muted(self):
106 | """
107 | Check if speaker is muted.
108 |
109 | :returns: True if muted, False otherwise
110 | """
111 | return self._api.get_mute()
112 |
113 | def mute(self):
114 | """Mute the speaker."""
115 | self._api.set_mute(True)
116 |
117 | def unmute(self):
118 | """Unmute the speaker."""
119 | self._api.set_mute(False)
120 |
121 | @property
122 | def event_loop(self):
123 | """
124 | Get event loop
125 |
126 | :returns: EventLoop instance
127 | """
128 | return self._event_loop
129 |
130 | def get_services_names(self):
131 | """
132 | Get all supported services names.
133 |
134 | :returns: List of strings
135 | """
136 | return self._service_registry.get_services_names()
137 |
138 | def service(self, name):
139 | """
140 | Get service by type
141 |
142 | :returns: Service instance
143 | """
144 | return self._service_registry.service(name)
145 |
146 | @property
147 | def clock(self):
148 | """
149 | Get clock to control time functions.
150 |
151 | :returns: Clock instance
152 | """
153 | return self._clock
154 |
155 | @property
156 | def equalizer(self):
157 | """
158 | Get equalizer to control sound adjustments.
159 |
160 | :returns: Equalizer instance
161 | """
162 | return self._equalizer
163 |
164 | @property
165 | def player(self):
166 | """
167 | Get currently active player.
168 |
169 | Use player to control playback e.g. pause, resume, get track info etc.
170 |
171 | :returns: Player instance
172 | """
173 | return self._player_operator
174 |
175 | def browser(self, name):
176 | """
177 | Get media browser by type
178 |
179 | :returns: Browser instance
180 | """
181 | return self._service_registry.service(name).browser
182 |
183 | def group(self, name, speakers):
184 | """
185 | Group this speaker with another ones.
186 |
187 | This speaker will be the main speaker controlling the playback.
188 |
189 | :param speaker: List of Speaker instances
190 | :returns: SpeakerGroup instance
191 | """
192 | if isinstance(speakers, Speaker):
193 | speakers = [speakers]
194 |
195 | speakers = [self] + speakers
196 |
197 | speakers_info = []
198 | for speaker in speakers:
199 | speakers_info.append({
200 | 'name': speaker.get_name(),
201 | 'ip': speaker.ip_address,
202 | 'mac': speaker.mac_address,
203 | })
204 |
205 | if not name:
206 | name = ' + '.join([s['name'] for s in speakers_info])
207 |
208 | self._api.set_multispk_group(name, speakers_info)
209 |
210 | return SpeakerGroup(self._api, name, speakers)
211 |
212 | def ungroup(self):
213 | """
214 | Remove this speaker from its current group.
215 | """
216 | self._api.set_ungroup()
217 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/player_operator.py:
--------------------------------------------------------------------------------
1 | """Select the right player for the current source."""
2 | from .player import REPEAT_OFF
3 | from .player import Player
4 | from .player import get_is_supported_function_name
5 | from .player import unsupported
6 |
7 |
8 | class PlayerOperator(Player):
9 | """Select the right player for the current source."""
10 |
11 | def __init__(self, api, players=None):
12 | """
13 | Initialise player operator.
14 |
15 | :param api: SamsungMultiroomApi instance
16 | :param players: List of Player instances
17 | """
18 | self._api = api
19 | self._players = []
20 |
21 | for player in (players or []):
22 | self.add_player(player)
23 |
24 | def add_player(self, player):
25 | """
26 | Add player.
27 |
28 | :param player: Player instance
29 | """
30 | if not isinstance(player, Player):
31 | raise ValueError('Player must be a subclass of Player class')
32 |
33 | self._players.append(player)
34 |
35 | def get_player(self):
36 | """
37 | Get currently active player based on selected source.
38 |
39 | :returns: Player instance
40 | """
41 | func = self._api.get_func()
42 |
43 | function = func['function']
44 | submode = func['submode']
45 |
46 | for player in self._players:
47 | if player.is_active(function, submode):
48 | return player
49 |
50 | return NullPlayer()
51 |
52 | def play(self, playlist):
53 | """
54 | Find a suitable player and play a playlist.
55 |
56 | :param playlist: Iterable returning player combatible objects
57 | :returns: True if playlist was accepted, False otherwise
58 | """
59 | for player in self._players:
60 | if player.play(playlist):
61 | return True
62 |
63 | return False
64 |
65 | def jump(self, time):
66 | """
67 | Advance current playback to specific time.
68 |
69 | :param time: Time from the beginning of the track in seconds
70 | """
71 | self.get_player().jump(time)
72 |
73 | def resume(self):
74 | """Play/resume current track."""
75 | self.get_player().resume()
76 |
77 | def stop(self):
78 | """Stop current track and reset position to the beginning."""
79 | self.get_player().stop()
80 |
81 | def pause(self):
82 | """Pause current track and retain position."""
83 | self.get_player().pause()
84 |
85 | def next(self):
86 | """Play next track in the queue."""
87 | self.get_player().next()
88 |
89 | def previous(self):
90 | """Play previous track in the queue."""
91 | self.get_player().previous()
92 |
93 | def repeat(self, mode):
94 | """
95 | Set playback repeat mode.
96 |
97 | :param mode: one of REPEAT_* constants
98 | """
99 | self.get_player().repeat(mode)
100 |
101 | def shuffle(self, enabled):
102 | """
103 | Enable/disable playback shuffle mode.
104 |
105 | :param enabled: True to enable, False to disable
106 | """
107 | self.get_player().shuffle(enabled)
108 |
109 | def get_repeat(self):
110 | """
111 | Get playback repeat mode.
112 |
113 | :returns: one of REPEAT_* constants
114 | """
115 | return self.get_player().get_repeat()
116 |
117 | def get_shuffle(self):
118 | """
119 | Get playback shuffle mode.
120 |
121 | :returns: boolean, True if enabled, False otherwise
122 | """
123 | return self.get_player().get_shuffle()
124 |
125 | def get_current_track(self):
126 | """
127 | Get current track info.
128 |
129 | :returns: Track instance, or None if unavailable
130 | """
131 | return self.get_player().get_current_track()
132 |
133 | def is_active(self, function, submode=None):
134 | """
135 | Check if this player is active based on current function/submode.
136 |
137 | :returns: Boolean True if function/submode is supported
138 | """
139 | for player in self._players:
140 | if player.is_active(function, submode):
141 | return True
142 |
143 | return False
144 |
145 | def __getattribute__(self, name):
146 | """
147 | Delegates is_[function_name]_supported to the currently active player.
148 | """
149 | if get_is_supported_function_name(name) is None:
150 | return super().__getattribute__(name)
151 |
152 | return getattr(self.get_player(), name)
153 |
154 |
155 | class NullPlayer(Player):
156 | """Catch all player if no others supported current function."""
157 |
158 | @unsupported
159 | def play(self, playlist):
160 | """
161 | Null player is unable to play anything.
162 |
163 | :param playlist: Playlist instance
164 | :returns: Boolean False
165 | """
166 | return False
167 |
168 | @unsupported
169 | def jump(self, time):
170 | """Do nothing."""
171 |
172 | @unsupported
173 | def resume(self):
174 | """Do nothing."""
175 |
176 | @unsupported
177 | def stop(self):
178 | """Do nothing."""
179 |
180 | @unsupported
181 | def pause(self):
182 | """Do nothing."""
183 |
184 | @unsupported
185 | def next(self):
186 | """Do nothing."""
187 |
188 | @unsupported
189 | def previous(self):
190 | """Do nothing."""
191 |
192 | @unsupported
193 | def repeat(self, mode):
194 | """Do nothing."""
195 |
196 | @unsupported
197 | def shuffle(self, enabled):
198 | """Do nothing."""
199 |
200 | def get_repeat(self):
201 | """Always off."""
202 | return REPEAT_OFF
203 |
204 | def get_shuffle(self):
205 | """Always False."""
206 | return False
207 |
208 | def get_current_track(self):
209 | """
210 | Do nothing.
211 |
212 | :returns: None
213 | """
214 | return None
215 |
216 | def is_active(self, function, submode=None):
217 | """
218 | Check if this player is active based on current function/submode.
219 |
220 | :returns: Boolean True
221 | """
222 | return True
223 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/tunein/test_browser_tuneinbrowser.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 | from unittest.mock import call
4 |
5 | from samsung_multiroom.service import Item
6 | from samsung_multiroom.service.tunein import TuneInBrowser
7 |
8 |
9 | def browser_main_return_value():
10 | return [
11 | {
12 | '@type': '0',
13 | 'title': 'Favorites',
14 | 'contentid': '0',
15 | },
16 | {
17 | '@type': '0',
18 | 'title': 'Local Radio',
19 | 'contentid': '1',
20 | },
21 | {
22 | '@type': '0',
23 | 'title': 'Recents',
24 | 'contentid': '2',
25 | },
26 | {
27 | '@type': '0',
28 | 'title': 'Trending',
29 | 'contentid': '3',
30 | },
31 | {
32 | '@type': '0',
33 | 'title': 'By Language',
34 | 'contentid': '10',
35 | }
36 | ]
37 |
38 |
39 | def get_select_radio_list_side_effect(content_id, start_index, list_count):
40 | if content_id == '10':
41 | return [
42 | {
43 | '@type': '0',
44 | 'title': 'Arabic',
45 | 'contentid': '4',
46 | },
47 | {
48 | '@type': '0',
49 | 'title': 'English',
50 | 'contentid': '24',
51 | },
52 | {
53 | '@type': '0',
54 | 'title': 'Finnish',
55 | 'contentid': '28',
56 | },
57 | {
58 | '@type': '0',
59 | 'title': 'French',
60 | 'contentid': '29',
61 | },
62 | ]
63 | if content_id == '24':
64 | return [
65 | {
66 | '@type': '0',
67 | 'title': 'Music',
68 | 'contentid': '0',
69 | },
70 | {
71 | '@type': '0',
72 | 'title': 'Talk',
73 | 'contentid': '1',
74 | },
75 | ]
76 | if content_id == '0':
77 | return [
78 | {
79 | '@type': '2',
80 | 'thumbnail': 'http://cdn-profiles.tunein.com/s297990/images/logot.png',
81 | 'description': 'MSNBC Live with Velshi & Ruhle',
82 | 'mediaid': 's297990',
83 | 'title': 'MSNBC',
84 | 'contentid': '0',
85 | },
86 | {
87 | '@type': '2',
88 | 'thumbnail': 'http://cdn-radiotime-logos.tunein.com/s24940t.png',
89 | 'description': 'Amazing music. Played by an amazing line up.',
90 | 'mediaid': 's24940',
91 | 'title': 'BBC1',
92 | 'contentid': '1',
93 | }
94 | ]
95 |
96 |
97 | class TestTuneInBrowser(unittest.TestCase):
98 |
99 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
100 | def test_browse_from_root(self, signature):
101 | signature.side_effect = [['start_index', 'list_count']] * 2
102 |
103 | api = MagicMock()
104 | api.browse_main.return_value = browser_main_return_value()
105 |
106 | browser = TuneInBrowser(api)
107 | browser = browser.browse()
108 |
109 | api.browse_main.assert_called_once_with(start_index=0, list_count=30)
110 |
111 | self.assertEqual(browser.get_path(), '/')
112 | self.assertEqual(len(browser), 5)
113 | self.assertIsInstance(browser[0], Item)
114 | self.assertEqual(browser[0].name, 'Favorites')
115 | self.assertEqual(browser[0].object_id, '0')
116 | self.assertEqual(browser[0].object_type, 'container')
117 |
118 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
119 | def test_browse_full_level(self, signature):
120 | signature.side_effect = [['start_index', 'list_count']] * 2 +\
121 | [['content_id', 'start_index', 'list_count']] * 6
122 |
123 | api = MagicMock()
124 | api.browse_main.return_value = browser_main_return_value()
125 | api.get_select_radio_list.side_effect = get_select_radio_list_side_effect
126 |
127 | browser = TuneInBrowser(api)
128 | browser = browser.browse('/By Language/English/Music')
129 |
130 | api.browse_main.assert_called_once_with(start_index=0, list_count=30)
131 | api.get_select_radio_list.assert_has_calls([
132 | call(content_id='10', start_index=0, list_count=30),
133 | call(content_id='24', start_index=0, list_count=30),
134 | call(content_id='0', start_index=0, list_count=30),
135 | ])
136 |
137 | self.assertEqual(browser.get_path(), '/By Language/English/Music')
138 | self.assertEqual(len(browser), 2)
139 | self.assertIsInstance(browser[0], Item)
140 | self.assertEqual(browser[0].name, 'MSNBC')
141 | self.assertEqual(browser[0].object_id, '0')
142 | self.assertEqual(browser[0].object_type, 'tunein_radio')
143 |
144 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
145 | def test_browse_relative(self, signature):
146 | signature.side_effect = [['start_index', 'list_count']] * 2 +\
147 | [['content_id', 'start_index', 'list_count']] * 6
148 |
149 | api = MagicMock()
150 | api.browse_main.return_value = browser_main_return_value()
151 | api.get_select_radio_list.side_effect = get_select_radio_list_side_effect
152 |
153 | browser = TuneInBrowser(api)
154 | browser = browser.browse('/By Language/English').browse('Music')
155 |
156 | api.browse_main.assert_called_once_with(start_index=0, list_count=30)
157 | api.get_select_radio_list.assert_has_calls([
158 | call(content_id='10', start_index=0, list_count=30),
159 | call(content_id='24', start_index=0, list_count=30),
160 | call(content_id='0', start_index=0, list_count=30),
161 | ])
162 |
163 | self.assertEqual(browser.get_path(), '/By Language/English/Music')
164 | self.assertEqual(len(browser), 2)
165 | self.assertIsInstance(browser[0], Item)
166 | self.assertEqual(browser[0].name, 'MSNBC')
167 | self.assertEqual(browser[0].object_id, '0')
168 | self.assertEqual(browser[0].object_type, 'tunein_radio')
169 |
--------------------------------------------------------------------------------
/samsung_multiroom/group.py:
--------------------------------------------------------------------------------
1 | """Speaker group."""
2 | from .base import SpeakerBase
3 | from .clock import ClockGroup
4 | from .equalizer import EqualizerGroup
5 |
6 |
7 | class SpeakerGroup(SpeakerBase):
8 | """
9 | Speaker group.
10 |
11 | Use Speaker.group() to initiate grouping.
12 | """
13 |
14 | def __init__(self, api, name, speakers):
15 | """
16 | The first speaker should be the main speaker controlling the playback.
17 |
18 | :param api: Api to control the main speaker
19 | :param name: Name to used for the group
20 | :param speaker: List of Speaker instances in this group
21 | """
22 | self._api = api
23 | self._name = name
24 | self._speakers = speakers
25 |
26 | @property
27 | def ip_address(self):
28 | """
29 | :returns: Speaker's ip address
30 | """
31 | return self._speakers[0].ip_address
32 |
33 | @property
34 | def mac_address(self):
35 | """
36 | :returns: Speaker's mac address
37 | """
38 | return self._speakers[0].mac_address
39 |
40 | @property
41 | def speakers(self):
42 | """
43 | :returns: List of speakers in this group.
44 | """
45 | return self._speakers
46 |
47 | def get_name(self):
48 | """
49 | :returns: Group's name
50 | """
51 | return self._name
52 |
53 | def set_name(self, name):
54 | """
55 | Set group name.
56 |
57 | :param name: New group name
58 | """
59 | self._api.set_group_name(name)
60 | self._name = name
61 |
62 | def get_volume(self):
63 | """
64 | Get current volume.
65 |
66 | It uses main speaker volume as a reference.
67 |
68 | :returns: int current volume
69 | """
70 | return self._speakers[0].get_volume()
71 |
72 | def set_volume(self, volume):
73 | """
74 | Set volume for all speakers in the group.
75 |
76 | It uses main speaker's volume as a reference point and updates secondary speakers proportionally.
77 |
78 | :param volume: speaker volume, integer between 0 and 100
79 | """
80 | ratio = volume / self._speakers[0].get_volume()
81 |
82 | self._speakers[0].set_volume(volume)
83 |
84 | for speaker in self._speakers[1:]:
85 | speaker.set_volume(min(100, int(speaker.get_volume() * ratio)))
86 |
87 | def get_sources(self):
88 | """
89 | Get all supported sources.
90 |
91 | :returns: List of supported sources.
92 | """
93 | return self._speakers[0].get_sources()
94 |
95 | def get_source(self):
96 | """
97 | Get currently selected source.
98 |
99 | :returns: selected source string.
100 | """
101 | return self._speakers[0].get_source()
102 |
103 | def set_source(self, source):
104 | """
105 | Set speaker source.
106 |
107 | :param source: Speaker source, one of returned by get_sources()
108 | """
109 | return self._speakers[0].set_source(source)
110 |
111 | def is_muted(self):
112 | """
113 | Check if all speakers in this group are muted.
114 |
115 | :returns: True if muted, False otherwise
116 | """
117 | for speaker in self._speakers:
118 | if not speaker.is_muted():
119 | return False
120 |
121 | return True
122 |
123 | def mute(self):
124 | """Mute all speakers in this group."""
125 | for speaker in self._speakers:
126 | speaker.mute()
127 |
128 | def unmute(self):
129 | """Unmute all speakers in this group."""
130 | for speaker in self._speakers:
131 | speaker.unmute()
132 |
133 | @property
134 | def event_loop(self):
135 | """
136 | Get event loop
137 |
138 | :returns: EventLoop instance
139 | """
140 | return self._speakers[0].event_loop
141 |
142 | def get_services_names(self):
143 | """
144 | Get all supported services names.
145 |
146 | :returns: List of strings
147 | """
148 | return self._speakers[0].get_services_names()
149 |
150 | def service(self, name):
151 | """
152 | Get service by type
153 |
154 | :returns: Service instance
155 | """
156 | return self._speakers[0].service(name)
157 |
158 | @property
159 | def clock(self):
160 | """
161 | Get clock to control time functions.
162 |
163 | :returns: Clock instance
164 | """
165 | return ClockGroup([s.clock for s in self._speakers])
166 |
167 | @property
168 | def equalizer(self):
169 | """
170 | Get equalizer to control sound adjustments.
171 |
172 | :returns: Equalizer instance
173 | """
174 | return EqualizerGroup([s.equalizer for s in self._speakers])
175 |
176 | @property
177 | def player(self):
178 | """
179 | Get currently active player.
180 |
181 | Use player to control playback e.g. pause, resume, get track info etc.
182 |
183 | :returns: Player instance
184 | """
185 | return self._speakers[0].player
186 |
187 | def browser(self, name):
188 | """
189 | Get media browser by type
190 |
191 | :returns: Browser instance
192 | """
193 | return self._speakers[0].browser(name)
194 |
195 | def group(self, name, speakers):
196 | """
197 | Adds speakers to this group.
198 |
199 | This group's main speaker will continue to be the main speaker controlling the playback.
200 |
201 | :param speaker: List of Speaker instances to add to this group
202 | :returns: This speaker group instance
203 | """
204 | if isinstance(speakers, SpeakerBase):
205 | speakers = [speakers]
206 |
207 | main_speaker = self._speakers[0]
208 | speakers = self._speakers[1:] + speakers
209 |
210 | speaker_group = main_speaker.group(name, speakers)
211 |
212 | self._name = speaker_group.get_name()
213 | self._speakers = speaker_group.speakers
214 |
215 | return self
216 |
217 | def ungroup(self):
218 | """
219 | Remove all speakers from this group and disband it.
220 | """
221 | for speaker in reversed(self._speakers):
222 | speaker.ungroup()
223 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/app/test_player_appplayer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service import REPEAT_ALL
5 | from samsung_multiroom.service import REPEAT_OFF
6 | from samsung_multiroom.service.app import AppPlayer
7 |
8 |
9 | def _get_player():
10 | api = MagicMock()
11 |
12 | player = AppPlayer(api)
13 |
14 | return (player, api)
15 |
16 |
17 | class TestAppPlayer(unittest.TestCase):
18 |
19 | def test_is_supported(self):
20 | player, api = _get_player()
21 |
22 | self.assertTrue(player.is_play_supported())
23 | self.assertFalse(player.is_jump_supported())
24 | self.assertTrue(player.is_resume_supported())
25 | self.assertFalse(player.is_stop_supported())
26 | self.assertTrue(player.is_pause_supported())
27 | self.assertTrue(player.is_next_supported())
28 | self.assertTrue(player.is_previous_supported())
29 | self.assertFalse(player.is_repeat_supported())
30 | self.assertFalse(player.is_shuffle_supported())
31 |
32 | def test_play(self):
33 | player, api = _get_player()
34 |
35 | playlist = [
36 | type('Item', (object, ), {
37 | 'object_id': 'id1',
38 | 'object_type': 'some_type',
39 | 'title': 'title 1',
40 | }),
41 | type('Item', (object, ), {
42 | 'device_udn': 'device_udn',
43 | 'object_id': '2',
44 | 'object_type': 'app_audio',
45 | 'title': 'track 2',
46 | 'artist': 'artist 2',
47 | 'thumbnail_url': 'thumb 2'
48 | }),
49 | type('Item', (object, ), {
50 | 'device_udn': 'device_udn',
51 | 'object_id': '3',
52 | 'object_type': 'app_audio',
53 | 'title': 'track 3',
54 | 'artist': 'artist 3',
55 | 'thumbnail_url': 'thumb 3'
56 | }),
57 | type('Item', (object, ), {
58 | 'object_id': 'id4',
59 | 'object_type': 'some_type2',
60 | 'title': 'title 4',
61 | })
62 | ]
63 |
64 | player.play(playlist)
65 |
66 | api.set_play_select.assert_called_once_with(['2', '3'])
67 |
68 | @unittest.skip('No access to app supporting this feature')
69 | def test_jump(self):
70 | pass
71 |
72 | def test_resume(self):
73 | player, api = _get_player()
74 |
75 | player.resume()
76 |
77 | api.set_playback_control.assert_called_once_with('play')
78 |
79 | @unittest.skip('No access to app supporting this feature')
80 | def test_stop(self):
81 | pass
82 |
83 | def test_pause(self):
84 | player, api = _get_player()
85 |
86 | player.pause()
87 |
88 | api.set_playback_control.assert_called_once_with('pause')
89 |
90 | def test_next(self):
91 | player, api = _get_player()
92 |
93 | player.next()
94 |
95 | api.set_skip_current_track.assert_called_once()
96 |
97 | def test_previous(self):
98 | player, api = _get_player()
99 |
100 | api.get_cp_player_playlist.return_value = [
101 | # we don't care about other attributes
102 | {
103 | 'contentid': '0',
104 | },
105 | {
106 | 'contentid': '1',
107 | },
108 | {
109 | '@currentplaying': '1',
110 | 'contentid': '2',
111 | },
112 | {
113 | 'contentid': '3',
114 | }
115 | ]
116 |
117 | player.previous()
118 |
119 | api.set_play_cp_playlist_track.assert_called_once_with('1')
120 |
121 | def test_repeat(self):
122 | player, api = _get_player()
123 |
124 | player.repeat(REPEAT_ALL)
125 |
126 | api.set_repeat_mode.assert_not_called()
127 |
128 | def test_shuffle(self):
129 | player, api = _get_player()
130 |
131 | player.shuffle(True)
132 |
133 | api.set_shuffle_mode.assert_not_called()
134 |
135 | def test_get_repeat(self):
136 | player, api = _get_player()
137 |
138 | repeat = player.get_repeat()
139 |
140 | self.assertEqual(repeat, REPEAT_OFF)
141 |
142 | api.get_repeat_mode.assert_not_called()
143 |
144 | def test_get_shuffle(self):
145 | player, api = _get_player()
146 |
147 | shuffle = player.get_shuffle()
148 |
149 | self.assertFalse(shuffle)
150 |
151 | api.get_repeat_mode.assert_not_called()
152 |
153 | def test_get_current_track(self):
154 | player, api = _get_player()
155 |
156 | api.get_cp_player_playlist.return_value = [
157 | {
158 | '@type': '1',
159 | '@available': '1',
160 | '@currentplaying': '1',
161 | 'artist': 'Madeleine Peyroux',
162 | 'album': 'Careless Love',
163 | 'mediaid': '881851',
164 | 'tracklength': '0',
165 | 'title': 'Don\'t Wait Too Long',
166 | 'contentid': '0',
167 | 'thumbnail': 'http://api.deezer.com/album/100127/image',
168 | },
169 | {
170 | '@type': '1',
171 | '@available': '1',
172 | 'artist': 'artist',
173 | 'album': 'album',
174 | 'mediaid': 'mediaid',
175 | 'tracklength': '0',
176 | 'title': 'title',
177 | 'contentid': '1',
178 | 'thumbnail': 'http://thumb.url/image',
179 | },
180 | {
181 | '@type': '1',
182 | '@available': '1',
183 | 'artist': 'artist',
184 | 'album': 'album',
185 | 'mediaid': 'mediaid',
186 | 'tracklength': '0',
187 | 'title': 'title',
188 | 'contentid': '2',
189 | 'thumbnail': 'http://thumb.url/image',
190 | }
191 | ]
192 |
193 | api.get_current_play_time.return_value = {
194 | 'timelength': '192',
195 | 'playtime': '35',
196 | }
197 |
198 | track = player.get_current_track()
199 |
200 | self.assertEqual(track.title, 'Don\'t Wait Too Long')
201 | self.assertEqual(track.artist, 'Madeleine Peyroux')
202 | self.assertEqual(track.album, 'Careless Love')
203 | self.assertEqual(track.duration, 192)
204 | self.assertEqual(track.position, 35)
205 | self.assertEqual(track.thumbnail_url, 'http://api.deezer.com/album/100127/image')
206 | self.assertEqual(track.object_id, '881851')
207 | self.assertEqual(track.object_type, 'app_audio')
208 |
209 | def test_is_active(self):
210 | player, api = _get_player()
211 |
212 | self.assertTrue(player.is_active('wifi', 'cp'))
213 | self.assertFalse(player.is_active('wifi', 'dlna'))
214 | self.assertFalse(player.is_active('bt'))
215 |
--------------------------------------------------------------------------------
/samsung_multiroom/service/player.py:
--------------------------------------------------------------------------------
1 | """Player allows playback control depending on selected source."""
2 | import abc
3 | import re
4 |
5 | # repeat mode constants
6 | REPEAT_ONE = 'one'
7 | REPEAT_ALL = 'all'
8 | REPEAT_OFF = 'off'
9 |
10 |
11 | class Player(metaclass=abc.ABCMeta):
12 | """Player interface to control playback functions."""
13 |
14 | @abc.abstractmethod
15 | def play(self, playlist):
16 | """
17 | Enqueue and play a playlist.
18 |
19 | Player may choose to not play the playlist if it's not compatible with this player. For instance you can't
20 | play DLNA source tracks using TuneIn player. If player is unable to play the playlist it must return False.
21 |
22 | :param playlist: Iterable returning player combatible objects
23 | :returns: True if playlist was accepted, False otherwise
24 | """
25 | raise NotImplementedError()
26 |
27 | @abc.abstractmethod
28 | def jump(self, time):
29 | """
30 | Advance current playback to specific time.
31 |
32 | :param time: Time from the beginning of the track in seconds
33 | """
34 | raise NotImplementedError()
35 |
36 | @abc.abstractmethod
37 | def resume(self):
38 | """Play/resume current track."""
39 | raise NotImplementedError()
40 |
41 | @abc.abstractmethod
42 | def stop(self):
43 | """Stop current track and reset position to the beginning."""
44 | raise NotImplementedError()
45 |
46 | @abc.abstractmethod
47 | def pause(self):
48 | """Pause current track and retain position."""
49 | raise NotImplementedError()
50 |
51 | @abc.abstractmethod
52 | def next(self):
53 | """Play next track in the queue."""
54 | raise NotImplementedError()
55 |
56 | @abc.abstractmethod
57 | def previous(self):
58 | """Play previous track in the queue."""
59 | raise NotImplementedError()
60 |
61 | @abc.abstractmethod
62 | def repeat(self, mode):
63 | """
64 | Set playback repeat mode.
65 |
66 | :param mode: one of REPEAT_* constants
67 | """
68 | raise NotImplementedError()
69 |
70 | @abc.abstractmethod
71 | def shuffle(self, enabled):
72 | """
73 | Enable/disable playback shuffle mode.
74 |
75 | :param enabled: True to enable, False to disable
76 | """
77 | raise NotImplementedError()
78 |
79 | @abc.abstractmethod
80 | def get_repeat(self):
81 | """
82 | Get playback repeat mode.
83 |
84 | :returns: one of REPEAT_* constants
85 | """
86 | raise NotImplementedError()
87 |
88 | @abc.abstractmethod
89 | def get_shuffle(self):
90 | """
91 | Get playback shuffle mode.
92 |
93 | :returns: boolean, True if enabled, False otherwise
94 | """
95 | raise NotImplementedError()
96 |
97 | @abc.abstractmethod
98 | def get_current_track(self):
99 | """
100 | Get current track info.
101 |
102 | :returns: Track instance, or None if unavailable
103 | """
104 | raise NotImplementedError()
105 |
106 | @abc.abstractmethod
107 | def is_active(self, function, submode=None):
108 | """
109 | Check if this player is active based on current function/submode.
110 |
111 | :returns: Boolean True if function/submode is supported
112 | """
113 | raise NotImplementedError()
114 |
115 | def __getattribute__(self, name):
116 | """
117 | Magic is_[function]_supported method.
118 |
119 | Function can be any Player method. In order to mark method as unsupported, use @unsupported decorator.
120 |
121 | Example:
122 | MyPlayer(Player):
123 | @unsupported
124 | def play(self, playlist):
125 | return False
126 |
127 | player = MyPlayer()
128 | player.is_play_supported() # returns False
129 | """
130 | try:
131 | return super().__getattribute__(name)
132 | except AttributeError:
133 | function_name = get_is_supported_function_name(name)
134 |
135 | if not function_name:
136 | raise
137 |
138 | if not hasattr(self, function_name):
139 | raise
140 |
141 | function = getattr(self, function_name)
142 | if not hasattr(function, '__is_supported__'):
143 | return lambda: True
144 |
145 | return lambda: bool(function.__is_supported__)
146 |
147 |
148 | class Track:
149 | """Defines a media track on the playlist."""
150 |
151 | def __init__(self, title, artist, album, duration, position, thumbnail_url, metadata=None):
152 | self._title = title
153 | self._artist = artist
154 | self._album = album
155 | self._duration = duration
156 | self._position = position
157 | self._thumbnail_url = thumbnail_url
158 | self._metadata = metadata or {}
159 |
160 | @property
161 | def title(self):
162 | """
163 | :returns: Title of the current track
164 | """
165 | return self._title
166 |
167 | @property
168 | def artist(self):
169 | """
170 | :returns: Artist of the current track
171 | """
172 | return self._artist
173 |
174 | @property
175 | def album(self):
176 | """
177 | :returns: Album title of the current track
178 | """
179 | return self._album
180 |
181 | @property
182 | def duration(self):
183 | """
184 | :returns: Duration in seconds
185 | """
186 | return self._duration
187 |
188 | @property
189 | def position(self):
190 | """
191 | :returns: Current playback position in seconds
192 | """
193 | return self._position
194 |
195 | @property
196 | def thumbnail_url(self):
197 | """
198 | :returns: URL of the track thumbnail
199 | """
200 | return self._thumbnail_url
201 |
202 | def __getattr__(self, name):
203 | """
204 | :returns: Metadata item value
205 | """
206 | if name in self._metadata:
207 | return self._metadata[name]
208 |
209 | return None
210 |
211 |
212 | def init_track_kwargs(object_type):
213 | """
214 | :returns: kwargs dict fro Track initialisation
215 | """
216 | return {
217 | 'title': None,
218 | 'artist': None,
219 | 'album': None,
220 | 'duration': None,
221 | 'position': None,
222 | 'thumbnail_url': None,
223 | 'metadata': {
224 | 'object_id': None,
225 | 'object_type': object_type,
226 | }
227 | }
228 |
229 |
230 | def unsupported(function):
231 | """Decorator to mark player function as unsupported."""
232 | function.__is_supported__ = False
233 |
234 | return function
235 |
236 |
237 | def get_is_supported_function_name(name):
238 | """
239 | :param name: function name
240 | :returns: Function name from is_[function_name]_supported structure, None otherwise
241 | """
242 | pattern = re.compile(r'^is_(\w+)_supported$')
243 | matches = pattern.findall(name)
244 |
245 | if not matches:
246 | return None
247 |
248 | return matches[0]
249 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/api/test_api_stream.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.api import ApiResponse
5 | from samsung_multiroom.api import ApiStream
6 |
7 |
8 | def _get_api_stream():
9 | return ApiStream('public', '192.168.1.129')
10 |
11 |
12 | class TestApiStream(unittest.TestCase):
13 |
14 | @unittest.mock.patch('socket.socket')
15 | def test_open_working_stream(self, s):
16 | s.return_value.recv.side_effect = [
17 | b"""HTTP/1.1 200 OK
18 | Date: Fri, 02 Jan 1970 10:53:13 GMT
19 | Server: Samsung/1.0
20 | Content-Type: text/html
21 | Content-Length: 215
22 | Connection: close
23 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
24 |
25 | RequestDeviceInfo1.0192.168.1.129public""",
26 | b"""HTTP/1.1 200 OK
27 | Date: Fri, 02 Jan 1970 10:53:13 GMT
28 | Server: Samsung/1.0
29 | Content-Type: text/html
30 | Content-Length: 678
31 | Connection: close
32 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
33 |
34 | MainInfo1.0192.168.1.129offN0.0.0.000:00:00:00:00:00xx:xx:xx:xx:xx:xxHW-K650nonefront0on1dfsoff2.3yy:yy:yy:yy:yy:yy""",
35 | ]
36 |
37 | expected_responses = [
38 | {
39 | 'success': True,
40 | 'name': 'RequestDeviceInfo',
41 | },
42 | {
43 | 'success': True,
44 | 'name': 'MainInfo',
45 | }
46 | ]
47 |
48 | stream = _get_api_stream()
49 |
50 | responses = list(stream.open('/UIC?cmd=%3Cname%3EGetMainInfo%3C/name%3E'))
51 |
52 | s.return_value.connect.assert_called_once_with(('192.168.1.129', 55001))
53 |
54 | self.assertEqual(len(responses), len(expected_responses))
55 |
56 | for i, response in enumerate(responses):
57 | self.assertIsInstance(response, ApiResponse)
58 | self.assertEqual(response.success, expected_responses[i]['success'])
59 | self.assertEqual(response.name, expected_responses[i]['name'])
60 |
61 |
62 | @unittest.mock.patch('socket.socket')
63 | def test_open_multiple_responses_in_recv(self, s):
64 | s.return_value.recv.side_effect = [
65 | b"""HTTP/1.1 200 OK
66 | Date: Fri, 02 Jan 1970 10:53:13 GMT
67 | Server: Samsung/1.0
68 | Content-Type: text/html
69 | Content-Length: 215
70 | Connection: close
71 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
72 |
73 | RequestDeviceInfo1.0192.168.1.129public""",
74 |
75 | b"""HTTP/1.1 200 OK
76 | Date: Fri, 02 Jan 1970 10:53:13 GMT
77 | Server: Samsung/1.0
78 | Content-Type: text/html
79 | Content-Length: 678
80 | Connection: close
81 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
82 |
83 | MainInfo1.0192.168.1.129offN0.0.0.000:00:00:00:00:00xx:xx:xx:xx:xx:xxHW-K650nonefront0on1dfsoff2.3yy:yy:yy:yy:yy:yy""",
84 |
85 | b"""HTTP/1.1 200 OK
86 | Date: Fri, 02 Jan 1970 10:53:13 GMT
87 | Server: Samsung/1.0
88 | Content-Type: text/html
89 | Content-Length: 228
90 | Connection: close
91 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
92 |
93 | VolumeLevel1.0192.168.1.129public10HTTP/1.1 200 OK
94 | Date: Fri, 02 Jan 1970 10:53:13 GMT
95 | Server: Samsung/1.0
96 | Content-Type: text/html
97 | Content-Length: 228
98 | Connection: close
99 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
100 |
101 | VolumeLevel1.0192.168.1.129public15HTTP/1.1 200 OK
102 | Date: Fri, 02 Jan 1970 10:53:13 GMT
103 | Server: Samsung/1.0
104 | Content-Type: text/html
105 | Content-Length: 228
106 | Connection: close
107 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
108 |
109 | VolumeLevel1.0192.168.1.129public20""",
110 |
111 | b"""HTTP/1.1 200 OK
112 | Date: Fri, 02 Jan 1970 10:53:13 GMT
113 | Server: Samsung/1.0
114 | Content-Type: text/html
115 | Content-Length: 228
116 | Connection: close
117 | Last-Modified: Fri, 02 Jan 1970 10:53:13 GMT
118 |
119 | VolumeLevel1.0192.168.1.129public30""",
120 | ]
121 |
122 | expected_responses = [
123 | {
124 | 'success': True,
125 | 'name': 'RequestDeviceInfo',
126 | },
127 | {
128 | 'success': True,
129 | 'name': 'MainInfo',
130 | },
131 | {
132 | 'success': True,
133 | 'name': 'VolumeLevel',
134 | },
135 | {
136 | 'success': True,
137 | 'name': 'VolumeLevel',
138 | },
139 | {
140 | 'success': True,
141 | 'name': 'VolumeLevel',
142 | },
143 | {
144 | 'success': True,
145 | 'name': 'VolumeLevel',
146 | }
147 | ]
148 |
149 | stream = _get_api_stream()
150 |
151 | responses = list(stream.open('/UIC?cmd=%3Cname%3EGetMainInfo%3C/name%3E'))
152 |
153 | s.return_value.connect.assert_called_once_with(('192.168.1.129', 55001))
154 |
155 | self.assertEqual(len(responses), len(expected_responses))
156 |
157 | for i, response in enumerate(responses):
158 | self.assertIsInstance(response, ApiResponse)
159 | self.assertEqual(response.success, expected_responses[i]['success'])
160 | self.assertEqual(response.name, expected_responses[i]['name'])
161 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/dlna/test_player_dlnaplayer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service import REPEAT_ALL
5 | from samsung_multiroom.service import REPEAT_ONE
6 | from samsung_multiroom.service.dlna import DlnaPlayer
7 |
8 |
9 | def _get_player():
10 | api = MagicMock()
11 |
12 | player = DlnaPlayer(api)
13 |
14 | return (player, api)
15 |
16 |
17 | class TestDlnaPlayer(unittest.TestCase):
18 |
19 | def test_is_supported(self):
20 | player, api = _get_player()
21 |
22 | self.assertTrue(player.is_play_supported())
23 | self.assertTrue(player.is_jump_supported())
24 | self.assertTrue(player.is_resume_supported())
25 | self.assertFalse(player.is_stop_supported())
26 | self.assertTrue(player.is_pause_supported())
27 | self.assertTrue(player.is_next_supported())
28 | self.assertTrue(player.is_previous_supported())
29 | self.assertTrue(player.is_repeat_supported())
30 | self.assertTrue(player.is_shuffle_supported())
31 |
32 | def test_play(self):
33 | playlist = [
34 | type('Item', (object, ), {
35 | 'object_id': 'id1',
36 | 'object_type': 'some_type',
37 | 'title': 'title 1',
38 | }),
39 | type('Item', (object, ), {
40 | 'device_udn': 'device_udn',
41 | 'object_id': 'id2',
42 | 'object_type': 'dlna_audio',
43 | 'title': 'track 2',
44 | 'artist': 'artist 2',
45 | 'thumbnail_url': 'thumb 2'
46 | }),
47 | type('Item', (object, ), {
48 | 'device_udn': 'device_udn',
49 | 'object_id': 'id3',
50 | 'object_type': 'dlna_audio',
51 | 'title': 'track 3',
52 | 'artist': 'artist 3',
53 | 'thumbnail_url': 'thumb 3'
54 | }),
55 | type('Item', (object, ), {
56 | 'object_id': 'id4',
57 | 'object_type': 'some_type2',
58 | 'title': 'title 4',
59 | })
60 | ]
61 |
62 | player, api = _get_player()
63 |
64 | self.assertTrue(player.play(playlist))
65 |
66 | items = [
67 | {
68 | 'device_udn': 'device_udn',
69 | 'object_id': 'id2',
70 | 'title': 'track 2',
71 | 'artist': 'artist 2',
72 | 'thumbnail': 'thumb 2'
73 | },
74 | {
75 | 'device_udn': 'device_udn',
76 | 'object_id': 'id3',
77 | 'title': 'track 3',
78 | 'artist': 'artist 3',
79 | 'thumbnail': 'thumb 3'
80 | }
81 | ]
82 |
83 | api.set_playlist_playback_control.assert_called_once_with(items)
84 |
85 | def test_play_returns_false_for_unsupported_playlist(self):
86 | playlist = [
87 | type('Item', (object, ), {
88 | 'object_id': 'id1',
89 | 'object_type': 'some_type',
90 | 'title': 'title 1',
91 | }),
92 | type('Item', (object, ), {
93 | 'object_id': 'id4',
94 | 'object_type': 'some_type2',
95 | 'title': 'title 4',
96 | })
97 | ]
98 |
99 | player, api = _get_player()
100 |
101 | self.assertFalse(player.play(playlist))
102 |
103 | api.set_playlist_playback_control.assert_not_called()
104 |
105 | def test_jump(self):
106 | player, api = _get_player()
107 |
108 | player.jump(50)
109 |
110 | api.set_search_time.assert_called_once_with(50)
111 |
112 | def test_resume(self):
113 | player, api = _get_player()
114 |
115 | player.resume()
116 |
117 | api.set_playback_control.assert_called_once_with('resume')
118 |
119 | @unittest.skip('Pending implementation')
120 | def test_stop(self):
121 | player, api = _get_player()
122 |
123 | player.stop()
124 |
125 | def test_pause(self):
126 | player, api = _get_player()
127 |
128 | player.pause()
129 |
130 | api.set_playback_control.assert_called_once_with('pause')
131 |
132 | def test_next(self):
133 | player, api = _get_player()
134 |
135 | player.next()
136 |
137 | api.set_trick_mode.assert_called_once_with('next')
138 |
139 | def test_previous(self):
140 | player, api = _get_player()
141 |
142 | player.previous()
143 |
144 | api.set_trick_mode.assert_called_once_with('previous')
145 |
146 | def test_repeat(self):
147 | player, api = _get_player()
148 |
149 | player.repeat(REPEAT_ALL)
150 |
151 | api.set_repeat_mode.assert_called_once_with('all')
152 |
153 | def test_shuffle(self):
154 | player, api = _get_player()
155 |
156 | player.shuffle(True)
157 |
158 | api.set_shuffle_mode.assert_called_once_with(True)
159 |
160 | def test_repeat_raises_value_error(self):
161 | player, api = _get_player()
162 |
163 | self.assertRaises(ValueError, player.repeat, 'unsupported_mode')
164 |
165 | api.set_repeat_mode.assert_not_called()
166 |
167 | def test_get_repeat(self):
168 | player, api = _get_player()
169 | api.get_repeat_mode.return_value = 'one'
170 |
171 | repeat = player.get_repeat()
172 |
173 | self.assertEqual(repeat, REPEAT_ONE)
174 |
175 | api.get_repeat_mode.assert_called_once()
176 |
177 | def test_get_shuffle(self):
178 | player, api = _get_player()
179 | api.get_shuffle_mode.return_value = True
180 |
181 | shuffle = player.get_shuffle()
182 |
183 | self.assertTrue(shuffle)
184 |
185 | api.get_shuffle_mode.assert_called_once()
186 |
187 | def test_get_current_track(self):
188 | player, api = _get_player()
189 | api.get_music_info.return_value = {
190 | 'device_udn': 'uuid:00113249-398f-0011-8f39-8f3949321100',
191 | 'playertype': 'allshare',
192 | 'playbacktype': 'folder',
193 | 'sourcename': None,
194 | 'parentid': '22$30224',
195 | 'parentid2': None,
196 | 'playindex': '8',
197 | 'objectid': '22$@52947',
198 | 'title': 'New star in the sky',
199 | 'artist': 'Air',
200 | 'album': 'Moon Safari',
201 | 'thumbnail': 'http://192.168.1.111:50002/transcoder/jpegtnscaler.cgi/folderart/52947.jpg',
202 | 'timelength': '0:05:40.000',
203 | 'playtime': '325067',
204 | 'seek': 'enable',
205 | 'pause': 'enable',
206 | }
207 |
208 | track = player.get_current_track()
209 |
210 | api.get_music_info.assert_called_once()
211 |
212 | self.assertEqual(track.title, 'New star in the sky')
213 | self.assertEqual(track.artist, 'Air')
214 | self.assertEqual(track.album, 'Moon Safari')
215 | self.assertEqual(track.duration, 340)
216 | self.assertEqual(track.position, 325)
217 | self.assertEqual(track.thumbnail_url, 'http://192.168.1.111:50002/transcoder/jpegtnscaler.cgi/folderart/52947.jpg')
218 | self.assertEqual(track.device_udn, 'uuid:00113249-398f-0011-8f39-8f3949321100')
219 | self.assertEqual(track.object_id, '22$@52947')
220 | self.assertEqual(track.object_type, 'dlna_audio')
221 |
222 | def test_is_active(self):
223 | player, api = _get_player()
224 |
225 | self.assertTrue(player.is_active('wifi', 'dlna'))
226 | self.assertFalse(player.is_active('wifi', 'cp'))
227 | self.assertFalse(player.is_active('bt'))
228 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/clock/test_alarm.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import unittest
3 | from unittest.mock import MagicMock
4 |
5 | from samsung_multiroom.clock import Alarm
6 | from samsung_multiroom.clock import AlarmSlot
7 |
8 |
9 | def get_alarm():
10 | api = MagicMock()
11 | api.get_alarm_info.return_value = [
12 | {
13 | '@index': '0',
14 | 'hour': '10',
15 | 'min': '15',
16 | 'week': '0x40',
17 | 'volume': '20',
18 | 'title': None,
19 | 'description': None,
20 | 'thumbnail': None,
21 | 'stationurl': None,
22 | 'set': 'on',
23 | 'soundenable': 'on',
24 | 'sound': '1',
25 | 'alarmsoundname': 'Disco',
26 | 'duration': '10',
27 | },
28 | {
29 | '@index': '1',
30 | 'hour': '8',
31 | 'min': '5',
32 | 'week': '0x3e',
33 | 'volume': '5',
34 | 'title': 'BBC Radio 4',
35 | 'description': 'Intelligent speech',
36 | 'thumbnail': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
37 | 'stationurl': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
38 | 'set': 'on',
39 | 'soundenable': 'off',
40 | 'sound': '-1',
41 | 'alarmsoundname': '',
42 | 'duration': '0',
43 | }
44 | ]
45 |
46 | alarm = Alarm(api)
47 |
48 | return (alarm, api)
49 |
50 |
51 | NOW = datetime.datetime(2018, 1, 7, 15, 45, 32)
52 |
53 |
54 | class TestAlarm(unittest.TestCase):
55 |
56 | def test_get_slots(self):
57 | alarm, api = get_alarm()
58 |
59 | slots = alarm.get_slots()
60 |
61 | self.assertEqual(len(slots), 3)
62 |
63 | # 0
64 | self.assertEqual(slots[0].index, 0)
65 | self.assertEqual(slots[0].time, '10:15')
66 | self.assertEqual(slots[0].weekdays, [6]) # Sunday
67 | self.assertEqual(slots[0].volume, 20)
68 | self.assertEqual(slots[0].duration, 10)
69 | self.assertEqual(slots[0].station_data, {
70 | 'title': None,
71 | 'description': None,
72 | 'thumbnail_url': None,
73 | 'station_url': None,
74 | })
75 | self.assertTrue(slots[0].enabled)
76 |
77 | # 1
78 | self.assertEqual(slots[1].index, 1)
79 | self.assertEqual(slots[1].time, '8:05')
80 | self.assertEqual(slots[1].weekdays, [0,1,2,3,4]) # Mon-Fri
81 | self.assertEqual(slots[1].volume, 5)
82 | self.assertEqual(slots[1].duration, 0)
83 | self.assertEqual(slots[1].station_data, {
84 | 'title': 'BBC Radio 4',
85 | 'description': 'Intelligent speech',
86 | 'thumbnail_url': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
87 | 'station_url': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
88 | })
89 | self.assertTrue(slots[1].enabled)
90 |
91 | def test_set(self):
92 | alarm, api = get_alarm()
93 |
94 | alarm.slot(2).set(
95 | time='7:12',
96 | weekdays=[5, 6], # Sat,Sun
97 | station_data={
98 | 'title': 'BBC Radio 4',
99 | 'description': 'Intelligent speech',
100 | 'thumbnail_url': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
101 | 'station_url': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
102 | },
103 | enabled=True,
104 | )
105 |
106 | api.set_alarm_info.assert_called_once_with(
107 | index=2,
108 | hour=7,
109 | minute=12,
110 | week='0x41',
111 | duration=0,
112 | volume=5,
113 | station_data={
114 | 'title': 'BBC Radio 4',
115 | 'description': 'Intelligent speech',
116 | 'thumbnail': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
117 | 'stationurl': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
118 | }
119 | )
120 | api.set_alarm_on_off.assert_called_once_with(2, 'on')
121 |
122 | def test_set_from_playlist(self):
123 | alarm, api = get_alarm()
124 |
125 | api.get_station_data.return_value = {
126 | 'cpname': 'TuneIn',
127 | 'browsemode': '0',
128 | 'title': 'BBC Radio 4',
129 | 'description': 'Intelligent speech',
130 | 'thumbnail': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
131 | 'stationurl': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
132 | 'timestamp': '2019-01-08T15:21:47Z',
133 | }
134 |
135 | playlist = [
136 | type('Item', (object, ), {
137 | 'object_type': 'unsupported_type',
138 | 'object_id': 1,
139 | 'name': 'Some audio'
140 | }),
141 | type('Item', (object, ), {
142 | 'object_type': 'tunein_radio',
143 | 'object_id': 20,
144 | 'name': 'Some radio'
145 | }),
146 | type('Item', (object, ), {
147 | 'object_type': 'tunein_radio',
148 | 'object_id': 21,
149 | 'name': 'Some other radio'
150 | })
151 | ]
152 |
153 | alarm.slot(2).set(
154 | time='7:12',
155 | weekdays=[5, 6], # Sat,Sun
156 | playlist=playlist,
157 | enabled=True,
158 | )
159 |
160 | api.set_alarm_info.assert_called_once_with(
161 | index=2,
162 | hour=7,
163 | minute=12,
164 | week='0x41',
165 | duration=0,
166 | volume=5,
167 | station_data={
168 | 'title': 'BBC Radio 4',
169 | 'description': 'Intelligent speech',
170 | 'thumbnail': 'http://cdn-radiotime-logos.tunein.com/s25419d.png',
171 | 'stationurl': 'http://opml.radiotime.com/Tune.ashx?id=s25419&partnerId=qDDAbg6M&serial=90F1AAD31D82&formats=mp3,wma,aac,qt,hls',
172 | }
173 | )
174 | api.set_alarm_on_off.assert_called_once_with(2, 'on')
175 |
176 | @unittest.mock.patch('datetime.datetime')
177 | def test_delete(self, dt):
178 | alarm, api = get_alarm()
179 |
180 | dt.now.return_value = NOW
181 |
182 | slots = alarm.get_slots()
183 | slots[0].delete()
184 |
185 | self.assertEqual(slots[0].index, 0)
186 | self.assertEqual(slots[0].time, '15:45')
187 | self.assertEqual(slots[0].weekdays, [NOW.weekday()])
188 | self.assertEqual(slots[0].volume, 5)
189 | self.assertEqual(slots[0].duration, 0)
190 | self.assertEqual(slots[0].station_data, None)
191 | self.assertFalse(slots[0].enabled)
192 |
193 | api.del_alarm.assert_called_once_with(0)
194 |
195 | def test_disable(self):
196 | alarm, api = get_alarm()
197 |
198 | slots = alarm.get_slots()
199 | slots[0].disable()
200 |
201 | self.assertFalse(slots[0].enabled)
202 | api.set_alarm_on_off.assert_called_once_with(0, 'off')
203 |
204 | def test_enable(self):
205 | alarm, api = get_alarm()
206 |
207 | slots = alarm.get_slots()
208 | slots[0].enable()
209 |
210 | self.assertTrue(slots[0].enabled)
211 | api.set_alarm_on_off.assert_called_once_with(0, 'on')
212 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/test_speaker.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom import SamsungMultiroomSpeaker
5 | from samsung_multiroom.group import SpeakerGroup
6 | from samsung_multiroom.speaker import Speaker
7 |
8 |
9 | def get_speaker():
10 | api = MagicMock()
11 | api.ip_address = '192.168.1.129'
12 |
13 | clock = MagicMock()
14 | equalizer = MagicMock()
15 | player_operator = MagicMock()
16 |
17 | service_registry = MagicMock()
18 |
19 | event_loop = MagicMock()
20 |
21 | speaker = Speaker(api, event_loop, clock, equalizer, player_operator, service_registry)
22 |
23 | return (speaker, api, event_loop, clock, equalizer, player_operator, service_registry)
24 |
25 |
26 | class TestSpeaker(unittest.TestCase):
27 |
28 | def test_factory(self):
29 | self.assertIsInstance(SamsungMultiroomSpeaker('192.168.1.129'), Speaker)
30 |
31 | def test_get_name(self):
32 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
33 | api.get_speaker_name.return_value = 'Speaker name'
34 |
35 | name = speaker.get_name()
36 |
37 | api.get_speaker_name.assert_called_once()
38 | self.assertEqual(name, 'Speaker name')
39 |
40 | def test_set_name(self):
41 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
42 |
43 | speaker.set_name('Living Room')
44 |
45 | api.set_speaker_name.assert_called_once_with('Living Room')
46 |
47 | def test_get_volume(self):
48 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
49 | api.get_volume.return_value = 10
50 |
51 | volume = speaker.get_volume()
52 |
53 | api.get_volume.assert_called_once()
54 | self.assertEqual(volume, 10)
55 |
56 | def test_set_volume(self):
57 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
58 |
59 | speaker.set_volume(10)
60 |
61 | api.set_volume.assert_called_once_with(10)
62 |
63 | def test_get_sources(self):
64 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
65 |
66 | sources = speaker.get_sources()
67 |
68 | self.assertEqual(sorted(sources), sorted(['aux', 'bt', 'hdmi', 'optical', 'soundshare', 'wifi']))
69 |
70 | def test_get_source(self):
71 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
72 | api.get_func.return_value = {'function':'wifi', 'submode':'dlna'}
73 |
74 | source = speaker.get_source()
75 |
76 | api.get_func.assert_called_once()
77 | self.assertEqual(source, 'wifi')
78 |
79 | def test_set_source(self):
80 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
81 |
82 | speaker.set_source('hdmi')
83 |
84 | api.set_func.assert_called_once_with('hdmi')
85 |
86 | def test_is_muted(self):
87 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
88 | api.get_mute.return_value = True
89 |
90 | muted = speaker.is_muted()
91 |
92 | api.get_mute.assert_called_once()
93 | self.assertTrue(muted)
94 |
95 | def test_mute(self):
96 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
97 |
98 | speaker.mute()
99 |
100 | api.set_mute.assert_called_once_with(True)
101 |
102 | def test_unmute(self):
103 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
104 |
105 | speaker.unmute()
106 |
107 | api.set_mute.assert_called_once_with(False)
108 |
109 | def test_event_loop(self):
110 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
111 |
112 | el = speaker.event_loop
113 |
114 | self.assertEqual(el, event_loop)
115 |
116 | def test_get_services_names(self):
117 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
118 | service_registry.get_services_names.return_value = ['dlna', 'tunein', 'deezer', 'spotify']
119 |
120 | services_names = speaker.get_services_names()
121 |
122 | self.assertEqual(services_names, ['dlna', 'tunein', 'deezer', 'spotify'])
123 | service_registry.get_services_names.assert_called_once()
124 |
125 | def test_service(self):
126 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
127 | service_registry.service.return_value = MagicMock()
128 |
129 | service = speaker.service('dlna')
130 |
131 | self.assertEqual(service, service_registry.service.return_value)
132 | service_registry.service.assert_called_once_with('dlna')
133 |
134 | def test_player(self):
135 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
136 |
137 | player = speaker.player
138 |
139 | self.assertEqual(player, player_operator)
140 |
141 | def test_browser(self):
142 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
143 |
144 | browser = MagicMock()
145 | service_registry.service.return_value = MagicMock(browser=browser)
146 |
147 | service_browser = speaker.browser('b2')
148 |
149 | self.assertEqual(service_browser, browser)
150 | service_registry.service.assert_called_once_with('b2')
151 |
152 | def test_equalizer(self):
153 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
154 |
155 | eq = speaker.equalizer
156 |
157 | self.assertEqual(eq, equalizer)
158 |
159 | def test_clock(self):
160 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
161 |
162 | cl = speaker.clock
163 |
164 | self.assertEqual(cl, clock)
165 |
166 | def test_group(self):
167 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
168 |
169 | speaker1 = MagicMock()
170 | speaker2 = MagicMock()
171 |
172 | api.get_speaker_name.return_value = 'This speaker'
173 | api.get_main_info.return_value = {
174 | 'spkmacaddr': '00:00:00:00:00:00'
175 | }
176 |
177 | speaker1.get_name.return_value = 'Speaker 1'
178 | speaker2.get_name.return_value = 'Speaker 2'
179 |
180 | speaker1.ip_address = '192.168.1.165'
181 | speaker2.ip_address = '192.168.1.216'
182 |
183 | speaker1.mac_address = '11:11:11:11:11:11'
184 | speaker2.mac_address = '22:22:22:22:22:22'
185 |
186 | group = speaker.group('My group', [speaker1, speaker2])
187 |
188 | self.assertIsInstance(group, SpeakerGroup)
189 | api.set_multispk_group.assert_called_once_with('My group', [
190 | {
191 | 'name': 'This speaker',
192 | 'ip': '192.168.1.129',
193 | 'mac': '00:00:00:00:00:00',
194 | },
195 | {
196 | 'name': 'Speaker 1',
197 | 'ip': '192.168.1.165',
198 | 'mac': '11:11:11:11:11:11',
199 | },
200 | {
201 | 'name': 'Speaker 2',
202 | 'ip': '192.168.1.216',
203 | 'mac': '22:22:22:22:22:22',
204 | }
205 | ])
206 |
207 | def test_ungroup(self):
208 | speaker, api, event_loop, clock, equalizer, player_operator, service_registry = get_speaker()
209 |
210 | speaker.ungroup()
211 |
212 | api.set_ungroup.assert_called_once()
213 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Samsung Multiroom (WIP)
2 | =======================
3 |
4 | Control Samsung Multiroom speakers.
5 |
6 | .. image:: https://img.shields.io/travis/krygal/samsung_multiroom/master.svg
7 | :target: https://travis-ci.org/krygal/samsung_multiroom
8 | .. image:: https://img.shields.io/librariesio/github/krygal/samsung_multiroom.svg
9 | .. image:: https://img.shields.io/codeclimate/maintainability-percentage/krygal/samsung_multiroom.svg
10 | :target: https://codeclimate.com/github/krygal/samsung_multiroom
11 | .. image:: https://img.shields.io/codeclimate/coverage/krygal/samsung_multiroom.svg
12 | :target: https://codeclimate.com/github/krygal/samsung_multiroom
13 | .. image:: https://img.shields.io/pypi/v/samsung_multiroom.svg
14 | :target: https://pypi.org/project/samsung_multiroom/
15 | .. image:: https://img.shields.io/pypi/pyversions/samsung_multiroom.svg
16 | .. image:: https://img.shields.io/pypi/l/samsung_multiroom.svg
17 |
18 |
19 | Installation
20 | -------------
21 |
22 | .. code:: bash
23 |
24 | pip install samsung_multiroom
25 |
26 |
27 | Example speaker control
28 | -----------------------
29 |
30 | **Initialise**
31 |
32 | .. code:: python
33 |
34 | from samsung_multiroom import SamsungMultiroomSpeaker
35 |
36 | # initialise (replace with your speaker's ip address)
37 | speaker = SamsungMultiroomSpeaker('192.168.1.129')
38 |
39 | # get speaker name
40 | speaker.get_name()
41 |
42 |
43 | **Basic functions**
44 |
45 | .. code:: python
46 |
47 | # get/set volume
48 | volume = speaker.get_volume()
49 | print(volume)
50 |
51 | speaker.set_volume(10)
52 |
53 | # switch source to connect with your samsung tv
54 | speaker.set_source('soundshare')
55 |
56 | # mute/unmute
57 | speaker.mute()
58 | speaker.unmute()
59 |
60 |
61 | **Audio source browsers**
62 |
63 | .. code:: python
64 |
65 | # browse dlna device called nas
66 | browser = speaker.service('dlna').browser
67 | # or shorter
68 | browser = speaker.browser('dlna')
69 | browser = browser.browse('/nas/Music/By Folder/Air/Moon Safari/CD 1')
70 |
71 | for item in browser:
72 | print(item.object_type, item.object_id, item.artist, '-', item.name)
73 |
74 |
75 | # browse TuneIn radios
76 | browser = speaker.service('tunein').browser
77 | browser = browser.browse('/Trending/')
78 |
79 | for item in browser:
80 | print(item.object_type, item.object_id, item.name)
81 |
82 |
83 | **App integrations**
84 |
85 | .. code:: python
86 |
87 | # check available services
88 | names = speaker.get_services_names()
89 | print(names)
90 |
91 | # authenticate (unless you've done it already via mobile app)
92 | speaker.service('Deezer').login('your email', 'your password')
93 |
94 | browser = speaker.service('Deezer').browser
95 | browser = browser.browse('/Browse/Rock/Artists/Queen')
96 |
97 | for item in browser:
98 | print(item.object_type, item.object_id, item.name)
99 |
100 |
101 | **Player functions**
102 |
103 | .. code:: python
104 |
105 | # create playlist from browser items (see above) and play
106 | speaker.player.play(browser)
107 |
108 | # pause/resume
109 | speaker.player.pause()
110 | speaker.player.resume()
111 |
112 | # repeat mode
113 | from samsung_multiroom import REPEAT_ALL, REPEAT_ONE, REPEAT_OFF
114 | speaker.player.repeat(REPEAT_ALL)
115 |
116 | # get track info
117 | track = speaker.player.get_current_track()
118 | print(track)
119 |
120 |
121 | **Equalizer functions**
122 |
123 | .. code:: python
124 |
125 | # get preset names
126 | presets = speaker.equalizer.get_presets_names()
127 | print(presets)
128 |
129 | # set predefined equalizer settings
130 | speaker.equalizer.set('Pop')
131 |
132 | # set adhoc settings
133 | speaker.equalizer.set([4,3,2,1,2,3,0]) # <-6, 6>
134 |
135 | # overwrite current preset
136 | speaker.equalizer.save()
137 |
138 | # ... or save as a new preset
139 | speaker.equalizer.save('Experimental')
140 |
141 |
142 | **Clock functions**
143 |
144 | .. code:: python
145 |
146 | # set alarm
147 | browser = speaker.service('tunein').browser
148 | browser = browser.browse('/Trending/')
149 |
150 | speaker.clock.alarm.slot(0).set(
151 | time='17:28',
152 | weekdays=[0,1,5], # Mon, Tue, Fri
153 | playlist=browser,
154 | )
155 |
156 | # enable/disable alarm 0
157 | speaker.clock.alarm.slot(0).enable()
158 | speaker.clock.alarm.slot(0).disable()
159 |
160 | # sleep after 30 seconds
161 | speaker.clock.timer.start(300)
162 |
163 | remaining_time = speaker.clock.timer.get_remaining_time()
164 | print(remaining_time)
165 |
166 |
167 | **Speaker discovery**
168 |
169 | .. code:: python
170 |
171 | from samsung_multiroom import SamsungSpeakerDiscovery
172 | speakers = SamsungSpeakerDiscovery().discover() # takes some time
173 |
174 | for s in speakers:
175 | print(s.get_name(), '@', s.ip_address)
176 |
177 |
178 | **Speaker grouping**
179 |
180 | .. code:: python
181 |
182 | # (after speaker discovery)
183 | main_speaker = speakers[0]
184 | rest_speakers = speakers[1:]
185 |
186 | speaker_group = main_speaker.group('My first group', rest_speakers)
187 |
188 | # now use speaker group like a speaker
189 | speaker_group.set_volume(10)
190 |
191 | browser = speaker_group.service('dlna').browser
192 | browser = browser.browse('/nas/Music/By Folder/Air/Moon Safari/CD 1')
193 |
194 | speaker_group.player.play(browser)
195 |
196 |
197 | **Events (preview)**
198 |
199 | You can monitor events emitted by the speaker without polling. Full list of supported events can be found in
200 | samsung_multiroom/event/type/.
201 |
202 | .. code:: python
203 |
204 | import asyncio
205 |
206 | from samsung_multiroom import SamsungMultiroomSpeaker
207 |
208 |
209 | # listener will be passed an Event object (see samsung_multiroom/event/type/)
210 | def listener(event):
211 | print(event.name, event)
212 |
213 |
214 | async def main():
215 | speaker = SamsungMultiroomSpeaker('192.168.1.129')
216 | event_loop = speaker.event_loop
217 | event_loop.add_listener('*', listener)
218 |
219 | await event_loop.loop()
220 |
221 |
222 | loop = asyncio.get_event_loop()
223 | loop.run_until_complete(main())
224 | loop.close()
225 |
226 | .. code:: python
227 |
228 | # listen to all events
229 | event_loop.add_listener('*', listener)
230 |
231 | # listen to events within a namespace
232 | event_loop.add_listener('speaker.service.*', listener)
233 |
234 | # listen to a single event
235 | event_loop.add_listener('speaker.service.changed', listener)
236 |
237 |
238 | License
239 | -------
240 |
241 | MIT License
242 |
243 | Copyright (c) 2018 Krystian Galutowski
244 |
245 | Permission is hereby granted, free of charge, to any person obtaining a copy
246 | of this software and associated documentation files (the "Software"), to deal
247 | in the Software without restriction, including without limitation the rights
248 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
249 | copies of the Software, and to permit persons to whom the Software is
250 | furnished to do so, subject to the following conditions:
251 |
252 | The above copyright notice and this permission notice shall be included in all
253 | copies or substantial portions of the Software.
254 |
255 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
256 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
257 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
258 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
259 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
260 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
261 | SOFTWARE.
262 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/tunein/test_player_tuneinplayer.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 |
4 | from samsung_multiroom.service import REPEAT_ALL
5 | from samsung_multiroom.service import REPEAT_OFF
6 | from samsung_multiroom.service.tunein import TuneInPlayer
7 |
8 |
9 | def _get_player():
10 | api = MagicMock()
11 | api.get_preset_list.return_value = [
12 | {
13 | 'kind': 'speaker',
14 | 'title': 'Radio 1',
15 | 'description': 'Radio 1 description',
16 | 'thumbnail': 'http://radio1.org/thumbnail.png',
17 | 'contentid': '0',
18 | 'mediaid': '1111',
19 | },
20 | {
21 | 'kind': 'speaker',
22 | 'title': 'Radio 2',
23 | 'description': 'Radio 2 description',
24 | 'thumbnail': 'http://radio2.org/thumbnail.png',
25 | 'contentid': '1',
26 | 'mediaid': '2222',
27 | },
28 | {
29 | 'kind': 'speaker',
30 | 'title': 'Radio 3',
31 | 'description': 'Radio 3 description',
32 | 'thumbnail': 'http://radio3.org/thumbnail.png',
33 | 'contentid': '2',
34 | 'mediaid': '3333',
35 | },
36 | {
37 | 'kind': 'my',
38 | 'title': 'Radio 4',
39 | 'description': 'Radio 4 description',
40 | 'thumbnail': 'http://radio4.org/thumbnail.png',
41 | 'contentid': '3',
42 | 'mediaid': '4444',
43 | },
44 | {
45 | 'kind': 'my',
46 | 'title': 'Radio 5',
47 | 'description': 'Radio 5 description',
48 | 'thumbnail': 'http://radio5.org/thumbnail.png',
49 | 'contentid': '4',
50 | 'mediaid': '5555',
51 | },
52 | ]
53 | api.get_radio_info.return_value = {
54 | 'cpname': 'TuneIn',
55 | 'root': 'Favorites',
56 | 'presetindex': '0',
57 | 'title': 'Radio 1',
58 | 'description': 'Radio 1 description',
59 | 'thumbnail': 'http://radio1.org/thumbnail.png',
60 | 'mediaid': '1111',
61 | 'allowfeedback': '0',
62 | 'timestamp': '2018-12-28T18:07:07Z',
63 | 'no_queue': '1',
64 | 'playstatus': 'play',
65 | }
66 |
67 | player = TuneInPlayer(api)
68 |
69 | return (player, api)
70 |
71 |
72 | class TestTuneInPlayer(unittest.TestCase):
73 |
74 | def test_is_supported(self):
75 | player, api = _get_player()
76 |
77 | self.assertTrue(player.is_play_supported())
78 | self.assertFalse(player.is_jump_supported())
79 | self.assertTrue(player.is_resume_supported())
80 | self.assertFalse(player.is_stop_supported())
81 | self.assertTrue(player.is_pause_supported())
82 | self.assertTrue(player.is_next_supported())
83 | self.assertTrue(player.is_previous_supported())
84 | self.assertFalse(player.is_repeat_supported())
85 | self.assertFalse(player.is_shuffle_supported())
86 |
87 | def test_play(self):
88 | playlist = [
89 | type('Item', (object, ), {
90 | 'object_id': '1',
91 | 'object_type': 'some_type',
92 | 'title': 'title 1',
93 | }),
94 | type('Item', (object, ), {
95 | 'object_id': '2',
96 | 'object_type': 'tunein_radio',
97 | 'title': 'radio 2',
98 | }),
99 | type('Item', (object, ), {
100 | 'object_id': '3',
101 | 'object_type': 'tunein_radio',
102 | 'title': 'radio 3',
103 | }),
104 | type('Item', (object, ), {
105 | 'object_id': '4',
106 | 'object_type': 'some_type2',
107 | 'title': 'title 4',
108 | })
109 | ]
110 |
111 | player, api = _get_player()
112 |
113 | player.play(playlist)
114 |
115 | api.set_play_select.assert_called_once_with('2')
116 |
117 | def test_play_returns_false_for_unsupported_playlist(self):
118 | playlist = [
119 | type('Item', (object, ), {
120 | 'object_id': '1',
121 | 'object_type': 'some_type',
122 | 'title': 'title 1',
123 | }),
124 | type('Item', (object, ), {
125 | 'object_id': '4',
126 | 'object_type': 'some_type2',
127 | 'title': 'title 4',
128 | })
129 | ]
130 |
131 | player, api = _get_player()
132 |
133 | self.assertFalse(player.play(playlist))
134 |
135 | api.set_play_select.assert_not_called()
136 |
137 | def test_jump(self):
138 | player, api = _get_player()
139 |
140 | player.jump(50)
141 |
142 | api.set_search_time.assert_not_called()
143 |
144 | def test_resume(self):
145 | player, api = _get_player()
146 |
147 | player.resume()
148 |
149 | api.set_select_radio.assert_called_once()
150 |
151 | @unittest.skip('Pending implementation')
152 | def test_stop(self):
153 | player, api = _get_player()
154 |
155 | player.stop()
156 |
157 | def test_pause(self):
158 | player, api = _get_player()
159 |
160 | player.pause()
161 |
162 | api.set_playback_control.assert_called_once_with('pause')
163 |
164 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
165 | def test_next(self, signature):
166 | signature.return_value = ['start_index', 'list_count']
167 |
168 | player, api = _get_player()
169 |
170 | player.next()
171 |
172 | api.get_preset_list.assert_called_once_with(start_index=0, list_count=30)
173 | api.get_radio_info.assert_called_once()
174 | api.set_play_preset.assert_called_once_with(1, 1)
175 | api.set_select_radio.assert_called_once()
176 |
177 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
178 | def test_previous(self, signature):
179 | signature.return_value = ['start_index', 'list_count']
180 |
181 | player, api = _get_player()
182 |
183 | player.previous()
184 |
185 | api.get_preset_list.assert_called_once_with(start_index=0, list_count=30)
186 | api.get_radio_info.assert_called_once()
187 | api.set_play_preset.assert_called_once_with(0, 4)
188 | api.set_select_radio.assert_called_once()
189 |
190 | def test_repeat(self):
191 | player, api = _get_player()
192 |
193 | player.repeat(REPEAT_ALL)
194 |
195 | api.set_repeat_mode.assert_not_called()
196 |
197 | def test_shuffle(self):
198 | player, api = _get_player()
199 |
200 | player.shuffle(True)
201 |
202 | api.set_shuffle_mode.assert_not_called()
203 |
204 | def test_get_repeat(self):
205 | player, api = _get_player()
206 |
207 | repeat = player.get_repeat()
208 |
209 | self.assertEqual(repeat, REPEAT_OFF)
210 |
211 | api.get_repeat_mode.assert_not_called()
212 |
213 | def test_get_shuffle(self):
214 | player, api = _get_player()
215 |
216 | shuffle = player.get_shuffle()
217 |
218 | self.assertFalse(shuffle)
219 |
220 | api.get_repeat_mode.assert_not_called()
221 |
222 | def test_get_current_track(self):
223 | player, api = _get_player()
224 |
225 | track = player.get_current_track()
226 |
227 | api.get_radio_info.assert_called_once()
228 |
229 | self.assertEqual(track.title, 'Radio 1 description')
230 | self.assertEqual(track.artist, 'Radio 1')
231 | self.assertEqual(track.album, None)
232 | self.assertEqual(track.duration, None)
233 | self.assertEqual(track.position, None)
234 | self.assertEqual(track.thumbnail_url, 'http://radio1.org/thumbnail.png')
235 | self.assertEqual(track.object_id, None)
236 | self.assertEqual(track.object_type, 'tunein_radio')
237 |
238 | def test_is_active(self):
239 | player, api = _get_player()
240 |
241 | self.assertTrue(player.is_active('wifi', 'cp'))
242 | self.assertFalse(player.is_active('wifi', 'dlna'))
243 | self.assertFalse(player.is_active('bt'))
244 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/app/test_browser_appbrowser.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest.mock import MagicMock
3 | from unittest.mock import call
4 |
5 | from samsung_multiroom.service import Item
6 | from samsung_multiroom.service.app import AppBrowser
7 |
8 |
9 | def _get_browser():
10 | api = MagicMock()
11 | api.get_cp_submenu.return_value = [
12 | {
13 | '@id': '0',
14 | 'submenuitem_localized': 'Flow',
15 | },
16 | {
17 | '@id': '1',
18 | 'submenuitem_localized': 'Browse',
19 | },
20 | {
21 | '@id': '2',
22 | 'submenuitem_localized': 'Playlist picks',
23 | },
24 | ]
25 | api.set_select_cp_submenu.return_value = [
26 | {
27 | '@type': '0',
28 | 'title': 'All',
29 | 'contentid': '0',
30 | },
31 | {
32 | '@type': '0',
33 | 'title': 'Pop',
34 | 'contentid': '1',
35 | },
36 | {
37 | '@type': '0',
38 | 'title': 'Rap/Hip Hop',
39 | 'contentid': '2',
40 | },
41 | {
42 | '@type': '0',
43 | 'title': 'Rock',
44 | 'contentid': '3',
45 | },
46 | {
47 | '@type': '0',
48 | 'title': 'Dance',
49 | 'contentid': '4',
50 | },
51 | ]
52 | api.get_select_radio_list.side_effect = [
53 | [
54 | {
55 | '@type': '0',
56 | 'title': 'Mixes',
57 | 'contentid': '0',
58 | },
59 | {
60 | '@type': '0',
61 | 'title': 'Playlists',
62 | 'contentid': '1',
63 | },
64 | {
65 | '@type': '0',
66 | 'title': 'Albums',
67 | 'contentid': '2',
68 | },
69 | {
70 | '@type': '0',
71 | 'title': 'Artists',
72 | 'contentid': '3',
73 | },
74 | {
75 | '@type': '0',
76 | 'title': 'Editor\'s Picks',
77 | 'contentid': '4',
78 | },
79 | ],
80 | [
81 | {
82 | '@type': '3',
83 | 'title': 'Queen',
84 | 'contentid': '0',
85 | 'thumbnail': 'http://api.deezer.com/artist/412/image',
86 | },
87 | {
88 | '@type': '3',
89 | 'title': 'The Beatles',
90 | 'contentid': '1',
91 | 'thumbnail': 'http://api.deezer.com/artist/1/image',
92 | },
93 | {
94 | '@type': '3',
95 | 'title': 'Linking Park',
96 | 'contentid': '2',
97 | 'thumbnail': 'http://api.deezer.com/artist/92/image',
98 | },
99 | ],
100 | [
101 | {
102 | '@type': '2',
103 | 'artist': 'Queen',
104 | 'album': None,
105 | 'mediaid': '412',
106 | 'title': 'Queen Mixes',
107 | 'contentid': '0',
108 | 'thumbnail': 'http://api.deezer.com/artist/412/image',
109 | },
110 | {
111 | '@cat': 'Top Tracks',
112 | '@type': '1',
113 | '@available': '1',
114 | '@currentplaying': '1',
115 | 'artist': 'Queen',
116 | 'album': 'A Night At The Opera (2011 Remaster)',
117 | 'mediaid': '9997018',
118 | 'tracklength': '358',
119 | 'title': 'Queen Mixes',
120 | 'contentid': '1',
121 | 'thumbnail': 'http://api.deezer.com/album/915785/image',
122 | },
123 | {
124 | '@cat': 'Top Tracks',
125 | '@type': '1',
126 | '@available': '1',
127 | 'artist': 'Queen',
128 | 'album': 'Jazz (2011 Remaster)',
129 | 'mediaid': '12209331',
130 | 'tracklength': '209',
131 | 'title': 'Don\'t Stop Me Now',
132 | 'contentid': '2',
133 | 'thumbnail': 'http://api.deezer.com/album/1121401/image',
134 | },
135 | {
136 | '@cat': 'Albums',
137 | '@type': '4',
138 | 'artist': None,
139 | 'title': 'Queen Forever',
140 | 'contentid': '6',
141 | 'thumbnail': 'http://api.deezer.com/album/8980335/image',
142 | },
143 | ]
144 | ]
145 |
146 | browser = AppBrowser(api, 3, 'service 3')
147 |
148 | return (browser, api)
149 |
150 |
151 | class TestAppBrowser(unittest.TestCase):
152 |
153 | def test_get_name(self):
154 | browser, api = _get_browser()
155 |
156 | name = browser.get_name()
157 |
158 | self.assertEqual(name, 'service 3')
159 |
160 | def test_browse_from_root(self):
161 | browser, api = _get_browser()
162 |
163 | browser = browser.browse()
164 |
165 | api.get_cp_submenu.assert_called_once()
166 |
167 | self.assertEqual(browser.get_path(), '/')
168 | self.assertIsInstance(browser[0], Item)
169 | self.assertEqual(browser[0].object_id, '0')
170 | self.assertEqual(browser[0].object_type, 'container')
171 | self.assertEqual(browser[0].name, 'Flow')
172 |
173 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
174 | def test_browse_second_level(self, signature):
175 | signature.side_effect = [['content_id', 'start_index', 'list_count']] * 2
176 |
177 | browser, api = _get_browser()
178 |
179 | browser = browser.browse('/Browse')
180 |
181 | api.get_cp_submenu.assert_called_once()
182 | api.set_select_cp_submenu.assert_called_once_with(content_id='1', start_index=0, list_count=30)
183 |
184 | self.assertEqual(browser.get_path(), '/Browse')
185 | self.assertIsInstance(browser[0], Item)
186 | self.assertEqual(browser[0].object_id, '0')
187 | self.assertEqual(browser[0].object_type, 'container')
188 | self.assertEqual(browser[0].name, 'All')
189 |
190 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
191 | def test_browse_full_level(self, signature):
192 | signature.side_effect = [['content_id', 'start_index', 'list_count']] * 8
193 |
194 | browser, api = _get_browser()
195 |
196 | browser = browser.browse('/Browse/Rock/Artists/Queen/')
197 |
198 | api.get_cp_submenu.assert_called_once()
199 | api.set_select_cp_submenu.assert_called_once_with(content_id='1', start_index=0, list_count=30)
200 | api.get_select_radio_list.assert_has_calls([
201 | call(content_id='3', start_index=0, list_count=30),
202 | call(content_id='3', start_index=0, list_count=30),
203 | call(content_id='0', start_index=0, list_count=30),
204 | ])
205 |
206 | self.assertEqual(browser.get_path(), '/Browse/Rock/Artists/Queen')
207 | self.assertIsInstance(browser[0], Item)
208 | self.assertEqual(browser[0].object_id, '0')
209 | self.assertEqual(browser[0].object_type, 'app_audio')
210 | self.assertEqual(browser[0].name, 'Queen Mixes')
211 |
212 | @unittest.mock.patch('samsung_multiroom.api.api._get_callable_parameters')
213 | def test_browse_relative_path(self, signature):
214 | signature.side_effect = [['content_id', 'start_index', 'list_count']] * 8
215 |
216 | browser, api = _get_browser()
217 |
218 | browser = browser.browse('/Browse/Rock').browse('Artists/Queen/')
219 |
220 | api.get_cp_submenu.assert_called_once()
221 | api.set_select_cp_submenu.assert_called_once_with(content_id='1', start_index=0, list_count=30)
222 | api.get_select_radio_list.assert_has_calls([
223 | call(content_id='3', start_index=0, list_count=30),
224 | call(content_id='3', start_index=0, list_count=30),
225 | call(content_id='0', start_index=0, list_count=30),
226 | ])
227 |
228 | self.assertEqual(browser.get_path(), '/Browse/Rock/Artists/Queen')
229 | self.assertIsInstance(browser[0], Item)
230 | self.assertEqual(browser[0].object_id, '0')
231 | self.assertEqual(browser[0].object_type, 'app_audio')
232 | self.assertEqual(browser[0].name, 'Queen Mixes')
233 |
--------------------------------------------------------------------------------
/tests/samsung_multiroom/service/test_playeroperator.py:
--------------------------------------------------------------------------------
1 | import abc
2 | import unittest
3 | from unittest.mock import MagicMock
4 |
5 | from samsung_multiroom.service import REPEAT_ALL
6 | from samsung_multiroom.service import REPEAT_ONE
7 | from samsung_multiroom.service import Player
8 | from samsung_multiroom.service import PlayerOperator
9 | from samsung_multiroom.service.player_operator import NullPlayer
10 |
11 |
12 | class FakePlayer(Player):
13 |
14 | @abc.abstractmethod
15 | def is_stop_supported(self):
16 | pass
17 |
18 | @abc.abstractmethod
19 | def is_repeat_supported(self):
20 | pass
21 |
22 |
23 | def get_mocks():
24 | api = MagicMock()
25 | api.get_func.return_value = {'function': 'test_function', 'submode': 'test_submode'}
26 |
27 | player1 = MagicMock(spec=FakePlayer, name='player1')
28 | player1.is_active.return_value = False
29 | player2 = MagicMock(spec=FakePlayer, name='player2')
30 | player2.is_active.return_value = True
31 |
32 | player_operator = PlayerOperator(api, [player1, player2])
33 |
34 | return (player_operator, api, [player1, player2])
35 |
36 |
37 | class TestPlayerOperator(unittest.TestCase):
38 |
39 | def test_is_supported_uses_active_player(self):
40 | player_operator, api, players = get_mocks()
41 |
42 | players[0].is_stop_supported.return_value = False
43 | players[0].is_repeat_supported.return_value = True
44 | players[1].is_stop_supported.return_value = True
45 | players[1].is_repeat_supported.return_value = False
46 |
47 | self.assertTrue(player_operator.is_stop_supported())
48 | self.assertFalse(player_operator.is_repeat_supported())
49 |
50 | def test_only_player_instances_are_allowed(self):
51 | api = MagicMock()
52 |
53 | player1 = MagicMock(spec=Player, name='player1')
54 | player2 = MagicMock(name='player2')
55 |
56 | self.assertRaises(ValueError, PlayerOperator, api, [player1, player2])
57 |
58 | def test_get_player_returns_active_player(self):
59 | api = MagicMock()
60 | api.get_func.return_value = {'function': 'test_function', 'submode': 'test_submode'}
61 |
62 | player1 = MagicMock(spec=Player, name='player1')
63 | player1.is_active.return_value = False
64 | player2 = MagicMock(spec=Player, name='player2')
65 | player2.is_active.return_value = True
66 |
67 | player_operator = PlayerOperator(api, [player1, player2])
68 | player = player_operator.get_player()
69 |
70 | self.assertEqual(player, player2)
71 | player1.is_active.assert_called_once_with('test_function', 'test_submode')
72 | player2.is_active.assert_called_once_with('test_function', 'test_submode')
73 |
74 | def test_get_player_returns_nullplayer_if_no_active(self):
75 | api = MagicMock()
76 | api.get_func.return_value = {'function': 'test_function', 'submode': 'test_submode'}
77 |
78 | player1 = MagicMock(spec=Player, name='player1')
79 | player1.is_active.return_value = False
80 | player2 = MagicMock(spec=Player, name='player2')
81 | player2.is_active.return_value = False
82 |
83 | player_operator = PlayerOperator(api, [player1, player2])
84 | player = player_operator.get_player()
85 |
86 | self.assertIsInstance(player, NullPlayer)
87 | player1.is_active.assert_called_once_with('test_function', 'test_submode')
88 | player2.is_active.assert_called_once_with('test_function', 'test_submode')
89 |
90 | def test_get_play(self):
91 | api = MagicMock()
92 | api.get_func.return_value = {'function': 'test_function', 'submode': 'test_submode'}
93 |
94 | player1 = MagicMock(spec=Player, name='player1')
95 | player1.play.return_value = False
96 | player2 = MagicMock(spec=Player, name='player2')
97 | player2.play.return_value = True
98 | player3 = MagicMock(spec=Player, name='player3')
99 | player3.play.return_value = True
100 |
101 | playlist = MagicMock()
102 |
103 | player_operator = PlayerOperator(api, [player1, player2, player3])
104 | self.assertTrue(player_operator.play(playlist))
105 |
106 | player1.play.assert_called_once_with(playlist)
107 | player2.play.assert_called_once_with(playlist)
108 | player3.play.assert_not_called()
109 |
110 | def test_get_play_returns_false(self):
111 | api = MagicMock()
112 | api.get_func.return_value = {'function': 'test_function', 'submode': 'test_submode'}
113 |
114 | player1 = MagicMock(spec=Player, name='player1')
115 | player1.play.return_value = False
116 | player2 = MagicMock(spec=Player, name='player2')
117 | player2.play.return_value = False
118 | player3 = MagicMock(spec=Player, name='player3')
119 | player3.play.return_value = False
120 |
121 | playlist = MagicMock()
122 |
123 | player_operator = PlayerOperator(api, [player1, player2, player3])
124 | self.assertFalse(player_operator.play(playlist))
125 |
126 | player1.play.assert_called_once_with(playlist)
127 | player2.play.assert_called_once_with(playlist)
128 | player3.play.assert_called_once_with(playlist)
129 |
130 | def test_jump(self):
131 | player_operator, api, players = get_mocks()
132 | player_operator.jump(50)
133 |
134 | players[0].jump.assert_not_called()
135 | players[1].jump.assert_called_once_with(50)
136 |
137 | def test_resume(self):
138 | player_operator, api, players = get_mocks()
139 | player_operator.resume()
140 |
141 | players[0].resume.assert_not_called()
142 | players[1].resume.assert_called_once()
143 |
144 | def test_stop(self):
145 | player_operator, api, players = get_mocks()
146 | player_operator.stop()
147 |
148 | players[0].stop.assert_not_called()
149 | players[1].stop.assert_called_once()
150 |
151 | def test_pause(self):
152 | player_operator, api, players = get_mocks()
153 | player_operator.pause()
154 |
155 | players[0].pause.assert_not_called()
156 | players[1].pause.assert_called_once()
157 |
158 | def test_next(self):
159 | player_operator, api, players = get_mocks()
160 | player_operator.next()
161 |
162 | players[0].next.assert_not_called()
163 | players[1].next.assert_called_once()
164 |
165 | def test_previous(self):
166 | player_operator, api, players = get_mocks()
167 | player_operator.previous()
168 |
169 | players[0].previous.assert_not_called()
170 | players[1].previous.assert_called_once()
171 |
172 | def test_repeat(self):
173 | player_operator, api, players = get_mocks()
174 | player_operator.repeat(REPEAT_ONE)
175 |
176 | players[0].repeat.assert_not_called()
177 | players[1].repeat.assert_called_once_with(REPEAT_ONE)
178 |
179 | def test_shuffle(self):
180 | player_operator, api, players = get_mocks()
181 | player_operator.shuffle(True)
182 |
183 | players[0].shuffle.assert_not_called()
184 | players[1].shuffle.assert_called_once_with(True)
185 |
186 | def test_get_repeat(self):
187 | player_operator, api, players = get_mocks()
188 |
189 | players[1].get_repeat.return_value = REPEAT_ALL
190 |
191 | repeat = player_operator.get_repeat()
192 |
193 | self.assertEqual(repeat, REPEAT_ALL)
194 |
195 | players[0].get_repeat.assert_not_called()
196 | players[1].get_repeat.assert_called_once()
197 |
198 | def test_get_shuffle(self):
199 | player_operator, api, players = get_mocks()
200 |
201 | players[1].get_shuffle.return_value = True
202 |
203 | shuffle = player_operator.get_shuffle()
204 |
205 | self.assertTrue(shuffle)
206 |
207 | players[0].get_shuffle.assert_not_called()
208 | players[1].get_shuffle.assert_called_once()
209 |
210 | def test_get_current_track(self):
211 | player_operator, api, players = get_mocks()
212 |
213 | track = MagicMock()
214 | players[1].get_current_track.return_value = track
215 |
216 | self.assertEqual(player_operator.get_current_track(), track)
217 |
218 | players[0].get_current_track.assert_not_called()
219 | players[1].get_current_track.assert_called_once()
220 |
221 | def test_is_active(self):
222 | player_operator, api, players = get_mocks()
223 | self.assertTrue(player_operator.is_active('test_function', 'test_submode'))
224 |
225 | players[0].is_active.assert_called_once_with('test_function', 'test_submode')
226 | players[1].is_active.assert_called_once_with('test_function', 'test_submode')
227 |
228 | def test_is_active_returns_false(self):
229 | player_operator, api, players = get_mocks()
230 |
231 | players[0].is_active.return_value = False
232 | players[1].is_active.return_value = False
233 |
234 | self.assertFalse(player_operator.is_active('test_function', 'test_submode'))
235 |
236 | players[0].is_active.assert_called_once_with('test_function', 'test_submode')
237 | players[1].is_active.assert_called_once_with('test_function', 'test_submode')
238 |
--------------------------------------------------------------------------------