├── 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 | --------------------------------------------------------------------------------