diff --git a/.coveragerc b/.coveragerc index 5fb2bdd55bb..0cce8526784 100644 --- a/.coveragerc +++ b/.coveragerc @@ -728,7 +728,10 @@ omit = homeassistant/components/sinch/* homeassistant/components/slide/* homeassistant/components/sma/sensor.py - homeassistant/components/smappee/* + homeassistant/components/smappee/__init__.py + homeassistant/components/smappee/api.py + homeassistant/components/smappee/sensor.py + homeassistant/components/smappee/switch.py homeassistant/components/smarty/* homeassistant/components/smarthab/* homeassistant/components/sms/* diff --git a/CODEOWNERS b/CODEOWNERS index 89244ccd809..8729873a1d0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -366,6 +366,7 @@ homeassistant/components/sinch/* @bendikrb homeassistant/components/sisyphus/* @jkeljo homeassistant/components/slide/* @ualex73 homeassistant/components/sma/* @kellerza +homeassistant/components/smappee/* @bsmappee homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smarty/* @z0mbieprocess diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index d230661a9f2..c0c6c41ad54 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -1,334 +1,109 @@ -"""Support for Smappee energy monitor.""" -from datetime import datetime, timedelta -import logging -import re +"""The Smappee integration.""" +import asyncio -from requests.exceptions import RequestException -import smappy +from pysmappee import Smappee import voluptuous as vol -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Smappee" -DEFAULT_HOST_PASSWORD = "admin" - -CONF_HOST_PASSWORD = "host_password" - -DOMAIN = "smappee" -DATA_SMAPPEE = "SMAPPEE" - -_SENSOR_REGEX = re.compile(r"(?P([A-Za-z]+))\=(?P([0-9\.]+))") +from . import api, config_flow +from .const import ( + AUTHORIZE_URL, + BASE, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + SMAPPEE_PLATFORMS, + TOKEN_URL, +) 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, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, } ) }, extra=vol.ALLOW_EXTRA, ) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Smappee component.""" + hass.data[DOMAIN] = {} -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) + if DOMAIN not in config: + return True - smappee = Smappee(client_id, client_secret, username, password, host, host_password) + config_flow.SmappeeFlowHandler.async_register_implementation( + hass, + config_entry_oauth2_flow.LocalOAuth2Implementation( + hass, + DOMAIN, + config[DOMAIN][CONF_CLIENT_ID], + config[DOMAIN][CONF_CLIENT_SECRET], + AUTHORIZE_URL, + TOKEN_URL, + ), + ) - if not smappee.is_local_active and not smappee.is_remote_active: - _LOGGER.error("Neither Smappee server or local integration enabled.") - return False - - hass.data[DATA_SMAPPEE] = smappee - load_platform(hass, "switch", DOMAIN, {}, config) - load_platform(hass, "sensor", DOMAIN, {}, config) return True -class Smappee: - """Stores data retrieved from Smappee sensor.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Smappee from a config entry.""" + implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) - def __init__( - self, client_id, client_secret, username, password, host, host_password - ): - """Initialize the data.""" + smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation) - 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 integration init skipped.") + smappee = Smappee(smappee_api) + await hass.async_add_executor_job(smappee.load_service_locations) - 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 integration init skipped.") + hass.data[DOMAIN][BASE] = SmappeeBase(hass, smappee) - self.locations = {} - self.info = {} - self.consumption = {} - self.sensor_consumption = {} - self.instantaneous = {} + for component in SMAPPEE_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) - if self._remote_active or self._local_active: - self.update() + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in SMAPPEE_PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(BASE, None) + + return unload_ok + + +class SmappeeBase: + """An object to hold the PySmappee instance.""" + + def __init__(self, hass, smappee): + """Initialize the Smappee API wrapper class.""" + self.hass = hass + self.smappee = smappee @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.sensor_consumption[location_id] = {} - 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[location_id] - ) - - for sensors in self.info[location_id].get("sensors"): - sensor_id = sensors.get("id") - self.sensor_consumption[location_id].update( - { - sensor_id: self.get_sensor_consumption( - location_id, sensor_id, aggregation=3, delta=1440 - ) - } - ) - _LOGGER.debug( - "Remote sensors %s %s", - self.locations, - self.sensor_consumption[location_id], - ) - - 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 consumption from Smappee cloud. (%s)", error) - - def get_sensor_consumption(self, location_id, sensor_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_sensor_consumption( - location_id, sensor_id, start, end, aggregation - ) - except RequestException as error: - _LOGGER.error("Error getting consumption 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 instantaneous 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) + async def async_update(self): + """Update all Smappee trends and appliance states.""" + await self.hass.async_add_executor_job( + self.smappee.update_trends_and_appliance_states + ) diff --git a/homeassistant/components/smappee/api.py b/homeassistant/components/smappee/api.py new file mode 100644 index 00000000000..703dd581d31 --- /dev/null +++ b/homeassistant/components/smappee/api.py @@ -0,0 +1,33 @@ +"""API for Smappee bound to Home Assistant OAuth.""" +from asyncio import run_coroutine_threadsafe + +from pysmappee import api + +from homeassistant import config_entries, core +from homeassistant.helpers import config_entry_oauth2_flow + + +class ConfigEntrySmappeeApi(api.SmappeeApi): + """Provide Smappee authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, + ): + """Initialize Smappee Auth.""" + self.hass = hass + self.config_entry = config_entry + self.session = config_entry_oauth2_flow.OAuth2Session( + hass, config_entry, implementation + ) + super().__init__(None, None, token=self.session.token) + + def refresh_tokens(self) -> dict: + """Refresh and return new Smappee tokens using Home Assistant OAuth2 session.""" + run_coroutine_threadsafe( + self.session.async_ensure_token_valid(), self.hass.loop + ).result() + + return self.session.token diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py new file mode 100644 index 00000000000..48aca5449ad --- /dev/null +++ b/homeassistant/components/smappee/config_flow.py @@ -0,0 +1,30 @@ +"""Config flow for Smappee.""" +import logging + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class SmappeeFlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Smappee OAuth2 authentication.""" + + DOMAIN = DOMAIN + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="single_instance_allowed") + + return await super().async_step_user(user_input) diff --git a/homeassistant/components/smappee/const.py b/homeassistant/components/smappee/const.py new file mode 100644 index 00000000000..3c174d47427 --- /dev/null +++ b/homeassistant/components/smappee/const.py @@ -0,0 +1,15 @@ +"""Constants for the Smappee integration.""" + +from datetime import timedelta + +DOMAIN = "smappee" +DATA_CLIENT = "smappee_data" + +BASE = "BASE" + +SMAPPEE_PLATFORMS = ["sensor", "switch"] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) + +AUTHORIZE_URL = "https://app1pub.smappee.net/dev/v1/oauth2/authorize" +TOKEN_URL = "https://app1pub.smappee.net/dev/v3/oauth2/token" diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json index e2c24bf6d71..e316273f3ed 100644 --- a/homeassistant/components/smappee/manifest.json +++ b/homeassistant/components/smappee/manifest.json @@ -1,7 +1,13 @@ { "domain": "smappee", "name": "Smappee", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smappee", - "requirements": ["smappy==0.2.16"], - "codeowners": [] + "dependencies": ["http"], + "requirements": [ + "pysmappee==0.1.0" + ], + "codeowners": [ + "@bsmappee" + ] } diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 9558bbc2e62..77578533749 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,170 +1,248 @@ """Support for monitoring a Smappee energy sensor.""" -from datetime import timedelta import logging -from homeassistant.const import ( - DEGREE, - ELECTRICAL_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, - UNIT_PERCENTAGE, - VOLT, - VOLUME_CUBIC_METERS, -) +from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, VOLT from homeassistant.helpers.entity import Entity -from . import DATA_SMAPPEE +from .const import BASE, DOMAIN _LOGGER = logging.getLogger(__name__) -SENSOR_PREFIX = "Smappee" -SENSOR_TYPES = { - "solar": ["Solar", "mdi:white-balance-sunny", "local", POWER_WATT, "solar"], - "active_power": [ - "Active Power", - "mdi:power-plug", - "local", +TREND_SENSORS = { + "total_power": [ + "Total consumption - Active power", + None, POWER_WATT, - "active_power", + "total_power", + DEVICE_CLASS_POWER, ], - "current": ["Current", "mdi:gauge", "local", ELECTRICAL_CURRENT_AMPERE, "current"], - "voltage": ["Voltage", "mdi:gauge", "local", VOLT, "voltage"], - "active_cosfi": [ - "Power Factor", - "mdi:gauge", - "local", - UNIT_PERCENTAGE, - "active_cosfi", + "total_reactive_power": [ + "Total consumption - Reactive power", + None, + POWER_WATT, + "total_reactive_power", + DEVICE_CLASS_POWER, ], - "alwayson_today": [ - "Always On Today", - "mdi:gauge", - "remote", - ENERGY_KILO_WATT_HOUR, - "alwaysOn", - ], - "solar_today": [ - "Solar Today", - "mdi:white-balance-sunny", - "remote", - ENERGY_KILO_WATT_HOUR, - "solar", + "alwayson": [ + "Always on - Active power", + None, + POWER_WATT, + "alwayson", + DEVICE_CLASS_POWER, ], "power_today": [ - "Power Today", + "Total consumption - Today", "mdi:power-plug", - "remote", - ENERGY_KILO_WATT_HOUR, - "consumption", + ENERGY_WATT_HOUR, + "power_today", + None, ], - "water_sensor_1": [ - "Water Sensor 1", - "mdi:water", - "water", - VOLUME_CUBIC_METERS, - "value1", + "power_current_hour": [ + "Total consumption - Current hour", + "mdi:power-plug", + ENERGY_WATT_HOUR, + "power_current_hour", + None, ], - "water_sensor_2": [ - "Water Sensor 2", - "mdi:water", - "water", - VOLUME_CUBIC_METERS, - "value2", + "power_last_5_minutes": [ + "Total consumption - Last 5 minutes", + "mdi:power-plug", + ENERGY_WATT_HOUR, + "power_last_5_minutes", + None, ], - "water_sensor_temperature": [ - "Water Sensor Temperature", - "mdi:temperature-celsius", - "water", - DEGREE, - "temperature", + "alwayson_today": [ + "Always on - Today", + "mdi:sleep", + ENERGY_WATT_HOUR, + "alwayson_today", + None, ], - "water_sensor_humidity": [ - "Water Sensor Humidity", - "mdi:water-percent", - "water", - UNIT_PERCENTAGE, - "humidity", +} +SOLAR_SENSORS = { + "solar_power": [ + "Total production - Active power", + None, + POWER_WATT, + "solar_power", + DEVICE_CLASS_POWER, ], - "water_sensor_battery": [ - "Water Sensor Battery", - "mdi:battery", - "water", - UNIT_PERCENTAGE, - "battery", + "solar_today": [ + "Total production - Today", + "mdi:white-balance-sunny", + ENERGY_WATT_HOUR, + "solar_today", + None, + ], + "solar_current_hour": [ + "Total production - Current hour", + "mdi:white-balance-sunny", + ENERGY_WATT_HOUR, + "solar_current_hour", + None, + ], +} +VOLTAGE_SENSORS = { + "phase_voltages_a": [ + "Phase voltages - A", + "mdi:flash", + VOLT, + "phase_voltage_a", + ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], + None, + ], + "phase_voltages_b": [ + "Phase voltages - B", + "mdi:flash", + VOLT, + "phase_voltage_b", + ["TWO", "THREE_STAR", "THREE_DELTA"], + None, + ], + "phase_voltages_c": [ + "Phase voltages - C", + "mdi:flash", + VOLT, + "phase_voltage_c", + ["THREE_STAR"], + None, + ], + "line_voltages_a": [ + "Line voltages - A", + "mdi:flash", + VOLT, + "line_voltage_a", + ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], + None, + ], + "line_voltages_b": [ + "Line voltages - B", + "mdi:flash", + VOLT, + "line_voltage_b", + ["TWO", "THREE_STAR", "THREE_DELTA"], + None, + ], + "line_voltages_c": [ + "Line voltages - C", + "mdi:flash", + VOLT, + "line_voltage_c", + ["THREE_STAR", "THREE_DELTA"], + None, ], } -SCAN_INTERVAL = timedelta(seconds=30) - -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smappee sensor.""" - smappee = hass.data[DATA_SMAPPEE] + smappee_base = hass.data[DOMAIN][BASE] - dev = [] - if smappee.is_remote_active: - for location_id in smappee.locations.keys(): - for sensor in SENSOR_TYPES: - if "remote" in SENSOR_TYPES[sensor]: - dev.append( - SmappeeSensor( - smappee, location_id, sensor, SENSOR_TYPES[sensor] - ) + entities = [] + for service_location in smappee_base.smappee.service_locations.values(): + # Add all basic sensors (realtime values and aggregators) + for sensor in TREND_SENSORS: + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor=sensor, + attributes=TREND_SENSORS[sensor], + ) + ) + + # Add solar sensors + if service_location.has_solar_production: + for sensor in SOLAR_SENSORS: + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor=sensor, + attributes=SOLAR_SENSORS[sensor], ) - elif "water" in SENSOR_TYPES[sensor]: - for items in smappee.info[location_id].get("sensors"): - dev.append( - SmappeeSensor( - smappee, - location_id, - "{}:{}".format(sensor, items.get("id")), - SENSOR_TYPES[sensor], - ) - ) + ) - if smappee.is_local_active: - if smappee.is_remote_active: - location_keys = smappee.locations.keys() - else: - location_keys = [None] - for location_id in location_keys: - for sensor in SENSOR_TYPES: - if "local" in SENSOR_TYPES[sensor]: - dev.append( - SmappeeSensor( - smappee, location_id, sensor, SENSOR_TYPES[sensor] - ) + # Add all CT measurements + for measurement_id, measurement in service_location.measurements.items(): + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor="load", + attributes=[ + measurement.name, + None, + POWER_WATT, + measurement_id, + DEVICE_CLASS_POWER, + ], + ) + ) + + # Add phase- and line voltages + for sensor_name, sensor in VOLTAGE_SENSORS.items(): + if service_location.phase_type in sensor[4]: + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor=sensor_name, + attributes=sensor, ) + ) - add_entities(dev, True) + # Add Gas and Water sensors + for sensor_id, sensor in service_location.sensors.items(): + for channel in sensor.channels: + gw_icon = "mdi:gas-cylinder" + if channel.get("type") == "water": + gw_icon = "mdi:water" + + entities.append( + SmappeeSensor( + smappee_base=smappee_base, + service_location=service_location, + sensor="sensor", + attributes=[ + channel.get("name"), + gw_icon, + channel.get("uom"), + f"{sensor_id}-{channel.get('channel')}", + None, + ], + ) + ) + + async_add_entities(entities, True) class SmappeeSensor(Entity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee, location_id, sensor, attributes): + def __init__(self, smappee_base, service_location, sensor, attributes): """Initialize the Smappee sensor.""" - self._smappee = smappee - self._location_id = location_id - self._attributes = attributes + self._smappee_base = smappee_base + self._service_location = service_location self._sensor = sensor self.data = None self._state = None - self._name = self._attributes[0] - self._icon = self._attributes[1] - self._type = self._attributes[2] - self._unit_of_measurement = self._attributes[3] - self._smappe_name = self._attributes[4] + self._name = attributes[0] + self._icon = attributes[1] + self._unit_of_measurement = attributes[2] + self._sensor_id = attributes[3] + self._device_class = attributes[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 the name for this sensor.""" + if self._sensor in ["sensor", "load"]: + return ( + f"{self._service_location.service_location_name} - " + f"{self._sensor.title()} - {self._name}" + ) - return f"{SENSOR_PREFIX} {location_name} {self._name}" + return f"{self._service_location.service_location_name} - {self._name}" @property def icon(self): @@ -176,97 +254,94 @@ class SmappeeSensor(Entity): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + @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) - elif self._type == "water": - sensor_name, sensor_id = self._sensor.split(":") - data = self._smappee.sensor_consumption[self._location_id].get( - int(sensor_id) + def unique_id(self,): + """Return the unique ID for this sensor.""" + if self._sensor in ["load", "sensor"]: + return ( + f"{self._service_location.device_serial_number}-" + f"{self._service_location.service_location_id}-" + f"{self._sensor}-{self._sensor_id}" ) - if data: - tempdata = data.get("records") - if tempdata: - consumption = tempdata[-1] - _LOGGER.debug("%s (%s) %s", sensor_name, sensor_id, consumption) - value = consumption.get(self._smappe_name) - self._state = value + + return ( + f"{self._service_location.device_serial_number}-" + f"{self._service_location.service_location_id}-" + f"{self._sensor}" + ) + + @property + def device_info(self): + """Return the device info for this sensor.""" + return { + "identifiers": {(DOMAIN, self._service_location.device_serial_number)}, + "name": self._service_location.service_location_name, + "manufacturer": "Smappee", + "model": self._service_location.device_model, + "sw_version": self._service_location.firmware_version, + } + + async def async_update(self): + """Get the latest data from Smappee and update the state.""" + await self._smappee_base.async_update() + + if self._sensor == "total_power": + self._state = self._service_location.total_power + elif self._sensor == "total_reactive_power": + self._state = self._service_location.total_reactive_power + elif self._sensor == "solar_power": + self._state = self._service_location.solar_power + elif self._sensor == "alwayson": + self._state = self._service_location.alwayson + elif self._sensor in [ + "phase_voltages_a", + "phase_voltages_b", + "phase_voltages_c", + ]: + phase_voltages = self._service_location.phase_voltages + if phase_voltages is not None: + if self._sensor == "phase_voltages_a": + self._state = phase_voltages[0] + elif self._sensor == "phase_voltages_b": + self._state = phase_voltages[1] + elif self._sensor == "phase_voltages_c": + self._state = phase_voltages[2] + elif self._sensor in ["line_voltages_a", "line_voltages_b", "line_voltages_c"]: + line_voltages = self._service_location.line_voltages + if line_voltages is not None: + if self._sensor == "line_voltages_a": + self._state = line_voltages[0] + elif self._sensor == "line_voltages_b": + self._state = line_voltages[1] + elif self._sensor == "line_voltages_c": + self._state = line_voltages[2] + elif self._sensor in [ + "power_today", + "power_current_hour", + "power_last_5_minutes", + "solar_today", + "solar_current_hour", + "alwayson_today", + ]: + trend_value = self._service_location.aggregated_values.get(self._sensor) + self._state = round(trend_value) if trend_value is not None else None + elif self._sensor == "load": + self._state = self._service_location.measurements.get( + self._sensor_id + ).active_total + elif self._sensor == "sensor": + sensor_id, channel_id = self._sensor_id.split("-") + sensor = self._service_location.sensors.get(int(sensor_id)) + for channel in sensor.channels: + if channel.get("channel") == int(channel_id): + self._state = channel.get("value_today") diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json new file mode 100644 index 00000000000..d89d15a92c6 --- /dev/null +++ b/homeassistant/components/smappee/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "abort": { + "authorize_url_timeout": "Timeout generating authorize url.", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "missing_configuration": "The component is not configured. Please follow the documentation." + } + } +} diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 6f6481d65f9..7d6a7f2405f 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -1,62 +1,105 @@ -"""Support for interacting with Smappee Comport Plugs.""" +"""Support for interacting with Smappee Comport Plugs, Switches and Output Modules.""" +from datetime import timedelta import logging from homeassistant.components.switch import SwitchEntity -from . import DATA_SMAPPEE +from .const import BASE, DOMAIN _LOGGER = logging.getLogger(__name__) -ICON = "mdi:power-plug" +SWITCH_PREFIX = "Switch" +ICON = "mdi:toggle-switch" +SCAN_INTERVAL = timedelta(seconds=5) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Smappee Comfort Plugs.""" - smappee = hass.data[DATA_SMAPPEE] + smappee_base = hass.data[DOMAIN][BASE] - 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") + entities = [] + for service_location in smappee_base.smappee.service_locations.values(): + for actuator_id, actuator in service_location.actuators.items(): + if actuator.type in ["SWITCH", "COMFORT_PLUG"]: + entities.append( + SmappeeActuator( + smappee_base, + service_location, + actuator.name, + actuator_id, + actuator.type, + actuator.serialnumber, + ) + ) + elif actuator.type == "INFINITY_OUTPUT_MODULE": + for option in actuator.state_options: + entities.append( + SmappeeActuator( + smappee_base, + service_location, + actuator.name, + actuator_id, + actuator.type, + actuator.serialnumber, + actuator_state_option=option, ) ) - 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_entities(dev) + + async_add_entities(entities, True) -class SmappeeSwitch(SwitchEntity): +class SmappeeActuator(SwitchEntity): """Representation of a Smappee Comport Plug.""" - def __init__(self, smappee, name, location_id, switch_id): + def __init__( + self, + smappee_base, + service_location, + name, + actuator_id, + actuator_type, + actuator_serialnumber, + actuator_state_option=None, + ): """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 + self._smappee_base = smappee_base + self._service_location = service_location + self._actuator_name = name + self._actuator_id = actuator_id + self._actuator_type = actuator_type + self._actuator_serialnumber = actuator_serialnumber + self._actuator_state_option = actuator_state_option + self._state = self._service_location.actuators.get(actuator_id).state + self._connection_state = self._service_location.actuators.get( + actuator_id + ).connection_state @property def name(self): """Return the name of the switch.""" - return self._name + if self._actuator_type == "INFINITY_OUTPUT_MODULE": + return ( + f"{self._service_location.service_location_name} - " + f"Output module - {self._actuator_name} - {self._actuator_state_option}" + ) + + # Switch or comfort plug + return ( + f"{self._service_location.service_location_name} - " + f"{self._actuator_type.title()} - {self._actuator_name}" + ) @property def is_on(self): """Return true if switch is on.""" - return self._state + if self._actuator_type == "INFINITY_OUTPUT_MODULE": + return ( + self._service_location.actuators.get(self._actuator_id).state + == self._actuator_state_option + ) + + # Switch or comfort plug + return self._state == "ON_ON" @property def icon(self): @@ -65,24 +108,80 @@ class SmappeeSwitch(SwitchEntity): 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 + if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + self._service_location.set_actuator_state(self._actuator_id, state="ON_ON") + elif self._actuator_type == "INFINITY_OUTPUT_MODULE": + self._service_location.set_actuator_state( + self._actuator_id, state=self._actuator_state_option + ) 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 + if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]: + self._service_location.set_actuator_state( + self._actuator_id, state="OFF_OFF" + ) + elif self._actuator_type == "INFINITY_OUTPUT_MODULE": + self._service_location.set_actuator_state( + self._actuator_id, state="PLACEHOLDER", api=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 + def available(self): + """Return True if entity is available. Unavailable for COMFORT_PLUGS.""" + return ( + self._connection_state == "CONNECTED" + or self._actuator_type == "COMFORT_PLUG" + ) + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if self._actuator_type == "SWITCH": + cons = self._service_location.actuators.get( + self._actuator_id + ).consumption_today + if cons is not None: + return round(cons / 1000.0, 2) + return None + + @property + def unique_id(self,): + """Return the unique ID for this switch.""" + if self._actuator_type == "INFINITY_OUTPUT_MODULE": + return ( + f"{self._service_location.device_serial_number}-" + f"{self._service_location.service_location_id}-actuator-" + f"{self._actuator_id}-{self._actuator_state_option}" + ) + + # Switch or comfort plug + return ( + f"{self._service_location.device_serial_number}-" + f"{self._service_location.service_location_id}-actuator-" + f"{self._actuator_id}" + ) + + @property + def device_info(self): + """Return the device info for this switch.""" + return { + "identifiers": {(DOMAIN, self._service_location.device_serial_number)}, + "name": self._service_location.service_location_name, + "manufacturer": "Smappee", + "model": self._service_location.device_model, + "sw_version": self._service_location.firmware_version, + } + + async def async_update(self): + """Get the latest data from Smappee and update the state.""" + await self._smappee_base.async_update() + + new_state = self._service_location.actuators.get(self._actuator_id).state + if new_state != self._state: + self._state = new_state + self.async_write_ha_state() + + self._connection_state = self._service_location.actuators.get( + self._actuator_id + ).connection_state diff --git a/homeassistant/components/smappee/translations/en.json b/homeassistant/components/smappee/translations/en.json new file mode 100644 index 00000000000..fc045ba9b3a --- /dev/null +++ b/homeassistant/components/smappee/translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "Pick Authentication Method" + } + }, + "abort": { + "authorize_url_timeout": "Timeout generating authorize url.", + "single_instance_allowed": "Already configured. Only a single configuration possible.", + "missing_configuration": "The component is not configured. Please follow the documentation." + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1a95a37c278..aa4e71c18f5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -135,6 +135,7 @@ FLOWS = [ "sentry", "shopping_list", "simplisafe", + "smappee", "smartthings", "smhi", "solaredge", diff --git a/requirements_all.txt b/requirements_all.txt index 3c00154dfad..16a2fda07d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1609,6 +1609,9 @@ pysignalclirestapi==0.3.4 # homeassistant.components.sma pysma==0.3.5 +# homeassistant.components.smappee +pysmappee==0.1.0 + # homeassistant.components.smartthings pysmartapp==0.3.2 @@ -1964,9 +1967,6 @@ sleepyq==0.7 # homeassistant.components.xmpp slixmpp==1.5.1 -# homeassistant.components.smappee -smappy==0.2.16 - # homeassistant.components.smarthab smarthab==0.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ca42c023d7..5882ba76c0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -702,6 +702,9 @@ pysignalclirestapi==0.3.4 # homeassistant.components.sma pysma==0.3.5 +# homeassistant.components.smappee +pysmappee==0.1.0 + # homeassistant.components.smartthings pysmartapp==0.3.2 diff --git a/tests/components/smappee/__init__.py b/tests/components/smappee/__init__.py new file mode 100644 index 00000000000..8e04b99fa37 --- /dev/null +++ b/tests/components/smappee/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smappee integration.""" diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py new file mode 100644 index 00000000000..e37733a1385 --- /dev/null +++ b/tests/components/smappee/test_config_flow.py @@ -0,0 +1,68 @@ +"""Test the Smappee config flow.""" +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.smappee.const import AUTHORIZE_URL, DOMAIN, TOKEN_URL +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.helpers import config_entry_oauth2_flow + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_abort_if_existing_entry(hass): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_full_flow(hass, aiohttp_client, aioclient_mock): + """Check full flow.""" + assert await setup.async_setup_component( + hass, + DOMAIN, + { + DOMAIN: {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET}, + "http": {"base_url": "https://example.com"}, + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]}) + + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await aiohttp_client(hass.http.app) + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.smappee.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1