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