From 72fa1702650842ae5202166104e7d46674fd3021 Mon Sep 17 00:00:00 2001 From: Henrik Nicolaisen Date: Sun, 18 Feb 2018 23:34:28 +0100 Subject: [PATCH] added smappee component (#11491) * added smappee component * Fixed pylint errors and a few use cases when starting up with invalid credentials Added coverage omit * Added support to run only locally Added a few more sensors Added more error handling Better parsing and debug message * fixed smappee switch after local/remote support was added * Smappee - update switches for local support (#3) * Merged with local version * Updated smappy library with the patched one Fixed lint, added merge missing param Fixed missing run for requirements_all.txt Fixed lint * Fixed on/off based on library. Reverted change used for testing stacktrace * Fixed switches to work with both remote and local active Fixed lint Fixed switches Fixed lint * nothing to update per switch as the states are not saved by smappee system * added better error handling for communication errors with smappee * fixed lint errors * fixed comment * fixed lint error * fixed lint error * update smappee module with reviewer comments - update smappy module - cache cloud api requests - added actuator info - updated return states --- .coveragerc | 3 + homeassistant/components/sensor/smappee.py | 162 ++++++++++ homeassistant/components/smappee.py | 337 +++++++++++++++++++++ homeassistant/components/switch/smappee.py | 92 ++++++ requirements_all.txt | 3 + 5 files changed, 597 insertions(+) create mode 100644 homeassistant/components/sensor/smappee.py create mode 100644 homeassistant/components/smappee.py create mode 100644 homeassistant/components/switch/smappee.py diff --git a/.coveragerc b/.coveragerc index ada79ca8f27..97be3406b37 100644 --- a/.coveragerc +++ b/.coveragerc @@ -208,6 +208,9 @@ omit = homeassistant/components/skybell.py homeassistant/components/*/skybell.py + homeassistant/components/smappee.py + homeassistant/components/*/smappee.py + homeassistant/components/tado.py homeassistant/components/*/tado.py diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py new file mode 100644 index 00000000000..51595d19b1a --- /dev/null +++ b/homeassistant/components/sensor/smappee.py @@ -0,0 +1,162 @@ +""" +Support for monitoring a Smappee energy sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.smappee/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_PREFIX = 'Smappee' +SENSOR_TYPES = { + 'solar': + ['Solar', 'mdi:white-balance-sunny', 'local', 'W', 'solar'], + 'active_power': + ['Active Power', 'mdi:power-plug', 'local', 'W', 'active_power'], + 'current': + ['Current', 'mdi:gauge', 'local', 'Amps', 'current'], + 'voltage': + ['Voltage', 'mdi:gauge', 'local', 'V', 'voltage'], + 'active_cosfi': + ['Power Factor', 'mdi:gauge', 'local', '%', 'active_cosfi'], + 'alwayson_today': + ['Always On Today', 'mdi:gauge', 'remote', 'kW', 'alwaysOn'], + 'solar_today': + ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kW', 'solar'], + 'power_today': + ['Power Today', 'mdi:power-plug', 'remote', 'kW', 'consumption'] +} + +SCAN_INTERVAL = timedelta(seconds=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee sensor.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + + if smappee.is_local_active: + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + dev.append(SmappeeSensor(smappee, location_id, sensor)) + else: + dev.append(SmappeeSensor(smappee, None, sensor)) + add_devices(dev, True) + + +class SmappeeSensor(Entity): + """Implementation of a Smappee sensor.""" + + def __init__(self, smappee, location_id, sensor): + """Initialize the sensor.""" + self._smappee = smappee + self._location_id = location_id + self._sensor = sensor + self.data = None + self._state = None + self._name = SENSOR_TYPES[self._sensor][0] + self._icon = SENSOR_TYPES[self._sensor][1] + self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] + self._smappe_name = SENSOR_TYPES[self._sensor][4] + + @property + def name(self): + """Return the name of the sensor.""" + if self._location_id: + location_name = self._smappee.locations[self._location_id] + else: + location_name = 'Local' + + return "{} {} {}".format(SENSOR_PREFIX, + location_name, + self._name) + + @property + def icon(self): + """Icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._location_id: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + return attr + + def update(self): + """Get the latest data from Smappee and update the state.""" + self._smappee.update() + + if self._sensor in ['alwayson_today', 'solar_today', 'power_today']: + data = self._smappee.consumption[self._location_id] + if data: + consumption = data.get('consumptions')[-1] + _LOGGER.debug("%s %s", self._sensor, consumption) + value = consumption.get(self._smappe_name) + self._state = round(value / 1000, 2) + elif self._sensor == 'active_cosfi': + cosfi = self._smappee.active_cosfi() + _LOGGER.debug("%s %s", self._sensor, cosfi) + if cosfi: + self._state = round(cosfi, 2) + elif self._sensor == 'current': + current = self._smappee.active_current() + _LOGGER.debug("%s %s", self._sensor, current) + if current: + self._state = round(current, 2) + elif self._sensor == 'voltage': + voltage = self._smappee.active_voltage() + _LOGGER.debug("%s %s", self._sensor, voltage) + if voltage: + self._state = round(voltage, 3) + elif self._sensor == 'active_power': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase0ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase1ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase2ActivePower')] + active_power = sum(value1 + value2 + value3) / 1000 + self._state = round(active_power, 2) + elif self._sensor == 'solar': + data = self._smappee.instantaneous + _LOGGER.debug("%s %s", self._sensor, data) + if data: + value1 = [float(i['value']) for i in data + if i['key'].endswith('phase3ActivePower')] + value2 = [float(i['value']) for i in data + if i['key'].endswith('phase4ActivePower')] + value3 = [float(i['value']) for i in data + if i['key'].endswith('phase5ActivePower')] + power = sum(value1 + value2 + value3) / 1000 + self._state = round(power, 2) diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py new file mode 100644 index 00000000000..0111e0437fb --- /dev/null +++ b/homeassistant/components/smappee.py @@ -0,0 +1,337 @@ +""" +Support for Smappee energy monitor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/smappee/ +""" +import logging +from datetime import datetime, timedelta +import re +import voluptuous as vol +from requests.exceptions import RequestException +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_HOST +) +from homeassistant.util import Throttle +from homeassistant.helpers.discovery import load_platform +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['smappy==0.2.15'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Smappee' +DEFAULT_HOST_PASSWORD = 'admin' + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_HOST_PASSWORD = 'host_password' + +DOMAIN = 'smappee' +DATA_SMAPPEE = 'SMAPPEE' + +_SENSOR_REGEX = re.compile( + r'(?P([A-Za-z]+))\=' + + r'(?P([0-9\.]+))') + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Inclusive(CONF_CLIENT_ID, 'Server credentials'): cv.string, + vol.Inclusive(CONF_CLIENT_SECRET, 'Server credentials'): cv.string, + vol.Inclusive(CONF_USERNAME, 'Server credentials'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'Server credentials'): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_HOST_PASSWORD, default=DEFAULT_HOST_PASSWORD): + cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + + +def setup(hass, config): + """Set up the Smapee component.""" + client_id = config.get(DOMAIN).get(CONF_CLIENT_ID) + client_secret = config.get(DOMAIN).get(CONF_CLIENT_SECRET) + username = config.get(DOMAIN).get(CONF_USERNAME) + password = config.get(DOMAIN).get(CONF_PASSWORD) + host = config.get(DOMAIN).get(CONF_HOST) + host_password = config.get(DOMAIN).get(CONF_HOST_PASSWORD) + + smappee = Smappee(client_id, client_secret, username, + password, host, host_password) + + if not smappee.is_local_active and not smappee.is_remote_active: + _LOGGER.error("Neither Smappee server or local component enabled.") + return False + + hass.data[DATA_SMAPPEE] = smappee + load_platform(hass, 'switch', DOMAIN) + load_platform(hass, 'sensor', DOMAIN) + return True + + +class Smappee(object): + """Stores data retrieved from Smappee sensor.""" + + def __init__(self, client_id, client_secret, username, + password, host, host_password): + """Initialize the data.""" + import smappy + + self._remote_active = False + self._local_active = False + if client_id is not None: + try: + self._smappy = smappy.Smappee(client_id, client_secret) + self._smappy.authenticate(username, password) + self._remote_active = True + except RequestException as error: + self._smappy = None + _LOGGER.exception( + "Smappee server authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee server component init skipped.") + + if host is not None: + try: + self._localsmappy = smappy.LocalSmappee(host) + self._localsmappy.logon(host_password) + self._local_active = True + except RequestException as error: + self._localsmappy = None + _LOGGER.exception( + "Local Smappee device authentication failed (%s)", + error) + else: + _LOGGER.warning("Smappee local component init skipped.") + + self.locations = {} + self.info = {} + self.consumption = {} + self.instantaneous = {} + + if self._remote_active or self._local_active: + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update data from Smappee API.""" + if self.is_remote_active: + service_locations = self._smappy.get_service_locations() \ + .get('serviceLocations') + for location in service_locations: + location_id = location.get('serviceLocationId') + if location_id is not None: + self.locations[location_id] = location.get('name') + self.info[location_id] = self._smappy \ + .get_service_location_info(location_id) + _LOGGER.debug("Remote info %s %s", + self.locations, self.info) + + self.consumption[location_id] = self.get_consumption( + location_id, aggregation=3, delta=1440) + _LOGGER.debug("Remote consumption %s %s", + self.locations, + self.consumption[location_id]) + + if self.is_local_active: + self.local_devices = self.get_switches() + _LOGGER.debug("Local switches %s", self.local_devices) + + self.instantaneous = self.load_instantaneous() + _LOGGER.debug("Local values %s", self.instantaneous) + + @property + def is_remote_active(self): + """Return true if Smappe server is configured and working.""" + return self._remote_active + + @property + def is_local_active(self): + """Return true if Smappe local device is configured and working.""" + return self._local_active + + def get_switches(self): + """Get switches from local Smappee.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_command_control_config() + except RequestException as error: + _LOGGER.error( + "Error getting switches from local Smappee. (%s)", + error) + + def get_consumption(self, location_id, aggregation, delta): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + end = datetime.utcnow() + start = end - timedelta(minutes=delta) + try: + return self._smappy.get_consumption(location_id, + start, + end, + aggregation) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def get_sensor_consumption(self, location_id, sensor_id): + """Update data from Smappee.""" + # Start & End accept epoch (in milliseconds), + # datetime and pandas timestamps + # Aggregation: + # 1 = 5 min values (only available for the last 14 days), + # 2 = hourly values, + # 3 = daily values, + # 4 = monthly values, + # 5 = quarterly values + if not self.is_remote_active: + return + + start = datetime.utcnow() - timedelta(minutes=30) + end = datetime.utcnow() + try: + return self._smappy.get_sensor_consumption(location_id, + sensor_id, + start, + end, 1) + except RequestException as error: + _LOGGER.error( + "Error getting comsumption from Smappee cloud. (%s)", + error) + + def actuator_on(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn on actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_on(location_id, actuator_id, duration) + self._smappy.actuator_on(location_id, actuator_id, duration) + else: + self._localsmappy.on_command_control(actuator_id) + self._localsmappy.on_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def actuator_off(self, location_id, actuator_id, + is_remote_switch, duration=None): + """Turn off actuator.""" + # Duration = 300,900,1800,3600 + # or any other value for an undetermined period of time. + # + # The comport plugs have a tendency to ignore the on/off signal. + # And because you can't read the status of a plug, it's more + # reliable to execute the command twice. + try: + if is_remote_switch: + self._smappy.actuator_off(location_id, actuator_id, duration) + self._smappy.actuator_off(location_id, actuator_id, duration) + else: + self._localsmappy.off_command_control(actuator_id) + self._localsmappy.off_command_control(actuator_id) + except RequestException as error: + _LOGGER.error( + "Error turning actuator on. (%s)", + error) + return False + + return True + + def active_power(self): + """Get sum of all instantanious active power values from local hub.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_power() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def active_cosfi(self): + """Get the average of all instantaneous cosfi values.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.active_cosfi() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) + + def instantaneous_values(self): + """ReportInstantaneousValues.""" + if not self.is_local_active: + return + + report_instantaneous_values = \ + self._localsmappy.report_instantaneous_values() + + report_result = \ + report_instantaneous_values['report'].split('
') + properties = {} + for lines in report_result: + lines_result = lines.split(',') + for prop in lines_result: + match = _SENSOR_REGEX.search(prop) + if match: + properties[match.group('key')] = \ + match.group('value') + _LOGGER.debug(properties) + return properties + + def active_current(self): + """Get current active Amps.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['current']) + + def active_voltage(self): + """Get current active Voltage.""" + if not self.is_local_active: + return + + properties = self.instantaneous_values() + return float(properties['voltage']) + + def load_instantaneous(self): + """LoadInstantaneous.""" + if not self.is_local_active: + return + + try: + return self._localsmappy.load_instantaneous() + except RequestException as error: + _LOGGER.error( + "Error getting data from Local Smappee unit. (%s)", + error) diff --git a/homeassistant/components/switch/smappee.py b/homeassistant/components/switch/smappee.py new file mode 100644 index 00000000000..fd8f141500b --- /dev/null +++ b/homeassistant/components/switch/smappee.py @@ -0,0 +1,92 @@ +""" +Support for interacting with Smappee Comport Plugs. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.smappee/ +""" +import logging + +from homeassistant.components.smappee import DATA_SMAPPEE +from homeassistant.components.switch import (SwitchDevice) + +DEPENDENCIES = ['smappee'] + +_LOGGER = logging.getLogger(__name__) + +ICON = 'mdi:power-plug' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Smappee Comfort Plugs.""" + smappee = hass.data[DATA_SMAPPEE] + + dev = [] + if smappee.is_remote_active: + for location_id in smappee.locations.keys(): + for items in smappee.info[location_id].get('actuators'): + if items.get('name') != '': + _LOGGER.debug("Remote actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('name'), + location_id, + items.get('id'))) + elif smappee.is_local_active: + for items in smappee.local_devices: + _LOGGER.debug("Local actuator %s", items) + dev.append(SmappeeSwitch(smappee, + items.get('value'), + None, + items.get('key'))) + add_devices(dev) + + +class SmappeeSwitch(SwitchDevice): + """Representation of a Smappee Comport Plug.""" + + def __init__(self, smappee, name, location_id, switch_id): + """Initialize a new Smappee Comfort Plug.""" + self._name = name + self._state = False + self._smappee = smappee + self._location_id = location_id + self._switch_id = switch_id + self._remoteswitch = True + if location_id is None: + self._remoteswitch = False + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return true if switch is on.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def turn_on(self, **kwargs): + """Turn on Comport Plug.""" + if self._smappee.actuator_on(self._location_id, self._switch_id, + self._remoteswitch): + self._state = True + + def turn_off(self, **kwargs): + """Turn off Comport Plug.""" + if self._smappee.actuator_off(self._location_id, self._switch_id, + self._remoteswitch): + self._state = False + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + if self._remoteswitch: + attr['Location Id'] = self._location_id + attr['Location Name'] = self._smappee.locations[self._location_id] + attr['Switch Id'] = self._switch_id + return attr diff --git a/requirements_all.txt b/requirements_all.txt index 50ee20f11d4..35a2e824da7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1112,6 +1112,9 @@ sleekxmpp==1.3.2 # homeassistant.components.sleepiq sleepyq==0.6 +# homeassistant.components.smappee +smappy==0.2.15 + # homeassistant.components.raspihats # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280