From 37cd63ea5a7a680ca47da03e5d5a98d2f0823bbf Mon Sep 17 00:00:00 2001
From: koolsb <14332595+koolsb@users.noreply.github.com>
Date: Thu, 19 Apr 2018 04:35:38 -0500
Subject: [PATCH] Add blackbird media player component (#13549)

---
 .../components/media_player/blackbird.py      | 213 ++++++++++++
 .../components/media_player/services.yaml     |  10 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 script/gen_requirements_all.py                |   1 +
 .../components/media_player/test_blackbird.py | 328 ++++++++++++++++++
 6 files changed, 558 insertions(+)
 create mode 100644 homeassistant/components/media_player/blackbird.py
 create mode 100644 tests/components/media_player/test_blackbird.py

diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py
new file mode 100644
index 00000000000..37b3c0ff819
--- /dev/null
+++ b/homeassistant/components/media_player/blackbird.py
@@ -0,0 +1,213 @@
+"""
+Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/media_player.blackbird
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+    DOMAIN, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE,
+    SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice)
+from homeassistant.const import (
+    ATTR_ENTITY_ID, CONF_NAME, CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+REQUIREMENTS = ['pyblackbird==0.5']
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_BLACKBIRD = 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'
+CONF_TYPE = 'type'
+
+DATA_BLACKBIRD = 'blackbird'
+
+SERVICE_SETALLZONES = 'blackbird_set_all_zones'
+ATTR_SOURCE = 'source'
+
+BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+    vol.Required(ATTR_SOURCE): cv.string
+})
+
+
+# Valid zone ids: 1-8
+ZONE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8))
+
+# Valid source ids: 1-8
+SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8))
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+    vol.Required(CONF_TYPE): vol.In(['serial', 'socket']),
+    vol.Optional(CONF_PORT): cv.string,
+    vol.Optional(CONF_HOST): 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 Blackbird 4k 8x8 HDBaseT Matrix platform."""
+    port = config.get(CONF_PORT)
+    host = config.get(CONF_HOST)
+    device_type = config.get(CONF_TYPE)
+
+    import socket
+    from pyblackbird import get_blackbird
+    from serial import SerialException
+
+    if device_type == 'serial':
+        if port is None:
+            _LOGGER.error("No port configured")
+            return
+        try:
+            blackbird = get_blackbird(port)
+        except SerialException:
+            _LOGGER.error("Error connecting to the Blackbird controller")
+            return
+
+    elif device_type == 'socket':
+        try:
+            if host is None:
+                _LOGGER.error("No host configured")
+                return
+            blackbird = get_blackbird(host, False)
+        except socket.timeout:
+            _LOGGER.error("Error connecting to the Blackbird controller")
+            return
+
+    else:
+        _LOGGER.error("Incorrect device type specified")
+        return
+
+    sources = {source_id: extra[CONF_NAME] for source_id, extra
+               in config[CONF_SOURCES].items()}
+
+    hass.data[DATA_BLACKBIRD] = []
+    for zone_id, extra in config[CONF_ZONES].items():
+        _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME])
+        hass.data[DATA_BLACKBIRD].append(BlackbirdZone(
+            blackbird, sources, zone_id, extra[CONF_NAME]))
+
+    add_devices(hass.data[DATA_BLACKBIRD], True)
+
+    def service_handle(service):
+        """Handle for services."""
+        entity_ids = service.data.get(ATTR_ENTITY_ID)
+        source = service.data.get(ATTR_SOURCE)
+        if entity_ids:
+            devices = [device for device in hass.data[DATA_BLACKBIRD]
+                       if device.entity_id in entity_ids]
+
+        else:
+            devices = hass.data[DATA_BLACKBIRD]
+
+        for device in devices:
+            if service.service == SERVICE_SETALLZONES:
+                device.set_all_zones(source)
+
+    hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle,
+                           schema=BLACKBIRD_SETALLZONES_SCHEMA)
+
+
+class BlackbirdZone(MediaPlayerDevice):
+    """Representation of a Blackbird matrix zone."""
+
+    def __init__(self, blackbird, sources, zone_id, zone_name):
+        """Initialize new zone."""
+        self._blackbird = blackbird
+        # 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._source = None
+
+    def update(self):
+        """Retrieve latest state."""
+        state = self._blackbird.zone_status(self._zone_id)
+        if not state:
+            return False
+        self._state = STATE_ON if state.power else STATE_OFF
+        idx = state.av
+        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 supported_features(self):
+        """Return flag of media commands that are supported."""
+        return SUPPORT_BLACKBIRD
+
+    @property
+    def media_title(self):
+        """Return the current source as media title."""
+        return self._source
+
+    @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 set_all_zones(self, source):
+        """Set all zones to one source."""
+        _LOGGER.debug("Setting all zones")
+        if source not in self._source_name_id:
+            return
+        idx = self._source_name_id[source]
+        _LOGGER.debug("Setting all zones source to %s", idx)
+        self._blackbird.set_all_zone_source(idx)
+
+    def select_source(self, source):
+        """Set input source."""
+        if source not in self._source_name_id:
+            return
+        idx = self._source_name_id[source]
+        _LOGGER.debug("Setting zone %d source to %s", self._zone_id, idx)
+        self._blackbird.set_zone_source(self._zone_id, idx)
+
+    def turn_on(self):
+        """Turn the media player on."""
+        _LOGGER.debug("Turning zone %d on", self._zone_id)
+        self._blackbird.set_zone_power(self._zone_id, True)
+
+    def turn_off(self):
+        """Turn the media player off."""
+        _LOGGER.debug("Turning zone %d off", self._zone_id)
+        self._blackbird.set_zone_power(self._zone_id, False)
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index 95072f0270c..0a6c413a688 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -402,3 +402,13 @@ songpal_set_sound_setting:
     value:
       description: Value to set.
       example: 'on'
+
+blackbird_set_all_zones:
+  description: Set all Blackbird zones to a single source.
+  fields:
+    entity_id:
+      description: Name of any blackbird zone.
+      example: 'media_player.zone_1'
+    source:
+      description: Name of source to switch to.
+      example: 'Source 1'
diff --git a/requirements_all.txt b/requirements_all.txt
index 71a32278822..be44da3c9b9 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -704,6 +704,9 @@ pyatv==0.3.9
 # homeassistant.components.sensor.bbox
 pybbox==0.0.5-alpha
 
+# homeassistant.components.media_player.blackbird
+pyblackbird==0.5
+
 # homeassistant.components.device_tracker.bluetooth_tracker
 # pybluez==0.22
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 7db4ead0856..f02b5fcdf2e 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -129,6 +129,9 @@ pushbullet.py==0.11.0
 # homeassistant.components.canary
 py-canary==0.5.0
 
+# homeassistant.components.media_player.blackbird
+pyblackbird==0.5
+
 # homeassistant.components.deconz
 pydeconz==36
 
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index f15425063b4..b5b636dc874 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -68,6 +68,7 @@ TEST_REQUIREMENTS = (
     'prometheus_client',
     'pushbullet.py',
     'py-canary',
+    'pyblackbird',
     'pydeconz',
     'pydispatcher',
     'PyJWT',
diff --git a/tests/components/media_player/test_blackbird.py b/tests/components/media_player/test_blackbird.py
new file mode 100644
index 00000000000..86bfdfb52c4
--- /dev/null
+++ b/tests/components/media_player/test_blackbird.py
@@ -0,0 +1,328 @@
+"""The tests for the Monoprice Blackbird media player platform."""
+import unittest
+from unittest import mock
+import voluptuous as vol
+
+from collections import defaultdict
+from homeassistant.components.media_player import (
+    DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_SELECT_SOURCE)
+from homeassistant.const import STATE_ON, STATE_OFF
+
+import tests.common
+from homeassistant.components.media_player.blackbird import (
+    DATA_BLACKBIRD, PLATFORM_SCHEMA, SERVICE_SETALLZONES, setup_platform)
+
+
+class AttrDict(dict):
+    """Helper clas for mocking attributes."""
+
+    def __setattr__(self, name, value):
+        """Set attribute."""
+        self[name] = value
+
+    def __getattr__(self, item):
+        """Get attribute."""
+        return self[item]
+
+
+class MockBlackbird(object):
+    """Mock for pyblackbird object."""
+
+    def __init__(self):
+        """Init mock object."""
+        self.zones = defaultdict(lambda: AttrDict(power=True,
+                                                  av=1))
+
+    def zone_status(self, zone_id):
+        """Get zone status."""
+        status = self.zones[zone_id]
+        status.zone = zone_id
+        return AttrDict(status)
+
+    def set_zone_source(self, zone_id, source_idx):
+        """Set source for zone."""
+        self.zones[zone_id].av = source_idx
+
+    def set_zone_power(self, zone_id, power):
+        """Turn zone on/off."""
+        self.zones[zone_id].power = power
+
+    def set_all_zone_source(self, source_idx):
+        """Set source for all zones."""
+        self.zones[3].av = source_idx
+
+
+class TestBlackbirdSchema(unittest.TestCase):
+    """Test Blackbird schema."""
+
+    def test_valid_serial_schema(self):
+        """Test valid schema."""
+        valid_schema = {
+            'platform': 'blackbird',
+            'type': 'serial',
+            'port': '/dev/ttyUSB0',
+            'zones': {1: {'name': 'a'},
+                      2: {'name': 'a'},
+                      3: {'name': 'a'},
+                      4: {'name': 'a'},
+                      5: {'name': 'a'},
+                      6: {'name': 'a'},
+                      7: {'name': 'a'},
+                      8: {'name': 'a'},
+                      },
+            'sources': {
+                1: {'name': 'a'},
+                2: {'name': 'a'},
+                3: {'name': 'a'},
+                4: {'name': 'a'},
+                5: {'name': 'a'},
+                6: {'name': 'a'},
+                7: {'name': 'a'},
+                8: {'name': 'a'},
+            }
+        }
+        PLATFORM_SCHEMA(valid_schema)
+
+    def test_valid_socket_schema(self):
+        """Test valid schema."""
+        valid_schema = {
+            'platform': 'blackbird',
+            'type': 'socket',
+            'port': '192.168.1.50',
+            'zones': {1: {'name': 'a'},
+                      2: {'name': 'a'},
+                      3: {'name': 'a'},
+                      4: {'name': 'a'},
+                      5: {'name': 'a'},
+                      },
+            'sources': {
+                1: {'name': 'a'},
+                2: {'name': 'a'},
+                3: {'name': 'a'},
+                4: {'name': 'a'},
+            }
+        }
+        PLATFORM_SCHEMA(valid_schema)
+
+    def test_invalid_schemas(self):
+        """Test invalid schemas."""
+        schemas = (
+            {},  # Empty
+            None,  # None
+            # Missing type
+            {
+                'platform': 'blackbird',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {1: {'name': 'a'}},
+                'sources': {1: {'name': 'b'}},
+            },
+            # Invalid zone number
+            {
+                'platform': 'blackbird',
+                'type': 'serial',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {11: {'name': 'a'}},
+                'sources': {1: {'name': 'b'}},
+            },
+            # Invalid source number
+            {
+                'platform': 'blackbird',
+                'type': 'serial',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {1: {'name': 'a'}},
+                'sources': {9: {'name': 'b'}},
+            },
+            # Zone missing name
+            {
+                'platform': 'blackbird',
+                'type': 'serial',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {1: {}},
+                'sources': {1: {'name': 'b'}},
+            },
+            # Source missing name
+            {
+                'platform': 'blackbird',
+                'type': 'serial',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {1: {'name': 'a'}},
+                'sources': {1: {}},
+            },
+            # Invalid type
+            {
+                'platform': 'blackbird',
+                'type': 'aaa',
+                'port': 'aaa',
+                'name': 'Name',
+                'zones': {1: {'name': 'a'}},
+                'sources': {1: {'name': 'b'}},
+            },
+        )
+        for value in schemas:
+            with self.assertRaises(vol.MultipleInvalid):
+                PLATFORM_SCHEMA(value)
+
+
+class TestBlackbirdMediaPlayer(unittest.TestCase):
+    """Test the media_player module."""
+
+    def setUp(self):
+        """Set up the test case."""
+        self.blackbird = MockBlackbird()
+        self.hass = tests.common.get_test_home_assistant()
+        self.hass.start()
+        # Note, source dictionary is unsorted!
+        with mock.patch('pyblackbird.get_blackbird',
+                        new=lambda *a: self.blackbird):
+            setup_platform(self.hass, {
+                'platform': 'blackbird',
+                'type': 'serial',
+                'port': '/dev/ttyUSB0',
+                'zones': {3: {'name': 'Zone name'}},
+                'sources': {1: {'name': 'one'},
+                            3: {'name': 'three'},
+                            2: {'name': 'two'}},
+            }, lambda *args, **kwargs: None, {})
+            self.hass.block_till_done()
+        self.media_player = self.hass.data[DATA_BLACKBIRD][0]
+        self.media_player.hass = self.hass
+        self.media_player.entity_id = 'media_player.zone_3'
+
+    def tearDown(self):
+        """Tear down the test case."""
+        self.hass.stop()
+
+    def test_setup_platform(self, *args):
+        """Test setting up platform."""
+        # One service must be registered
+        self.assertTrue(self.hass.services.has_service(DOMAIN,
+                                                       SERVICE_SETALLZONES))
+        self.assertEqual(len(self.hass.data[DATA_BLACKBIRD]), 1)
+        self.assertEqual(self.hass.data[DATA_BLACKBIRD][0].name, 'Zone name')
+
+    def test_setallzones_service_call_with_entity_id(self):
+        """Test set all zone source service call with entity id."""
+        self.media_player.update()
+        self.assertEqual('Zone name', self.media_player.name)
+        self.assertEqual(STATE_ON, self.media_player.state)
+        self.assertEqual('one', self.media_player.source)
+
+        # Call set all zones service
+        self.hass.services.call(DOMAIN, SERVICE_SETALLZONES,
+                                {'entity_id': 'media_player.zone_3',
+                                 'source': 'three'},
+                                blocking=True)
+
+        # Check that source was changed
+        self.assertEqual(3, self.blackbird.zones[3].av)
+        self.media_player.update()
+        self.assertEqual('three', self.media_player.source)
+
+    def test_setallzones_service_call_without_entity_id(self):
+        """Test set all zone source service call without entity id."""
+        self.media_player.update()
+        self.assertEqual('Zone name', self.media_player.name)
+        self.assertEqual(STATE_ON, self.media_player.state)
+        self.assertEqual('one', self.media_player.source)
+
+        # Call set all zones service
+        self.hass.services.call(DOMAIN, SERVICE_SETALLZONES,
+                                {'source': 'three'}, blocking=True)
+
+        # Check that source was changed
+        self.assertEqual(3, self.blackbird.zones[3].av)
+        self.media_player.update()
+        self.assertEqual('three', self.media_player.source)
+
+    def test_update(self):
+        """Test updating values from blackbird."""
+        self.assertIsNone(self.media_player.state)
+        self.assertIsNone(self.media_player.source)
+
+        self.media_player.update()
+
+        self.assertEqual(STATE_ON, self.media_player.state)
+        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.blackbird.zones[3].power = False
+        self.media_player.update()
+        self.assertEqual(STATE_OFF, self.media_player.state)
+
+    def test_supported_features(self):
+        """Test supported features property."""
+        self.assertEqual(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_media_title(self):
+        """Test media title property."""
+        self.assertIsNone(self.media_player.media_title)
+        self.media_player.update()
+        self.assertEqual('one', self.media_player.media_title)
+
+    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.blackbird.zones[3].av)
+        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.blackbird.zones[3].av)
+        self.media_player.update()
+        self.assertEqual('two', self.media_player.source)
+
+    def test_turn_on(self):
+        """Testing turning on the zone."""
+        self.blackbird.zones[3].power = False
+        self.media_player.update()
+        self.assertEqual(STATE_OFF, self.media_player.state)
+
+        self.media_player.turn_on()
+        self.assertTrue(self.blackbird.zones[3].power)
+        self.media_player.update()
+        self.assertEqual(STATE_ON, self.media_player.state)
+
+    def test_turn_off(self):
+        """Testing turning off the zone."""
+        self.blackbird.zones[3].power = True
+        self.media_player.update()
+        self.assertEqual(STATE_ON, self.media_player.state)
+
+        self.media_player.turn_off()
+        self.assertFalse(self.blackbird.zones[3].power)
+        self.media_player.update()
+        self.assertEqual(STATE_OFF, self.media_player.state)
-- 
GitLab