Renew Smappee (sensors and switches) (#36445)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
bsmappee 2020-06-17 13:28:28 +02:00 committed by GitHub
parent fd67a079db
commit 5228282f69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 709 additions and 571 deletions

View file

@ -728,7 +728,10 @@ omit =
homeassistant/components/sinch/* homeassistant/components/sinch/*
homeassistant/components/slide/* homeassistant/components/slide/*
homeassistant/components/sma/sensor.py 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/smarty/*
homeassistant/components/smarthab/* homeassistant/components/smarthab/*
homeassistant/components/sms/* homeassistant/components/sms/*

View file

@ -366,6 +366,7 @@ homeassistant/components/sinch/* @bendikrb
homeassistant/components/sisyphus/* @jkeljo homeassistant/components/sisyphus/* @jkeljo
homeassistant/components/slide/* @ualex73 homeassistant/components/slide/* @ualex73
homeassistant/components/sma/* @kellerza homeassistant/components/sma/* @kellerza
homeassistant/components/smappee/* @bsmappee
homeassistant/components/smarthab/* @outadoc homeassistant/components/smarthab/* @outadoc
homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smartthings/* @andrewsayre
homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smarty/* @z0mbieprocess

View file

@ -1,334 +1,109 @@
"""Support for Smappee energy monitor.""" """The Smappee integration."""
from datetime import datetime, timedelta import asyncio
import logging
import re
from requests.exceptions import RequestException from pysmappee import Smappee
import smappy
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.config_entries import ConfigEntry
CONF_CLIENT_ID, from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
CONF_CLIENT_SECRET, from homeassistant.core import HomeAssistant
CONF_HOST, from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
CONF_PASSWORD,
CONF_USERNAME,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.discovery import load_platform
from homeassistant.util import Throttle from homeassistant.util import Throttle
_LOGGER = logging.getLogger(__name__) from . import api, config_flow
from .const import (
DEFAULT_NAME = "Smappee" AUTHORIZE_URL,
DEFAULT_HOST_PASSWORD = "admin" BASE,
DOMAIN,
CONF_HOST_PASSWORD = "host_password" MIN_TIME_BETWEEN_UPDATES,
SMAPPEE_PLATFORMS,
DOMAIN = "smappee" TOKEN_URL,
DATA_SMAPPEE = "SMAPPEE" )
_SENSOR_REGEX = re.compile(r"(?P<key>([A-Za-z]+))\=(?P<value>([0-9\.]+))")
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
{ {
vol.Inclusive(CONF_CLIENT_ID, "Server credentials"): cv.string, vol.Required(CONF_CLIENT_ID): cv.string,
vol.Inclusive(CONF_CLIENT_SECRET, "Server credentials"): cv.string, vol.Required(CONF_CLIENT_SECRET): 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, 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): if DOMAIN not in config:
"""Set up the Smapee component.""" return True
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) 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 return True
class Smappee: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Stores data retrieved from Smappee sensor.""" """Set up Smappee from a config entry."""
implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
def __init__( smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation)
self, client_id, client_secret, username, password, host, host_password
):
"""Initialize the data."""
self._remote_active = False smappee = Smappee(smappee_api)
self._local_active = False await hass.async_add_executor_job(smappee.load_service_locations)
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.")
if host is not None: hass.data[DOMAIN][BASE] = SmappeeBase(hass, smappee)
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.")
self.locations = {} for component in SMAPPEE_PLATFORMS:
self.info = {} hass.async_create_task(
self.consumption = {} hass.config_entries.async_forward_entry_setup(entry, component)
self.sensor_consumption = {} )
self.instantaneous = {}
if self._remote_active or self._local_active: return True
self.update()
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) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): async def async_update(self):
"""Update data from Smappee API.""" """Update all Smappee trends and appliance states."""
if self.is_remote_active: await self.hass.async_add_executor_job(
service_locations = self._smappy.get_service_locations().get( self.smappee.update_trends_and_appliance_states
"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("<BR>")
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)

View file

@ -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

View file

@ -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)

View file

@ -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"

View file

@ -1,7 +1,13 @@
{ {
"domain": "smappee", "domain": "smappee",
"name": "Smappee", "name": "Smappee",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/smappee", "documentation": "https://www.home-assistant.io/integrations/smappee",
"requirements": ["smappy==0.2.16"], "dependencies": ["http"],
"codeowners": [] "requirements": [
"pysmappee==0.1.0"
],
"codeowners": [
"@bsmappee"
]
} }

View file

@ -1,170 +1,248 @@
"""Support for monitoring a Smappee energy sensor.""" """Support for monitoring a Smappee energy sensor."""
from datetime import timedelta
import logging import logging
from homeassistant.const import ( from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, VOLT
DEGREE,
ELECTRICAL_CURRENT_AMPERE,
ENERGY_KILO_WATT_HOUR,
POWER_WATT,
UNIT_PERCENTAGE,
VOLT,
VOLUME_CUBIC_METERS,
)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from . import DATA_SMAPPEE from .const import BASE, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SENSOR_PREFIX = "Smappee" TREND_SENSORS = {
SENSOR_TYPES = { "total_power": [
"solar": ["Solar", "mdi:white-balance-sunny", "local", POWER_WATT, "solar"], "Total consumption - Active power",
"active_power": [ None,
"Active Power",
"mdi:power-plug",
"local",
POWER_WATT, POWER_WATT,
"active_power", "total_power",
DEVICE_CLASS_POWER,
], ],
"current": ["Current", "mdi:gauge", "local", ELECTRICAL_CURRENT_AMPERE, "current"], "total_reactive_power": [
"voltage": ["Voltage", "mdi:gauge", "local", VOLT, "voltage"], "Total consumption - Reactive power",
"active_cosfi": [ None,
"Power Factor", POWER_WATT,
"mdi:gauge", "total_reactive_power",
"local", DEVICE_CLASS_POWER,
UNIT_PERCENTAGE,
"active_cosfi",
], ],
"alwayson_today": [ "alwayson": [
"Always On Today", "Always on - Active power",
"mdi:gauge", None,
"remote", POWER_WATT,
ENERGY_KILO_WATT_HOUR, "alwayson",
"alwaysOn", DEVICE_CLASS_POWER,
],
"solar_today": [
"Solar Today",
"mdi:white-balance-sunny",
"remote",
ENERGY_KILO_WATT_HOUR,
"solar",
], ],
"power_today": [ "power_today": [
"Power Today", "Total consumption - Today",
"mdi:power-plug", "mdi:power-plug",
"remote", ENERGY_WATT_HOUR,
ENERGY_KILO_WATT_HOUR, "power_today",
"consumption", None,
], ],
"water_sensor_1": [ "power_current_hour": [
"Water Sensor 1", "Total consumption - Current hour",
"mdi:water", "mdi:power-plug",
"water", ENERGY_WATT_HOUR,
VOLUME_CUBIC_METERS, "power_current_hour",
"value1", None,
], ],
"water_sensor_2": [ "power_last_5_minutes": [
"Water Sensor 2", "Total consumption - Last 5 minutes",
"mdi:water", "mdi:power-plug",
"water", ENERGY_WATT_HOUR,
VOLUME_CUBIC_METERS, "power_last_5_minutes",
"value2", None,
], ],
"water_sensor_temperature": [ "alwayson_today": [
"Water Sensor Temperature", "Always on - Today",
"mdi:temperature-celsius", "mdi:sleep",
"water", ENERGY_WATT_HOUR,
DEGREE, "alwayson_today",
"temperature", None,
], ],
"water_sensor_humidity": [ }
"Water Sensor Humidity", SOLAR_SENSORS = {
"mdi:water-percent", "solar_power": [
"water", "Total production - Active power",
UNIT_PERCENTAGE, None,
"humidity", POWER_WATT,
"solar_power",
DEVICE_CLASS_POWER,
], ],
"water_sensor_battery": [ "solar_today": [
"Water Sensor Battery", "Total production - Today",
"mdi:battery", "mdi:white-balance-sunny",
"water", ENERGY_WATT_HOUR,
UNIT_PERCENTAGE, "solar_today",
"battery", 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)
async def async_setup_entry(hass, config_entry, async_add_entities):
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Smappee sensor.""" """Set up the Smappee sensor."""
smappee = hass.data[DATA_SMAPPEE] smappee_base = hass.data[DOMAIN][BASE]
dev = [] entities = []
if smappee.is_remote_active: for service_location in smappee_base.smappee.service_locations.values():
for location_id in smappee.locations.keys(): # Add all basic sensors (realtime values and aggregators)
for sensor in SENSOR_TYPES: for sensor in TREND_SENSORS:
if "remote" in SENSOR_TYPES[sensor]: entities.append(
dev.append( SmappeeSensor(
SmappeeSensor( smappee_base=smappee_base,
smappee, location_id, sensor, SENSOR_TYPES[sensor] 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: # Add all CT measurements
if smappee.is_remote_active: for measurement_id, measurement in service_location.measurements.items():
location_keys = smappee.locations.keys() entities.append(
else: SmappeeSensor(
location_keys = [None] smappee_base=smappee_base,
for location_id in location_keys: service_location=service_location,
for sensor in SENSOR_TYPES: sensor="load",
if "local" in SENSOR_TYPES[sensor]: attributes=[
dev.append( measurement.name,
SmappeeSensor( None,
smappee, location_id, sensor, SENSOR_TYPES[sensor] 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): class SmappeeSensor(Entity):
"""Implementation of a Smappee sensor.""" """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.""" """Initialize the Smappee sensor."""
self._smappee = smappee self._smappee_base = smappee_base
self._location_id = location_id self._service_location = service_location
self._attributes = attributes
self._sensor = sensor self._sensor = sensor
self.data = None self.data = None
self._state = None self._state = None
self._name = self._attributes[0] self._name = attributes[0]
self._icon = self._attributes[1] self._icon = attributes[1]
self._type = self._attributes[2] self._unit_of_measurement = attributes[2]
self._unit_of_measurement = self._attributes[3] self._sensor_id = attributes[3]
self._smappe_name = self._attributes[4] self._device_class = attributes[4]
@property @property
def name(self): def name(self):
"""Return the name of the sensor.""" """Return the name for this sensor."""
if self._location_id: if self._sensor in ["sensor", "load"]:
location_name = self._smappee.locations[self._location_id] return (
else: f"{self._service_location.service_location_name} - "
location_name = "Local" 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 @property
def icon(self): def icon(self):
@ -176,97 +254,94 @@ class SmappeeSensor(Entity):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self._state return self._state
@property
def device_class(self):
"""Return the class of this device, from component DEVICE_CLASSES."""
return self._device_class
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
return self._unit_of_measurement return self._unit_of_measurement
@property @property
def device_state_attributes(self): def unique_id(self,):
"""Return the state attributes of the device.""" """Return the unique ID for this sensor."""
attr = {} if self._sensor in ["load", "sensor"]:
if self._location_id: return (
attr["Location Id"] = self._location_id f"{self._service_location.device_serial_number}-"
attr["Location Name"] = self._smappee.locations[self._location_id] f"{self._service_location.service_location_id}-"
return attr f"{self._sensor}-{self._sensor_id}"
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)
) )
if data:
tempdata = data.get("records") return (
if tempdata: f"{self._service_location.device_serial_number}-"
consumption = tempdata[-1] f"{self._service_location.service_location_id}-"
_LOGGER.debug("%s (%s) %s", sensor_name, sensor_id, consumption) f"{self._sensor}"
value = consumption.get(self._smappe_name) )
self._state = value
@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")

View file

@ -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."
}
}
}

View file

@ -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 import logging
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from . import DATA_SMAPPEE from .const import BASE, DOMAIN
_LOGGER = logging.getLogger(__name__) _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.""" """Set up the Smappee Comfort Plugs."""
smappee = hass.data[DATA_SMAPPEE] smappee_base = hass.data[DOMAIN][BASE]
dev = [] entities = []
if smappee.is_remote_active: for service_location in smappee_base.smappee.service_locations.values():
for location_id in smappee.locations.keys(): for actuator_id, actuator in service_location.actuators.items():
for items in smappee.info[location_id].get("actuators"): if actuator.type in ["SWITCH", "COMFORT_PLUG"]:
if items.get("name") != "": entities.append(
_LOGGER.debug("Remote actuator %s", items) SmappeeActuator(
dev.append( smappee_base,
SmappeeSwitch( service_location,
smappee, items.get("name"), location_id, items.get("id") 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: async_add_entities(entities, True)
_LOGGER.debug("Local actuator %s", items)
dev.append(
SmappeeSwitch(smappee, items.get("value"), None, items.get("key"))
)
add_entities(dev)
class SmappeeSwitch(SwitchEntity): class SmappeeActuator(SwitchEntity):
"""Representation of a Smappee Comport Plug.""" """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.""" """Initialize a new Smappee Comfort Plug."""
self._name = name self._smappee_base = smappee_base
self._state = False self._service_location = service_location
self._smappee = smappee self._actuator_name = name
self._location_id = location_id self._actuator_id = actuator_id
self._switch_id = switch_id self._actuator_type = actuator_type
self._remoteswitch = True self._actuator_serialnumber = actuator_serialnumber
if location_id is None: self._actuator_state_option = actuator_state_option
self._remoteswitch = False self._state = self._service_location.actuators.get(actuator_id).state
self._connection_state = self._service_location.actuators.get(
actuator_id
).connection_state
@property @property
def name(self): def name(self):
"""Return the name of the switch.""" """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 @property
def is_on(self): def is_on(self):
"""Return true if switch is on.""" """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 @property
def icon(self): def icon(self):
@ -65,24 +108,80 @@ class SmappeeSwitch(SwitchEntity):
def turn_on(self, **kwargs): def turn_on(self, **kwargs):
"""Turn on Comport Plug.""" """Turn on Comport Plug."""
if self._smappee.actuator_on( if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]:
self._location_id, self._switch_id, self._remoteswitch self._service_location.set_actuator_state(self._actuator_id, state="ON_ON")
): elif self._actuator_type == "INFINITY_OUTPUT_MODULE":
self._state = True self._service_location.set_actuator_state(
self._actuator_id, state=self._actuator_state_option
)
def turn_off(self, **kwargs): def turn_off(self, **kwargs):
"""Turn off Comport Plug.""" """Turn off Comport Plug."""
if self._smappee.actuator_off( if self._actuator_type in ["SWITCH", "COMFORT_PLUG"]:
self._location_id, self._switch_id, self._remoteswitch self._service_location.set_actuator_state(
): self._actuator_id, state="OFF_OFF"
self._state = False )
elif self._actuator_type == "INFINITY_OUTPUT_MODULE":
self._service_location.set_actuator_state(
self._actuator_id, state="PLACEHOLDER", api=False
)
@property @property
def device_state_attributes(self): def available(self):
"""Return the state attributes of the device.""" """Return True if entity is available. Unavailable for COMFORT_PLUGS."""
attr = {} return (
if self._remoteswitch: self._connection_state == "CONNECTED"
attr["Location Id"] = self._location_id or self._actuator_type == "COMFORT_PLUG"
attr["Location Name"] = self._smappee.locations[self._location_id] )
attr["Switch Id"] = self._switch_id
return attr @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

View file

@ -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."
}
}
}

View file

@ -135,6 +135,7 @@ FLOWS = [
"sentry", "sentry",
"shopping_list", "shopping_list",
"simplisafe", "simplisafe",
"smappee",
"smartthings", "smartthings",
"smhi", "smhi",
"solaredge", "solaredge",

View file

@ -1609,6 +1609,9 @@ pysignalclirestapi==0.3.4
# homeassistant.components.sma # homeassistant.components.sma
pysma==0.3.5 pysma==0.3.5
# homeassistant.components.smappee
pysmappee==0.1.0
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartapp==0.3.2 pysmartapp==0.3.2
@ -1964,9 +1967,6 @@ sleepyq==0.7
# homeassistant.components.xmpp # homeassistant.components.xmpp
slixmpp==1.5.1 slixmpp==1.5.1
# homeassistant.components.smappee
smappy==0.2.16
# homeassistant.components.smarthab # homeassistant.components.smarthab
smarthab==0.20 smarthab==0.20

View file

@ -702,6 +702,9 @@ pysignalclirestapi==0.3.4
# homeassistant.components.sma # homeassistant.components.sma
pysma==0.3.5 pysma==0.3.5
# homeassistant.components.smappee
pysmappee==0.1.0
# homeassistant.components.smartthings # homeassistant.components.smartthings
pysmartapp==0.3.2 pysmartapp==0.3.2

View file

@ -0,0 +1 @@
"""Tests for the Smappee integration."""

View file

@ -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