diff --git a/.coveragerc b/.coveragerc index 7e4fdaed8cb..43f0274190f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -691,6 +691,7 @@ omit = homeassistant/components/zigbee/* homeassistant/components/ziggo_mediabox_xl/media_player.py homeassistant/components/zoneminder/* + homeassistant/components/supla/* homeassistant/components/zwave/util.py [report] diff --git a/CODEOWNERS b/CODEOWNERS index 9c8cc4b1c0e..d6d2b236eb6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -198,6 +198,7 @@ homeassistant/components/sql/* @dgomes homeassistant/components/statistics/* @fabaff homeassistant/components/stiebel_eltron/* @fucm homeassistant/components/sun/* @home-assistant/core +homeassistant/components/supla/* @mwegrzynek homeassistant/components/swiss_hydrological_data/* @fabaff homeassistant/components/swiss_public_transport/* @fabaff homeassistant/components/switchbot/* @danielhiversen diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py new file mode 100644 index 00000000000..127582395e7 --- /dev/null +++ b/homeassistant/components/supla/__init__.py @@ -0,0 +1,162 @@ +"""Support for Supla devices.""" +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pysupla==0.0.3'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'supla' + +CONF_SERVER = 'server' +CONF_SERVERS = 'servers' + +SUPLA_FUNCTION_HA_CMP_MAP = { + 'CONTROLLINGTHEROLLERSHUTTER': 'cover' +} +SUPLA_CHANNELS = 'supla_channels' +SUPLA_SERVERS = 'supla_servers' + +SERVER_CONFIG = vol.Schema({ + vol.Required(CONF_SERVER): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_SERVERS): + vol.All(cv.ensure_list, [SERVER_CONFIG]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, base_config): + """Set up the Supla component.""" + from pysupla import SuplaAPI + + server_confs = base_config[DOMAIN][CONF_SERVERS] + + hass.data[SUPLA_SERVERS] = {} + hass.data[SUPLA_CHANNELS] = {} + + for server_conf in server_confs: + + server_address = server_conf[CONF_SERVER] + + server = SuplaAPI( + server_address, + server_conf[CONF_ACCESS_TOKEN] + ) + + # Test connection + try: + srv_info = server.get_server_info() + if srv_info.get('authenticated'): + hass.data[SUPLA_SERVERS][server_conf[CONF_SERVER]] = server + else: + _LOGGER.error( + 'Server: %s not configured. API call returned: %s', + server_address, + srv_info + ) + return False + except IOError: + _LOGGER.exception( + 'Server: %s not configured. Error on Supla API access: ', + server_address + ) + return False + + discover_devices(hass, base_config) + + return True + + +def discover_devices(hass, hass_config): + """ + Run periodically to discover new devices. + + Currently it's only run at startup. + """ + component_configs = {} + + for server_name, server in hass.data[SUPLA_SERVERS].items(): + + for channel in server.get_channels(include=['iodevice']): + channel_function = channel['function']['name'] + component_name = SUPLA_FUNCTION_HA_CMP_MAP.get(channel_function) + + if component_name is None: + _LOGGER.warning( + 'Unsupported function: %s, channel id: %s', + channel_function, channel['id'] + ) + continue + + channel['server_name'] = server_name + component_configs.setdefault(component_name, []).append(channel) + + # Load discovered devices + for component_name, channel in component_configs.items(): + load_platform( + hass, + component_name, + 'supla', + channel, + hass_config + ) + + +class SuplaChannel(Entity): + """Base class of a Supla Channel (an equivalent of HA's Entity).""" + + def __init__(self, channel_data): + """Channel data -- raw channel information from PySupla.""" + self.server_name = channel_data['server_name'] + self.channel_data = channel_data + + @property + def server(self): + """Return PySupla's server component associated with entity.""" + return self.hass.data[SUPLA_SERVERS][self.server_name] + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return 'supla-{}-{}'.format( + self.channel_data['iodevice']['gUIDString'].lower(), + self.channel_data['channelNumber'] + ) + + @property + def name(self) -> Optional[str]: + """Return the name of the device.""" + return self.channel_data['caption'] + + def action(self, action, **add_pars): + """ + Run server action. + + Actions are currently hardcoded in components. + Supla's API enables autodiscovery + """ + _LOGGER.debug( + 'Executing action %s on channel %d, params: %s', + action, + self.channel_data['id'], + add_pars + ) + self.server.execute_action(self.channel_data['id'], action, **add_pars) + + def update(self): + """Call to update state.""" + self.channel_data = self.server.get_channel( + self.channel_data['id'], + include=['connected', 'state'] + ) diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py new file mode 100644 index 00000000000..c521cf48b94 --- /dev/null +++ b/homeassistant/components/supla/cover.py @@ -0,0 +1,57 @@ +"""Support for Supla cover - curtains, rollershutters etc.""" +import logging +from pprint import pformat + +from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.components.supla import SuplaChannel + +DEPENDENCIES = ['supla'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Supla covers.""" + if discovery_info is None: + return + + _LOGGER.debug('Discovery: %s', pformat(discovery_info)) + + add_entities([ + SuplaCover(device) for device in discovery_info + ]) + + +class SuplaCover(SuplaChannel, CoverDevice): + """Representation of a Supla Cover.""" + + @property + def current_cover_position(self): + """Return current position of cover. 0 is closed, 100 is open.""" + state = self.channel_data.get('state') + if state: + return 100 - state['shut'] + return None + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.action('REVEAL', percentage=kwargs.get(ATTR_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self.action('REVEAL') + + def close_cover(self, **kwargs): + """Close the cover.""" + self.action('SHUT') + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.action('STOP') diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json new file mode 100644 index 00000000000..cac1a5f18ab --- /dev/null +++ b/homeassistant/components/supla/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "supla", + "name": "Supla", + "documentation": "https://www.home-assistant.io/components/supla", + "requirements": [ + "pysupla==0.0.3" + ], + "dependencies": [], + "codeowners": [ + "@mwegrzynek" + ] +} diff --git a/requirements_all.txt b/requirements_all.txt index edd5e3df115..d954f8749b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1287,6 +1287,9 @@ pystiebeleltron==0.0.1.dev2 # homeassistant.components.stride pystride==0.1.7 +# homeassistant.components.supla +pysupla==0.0.3 + # homeassistant.components.syncthru pysyncthru==0.3.1