From 04de22613c57e98c75315e6323833dc361399b53 Mon Sep 17 00:00:00 2001
From: Frantz <ungureanu.francisc@gmail.com>
Date: Thu, 4 Jan 2018 12:05:27 +0200
Subject: [PATCH] Added new climate component from Daikin (#10983)

* Added Daikin climate component

* Fixed tox & hound

* Place up the REQUIREMENTS var

* Update .coveragerc

* Removed unused customization

* Prevent setting invalid operation state

* Fixed hound

* Small refactor according to code review

* Fixed latest code review comments

* Used host instead of ip_address

* No real change

* No real change

* Fixed lint errors

* More pylint fixes

* Shush Hound

* Applied suggested changes for temperature & humidity settings

* Fixed hound

* Fixed upper case texts

* Fixed hound

* Fixed hound

* Fixed hound

* Removed humidity since even the device has the feature it cant be set from API

* Code review requested changes

* Fixed hound

* Fixed hound

* Trigger update after adding device

* Added Daikin sensors

* Fixed hound

* Fixed hound

* Fixed travis

* Fixed hound

* Fixed hound

* Fixed travis

* Fixed coverage decrease issue

* Do less API calls and fixed Travis failures

* Distributed code from platform to climate and sensor componenets

* Rename sensor state to device_attribute

* Fixed hound

* Updated requirements

* Simplified code

* Implemented requested changes

* Forgot one change

* Don't allow customizing temperature unit and take it from hass (FOR NOW)

* Additional code review changes applied

* Condensed import even more

* Simplify condition check

* Reordered imports

* Disabled autodiscovery FOR NOW :(

* Give more suggestive names to sensors
---
 .coveragerc                                |   3 +
 homeassistant/components/climate/daikin.py | 257 +++++++++++++++++++++
 homeassistant/components/daikin.py         | 138 +++++++++++
 homeassistant/components/discovery.py      |   1 +
 homeassistant/components/sensor/daikin.py  | 124 ++++++++++
 requirements_all.txt                       |   4 +
 6 files changed, 527 insertions(+)
 create mode 100644 homeassistant/components/climate/daikin.py
 create mode 100644 homeassistant/components/daikin.py
 create mode 100644 homeassistant/components/sensor/daikin.py

diff --git a/.coveragerc b/.coveragerc
index a7c961d5a09..70a0597e6b7 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -266,6 +266,9 @@ omit =
     homeassistant/components/zoneminder.py
     homeassistant/components/*/zoneminder.py
 
+    homeassistant/components/daikin.py
+    homeassistant/components/*/daikin.py
+
     homeassistant/components/alarm_control_panel/alarmdotcom.py
     homeassistant/components/alarm_control_panel/canary.py
     homeassistant/components/alarm_control_panel/concord232.py
diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py
new file mode 100644
index 00000000000..8f6df034b89
--- /dev/null
+++ b/homeassistant/components/climate/daikin.py
@@ -0,0 +1,257 @@
+"""
+Support for the Daikin HVAC.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/climate.daikin/
+"""
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.climate import (
+    ATTR_OPERATION_MODE, ATTR_FAN_MODE, ATTR_SWING_MODE,
+    ATTR_CURRENT_TEMPERATURE, ClimateDevice, PLATFORM_SCHEMA,
+    SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE,
+    SUPPORT_SWING_MODE, STATE_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL,
+    STATE_DRY, STATE_FAN_ONLY
+)
+from homeassistant.components.daikin import (
+    daikin_api_setup,
+    ATTR_TARGET_TEMPERATURE,
+    ATTR_INSIDE_TEMPERATURE,
+    ATTR_OUTSIDE_TEMPERATURE
+)
+from homeassistant.const import (
+    CONF_HOST, CONF_NAME,
+    TEMP_CELSIUS,
+    ATTR_TEMPERATURE
+)
+
+REQUIREMENTS = ['pydaikin==0.4']
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
+                 SUPPORT_FAN_MODE |
+                 SUPPORT_OPERATION_MODE |
+                 SUPPORT_SWING_MODE)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+    vol.Required(CONF_HOST): cv.string,
+    vol.Optional(CONF_NAME, default=None): cv.string,
+})
+
+HA_STATE_TO_DAIKIN = {
+    STATE_FAN_ONLY: 'fan',
+    STATE_DRY: 'dry',
+    STATE_COOL: 'cool',
+    STATE_HEAT: 'hot',
+    STATE_AUTO: 'auto',
+    STATE_OFF: 'off',
+}
+
+HA_ATTR_TO_DAIKIN = {
+    ATTR_OPERATION_MODE: 'mode',
+    ATTR_FAN_MODE: 'f_rate',
+    ATTR_SWING_MODE: 'f_dir',
+}
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Setup the Daikin HVAC platform."""
+    if discovery_info is not None:
+        host = discovery_info.get('ip')
+        name = None
+        _LOGGER.info("Discovered a Daikin AC on %s", host)
+    else:
+        host = config.get(CONF_HOST)
+        name = config.get(CONF_NAME)
+        _LOGGER.info("Added Daikin AC on %s", host)
+
+    api = daikin_api_setup(hass, host, name)
+    add_devices([DaikinClimate(api)], True)
+
+
+class DaikinClimate(ClimateDevice):
+    """Representation of a Daikin HVAC."""
+
+    def __init__(self, api):
+        """Initialize the climate device."""
+        from pydaikin import appliance
+
+        self._api = api
+        self._force_refresh = False
+        self._list = {
+            ATTR_OPERATION_MODE: list(
+                map(str.title, set(HA_STATE_TO_DAIKIN.values()))
+            ),
+            ATTR_FAN_MODE: list(
+                map(
+                    str.title,
+                    appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])
+                )
+            ),
+            ATTR_SWING_MODE: list(
+                map(
+                    str.title,
+                    appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])
+                )
+            ),
+        }
+
+    def get(self, key):
+        """Retrieve device settings from API library cache."""
+        value = None
+        cast_to_float = False
+
+        if key in [ATTR_TEMPERATURE, ATTR_INSIDE_TEMPERATURE,
+                   ATTR_CURRENT_TEMPERATURE]:
+            value = self._api.device.values.get('htemp')
+            cast_to_float = True
+        if key == ATTR_TARGET_TEMPERATURE:
+            value = self._api.device.values.get('stemp')
+            cast_to_float = True
+        elif key == ATTR_OUTSIDE_TEMPERATURE:
+            value = self._api.device.values.get('otemp')
+            cast_to_float = True
+        elif key == ATTR_FAN_MODE:
+            value = self._api.device.represent('f_rate')[1].title()
+        elif key == ATTR_SWING_MODE:
+            value = self._api.device.represent('f_dir')[1].title()
+        elif key == ATTR_OPERATION_MODE:
+            # Daikin can return also internal states auto-1 or auto-7
+            # and we need to translate them as AUTO
+            value = re.sub(
+                '[^a-z]',
+                '',
+                self._api.device.represent('mode')[1]
+            ).title()
+
+        if value is None:
+            _LOGGER.warning("Invalid value requested for key %s", key)
+        else:
+            if value == "-" or value == "--":
+                value = None
+            elif cast_to_float:
+                try:
+                    value = float(value)
+                except ValueError:
+                    value = None
+
+        return value
+
+    def set(self, settings):
+        """Set device settings using API."""
+        values = {}
+
+        for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE,
+                     ATTR_OPERATION_MODE]:
+            value = settings.get(attr)
+            if value is None:
+                continue
+
+            daikin_attr = HA_ATTR_TO_DAIKIN.get(attr)
+            if daikin_attr is not None:
+                if value.title() in self._list[attr]:
+                    values[daikin_attr] = value.lower()
+                else:
+                    _LOGGER.error("Invalid value %s for %s", attr, value)
+
+            # temperature
+            elif attr == ATTR_TEMPERATURE:
+                try:
+                    values['stemp'] = str(int(value))
+                except ValueError:
+                    _LOGGER.error("Invalid temperature %s", value)
+
+        if values:
+            self._force_refresh = True
+            self._api.device.set(values)
+
+    @property
+    def unique_id(self):
+        """Return the ID of this AC."""
+        return "{}.{}".format(self.__class__, self._api.ip_address)
+
+    @property
+    def supported_features(self):
+        """Return the list of supported features."""
+        return SUPPORT_FLAGS
+
+    @property
+    def name(self):
+        """Return the name of the thermostat, if any."""
+        return self._api.name
+
+    @property
+    def temperature_unit(self):
+        """Return the unit of measurement which this thermostat uses."""
+        return TEMP_CELSIUS
+
+    @property
+    def current_temperature(self):
+        """Return the current temperature."""
+        return self.get(ATTR_CURRENT_TEMPERATURE)
+
+    @property
+    def target_temperature(self):
+        """Return the temperature we try to reach."""
+        return self.get(ATTR_TARGET_TEMPERATURE)
+
+    @property
+    def target_temperature_step(self):
+        """Return the supported step of target temperature."""
+        return 1
+
+    def set_temperature(self, **kwargs):
+        """Set new target temperature."""
+        self.set(kwargs)
+
+    @property
+    def current_operation(self):
+        """Return current operation ie. heat, cool, idle."""
+        return self.get(ATTR_OPERATION_MODE)
+
+    @property
+    def operation_list(self):
+        """Return the list of available operation modes."""
+        return self._list.get(ATTR_OPERATION_MODE)
+
+    def set_operation_mode(self, operation_mode):
+        """Set HVAC mode."""
+        self.set({ATTR_OPERATION_MODE: operation_mode})
+
+    @property
+    def current_fan_mode(self):
+        """Return the fan setting."""
+        return self.get(ATTR_FAN_MODE)
+
+    def set_fan_mode(self, fan):
+        """Set fan mode."""
+        self.set({ATTR_FAN_MODE: fan})
+
+    @property
+    def fan_list(self):
+        """List of available fan modes."""
+        return self._list.get(ATTR_FAN_MODE)
+
+    @property
+    def current_swing_mode(self):
+        """Return the fan setting."""
+        return self.get(ATTR_SWING_MODE)
+
+    def set_swing_mode(self, swing_mode):
+        """Set new target temperature."""
+        self.set({ATTR_SWING_MODE: swing_mode})
+
+    @property
+    def swing_list(self):
+        """List of available swing modes."""
+        return self._list.get(ATTR_SWING_MODE)
+
+    def update(self):
+        """Retrieve latest state."""
+        self._api.update(no_throttle=self._force_refresh)
+        self._force_refresh = False
diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py
new file mode 100644
index 00000000000..5808528ca5a
--- /dev/null
+++ b/homeassistant/components/daikin.py
@@ -0,0 +1,138 @@
+"""
+Platform for the Daikin AC.
+
+For more details about this component, please refer to the documentation
+https://home-assistant.io/components/daikin/
+"""
+import logging
+from datetime import timedelta
+from socket import timeout
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.discovery import SERVICE_DAIKIN
+from homeassistant.const import (
+    CONF_HOSTS, CONF_ICON, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TYPE
+)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import Throttle
+
+REQUIREMENTS = ['pydaikin==0.4']
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'daikin'
+HTTP_RESOURCES = ['aircon/get_sensor_info', 'aircon/get_control_info']
+
+ATTR_TARGET_TEMPERATURE = 'target_temperature'
+ATTR_INSIDE_TEMPERATURE = 'inside_temperature'
+ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+COMPONENT_TYPES = ['climate', 'sensor']
+
+SENSOR_TYPE_TEMPERATURE = 'temperature'
+
+SENSOR_TYPES = {
+    ATTR_INSIDE_TEMPERATURE: {
+        CONF_NAME: 'Inside Temperature',
+        CONF_ICON: 'mdi:thermometer',
+        CONF_TYPE: SENSOR_TYPE_TEMPERATURE
+    },
+    ATTR_OUTSIDE_TEMPERATURE: {
+        CONF_NAME: 'Outside Temperature',
+        CONF_ICON: 'mdi:thermometer',
+        CONF_TYPE: SENSOR_TYPE_TEMPERATURE
+    }
+
+}
+
+CONFIG_SCHEMA = vol.Schema({
+    DOMAIN: vol.Schema({
+        vol.Optional(
+            CONF_HOSTS, default=[]
+        ): vol.All(cv.ensure_list, [cv.string]),
+        vol.Optional(
+            CONF_MONITORED_CONDITIONS,
+            default=list(SENSOR_TYPES.keys())
+        ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)])
+    })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+    """Establish connection with Daikin."""
+    def discovery_dispatch(service, discovery_info):
+        """Dispatcher for Daikin discovery events."""
+        host = discovery_info.get('ip')
+
+        if daikin_api_setup(hass, host) is None:
+            return
+
+        for component in COMPONENT_TYPES:
+            load_platform(hass, component, DOMAIN, discovery_info,
+                          config)
+
+    discovery.listen(hass, SERVICE_DAIKIN, discovery_dispatch)
+
+    for host in config.get(DOMAIN, {}).get(CONF_HOSTS, []):
+        if daikin_api_setup(hass, host) is None:
+            continue
+
+        discovery_info = {
+            'ip': host,
+            CONF_MONITORED_CONDITIONS:
+                config[DOMAIN][CONF_MONITORED_CONDITIONS]
+        }
+        load_platform(hass, 'sensor', DOMAIN, discovery_info, config)
+
+    return True
+
+
+def daikin_api_setup(hass, host, name=None):
+    """Create a Daikin instance only once."""
+    if DOMAIN not in hass.data:
+        hass.data[DOMAIN] = {}
+
+    api = hass.data[DOMAIN].get(host)
+    if api is None:
+        from pydaikin import appliance
+
+        try:
+            device = appliance.Appliance(host)
+        except timeout:
+            _LOGGER.error("Connection to Daikin could not be established")
+            return False
+
+        if name is None:
+            name = device.values['name']
+
+        api = DaikinApi(device, name)
+
+    return api
+
+
+class DaikinApi(object):
+    """Keep the Daikin instance in one place and centralize the update."""
+
+    def __init__(self, device, name):
+        """Initialize the Daikin Handle."""
+        self.device = device
+        self.name = name
+        self.ip_address = device.ip
+
+    @Throttle(MIN_TIME_BETWEEN_UPDATES)
+    def update(self, **kwargs):
+        """Pull the latest data from Daikin."""
+        try:
+            for resource in HTTP_RESOURCES:
+                self.device.values.update(
+                    self.device.get_resource(resource)
+                )
+        except timeout:
+            _LOGGER.warning(
+                "Connection failed for %s", self.ip_address
+            )
diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py
index b6578dd70fe..0c3152db3d6 100644
--- a/homeassistant/components/discovery.py
+++ b/homeassistant/components/discovery.py
@@ -38,6 +38,7 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw'
 SERVICE_TELLDUSLIVE = 'tellstick'
 SERVICE_HUE = 'philips_hue'
 SERVICE_DECONZ = 'deconz'
+SERVICE_DAIKIN = 'daikin'
 
 SERVICE_HANDLERS = {
     SERVICE_HASS_IOS_APP: ('ios', None),
diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py
new file mode 100644
index 00000000000..ad571110e88
--- /dev/null
+++ b/homeassistant/components/sensor/daikin.py
@@ -0,0 +1,124 @@
+"""
+Support for Daikin AC Sensors.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.daikin/
+"""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.daikin import (
+    SENSOR_TYPES, SENSOR_TYPE_TEMPERATURE,
+    ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE,
+    daikin_api_setup
+)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+    CONF_HOST, CONF_ICON, CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_TYPE
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util.unit_system import UnitSystem
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+    vol.Required(CONF_HOST): cv.string,
+    vol.Optional(CONF_NAME, default=None): cv.string,
+    vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()):
+        vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+    """Set up the Daikin sensors."""
+    if discovery_info is not None:
+        host = discovery_info.get('ip')
+        name = None
+        monitored_conditions = discovery_info.get(
+            CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES.keys())
+        )
+    else:
+        host = config[CONF_HOST]
+        name = config.get(CONF_NAME)
+        monitored_conditions = config[CONF_MONITORED_CONDITIONS]
+        _LOGGER.info("Added Daikin AC sensor on %s", host)
+
+    api = daikin_api_setup(hass, host, name)
+    units = hass.config.units
+    sensors = []
+    for monitored_state in monitored_conditions:
+        sensors.append(DaikinClimateSensor(api, monitored_state, units, name))
+
+    add_devices(sensors, True)
+
+
+class DaikinClimateSensor(Entity):
+    """Representation of a Sensor."""
+
+    def __init__(self, api, monitored_state, units: UnitSystem, name=None):
+        """Initialize the sensor."""
+        self._api = api
+        self._sensor = SENSOR_TYPES.get(monitored_state)
+        if name is None:
+            name = "{} {}".format(self._sensor[CONF_NAME], api.name)
+
+        self._name = "{} {}".format(name, monitored_state.replace("_", " "))
+        self._device_attribute = monitored_state
+
+        if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE:
+            self._unit_of_measurement = units.temperature_unit
+
+    def get(self, key):
+        """Retrieve device settings from API library cache."""
+        value = None
+        cast_to_float = False
+
+        if key == ATTR_INSIDE_TEMPERATURE:
+            value = self._api.device.values.get('htemp')
+            cast_to_float = True
+        elif key == ATTR_OUTSIDE_TEMPERATURE:
+            value = self._api.device.values.get('otemp')
+
+        if value is None:
+            _LOGGER.warning("Invalid value requested for key %s", key)
+        else:
+            if value == "-" or value == "--":
+                value = None
+            elif cast_to_float:
+                try:
+                    value = float(value)
+                except ValueError:
+                    value = None
+
+        return value
+
+    @property
+    def unique_id(self):
+        """Return the ID of this AC."""
+        return "{}.{}".format(self.__class__, self._api.ip_address)
+
+    @property
+    def icon(self):
+        """Icon to use in the frontend, if any."""
+        return self._sensor[CONF_ICON]
+
+    @property
+    def name(self):
+        """Return the name of the sensor."""
+        return self._name
+
+    @property
+    def state(self):
+        """Return the state of the sensor."""
+        return self.get(self._device_attribute)
+
+    @property
+    def unit_of_measurement(self):
+        """Return the unit of measurement."""
+        return self._unit_of_measurement
+
+    def update(self):
+        """Retrieve latest state."""
+        self._api.update()
diff --git a/requirements_all.txt b/requirements_all.txt
index abcbe7fd127..c08ac5b703c 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -664,6 +664,10 @@ pycsspeechtts==1.0.2
 # homeassistant.components.sensor.cups
 # pycups==1.9.73
 
+# homeassistant.components.daikin
+# homeassistant.components.climate.daikin
+pydaikin==0.4
+
 # homeassistant.components.deconz
 pydeconz==23
 
-- 
GitLab