From f5d74e07d51b0992d37105e1eb0fbbeca99a32ef Mon Sep 17 00:00:00 2001
From: Matt Schmitt <schmittx@users.noreply.github.com>
Date: Fri, 1 Jun 2018 12:04:54 -0400
Subject: [PATCH] Add support for outlets in HomeKit (#14628)

---
 homeassistant/components/homekit/__init__.py  | 13 ++++--
 homeassistant/components/homekit/const.py     |  6 +++
 .../components/homekit/type_switches.py       | 39 ++++++++++++++++-
 homeassistant/components/homekit/util.py      | 13 +++++-
 .../homekit/test_get_accessories.py           | 23 +++++-----
 .../components/homekit/test_type_switches.py  | 42 ++++++++++++++++++-
 tests/components/homekit/test_util.py         |  9 ++--
 7 files changed, 123 insertions(+), 22 deletions(-)

diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index ce3b79e6c72..34372b8b6a8 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -12,7 +12,7 @@ import voluptuous as vol
 import homeassistant.components.cover as cover
 from homeassistant.const import (
     ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT,
-    CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, DEVICE_CLASS_HUMIDITY,
+    CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY,
     DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE,
     EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
     TEMP_CELSIUS, TEMP_FAHRENHEIT)
@@ -23,7 +23,7 @@ from homeassistant.util.decorator import Registry
 from .const import (
     CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, CONF_FILTER,
     DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25,
-    DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START)
+    DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_OUTLET, TYPE_SWITCH)
 from .util import (
     show_setup_message, validate_entity_config, validate_media_player_features)
 
@@ -38,6 +38,8 @@ STATUS_RUNNING = 1
 STATUS_STOPPED = 2
 STATUS_WAIT = 3
 
+SWITCH_TYPES = {TYPE_OUTLET: 'Outlet',
+                TYPE_SWITCH: 'Switch'}
 
 CONFIG_SCHEMA = vol.Schema({
     DOMAIN: vol.All({
@@ -149,8 +151,11 @@ def get_accessory(hass, driver, state, aid, config):
         elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ('lm', 'lx'):
             a_type = 'LightSensor'
 
-    elif state.domain in ('automation', 'input_boolean', 'remote', 'script',
-                          'switch'):
+    elif state.domain == 'switch':
+        switch_type = config.get(CONF_TYPE, TYPE_SWITCH)
+        a_type = SWITCH_TYPES[switch_type]
+
+    elif state.domain in ('automation', 'input_boolean', 'remote', 'script'):
         a_type = 'Switch'
 
     if a_type is None:
diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py
index 6d49c806e0f..dec6353850e 100644
--- a/homeassistant/components/homekit/const.py
+++ b/homeassistant/components/homekit/const.py
@@ -31,6 +31,10 @@ BRIDGE_NAME = 'Home Assistant Bridge'
 BRIDGE_SERIAL_NUMBER = 'homekit.bridge'
 MANUFACTURER = 'Home Assistant'
 
+# #### Switch Types ####
+TYPE_OUTLET = 'outlet'
+TYPE_SWITCH = 'switch'
+
 # #### Services ####
 SERV_ACCESSORY_INFO = 'AccessoryInformation'
 SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor'
@@ -46,6 +50,7 @@ SERV_LIGHTBULB = 'Lightbulb'
 SERV_LOCK = 'LockMechanism'
 SERV_MOTION_SENSOR = 'MotionSensor'
 SERV_OCCUPANCY_SENSOR = 'OccupancySensor'
+SERV_OUTLET = 'Outlet'
 SERV_SECURITY_SYSTEM = 'SecuritySystem'
 SERV_SMOKE_SENSOR = 'SmokeSensor'
 SERV_SWITCH = 'Switch'
@@ -84,6 +89,7 @@ CHAR_MODEL = 'Model'
 CHAR_MOTION_DETECTED = 'MotionDetected'
 CHAR_NAME = 'Name'
 CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected'
+CHAR_OUTLET_IN_USE = 'OutletInUse'
 CHAR_ON = 'On'
 CHAR_POSITION_STATE = 'PositionState'
 CHAR_ROTATION_DIRECTION = 'RotationDirection'
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
index 69f14821bd6..c8bf8c7ad7c 100644
--- a/homeassistant/components/homekit/type_switches.py
+++ b/homeassistant/components/homekit/type_switches.py
@@ -1,19 +1,54 @@
 """Class to hold all switch accessories."""
 import logging
 
-from pyhap.const import CATEGORY_SWITCH
+from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH
 
+from homeassistant.components.switch import DOMAIN as SWITCH
 from homeassistant.const import (
     ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON)
 from homeassistant.core import split_entity_id
 
 from . import TYPES
 from .accessories import HomeAccessory
-from .const import SERV_SWITCH, CHAR_ON
+from .const import CHAR_ON, CHAR_OUTLET_IN_USE, SERV_OUTLET, SERV_SWITCH
 
 _LOGGER = logging.getLogger(__name__)
 
 
+@TYPES.register('Outlet')
+class Outlet(HomeAccessory):
+    """Generate an Outlet accessory."""
+
+    def __init__(self, *args):
+        """Initialize an Outlet accessory object."""
+        super().__init__(*args, category=CATEGORY_OUTLET)
+        self.flag_target_state = False
+
+        serv_outlet = self.add_preload_service(SERV_OUTLET)
+        self.char_on = serv_outlet.configure_char(
+            CHAR_ON, value=False, setter_callback=self.set_state)
+        self.char_outlet_in_use = serv_outlet.configure_char(
+            CHAR_OUTLET_IN_USE, value=True)
+
+    def set_state(self, value):
+        """Move switch state to value if call came from HomeKit."""
+        _LOGGER.debug('%s: Set switch state to %s',
+                      self.entity_id, value)
+        self.flag_target_state = True
+        params = {ATTR_ENTITY_ID: self.entity_id}
+        service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+        self.hass.services.call(SWITCH, service, params)
+
+    def update_state(self, new_state):
+        """Update switch state after state changed."""
+        current_state = (new_state.state == STATE_ON)
+        if not self.flag_target_state:
+            _LOGGER.debug('%s: Set current state to %s',
+                          self.entity_id, current_state)
+            self.char_on.set_value(current_state)
+        self.flag_target_state = False
+
+
 @TYPES.register('Switch')
 class Switch(HomeAccessory):
     """Generate a Switch accessory."""
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index 50095844757..6a43a0c6228 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -6,12 +6,13 @@ import voluptuous as vol
 import homeassistant.components.media_player as media_player
 from homeassistant.core import split_entity_id
 from homeassistant.const import (
-    ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, TEMP_CELSIUS)
+    ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS)
 import homeassistant.helpers.config_validation as cv
 import homeassistant.util.temperature as temp_util
 from .const import (
     CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF,
-    FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE)
+    FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_OUTLET,
+    TYPE_SWITCH)
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -35,6 +36,11 @@ MEDIA_PLAYER_SCHEMA = vol.Schema({
                            FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))),
 })
 
+SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({
+    vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All(
+        cv.string, vol.In((TYPE_OUTLET, TYPE_SWITCH))),
+})
+
 
 def validate_entity_config(values):
     """Validate config entry for CONF_ENTITY."""
@@ -62,6 +68,9 @@ def validate_entity_config(values):
                 feature_list[key] = params
             config[CONF_FEATURE_LIST] = feature_list
 
+        elif domain == 'switch':
+            config = SWITCH_TYPE_SCHEMA(config)
+
         else:
             config = BASIC_INFO_SCHEMA(config)
 
diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py
index 3b7f307fce7..4de68057084 100644
--- a/tests/components/homekit/test_get_accessories.py
+++ b/tests/components/homekit/test_get_accessories.py
@@ -9,10 +9,11 @@ import homeassistant.components.climate as climate
 import homeassistant.components.media_player as media_player
 from homeassistant.components.homekit import get_accessory, TYPES
 from homeassistant.components.homekit.const import (
-    CONF_FEATURE_LIST, FEATURE_ON_OFF)
+    CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_OUTLET, TYPE_SWITCH)
 from homeassistant.const import (
     ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES,
-    ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+    ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, TEMP_CELSIUS,
+    TEMP_FAHRENHEIT)
 
 
 def test_not_supported(caplog):
@@ -129,17 +130,19 @@ def test_type_sensors(type_name, entity_id, state, attrs):
     assert mock_type.called
 
 
-@pytest.mark.parametrize('type_name, entity_id, state, attrs', [
-    ('Switch', 'automation.test', 'on', {}),
-    ('Switch', 'input_boolean.test', 'on', {}),
-    ('Switch', 'remote.test', 'on', {}),
-    ('Switch', 'script.test', 'on', {}),
-    ('Switch', 'switch.test', 'on', {}),
+@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
+    ('Outlet', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_OUTLET}),
+    ('Switch', 'automation.test', 'on', {}, {}),
+    ('Switch', 'input_boolean.test', 'on', {}, {}),
+    ('Switch', 'remote.test', 'on', {}, {}),
+    ('Switch', 'script.test', 'on', {}, {}),
+    ('Switch', 'switch.test', 'on', {}, {}),
+    ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}),
 ])
-def test_type_switches(type_name, entity_id, state, attrs):
+def test_type_switches(type_name, entity_id, state, attrs, config):
     """Test if switch types are associated correctly."""
     mock_type = Mock()
     with patch.dict(TYPES, {type_name: mock_type}):
         entity_state = State(entity_id, state, attrs)
-        get_accessory(None, None, entity_state, 2, {})
+        get_accessory(None, None, entity_state, 2, config)
     assert mock_type.called
diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py
index b1830d1926f..3a09d2715d1 100644
--- a/tests/components/homekit/test_type_switches.py
+++ b/tests/components/homekit/test_type_switches.py
@@ -2,12 +2,51 @@
 import pytest
 
 from homeassistant.core import split_entity_id
-from homeassistant.components.homekit.type_switches import Switch
+from homeassistant.components.homekit.type_switches import Outlet, Switch
 from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
 
 from tests.common import async_mock_service
 
 
+async def test_outlet_set_state(hass, hk_driver):
+    """Test if Outlet accessory and HA are updated accordingly."""
+    entity_id = 'switch.outlet_test'
+
+    hass.states.async_set(entity_id, None)
+    await hass.async_block_till_done()
+    acc = Outlet(hass, hk_driver, 'Outlet', entity_id, 2, None)
+    await hass.async_add_job(acc.run)
+    await hass.async_block_till_done()
+
+    assert acc.aid == 2
+    assert acc.category == 7  # Outlet
+
+    assert acc.char_on.value is False
+    assert acc.char_outlet_in_use.value is True
+
+    hass.states.async_set(entity_id, STATE_ON)
+    await hass.async_block_till_done()
+    assert acc.char_on.value is True
+
+    hass.states.async_set(entity_id, STATE_OFF)
+    await hass.async_block_till_done()
+    assert acc.char_on.value is False
+
+    # Set from HomeKit
+    call_turn_on = async_mock_service(hass, 'switch', 'turn_on')
+    call_turn_off = async_mock_service(hass, 'switch', 'turn_off')
+
+    await hass.async_add_job(acc.char_on.client_update_value, True)
+    await hass.async_block_till_done()
+    assert call_turn_on
+    assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+
+    await hass.async_add_job(acc.char_on.client_update_value, False)
+    await hass.async_block_till_done()
+    assert call_turn_off
+    assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+
+
 @pytest.mark.parametrize('entity_id', [
     'automation.test',
     'input_boolean.test',
@@ -23,6 +62,7 @@ async def test_switch_set_state(hass, hk_driver, entity_id):
     await hass.async_block_till_done()
     acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None)
     await hass.async_add_job(acc.run)
+    await hass.async_block_till_done()
 
     assert acc.aid == 2
     assert acc.category == 8  # Switch
diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py
index 0bc1eb96841..fa9fddee5fc 100644
--- a/tests/components/homekit/test_util.py
+++ b/tests/components/homekit/test_util.py
@@ -5,7 +5,7 @@ import voluptuous as vol
 from homeassistant.core import State
 from homeassistant.components.homekit.const import (
     CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF,
-    FEATURE_PLAY_PAUSE)
+    FEATURE_PLAY_PAUSE, TYPE_OUTLET)
 from homeassistant.components.homekit.util import (
     convert_to_float, density_to_air_quality, dismiss_setup_message,
     show_setup_message, temperature_to_homekit, temperature_to_states,
@@ -15,7 +15,7 @@ from homeassistant.components.homekit.util import validate_entity_config \
 from homeassistant.components.persistent_notification import (
     ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN)
 from homeassistant.const import (
-    ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, STATE_UNKNOWN,
+    ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN,
     TEMP_CELSIUS, TEMP_FAHRENHEIT)
 
 from tests.common import async_mock_service
@@ -30,7 +30,8 @@ def test_validate_entity_config():
                     {CONF_FEATURE: 'invalid_feature'}]}},
                {'media_player.test': {CONF_FEATURE_LIST: [
                     {CONF_FEATURE: FEATURE_ON_OFF},
-                    {CONF_FEATURE: FEATURE_ON_OFF}]}}, ]
+                    {CONF_FEATURE: FEATURE_ON_OFF}]}},
+               {'switch.test': {CONF_TYPE: 'invalid_type'}}]
 
     for conf in configs:
         with pytest.raises(vol.Invalid):
@@ -56,6 +57,8 @@ def test_validate_entity_config():
     assert vec({'media_player.demo': config}) == \
         {'media_player.demo': {CONF_FEATURE_LIST:
                                {FEATURE_ON_OFF: {}, FEATURE_PLAY_PAUSE: {}}}}
+    assert vec({'switch.demo': {CONF_TYPE: TYPE_OUTLET}}) == \
+        {'switch.demo': {CONF_TYPE: TYPE_OUTLET}}
 
 
 def test_validate_media_player_features():
-- 
GitLab