From f4aec3ac889cbc471f99996388c79bd578af0a6b Mon Sep 17 00:00:00 2001 From: Wolfgang Malgadey Date: Wed, 22 Mar 2017 13:18:13 +0100 Subject: [PATCH] Tado climate device (#6572) * Added tado climate component named the component v1 because of the unsupported state of the api I used (mytado.com) * sensor component * climate component which uses sensors * main component initiating sensor and climate devices * order of imports * consts for username and password * removed redundant code * changed wrong calls and properties * remove pylint overrides * merged update() and push_update() * changed wrong calls * removed pylint overrides * moved try..except * renamed MyTado hass-data object * added TadoDataStore * moved update methods from sensor to TadoDataStore * reorganised climate component * use new TadoDataStore * small change to overlay handling * code refactoring * removed unnessesary comments * changed throttle to attribute * changed suggestions from PR * Added constant variable for string literal * remove wrong fget() call * changed dependencies * Changed operation mode list * added human readable list of operations * removed unnecessary const * activated update on add_devices * droped unit * removed unnused property * changed temperature conversion * removed defaults from config changed naming of tado data const * switched operation_list key/values * changed the value returned as state * added one extra line * dropped state to use base impl. * renamed component * had to inplement temperature_unit * because it is not implemented in base class * create a copy of the sensors list * because it can be changed by other components * had to check for empty data object * hass is too fast now --- .coveragerc | 3 + homeassistant/components/climate/tado.py | 296 +++++++++++++++++++++++ homeassistant/components/sensor/tado.py | 209 ++++++++++++++++ homeassistant/components/tado.py | 131 ++++++++++ requirements_all.txt | 3 + 5 files changed, 642 insertions(+) create mode 100644 homeassistant/components/climate/tado.py create mode 100644 homeassistant/components/sensor/tado.py create mode 100644 homeassistant/components/tado.py diff --git a/.coveragerc b/.coveragerc index 810764a7708..91dbea13b21 100644 --- a/.coveragerc +++ b/.coveragerc @@ -144,6 +144,9 @@ omit = homeassistant/components/maxcube.py homeassistant/components/*/maxcube.py + homeassistant/components/tado.py + homeassistant/components/*/tado.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py homeassistant/components/alarm_control_panel/nx584.py diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py new file mode 100644 index 00000000000..e5242f88162 --- /dev/null +++ b/homeassistant/components/climate/tado.py @@ -0,0 +1,296 @@ +"""tado component to create a climate device for each zone.""" + +import logging + +from homeassistant.const import TEMP_CELSIUS + +from homeassistant.components.climate import ( + ClimateDevice) +from homeassistant.const import ( + ATTR_TEMPERATURE) +from homeassistant.components.tado import ( + DATA_TADO) + +CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Default mytado mode +CONST_MODE_OFF = "OFF" # Switch off heating in a zone + +# When we change the temperature setting, we need an overlay mode +# wait until tado changes the mode automatic +CONST_OVERLAY_TADO_MODE = "TADO_MODE" +# the user has change the temperature or mode manually +CONST_OVERLAY_MANUAL = "MANUAL" +# the temperature will be reset after a timespan +CONST_OVERLAY_TIMER = "TIMER" + +OPERATION_LIST = { + CONST_OVERLAY_MANUAL: "Manual", + CONST_OVERLAY_TIMER: "Timer", + CONST_OVERLAY_TADO_MODE: "Tado mode", + CONST_MODE_SMART_SCHEDULE: "Smart schedule", + CONST_MODE_OFF: "Off"} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the climate platform.""" + # get the PyTado object from the hub component + tado = hass.data[DATA_TADO] + + try: + zones = tado.get_zones() + except RuntimeError: + _LOGGER.error("Unable to get zone info from mytado") + return False + + climate_devices = [] + for zone in zones: + climate_devices.append(create_climate_device(tado, hass, + zone, + zone['name'], + zone['id'])) + + if len(climate_devices) > 0: + add_devices(climate_devices, True) + return True + else: + return False + + +def create_climate_device(tado, hass, zone, name, zone_id): + """Create a climate device.""" + capabilities = tado.get_capabilities(zone_id) + + unit = TEMP_CELSIUS + min_temp = float(capabilities["temperatures"]["celsius"]["min"]) + max_temp = float(capabilities["temperatures"]["celsius"]["max"]) + ac_mode = capabilities["type"] != "HEATING" + + data_id = 'zone {} {}'.format(name, zone_id) + device = TadoClimate(tado, + name, zone_id, data_id, + hass.config.units.temperature(min_temp, unit), + hass.config.units.temperature(max_temp, unit), + ac_mode) + + tado.add_sensor(data_id, { + "id": zone_id, + "zone": zone, + "name": name, + "climate": device + }) + + return device + + +class TadoClimate(ClimateDevice): + """Representation of a tado climate device.""" + + def __init__(self, store, zone_name, zone_id, data_id, + min_temp, max_temp, ac_mode, + tolerance=0.3): + """Initialization of TadoClimate device.""" + self._store = store + self._data_id = data_id + + self.zone_name = zone_name + self.zone_id = zone_id + + self.ac_mode = ac_mode + + self._active = False + self._device_is_active = False + + self._unit = TEMP_CELSIUS + self._cur_temp = None + self._cur_humidity = None + self._is_away = False + self._min_temp = min_temp + self._max_temp = max_temp + self._target_temp = None + self._tolerance = tolerance + + self._current_operation = CONST_MODE_SMART_SCHEDULE + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + + @property + def name(self): + """Return the name of the sensor.""" + return self.zone_name + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._cur_humidity + + @property + def current_temperature(self): + """Return the sensor temperature.""" + return self._cur_temp + + @property + def current_operation(self): + """Return current readable operation mode.""" + return OPERATION_LIST.get(self._current_operation) + + @property + def operation_list(self): + """List of available operation modes (readable).""" + return list(OPERATION_LIST.values()) + + @property + def temperature_unit(self): + """The unit of measurement used by the platform.""" + return self._unit + + @property + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._is_away + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + self._current_operation = CONST_OVERLAY_TADO_MODE + self._overlay_mode = None + self._target_temp = temperature + self._control_heating() + + def set_operation_mode(self, readable_operation_mode): + """Set new operation mode.""" + operation_mode = CONST_MODE_SMART_SCHEDULE + + for mode, readable in OPERATION_LIST.items(): + if readable == readable_operation_mode: + operation_mode = mode + break + + self._current_operation = operation_mode + self._overlay_mode = None + self._control_heating() + + @property + def min_temp(self): + """Return the minimum temperature.""" + if self._min_temp: + return self._min_temp + else: + # get default temp from super class + return super().min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + if self._max_temp: + return self._max_temp + else: + # Get default temp from super class + return super().max_temp + + def update(self): + """Update the state of this climate device.""" + self._store.update() + + data = self._store.get_data(self._data_id) + + if data is None: + _LOGGER.debug('Recieved no data for zone %s', + self.zone_name) + return + + if 'sensorDataPoints' in data: + sensor_data = data['sensorDataPoints'] + temperature = float( + sensor_data['insideTemperature']['celsius']) + humidity = float( + sensor_data['humidity']['percentage']) + setting = 0 + + # temperature setting will not exist when device is off + if 'temperature' in data['setting'] and \ + data['setting']['temperature'] is not None: + setting = float( + data['setting']['temperature']['celsius']) + + unit = TEMP_CELSIUS + + self._cur_temp = self.hass.config.units.temperature( + temperature, unit) + + self._target_temp = self.hass.config.units.temperature( + setting, unit) + + self._cur_humidity = humidity + + if 'tadoMode' in data: + mode = data['tadoMode'] + self._is_away = mode == "AWAY" + + if 'setting' in data: + power = data['setting']['power'] + if power == "OFF": + self._current_operation = CONST_MODE_OFF + self._device_is_active = False + else: + self._device_is_active = True + + if 'overlay' in data and data['overlay'] is not None: + overlay = True + termination = data['overlay']['termination']['type'] + else: + overlay = False + termination = "" + + # if you set mode manualy to off, there will be an overlay + # and a termination, but we want to see the mode "OFF" + + if overlay and self._device_is_active: + # there is an overlay the device is on + self._overlay_mode = termination + self._current_operation = termination + else: + # there is no overlay, the mode will always be + # "SMART_SCHEDULE" + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._current_operation = CONST_MODE_SMART_SCHEDULE + + def _control_heating(self): + """Send new target temperature to mytado.""" + if not self._active and None not in (self._cur_temp, + self._target_temp): + self._active = True + _LOGGER.info('Obtained current and target temperature. ' + 'tado thermostat active.') + + if not self._active or self._current_operation == self._overlay_mode: + return + + if self._current_operation == CONST_MODE_SMART_SCHEDULE: + _LOGGER.info('Switching mytado.com to SCHEDULE (default) ' + 'for zone %s', self.zone_name) + self._store.reset_zone_overlay(self.zone_id) + self._overlay_mode = self._current_operation + return + + if self._current_operation == CONST_MODE_OFF: + _LOGGER.info('Switching mytado.com to OFF for zone %s', + self.zone_name) + self._store.set_zone_overlay(self.zone_id, CONST_OVERLAY_MANUAL) + self._overlay_mode = self._current_operation + return + + _LOGGER.info('Switching mytado.com to %s mode for zone %s', + self._current_operation, self.zone_name) + self._store.set_zone_overlay(self.zone_id, + self._current_operation, + self._target_temp) + + self._overlay_mode = self._current_operation diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py new file mode 100644 index 00000000000..3e5d0101ade --- /dev/null +++ b/homeassistant/components/sensor/tado.py @@ -0,0 +1,209 @@ +"""tado component to create some sensors for each zone.""" + +import logging + +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.entity import Entity +from homeassistant.components.tado import ( + DATA_TADO) + +_LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = ['temperature', 'humidity', 'power', + 'link', 'heating', 'tado mode', 'overlay'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the sensor platform.""" + # get the PyTado object from the hub component + tado = hass.data[DATA_TADO] + + try: + zones = tado.get_zones() + except RuntimeError: + _LOGGER.error("Unable to get zone info from mytado") + return False + + sensor_items = [] + for zone in zones: + if zone['type'] == 'HEATING': + for variable in SENSOR_TYPES: + sensor_items.append(create_zone_sensor( + tado, zone, zone['name'], zone['id'], + variable)) + + me_data = tado.get_me() + sensor_items.append(create_device_sensor( + tado, me_data, me_data['homes'][0]['name'], + me_data['homes'][0]['id'], "tado bridge status")) + + if len(sensor_items) > 0: + add_devices(sensor_items, True) + return True + else: + return False + + +def create_zone_sensor(tado, zone, name, zone_id, variable): + """Create a zone sensor.""" + data_id = 'zone {} {}'.format(name, zone_id) + + tado.add_sensor(data_id, { + "zone": zone, + "name": name, + "id": zone_id, + "data_id": data_id + }) + + return TadoSensor(tado, name, zone_id, variable, data_id) + + +def create_device_sensor(tado, device, name, device_id, variable): + """Create a device sensor.""" + data_id = 'device {} {}'.format(name, device_id) + + tado.add_sensor(data_id, { + "device": device, + "name": name, + "id": device_id, + "data_id": data_id + }) + + return TadoSensor(tado, name, device_id, variable, data_id) + + +class TadoSensor(Entity): + """Representation of a tado Sensor.""" + + def __init__(self, store, zone_name, zone_id, zone_variable, data_id): + """Initialization of TadoSensor class.""" + self._store = store + + self.zone_name = zone_name + self.zone_id = zone_id + self.zone_variable = zone_variable + + self._unique_id = '{} {}'.format(zone_variable, zone_id) + self._data_id = data_id + + self._state = None + self._state_attributes = None + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.zone_name, self.zone_variable) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._state_attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.zone_variable == "temperature": + return self.hass.config.units.temperature_unit + elif self.zone_variable == "humidity": + return '%' + elif self.zone_variable == "heating": + return '%' + + @property + def icon(self): + """Icon for the sensor.""" + if self.zone_variable == "temperature": + return 'mdi:thermometer' + elif self.zone_variable == "humidity": + return 'mdi:water-percent' + + def update(self): + """Update method called when should_poll is true.""" + self._store.update() + + data = self._store.get_data(self._data_id) + + if data is None: + _LOGGER.debug('Recieved no data for zone %s', + self.zone_name) + return + + unit = TEMP_CELSIUS + + # pylint: disable=R0912 + if self.zone_variable == 'temperature': + if 'sensorDataPoints' in data: + sensor_data = data['sensorDataPoints'] + temperature = float( + sensor_data['insideTemperature']['celsius']) + + self._state = self.hass.config.units.temperature( + temperature, unit) + self._state_attributes = { + "time": + sensor_data['insideTemperature']['timestamp'], + "setting": 0 # setting is used in climate device + } + + # temperature setting will not exist when device is off + if 'temperature' in data['setting'] and \ + data['setting']['temperature'] is not None: + temperature = float( + data['setting']['temperature']['celsius']) + + self._state_attributes["setting"] = \ + self.hass.config.units.temperature( + temperature, unit) + + elif self.zone_variable == 'humidity': + if 'sensorDataPoints' in data: + sensor_data = data['sensorDataPoints'] + self._state = float( + sensor_data['humidity']['percentage']) + self._state_attributes = { + "time": sensor_data['humidity']['timestamp'], + } + + elif self.zone_variable == 'power': + if 'setting' in data: + self._state = data['setting']['power'] + + elif self.zone_variable == 'link': + if 'link' in data: + self._state = data['link']['state'] + + elif self.zone_variable == 'heating': + if 'activityDataPoints' in data: + activity_data = data['activityDataPoints'] + self._state = float( + activity_data['heatingPower']['percentage']) + self._state_attributes = { + "time": activity_data['heatingPower']['timestamp'], + } + + elif self.zone_variable == 'tado bridge status': + if 'connectionState' in data: + self._state = data['connectionState']['value'] + + elif self.zone_variable == 'tado mode': + if 'tadoMode' in data: + self._state = data['tadoMode'] + + elif self.zone_variable == 'overlay': + if 'overlay' in data and data['overlay'] is not None: + self._state = True + self._state_attributes = { + "termination": data['overlay']['termination']['type'], + } + else: + self._state = False + self._state_attributes = {} diff --git a/homeassistant/components/tado.py b/homeassistant/components/tado.py new file mode 100644 index 00000000000..b0aae8b3f4a --- /dev/null +++ b/homeassistant/components/tado.py @@ -0,0 +1,131 @@ +""" +Support for the (unofficial) tado api. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tado/ +""" + +import logging +import urllib + +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers import config_validation as cv +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD) +from homeassistant.util import Throttle + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'tado' + +REQUIREMENTS = ['https://github.com/wmalgadey/PyTado/archive/' + '0.1.10.zip#' + 'PyTado==0.1.10'] + +TADO_COMPONENTS = [ + 'sensor', 'climate' +] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DATA_TADO = 'tado_data' + + +def setup(hass, config): + """Your controller/hub specific code.""" + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + + from PyTado.interface import Tado + + try: + tado = Tado(username, password) + except (RuntimeError, urllib.error.HTTPError): + _LOGGER.error("Unable to connect to mytado with username and password") + return False + + hass.data[DATA_TADO] = TadoDataStore(tado) + + for component in TADO_COMPONENTS: + load_platform(hass, component, DOMAIN, {}, config) + + return True + + +class TadoDataStore: + """An object to store the tado data.""" + + def __init__(self, tado): + """Initialize Tado data store.""" + self.tado = tado + + self.sensors = {} + self.data = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the internal data from mytado.com.""" + for data_id, sensor in list(self.sensors.items()): + data = None + + try: + if "zone" in sensor: + _LOGGER.info("querying mytado.com for zone %s %s", + sensor["id"], sensor["name"]) + data = self.tado.getState(sensor["id"]) + + if "device" in sensor: + _LOGGER.info("querying mytado.com for device %s %s", + sensor["id"], sensor["name"]) + data = self.tado.getDevices()[0] + + except RuntimeError: + _LOGGER.error("Unable to connect to myTado. %s %s", + sensor["id"], sensor["id"]) + + self.data[data_id] = data + + def add_sensor(self, data_id, sensor): + """Add a sensor to update in _update().""" + self.sensors[data_id] = sensor + self.data[data_id] = None + + def get_data(self, data_id): + """Get the cached data.""" + data = {"error": "no data"} + + if data_id in self.data: + data = self.data[data_id] + + return data + + def get_zones(self): + """Wrapper for getZones().""" + return self.tado.getZones() + + def get_capabilities(self, tado_id): + """Wrapper for getCapabilities(..).""" + return self.tado.getCapabilities(tado_id) + + def get_me(self): + """Wrapper for getMet().""" + return self.tado.getMe() + + def reset_zone_overlay(self, zone_id): + """Wrapper for resetZoneOverlay(..).""" + return self.tado.resetZoneOverlay(zone_id) + + def set_zone_overlay(self, zone_id, mode, temperature): + """Wrapper for setZoneOverlay(..).""" + return self.tado.setZoneOverlay(zone_id, mode, temperature) diff --git a/requirements_all.txt b/requirements_all.txt index d3bf15378cd..4937153bd73 100755 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -306,6 +306,9 @@ https://github.com/theolind/pymysensors/archive/0b705119389be58332f17753c53167f5 # homeassistant.components.sensor.modem_callerid https://github.com/vroomfonde1/basicmodem/archive/0.7.zip#basicmodem==0.7 +# homeassistant.components.tado +https://github.com/wmalgadey/PyTado/archive/0.1.10.zip#PyTado==0.1.10 + # homeassistant.components.media_player.lg_netcast https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0