diff --git a/CODEOWNERS b/CODEOWNERS index d78081352f21303d5262c75c4220908d5ca5e6b9..0560f5d53109c1c29e4a7eab5a66dde174dc5f2c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,7 @@ homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/media_player/kodi.py @armills +homeassistant/components/media_player/monoprice.py @etsinko homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/miflora.py @danielhiversen homeassistant/components/sensor/tibber.py @danielhiversen diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py new file mode 100644 index 0000000000000000000000000000000000000000..b9a253676608298d8098e92792aec5514baff84f --- /dev/null +++ b/homeassistant/components/media_player/monoprice.py @@ -0,0 +1,185 @@ +""" +Support for interfacing with Monoprice 6 zone home audio controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.monoprice/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) + + +REQUIREMENTS = ['pymonoprice==0.2'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_MONOPRICE = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \ + SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +SOURCE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +CONF_ZONES = 'zones' +CONF_SOURCES = 'sources' + +# Valid zone ids: 11-16 or 21-26 or 31-36 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Any(vol.Range(min=11, max=16), + vol.Range(min=21, max=26), + vol.Range(min=31, max=36))) + +# Valid source ids: 1-6 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6)) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PORT): cv.string, + vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}), + vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Monoprice 6-zone amplifier platform.""" + port = config.get(CONF_PORT) + + from serial import SerialException + from pymonoprice import Monoprice + try: + monoprice = Monoprice(port) + except SerialException: + _LOGGER.error('Error connecting to Monoprice controller.') + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + add_devices([MonopriceZone(monoprice, sources, + zone_id, extra[CONF_NAME])], True) + + +class MonopriceZone(MediaPlayerDevice): + """Representation of a a Monoprice amplifier zone.""" + + # pylint: disable=too-many-public-methods + + def __init__(self, monoprice, sources, zone_id, zone_name): + """Initialize new zone.""" + self._monoprice = monoprice + # dict source_id -> source name + self._source_id_name = sources + # dict source name -> source_id + self._source_name_id = {v: k for k, v in sources.items()} + # ordered list of all source names + self._source_names = sorted(self._source_name_id.keys(), + key=lambda v: self._source_name_id[v]) + self._zone_id = zone_id + self._name = zone_name + + self._state = None + self._volume = None + self._source = None + self._mute = None + + def update(self): + """Retrieve latest state.""" + state = self._monoprice.zone_status(self._zone_id) + if not state: + return False + self._state = STATE_ON if state.power else STATE_OFF + self._volume = state.volume + self._mute = state.mute + idx = state.source + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + return True + + @property + def name(self): + """Return the name of the zone.""" + return self._name + + @property + def state(self): + """Return the state of the zone.""" + return self._state + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + if self._volume is None: + return None + return self._volume / 38.0 + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._mute + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_MONOPRICE + + @property + def source(self): + """"Return the current input source of the device.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + self._monoprice.set_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + self._monoprice.set_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + self._monoprice.set_power(self._zone_id, False) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + self._monoprice.set_mute(self._zone_id, mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._monoprice.set_volume(self._zone_id, int(volume * 38)) + + def volume_up(self): + """Volume up the media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, + min(self._volume + 1, 38)) + + def volume_down(self): + """Volume down media player.""" + if self._volume is None: + return + self._monoprice.set_volume(self._zone_id, + max(self._volume - 1, 0)) diff --git a/requirements_all.txt b/requirements_all.txt index 483f842379bd7b84fcad304437757422d6bc33ad..61d711d5fe2320bc8fd86352e7839dddf69199e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -680,6 +680,9 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.monoprice +pymonoprice==0.2 + # homeassistant.components.media_player.yamaha_musiccast pymusiccast==0.1.2 diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py new file mode 100644 index 0000000000000000000000000000000000000000..451b6b51febdade003932c3a38392f3ad67c0a19 --- /dev/null +++ b/tests/components/media_player/test_monoprice.py @@ -0,0 +1,323 @@ +"""The tests for Monoprice Media player platform.""" +import unittest +import voluptuous as vol + +from collections import defaultdict + +from homeassistant.components.media_player import ( + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE) +from homeassistant.const import STATE_ON, STATE_OFF + +from components.media_player.monoprice import MonopriceZone, PLATFORM_SCHEMA + + +class MockState(object): + """Mock for zone state object.""" + + def __init__(self): + """Init zone state.""" + self.power = True + self.volume = 0 + self.mute = True + self.source = 1 + + +class MockMonoprice(object): + """Mock for pymonoprice object.""" + + def __init__(self): + """Init mock object.""" + self.zones = defaultdict(lambda *a: MockState()) + + def zone_status(self, zone_id): + """Get zone status.""" + return self.zones[zone_id] + + def set_source(self, zone_id, source_idx): + """Set source for zone.""" + self.zones[zone_id].source = source_idx + + def set_power(self, zone_id, power): + """Turn zone on/off.""" + self.zones[zone_id].power = power + + def set_mute(self, zone_id, mute): + """Mute/unmute zone.""" + self.zones[zone_id].mute = mute + + def set_volume(self, zone_id, volume): + """Set volume for zone.""" + self.zones[zone_id].volume = volume + + +class TestMonopriceSchema(unittest.TestCase): + """Test Monoprice schema.""" + + def test_valid_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'monoprice', + 'port': '/dev/ttyUSB0', + 'zones': {11: {'name': 'a'}, + 12: {'name': 'a'}, + 13: {'name': 'a'}, + 14: {'name': 'a'}, + 15: {'name': 'a'}, + 16: {'name': 'a'}, + 21: {'name': 'a'}, + 22: {'name': 'a'}, + 23: {'name': 'a'}, + 24: {'name': 'a'}, + 25: {'name': 'a'}, + 26: {'name': 'a'}, + 31: {'name': 'a'}, + 32: {'name': 'a'}, + 33: {'name': 'a'}, + 34: {'name': 'a'}, + 35: {'name': 'a'}, + 36: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'} + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_invalid_schemas(self): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Missing port + { + 'platform': 'monoprice', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid zone number + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {10: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid source number + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {0: {'name': 'b'}}, + }, + # Zone missing name + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {}}, + 'sources': {1: {'name': 'b'}}, + }, + # Source missing name + { + 'platform': 'monoprice', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {}}, + }, + + ) + for value in schemas: + with self.assertRaises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +class TestMonopriceMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): + """Set up the test case.""" + self.monoprice = MockMonoprice() + # Note, source dictionary is unsorted! + self.media_player = MonopriceZone(self.monoprice, {1: 'one', + 3: 'three', + 2: 'two'}, + 12, 'Zone name') + + def test_update(self): + """Test updating values from monoprice.""" + self.assertIsNone(self.media_player.state) + self.assertIsNone(self.media_player.volume_level) + self.assertIsNone(self.media_player.is_volume_muted) + self.assertIsNone(self.media_player.source) + + self.media_player.update() + + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + self.assertTrue(self.media_player.is_volume_muted) + self.assertEqual('one', self.media_player.source) + + def test_name(self): + """Test name property.""" + self.assertEqual('Zone name', self.media_player.name) + + def test_state(self): + """Test state property.""" + self.assertIsNone(self.media_player.state) + + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.monoprice.zones[12].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_volume_level(self): + """Test volume level property.""" + self.assertIsNone(self.media_player.volume_level) + self.media_player.update() + self.assertEqual(0.0, self.media_player.volume_level, 0.0001) + + self.monoprice.zones[12].volume = 38 + self.media_player.update() + self.assertEqual(1.0, self.media_player.volume_level, 0.0001) + + self.monoprice.zones[12].volume = 19 + self.media_player.update() + self.assertEqual(.5, self.media_player.volume_level, 0.0001) + + def test_is_volume_muted(self): + """Test volume muted property.""" + self.assertIsNone(self.media_player.is_volume_muted) + + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + self.monoprice.zones[12].mute = False + self.media_player.update() + self.assertFalse(self.media_player.is_volume_muted) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | + SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | + SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE, + self.media_player.supported_features) + + def test_source(self): + """Test source property.""" + self.assertIsNone(self.media_player.source) + self.media_player.update() + self.assertEqual('one', self.media_player.source) + + def test_source_list(self): + """Test source list property.""" + # Note, the list is sorted! + self.assertEqual(['one', 'two', 'three'], + self.media_player.source_list) + + def test_select_source(self): + """Test source selection methods.""" + self.media_player.update() + + self.assertEqual('one', self.media_player.source) + + self.media_player.select_source('two') + self.assertEqual(2, self.monoprice.zones[12].source) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + # Trying to set unknown source + self.media_player.select_source('no name') + self.assertEqual(2, self.monoprice.zones[12].source) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + def test_turn_on(self): + """Test turning on the zone.""" + self.monoprice.zones[12].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + self.media_player.turn_on() + self.assertTrue(self.monoprice.zones[12].power) + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + def test_turn_off(self): + """Test turning off the zone.""" + self.monoprice.zones[12].power = True + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.media_player.turn_off() + self.assertFalse(self.monoprice.zones[12].power) + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_mute_volume(self): + """Test mute functionality.""" + self.monoprice.zones[12].mute = True + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + self.media_player.mute_volume(False) + self.assertFalse(self.monoprice.zones[12].mute) + self.media_player.update() + self.assertFalse(self.media_player.is_volume_muted) + + self.media_player.mute_volume(True) + self.assertTrue(self.monoprice.zones[12].mute) + self.media_player.update() + self.assertTrue(self.media_player.is_volume_muted) + + def test_set_volume_level(self): + """Test set volume level.""" + self.media_player.set_volume_level(1.0) + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + self.media_player.set_volume_level(0.0) + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + self.media_player.set_volume_level(0.5) + self.assertEqual(19, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + def test_volume_up(self): + """Test increasing volume by one.""" + self.monoprice.zones[12].volume = 37 + self.media_player.update() + self.media_player.volume_up() + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + # Try to raise value beyond max + self.media_player.update() + self.media_player.volume_up() + self.assertEqual(38, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + def test_volume_down(self): + """Test decreasing volume by one.""" + self.monoprice.zones[12].volume = 1 + self.media_player.update() + self.media_player.volume_down() + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int)) + + # Try to lower value beyond minimum + self.media_player.update() + self.media_player.volume_down() + self.assertEqual(0, self.monoprice.zones[12].volume) + self.assertTrue(isinstance(self.monoprice.zones[12].volume, int))