Rewrite of Toon component (#21186)

* 🚜 Rewrite of Toon component

* 🔥 Removed manual state from list

* 👕 Addresses code review comments

* 🔥 Removes a log line that should not have been left behind

* 👕 Addresses linting warnings

* 👕 Addresses Hound CI warning

* 👕 Fixes small code styling issues

*  Sets an appropriate SCAN_INTERVAL

*  Sets min/max temperature for climate platform

* 👕 Makes imports more consistent with codebase

* 🚑 Fixes incorrect SCAN_INTERVAL value in climate platform

* 🚑 Uses OrderedDict for config_flow schema

* 👕 Adds return types for min/max temp

* 🚜 Refactors entities into their actual devices

* ⬆️ Updates toonapilib to 3.0.7

* 🚜 Refactors binary sensor state inversion

* 🚑 Fixes states of OpenTherm connection and Hot Tap Water

*  Adds Boiler Preheat binary sensor

*  Adds Toon Thermostat Program binary sensor

*  Adds Boiler Modulation Level sensor

*  Adds Daily Power Cost sensor

* 🔥 Cleanup of Toon Thermostat climate attributes

* 🚜 Adjusts config_flow with Tenant selection

* 🙋 Adds myself to codeowners file as maintainer

* ⬆️ Gen requirements

* ⬆️ Updates toonapilib to 3.0.9

*  Adds config_flow tests
This commit is contained in:
Franck Nijhof 2019-02-26 19:18:09 +01:00 committed by Paulus Schoutsen
parent f0268688be
commit f3c9327ccf
16 changed files with 943 additions and 389 deletions

View File

@ -265,6 +265,7 @@ homeassistant/components/tibber/* @danielhiversen
homeassistant/components/*/tibber.py @danielhiversen
homeassistant/components/tradfri/* @ggravlingen
homeassistant/components/*/tradfri.py @ggravlingen
homeassistant/components/toon/* @frenck
# U
homeassistant/components/unifi/* @kane610

View File

@ -0,0 +1,34 @@
{
"config": {
"title": "Toon",
"step": {
"authenticate": {
"title": "Link your Toon account",
"description": "Authenticate with your Eneco Toon account (not the developer account).",
"data": {
"username": "Username",
"password": "Password",
"tenant": "Tenant"
}
},
"display": {
"title": "Select display",
"description": "Select the Toon display to connect with.",
"data": {
"display": "Choose display"
}
}
},
"error": {
"credentials": "The provided credentials are invalid.",
"display_exists": "The selected display is already configured."
},
"abort": {
"client_id": "The client ID from the configuration is invalid.",
"client_secret": "The client secret from the configuration is invalid.",
"unknown_auth_fail": "Unexpected error occured, while authenticating.",
"no_agreements": "This account has no Toon displays.",
"no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/)."
}
}
}

View File

@ -1,142 +1,195 @@
"""Support for Toon van Eneco devices."""
from datetime import datetime, timedelta
import logging
from typing import Any, Dict
import voluptuous as vol
from homeassistant.const import 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.helpers import (config_validation as cv,
device_registry as dr)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
REQUIREMENTS = ['toonlib==1.1.3']
from . import config_flow # noqa pylint_disable=unused-import
from .const import (
CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT,
DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN)
REQUIREMENTS = ['toonapilib==3.0.9']
_LOGGER = logging.getLogger(__name__)
CONF_GAS = 'gas'
CONF_SOLAR = 'solar'
DEFAULT_GAS = True
DEFAULT_SOLAR = False
DOMAIN = 'toon'
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
TOON_HANDLE = 'toon_handle'
# Validation of the user's configuration
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_GAS, default=DEFAULT_GAS): cv.boolean,
vol.Optional(CONF_SOLAR, default=DEFAULT_SOLAR): cv.boolean,
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)
def setup(hass, config):
"""Set up the Toon component."""
from toonlib import InvalidCredentials
gas = config[DOMAIN][CONF_GAS]
solar = config[DOMAIN][CONF_SOLAR]
username = config[DOMAIN][CONF_USERNAME]
password = config[DOMAIN][CONF_PASSWORD]
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up the Toon components."""
if DOMAIN not in config:
return True
try:
hass.data[TOON_HANDLE] = ToonDataStore(username, password, gas, solar)
except InvalidCredentials:
return False
conf = config[DOMAIN]
for platform in ('climate', 'sensor', 'switch'):
load_platform(hass, platform, DOMAIN, {}, config)
# Store config to be used during entry setup
hass.data[DATA_TOON_CONFIG] = conf
return True
class ToonDataStore:
"""An object to store the Toon data."""
async def async_setup_entry(hass: HomeAssistantType,
entry: ConfigType) -> bool:
"""Set up Toon from a config entry."""
from toonapilib import Toon
def __init__(
self, username, password, gas=DEFAULT_GAS, solar=DEFAULT_SOLAR):
"""Initialize Toon."""
from toonlib import Toon
conf = hass.data.get(DATA_TOON_CONFIG)
toon = Toon(username, password)
toon = Toon(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD],
conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET],
tenant_id=entry.data[CONF_TENANT],
display_common_name=entry.data[CONF_DISPLAY])
hass.data.setdefault(DATA_TOON_CLIENT, {})[entry.entry_id] = toon
# Register device for the Meter Adapter, since it will have no entities.
device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={
(DOMAIN, toon.agreement.id, 'meter_adapter'),
},
manufacturer='Eneco',
name="Meter Adapter",
via_hub=(DOMAIN, toon.agreement.id)
)
for component in 'binary_sensor', 'climate', 'sensor':
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component))
return True
class ToonEntity(Entity):
"""Defines a base Toon entity."""
def __init__(self, toon, name: str, icon: str) -> None:
"""Initialize the Toon entity."""
self._name = name
self._state = None
self._icon = icon
self.toon = toon
self.gas = gas
self.solar = solar
self.data = {}
self.last_update = datetime.min
self.update()
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Update Toon data."""
self.last_update = datetime.now()
@property
def icon(self) -> str:
"""Return the mdi icon of the entity."""
return self._icon
self.data['power_current'] = self.toon.power.value
self.data['power_today'] = round(
(float(self.toon.power.daily_usage) +
float(self.toon.power.daily_usage_low)) / 1000, 2)
self.data['temp'] = self.toon.temperature
if self.toon.thermostat_state:
self.data['state'] = self.toon.thermostat_state.name
else:
self.data['state'] = 'Manual'
class ToonDisplayDeviceEntity(ToonEntity):
"""Defines a Toon display device entity."""
self.data['setpoint'] = float(
self.toon.thermostat_info.current_set_point) / 100
self.data['gas_current'] = self.toon.gas.value
self.data['gas_today'] = round(float(self.toon.gas.daily_usage) /
1000, 2)
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this thermostat."""
agreement = self.toon.agreement
model = agreement.display_hardware_version.rpartition('/')[0]
sw_version = agreement.display_software_version.rpartition('/')[-1]
return {
'identifiers': {
(DOMAIN, agreement.id),
},
'name': 'Toon Display',
'manufacturer': 'Eneco',
'model': model,
'sw_version': sw_version,
}
for plug in self.toon.smartplugs:
self.data[plug.name] = {
'current_power': plug.current_usage,
'today_energy': round(float(plug.daily_usage) / 1000, 2),
'current_state': plug.current_state,
'is_connected': plug.is_connected,
}
self.data['solar_maximum'] = self.toon.solar.maximum
self.data['solar_produced'] = self.toon.solar.produced
self.data['solar_value'] = self.toon.solar.value
self.data['solar_average_produced'] = self.toon.solar.average_produced
self.data['solar_meter_reading_low_produced'] = \
self.toon.solar.meter_reading_low_produced
self.data['solar_meter_reading_produced'] = \
self.toon.solar.meter_reading_produced
self.data['solar_daily_cost_produced'] = \
self.toon.solar.daily_cost_produced
class ToonElectricityMeterDeviceEntity(ToonEntity):
"""Defines a Electricity Meter device entity."""
for detector in self.toon.smokedetectors:
value = '{}_smoke_detector'.format(detector.name)
self.data[value] = {
'smoke_detector': detector.battery_level,
'device_type': detector.device_type,
'is_connected': detector.is_connected,
'last_connected_change': detector.last_connected_change,
}
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this entity."""
return {
'name': 'Electricity Meter',
'identifiers': {
(DOMAIN, self.toon.agreement.id, 'electricity'),
},
'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'),
}
def set_state(self, state):
"""Push a new state to the Toon unit."""
self.toon.thermostat_state = state
def set_temp(self, temp):
"""Push a new temperature to the Toon unit."""
self.toon.thermostat = temp
class ToonGasMeterDeviceEntity(ToonEntity):
"""Defines a Gas Meter device entity."""
def get_data(self, data_id, plug_name=None):
"""Get the cached data."""
data = {'error': 'no data'}
if plug_name:
if data_id in self.data[plug_name]:
data = self.data[plug_name][data_id]
else:
if data_id in self.data:
data = self.data[data_id]
return data
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this entity."""
via_hub = 'meter_adapter'
if self.toon.gas.is_smart:
via_hub = 'electricity'
return {
'name': 'Gas Meter',
'identifiers': {
(DOMAIN, self.toon.agreement.id, 'gas'),
},
'via_hub': (DOMAIN, self.toon.agreement.id, via_hub),
}
class ToonSolarDeviceEntity(ToonEntity):
"""Defines a Solar Device device entity."""
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this entity."""
return {
'name': 'Solar Panels',
'identifiers': {
(DOMAIN, self.toon.agreement.id, 'solar'),
},
'via_hub': (DOMAIN, self.toon.agreement.id, 'meter_adapter'),
}
class ToonBoilerModuleDeviceEntity(ToonEntity):
"""Defines a Boiler Module device entity."""
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this entity."""
return {
'name': 'Boiler Module',
'manufacturer': 'Eneco',
'identifiers': {
(DOMAIN, self.toon.agreement.id, 'boiler_module'),
},
'via_hub': (DOMAIN, self.toon.agreement.id),
}
class ToonBoilerDeviceEntity(ToonEntity):
"""Defines a Boiler device entity."""
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this entity."""
return {
'name': 'Boiler',
'identifiers': {
(DOMAIN, self.toon.agreement.id, 'boiler'),
},
'via_hub': (DOMAIN, self.toon.agreement.id, 'boiler_module'),
}

View File

@ -0,0 +1,127 @@
"""Support for Toon binary sensors."""
from datetime import timedelta
import logging
from typing import Any
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import (ToonEntity, ToonDisplayDeviceEntity, ToonBoilerDeviceEntity,
ToonBoilerModuleDeviceEntity)
from .const import DATA_TOON_CLIENT, DOMAIN
DEPENDENCIES = ['toon']
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
async_add_entities) -> None:
"""Set up a Toon binary sensor based on a config entry."""
toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
sensors = [
ToonBoilerModuleBinarySensor(toon, 'thermostat_info',
'boiler_connected', None,
'Boiler Module Connection',
'mdi:check-network-outline',
'connectivity'),
ToonDisplayBinarySensor(toon, 'thermostat_info', 'active_state', 4,
"Toon Holiday Mode", 'mdi:airport', None),
ToonDisplayBinarySensor(toon, 'thermostat_info', 'next_program', None,
"Toon Program", 'mdi:calendar-clock', None),
]
if toon.thermostat_info.have_ot_boiler:
sensors.extend([
ToonBoilerBinarySensor(toon, 'thermostat_info',
'ot_communication_error', '0',
"OpenTherm Connection",
'mdi:check-network-outline',
'connectivity'),
ToonBoilerBinarySensor(toon, 'thermostat_info', 'error_found', 255,
"Boiler Status", 'mdi:alert', 'problem',
inverted=True),
ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info',
None, "Boiler Burner", 'mdi:fire', None),
ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info', '2',
"Hot Tap Water", 'mdi:water-pump', None),
ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info', '3',
"Boiler Preheating", 'mdi:fire', None),
])
async_add_entities(sensors)
class ToonBinarySensor(ToonEntity, BinarySensorDevice):
"""Defines an Toon binary sensor."""
def __init__(self, toon, section: str, measurement: str, on_value: Any,
name: str, icon: str, device_class: str,
inverted: bool = False) -> None:
"""Initialize the Toon sensor."""
self._state = inverted
self._device_class = device_class
self.section = section
self.measurement = measurement
self.on_value = on_value
self.inverted = inverted
super().__init__(toon, name, icon)
@property
def unique_id(self) -> str:
"""Return the unique ID for this binary sensor."""
return '_'.join([DOMAIN, self.toon.agreement.id, 'binary_sensor',
self.section, self.measurement, str(self.on_value)])
@property
def device_class(self) -> str:
"""Return the device class."""
return self._device_class
@property
def is_on(self) -> bool:
"""Return the status of the binary sensor."""
if self.on_value is not None:
value = self._state == self.on_value
elif self._state is None:
value = False
else:
value = bool(max(0, int(self._state)))
if self.inverted:
return not value
return value
async def async_update(self) -> None:
"""Get the latest data from the binary sensor."""
section = getattr(self.toon, self.section)
self._state = getattr(section, self.measurement)
class ToonBoilerBinarySensor(ToonBinarySensor, ToonBoilerDeviceEntity):
"""Defines a Boiler binary sensor."""
pass
class ToonDisplayBinarySensor(ToonBinarySensor, ToonDisplayDeviceEntity):
"""Defines a Toon Display binary sensor."""
pass
class ToonBoilerModuleBinarySensor(ToonBinarySensor,
ToonBoilerModuleDeviceEntity):
"""Defines a Boiler module binary sensor."""
pass

View File

@ -1,90 +1,129 @@
"""Support for Toon van Eneco Thermostats."""
"""Support for Toon thermostat."""
from datetime import timedelta
import logging
from typing import Any, Dict, List
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
STATE_COOL, STATE_ECO, STATE_HEAT, STATE_AUTO,
SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
import homeassistant.components.toon as toon_main
STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_OPERATION_MODE,
SUPPORT_TARGET_TEMPERATURE)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
from homeassistant.helpers.typing import HomeAssistantType
from . import ToonDisplayDeviceEntity
from .const import DATA_TOON_CLIENT, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN
DEPENDENCIES = ['toon']
_LOGGER = logging.getLogger(__name__)
SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
SCAN_INTERVAL = timedelta(seconds=30)
HA_TOON = {
STATE_AUTO: 'Comfort',
STATE_HEAT: 'Home',
STATE_ECO: 'Away',
STATE_COOL: 'Sleep',
}
TOON_HA = {value: key for key, value in HA_TOON.items()}
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Toon climate device."""
add_entities([ThermostatDevice(hass)], True)
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
async_add_entities) -> None:
"""Set up a Toon binary sensors based on a config entry."""
toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
async_add_entities([ToonThermostatDevice(toon)], True)
class ThermostatDevice(ClimateDevice):
class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateDevice):
"""Representation of a Toon climate device."""
def __init__(self, hass):
def __init__(self, toon) -> None:
"""Initialize the Toon climate device."""
self._name = 'Toon van Eneco'
self.hass = hass
self.thermos = hass.data[toon_main.TOON_HANDLE]
self._state = None
self._temperature = None
self._setpoint = None
self._operation_list = [
STATE_AUTO,
STATE_HEAT,
STATE_ECO,
STATE_COOL,
]
self._current_temperature = None
self._target_temperature = None
self._next_target_temperature = None
self._heating_type = None
super().__init__(toon, "Toon Thermostat", 'mdi:thermostat')
@property
def supported_features(self):
def unique_id(self) -> str:
"""Return the unique ID for this thermostat."""
return '_'.join([DOMAIN, self.toon.agreement.id, 'climate'])
@property
def supported_features(self) -> int:
"""Return the list of supported features."""
return SUPPORT_FLAGS
@property
def name(self):
"""Return the name of this thermostat."""
return self._name
@property
def temperature_unit(self):
"""Return the unit of measurement used by the platform."""
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def current_operation(self):
def current_operation(self) -> str:
"""Return current operation i.e. comfort, home, away."""
return TOON_HA.get(self.thermos.get_data('state'))
return TOON_HA.get(self._state)
@property
def operation_list(self):
def operation_list(self) -> List[str]:
"""Return a list of available operation modes."""
return self._operation_list
return list(HA_TOON.keys())
@property
def current_temperature(self):
def current_temperature(self) -> float:
"""Return the current temperature."""
return self.thermos.get_data('temp')
return self._current_temperature
@property
def target_temperature(self):
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
return self.thermos.get_data('setpoint')
return self._target_temperature
def set_temperature(self, **kwargs):
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
return DEFAULT_MIN_TEMP
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
return DEFAULT_MAX_TEMP
@property
def device_state_attributes(self) -> Dict[str, Any]:
"""Return the current state of the burner."""
return {
'heating_type': self._heating_type,
}
def set_temperature(self, **kwargs) -> None:
"""Change the setpoint of the thermostat."""
temp = kwargs.get(ATTR_TEMPERATURE)
self.thermos.set_temp(temp)
temperature = kwargs.get(ATTR_TEMPERATURE)
self.toon.thermostat = temperature
def set_operation_mode(self, operation_mode):
def set_operation_mode(self, operation_mode: str) -> None:
"""Set new operation mode."""
self.thermos.set_state(HA_TOON[operation_mode])
self.toon.thermostat_state = HA_TOON[operation_mode]
def update(self):
async def async_update(self) -> None:
"""Update local state."""
self.thermos.update()
if self.toon.thermostat_state is None:
self._state = None
else:
self._state = self.toon.thermostat_state.name
self._current_temperature = self.toon.temperature
self._target_temperature = self.toon.thermostat
self._heating_type = self.toon.agreement.heating_type

View File

@ -0,0 +1,158 @@
"""Config flow to configure the Toon component."""
from collections import OrderedDict
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from .const import (
CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT,
DATA_TOON_CONFIG, DOMAIN)
_LOGGER = logging.getLogger(__name__)
@callback
def configured_displays(hass):
"""Return a set of configured Toon displays."""
return set(
entry.data[CONF_DISPLAY]
for entry in hass.config_entries.async_entries(DOMAIN)
)
@config_entries.HANDLERS.register(DOMAIN)
class ToonFlowHandler(config_entries.ConfigFlow):
"""Handle a Toon config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize the Toon flow."""
self.displays = None
self.username = None
self.password = None
self.tenant = None
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
app = self.hass.data.get(DATA_TOON_CONFIG, {})
if not app:
return self.async_abort(reason='no_app')
return await self.async_step_authenticate(user_input)
async def _show_authenticaticate_form(self, errors=None):
"""Show the authentication form to the user."""
fields = OrderedDict()
fields[vol.Required(CONF_USERNAME)] = str
fields[vol.Required(CONF_PASSWORD)] = str
fields[vol.Optional(CONF_TENANT)] = vol.In([
'eneco', 'electrabel', 'viesgo'
])
return self.async_show_form(
step_id='authenticate',
data_schema=vol.Schema(fields),
errors=errors if errors else {},
)
async def async_step_authenticate(self, user_input=None):
"""Attempt to authenticate with the Toon account."""
from toonapilib import Toon
from toonapilib.toonapilibexceptions import (InvalidConsumerSecret,
InvalidConsumerKey,
InvalidCredentials,
AgreementsRetrievalError)
if user_input is None:
return await self._show_authenticaticate_form()
app = self.hass.data.get(DATA_TOON_CONFIG, {})
try:
toon = Toon(user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
app[CONF_CLIENT_ID],
app[CONF_CLIENT_SECRET],
tenant_id=user_input[CONF_TENANT])
displays = toon.display_names
except InvalidConsumerKey:
return self.async_abort(reason='client_id')
except InvalidConsumerSecret:
return self.async_abort(reason='client_secret')
except InvalidCredentials:
return await self._show_authenticaticate_form({
'base': 'credentials'
})
except AgreementsRetrievalError:
return self.async_abort(reason='no_agreements')
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error while authenticating")
return self.async_abort(reason='unknown_auth_fail')
self.displays = displays
self.username = user_input[CONF_USERNAME]
self.password = user_input[CONF_PASSWORD]
self.tenant = user_input[CONF_TENANT]
return await self.async_step_display()
async def _show_display_form(self, errors=None):
"""Show the select display form to the user."""
fields = OrderedDict()
fields[vol.Required(CONF_DISPLAY)] = vol.In(self.displays)
return self.async_show_form(
step_id='display',
data_schema=vol.Schema(fields),
errors=errors if errors else {},
)
async def async_step_display(self, user_input=None):
"""Select Toon display to add."""
from toonapilib import Toon
if not self.displays:
return self.async_abort(reason='no_displays')
if user_input is None:
return await self._show_display_form()
if user_input[CONF_DISPLAY] in configured_displays(self.hass):
return await self._show_display_form({
'base': 'display_exists'
})
app = self.hass.data.get(DATA_TOON_CONFIG, {})
try:
Toon(self.username,
self.password,
app[CONF_CLIENT_ID],
app[CONF_CLIENT_SECRET],
tenant_id=self.tenant,
display_common_name=user_input[CONF_DISPLAY])
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected error while authenticating")
return self.async_abort(reason='unknown_auth_fail')
return self.async_create_entry(
title=user_input[CONF_DISPLAY],
data={
CONF_USERNAME: self.username,
CONF_PASSWORD: self.password,
CONF_TENANT: self.tenant,
CONF_DISPLAY: user_input[CONF_DISPLAY]
}
)

View File

@ -0,0 +1,21 @@
"""Constants for the Toon integration."""
DOMAIN = 'toon'
DATA_TOON = 'toon'
DATA_TOON_CONFIG = 'toon_config'
DATA_TOON_CLIENT = 'toon_client'
CONF_CLIENT_ID = 'client_id'
CONF_CLIENT_SECRET = 'client_secret'
CONF_DISPLAY = 'display'
CONF_TENANT = 'tenant'
DEFAULT_MAX_TEMP = 30.0
DEFAULT_MIN_TEMP = 6.0
CURRENCY_EUR = 'EUR'
POWER_WATT = 'Watt'
POWER_KWH = 'kWh'
RATIO_PERCENT = '%'
VOLUME_CM3 = 'CM3'
VOLUME_M3 = 'M3'

View File

@ -1,217 +1,189 @@
"""Support for rebranded Quby thermostat as provided by Eneco."""
"""Support for Toon sensors."""
from datetime import timedelta
import logging
import datetime
from homeassistant.helpers.entity import Entity
import homeassistant.components.toon as toon_main
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from . import (ToonEntity, ToonElectricityMeterDeviceEntity,
ToonGasMeterDeviceEntity, ToonSolarDeviceEntity,
ToonBoilerDeviceEntity)
from .const import (CURRENCY_EUR, DATA_TOON_CLIENT, DOMAIN, POWER_KWH,
POWER_WATT, VOLUME_CM3, VOLUME_M3, RATIO_PERCENT)
DEPENDENCIES = ['toon']
_LOGGER = logging.getLogger(__name__)
STATE_ATTR_DEVICE_TYPE = 'device_type'
STATE_ATTR_LAST_CONNECTED_CHANGE = 'last_connected_change'
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
SCAN_INTERVAL = timedelta(seconds=30)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Toon sensors."""
_toon_main = hass.data[toon_main.TOON_HANDLE]
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
async_add_entities) -> None:
"""Set up Toon sensors based on a config entry."""
toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
sensor_items = []
sensor_items.extend([
ToonSensor(hass, 'Power_current', 'power-plug', 'Watt'),
ToonSensor(hass, 'Power_today', 'power-plug', 'kWh'),
])
sensors = [
ToonElectricityMeterDeviceSensor(toon, 'power', 'value',
"Current Power Usage",
'mdi:power-plug', POWER_WATT),
ToonElectricityMeterDeviceSensor(toon, 'power', 'average',
"Average Power Usage",
'mdi:power-plug', POWER_WATT),
ToonElectricityMeterDeviceSensor(toon, 'power', 'daily_value',
"Power Usage Today",
'mdi:power-plug', POWER_KWH),
ToonElectricityMeterDeviceSensor(toon, 'power', 'daily_cost',
"Power Cost Today",
'mdi:power-plug', CURRENCY_EUR),
ToonElectricityMeterDeviceSensor(toon, 'power', 'average_daily',
"Average Daily Power Usage",
'mdi:power-plug', POWER_KWH),
ToonElectricityMeterDeviceSensor(toon, 'power', 'meter_reading',
"Power Meter Feed IN Tariff 1",
'mdi:power-plug', POWER_KWH),
ToonElectricityMeterDeviceSensor(toon, 'power', 'meter_reading_low',
"Power Meter Feed IN Tariff 2",
'mdi:power-plug', POWER_KWH),
]
if _toon_main.gas:
sensor_items.extend([
ToonSensor(hass, 'Gas_current', 'gas-cylinder', 'CM3'),
ToonSensor(hass, 'Gas_today', 'gas-cylinder', 'M3'),
if toon.gas:
sensors.extend([
ToonGasMeterDeviceSensor(toon, 'gas', 'value', "Current Gas Usage",
'mdi:gas-cylinder', VOLUME_CM3),
ToonGasMeterDeviceSensor(toon, 'gas', 'average',
"Average Gas Usage", 'mdi:gas-cylinder',
VOLUME_CM3),
ToonGasMeterDeviceSensor(toon, 'gas', 'daily_usage',
"Gas Usage Today", 'mdi:gas-cylinder',
VOLUME_M3),
ToonGasMeterDeviceSensor(toon, 'gas', 'average_daily',
"Average Daily Gas Usage",
'mdi:gas-cylinder', VOLUME_M3),
ToonGasMeterDeviceSensor(toon, 'gas', 'meter_reading', "Gas Meter",
'mdi:gas-cylinder', VOLUME_M3),
ToonGasMeterDeviceSensor(toon, 'gas', 'daily_cost',
"Gas Cost Today", 'mdi:gas-cylinder',
CURRENCY_EUR),
])
for plug in _toon_main.toon.smartplugs:
sensor_items.extend([
FibaroSensor(hass, '{}_current_power'.format(plug.name),
plug.name, 'power-socket-eu', 'Watt'),
FibaroSensor(hass, '{}_today_energy'.format(plug.name),
plug.name, 'power-socket-eu', 'kWh'),
if toon.solar:
sensors.extend([
ToonSolarDeviceSensor(toon, 'solar', 'value',
"Current Solar Production",
'mdi:solar-power', POWER_WATT),
ToonSolarDeviceSensor(toon, 'solar', 'maximum',
"Max Solar Production", 'mdi:solar-power',
POWER_WATT),
ToonSolarDeviceSensor(toon, 'solar', 'produced',
"Solar Production to Grid",
'mdi:solar-power', POWER_WATT),
ToonSolarDeviceSensor(toon, 'solar', 'average_produced',
"Average Solar Production to Grid",
'mdi:solar-power', POWER_WATT),
ToonElectricityMeterDeviceSensor(toon, 'solar',
'meter_reading_produced',
"Power Meter Feed OUT Tariff 1",
'mdi:solar-power',
POWER_KWH),
ToonElectricityMeterDeviceSensor(toon, 'solar',
'meter_reading_low_produced',
"Power Meter Feed OUT Tariff 2",
'mdi:solar-power', POWER_KWH),
])
if _toon_main.toon.solar.produced or _toon_main.solar:
sensor_items.extend([
SolarSensor(hass, 'Solar_maximum', 'kWh'),
SolarSensor(hass, 'Solar_produced', 'kWh'),
SolarSensor(hass, 'Solar_value', 'Watt'),
SolarSensor(hass, 'Solar_average_produced', 'kWh'),
SolarSensor(hass, 'Solar_meter_reading_low_produced', 'kWh'),
SolarSensor(hass, 'Solar_meter_reading_produced', 'kWh'),
SolarSensor(hass, 'Solar_daily_cost_produced', 'Euro'),
if toon.thermostat_info.have_ot_boiler:
sensors.extend([
ToonBoilerDeviceSensor(toon, 'thermostat_info',
'current_modulation_level',
"Boiler Modulation Level",
'mdi:percent',
RATIO_PERCENT),
])
for smokedetector in _toon_main.toon.smokedetectors:
sensor_items.append(
FibaroSmokeDetector(
hass, '{}_smoke_detector'.format(smokedetector.name),
smokedetector.device_uuid, 'alarm-bell', '%')
)
add_entities(sensor_items)
async_add_entities(sensors)
class ToonSensor(Entity):
"""Representation of a Toon sensor."""
class ToonSensor(ToonEntity):
"""Defines a Toon sensor."""
def __init__(self, hass, name, icon, unit_of_measurement):
def __init__(self, toon, section: str, measurement: str,
name: str, icon: str, unit_of_measurement: str) -> None:
"""Initialize the Toon sensor."""
self._name = name
self._state = None
self._icon = 'mdi:{}'.format(icon)
self._unit_of_measurement = unit_of_measurement
self.thermos = hass.data[toon_main.TOON_HANDLE]
self.section = section
self.measurement = measurement
super().__init__(toon, name, icon)
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def icon(self):
"""Return the mdi icon of the sensor."""
return self._icon
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return '_'.join([DOMAIN, self.toon.agreement.id, 'sensor',
self.section, self.measurement])
@property
def state(self):
"""Return the state of the sensor."""
return self.thermos.get_data(self.name.lower())
return self._state
@property
def unit_of_measurement(self):
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
def update(self):
async def async_update(self) -> None:
"""Get the latest data from the sensor."""
self.thermos.update()
section = getattr(self.toon, self.section)
value = None
if self.section == 'power' and self.measurement == 'daily_value':
value = round((float(section.daily_usage)
+ float(section.daily_usage_low)) / 1000.0, 2)
if value is None:
value = getattr(section, self.measurement)
if self.section == 'power' and \
self.measurement in ['meter_reading', 'meter_reading_low',
'average_daily']:
value = round(float(value)/1000.0, 2)
if self.section == 'solar' and \
self.measurement in ['meter_reading_produced',
'meter_reading_low_produced']:
value = float(value)/1000.0
if self.section == 'gas' and \
self.measurement in ['average_daily', 'daily_usage',
'meter_reading']:
value = round(float(value)/1000.0, 2)
self._state = max(0, value)
class FibaroSensor(Entity):
"""Representation of a Fibaro sensor."""
class ToonElectricityMeterDeviceSensor(ToonSensor,
ToonElectricityMeterDeviceEntity):
"""Defines a Eletricity Meter sensor."""
def __init__(self, hass, name, plug_name, icon, unit_of_measurement):
"""Initialize the Fibaro sensor."""
self._name = name
self._plug_name = plug_name
self._state = None
self._icon = 'mdi:{}'.format(icon)
self._unit_of_measurement = unit_of_measurement
self.toon = hass.data[toon_main.TOON_HANDLE]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def icon(self):
"""Return the mdi icon of the sensor."""
return self._icon
@property
def state(self):
"""Return the state of the sensor."""
value = '_'.join(self.name.lower().split('_')[1:])
return self.toon.get_data(value, self._plug_name)
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
def update(self):
"""Get the latest data from the sensor."""
self.toon.update()
pass
class SolarSensor(Entity):
"""Representation of a Solar sensor."""
class ToonGasMeterDeviceSensor(ToonSensor, ToonGasMeterDeviceEntity):
"""Defines a Gas Meter sensor."""
def __init__(self, hass, name, unit_of_measurement):
"""Initialize the Solar sensor."""
self._name = name
self._state = None
self._icon = 'mdi:weather-sunny'
self._unit_of_measurement = unit_of_measurement
self.toon = hass.data[toon_main.TOON_HANDLE]
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def icon(self):
"""Return the mdi icon of the sensor."""
return self._icon
@property
def state(self):
"""Return the state of the sensor."""
return self.toon.get_data(self.name.lower())
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
def update(self):
"""Get the latest data from the sensor."""
self.toon.update()
pass
class FibaroSmokeDetector(Entity):
"""Representation of a Fibaro smoke detector."""
class ToonSolarDeviceSensor(ToonSensor, ToonSolarDeviceEntity):
"""Defines a Solar sensor."""
def __init__(self, hass, name, uid, icon, unit_of_measurement):
"""Initialize the Fibaro smoke sensor."""
self._name = name
self._uid = uid
self._state = None
self._icon = 'mdi:{}'.format(icon)
self._unit_of_measurement = unit_of_measurement
self.toon = hass.data[toon_main.TOON_HANDLE]
pass
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def icon(self):
"""Return the mdi icon of the sensor."""
return self._icon
class ToonBoilerDeviceSensor(ToonSensor, ToonBoilerDeviceEntity):
"""Defines a Boiler sensor."""
@property
def state_attributes(self):
"""Return the state attributes of the smoke detectors."""
value = datetime.datetime.fromtimestamp(
int(self.toon.get_data('last_connected_change', self.name))
).strftime('%Y-%m-%d %H:%M:%S')
return {
STATE_ATTR_DEVICE_TYPE:
self.toon.get_data('device_type', self.name),
STATE_ATTR_LAST_CONNECTED_CHANGE: value,
}
@property
def state(self):
"""Return the state of the sensor."""
value = self.name.lower().split('_', 1)[1]
return self.toon.get_data(value, self.name)
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
def update(self):
"""Get the latest data from the sensor."""
self.toon.update()
pass

View File

@ -0,0 +1,34 @@
{
"config": {
"title": "Toon",
"step": {
"authenticate": {
"title": "Link your Toon account",
"description": "Authenticate with your Eneco Toon account (not the developer account).",
"data": {
"username": "Username",
"password": "Password",
"tenant": "Tenant"
}
},
"display": {
"title": "Select display",
"description": "Select the Toon display to connect with.",
"data": {
"display": "Choose display"
}
}
},
"error": {
"credentials": "The provided credentials are invalid.",
"display_exists": "The selected display is already configured."
},
"abort": {
"client_id": "The client ID from the configuration is invalid.",
"client_secret": "The client secret from the configuration is invalid.",
"unknown_auth_fail": "Unexpected error occured, while authenticating.",
"no_agreements": "This account has no Toon displays.",
"no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/)."
}
}
}

View File

@ -1,68 +0,0 @@
"""Support for Eneco Slimmer stekkers (Smart Plugs)."""
import logging
from homeassistant.components.switch import SwitchDevice
import homeassistant.components.toon as toon_main
_LOGGER = logging.getLogger(__name__)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the discovered Toon Smart Plugs."""
_toon_main = hass.data[toon_main.TOON_HANDLE]
switch_items = []
for plug in _toon_main.toon.smartplugs:
switch_items.append(EnecoSmartPlug(hass, plug))
add_entities(switch_items)
class EnecoSmartPlug(SwitchDevice):
"""Representation of a Toon Smart Plug."""
def __init__(self, hass, plug):
"""Initialize the Smart Plug."""
self.smartplug = plug
self.toon_data_store = hass.data[toon_main.TOON_HANDLE]
@property
def unique_id(self):
"""Return the ID of this switch."""
return self.smartplug.device_uuid
@property
def name(self):
"""Return the name of the switch if any."""
return self.smartplug.name
@property
def current_power_w(self):
"""Return the current power usage in W."""
return self.toon_data_store.get_data('current_power', self.name)
@property
def today_energy_kwh(self):
"""Return the today total energy usage in kWh."""
return self.toon_data_store.get_data('today_energy', self.name)
@property
def is_on(self):
"""Return true if switch is on. Standby is on."""
return self.toon_data_store.get_data('current_state', self.name)
@property
def available(self):
"""Return true if switch is available."""
return self.smartplug.can_toggle
def turn_on(self, **kwargs):
"""Turn the switch on."""
return self.smartplug.turn_on()
def turn_off(self, **kwargs):
"""Turn the switch off."""
return self.smartplug.turn_off()
def update(self):
"""Update state."""
self.toon_data_store.update()

View File

@ -172,6 +172,7 @@ FLOWS = [
'smhi',
'sonos',
'tellduslive',
'toon',
'tplink',
'tradfri',
'twilio',

View File

@ -1685,7 +1685,7 @@ tikteck==0.4
todoist-python==7.0.17
# homeassistant.components.toon
toonlib==1.1.3
toonapilib==3.0.9
# homeassistant.components.alarm_control_panel.totalconnect
total_connect_client==0.22

View File

@ -290,6 +290,9 @@ srpenergy==1.0.5
# homeassistant.components.statsd
statsd==3.2.1
# homeassistant.components.toon
toonapilib==3.0.9
# homeassistant.components.camera.uvc
uvcclient==0.11.0

View File

@ -118,6 +118,7 @@ TEST_REQUIREMENTS = (
'sqlalchemy',
'srpenergy',
'statsd',
'toonapilib',
'uvcclient',
'vsure',
'warrant',

View File

@ -0,0 +1 @@
"""Tests for the Toon component."""

View File

@ -0,0 +1,177 @@
"""Tests for the Toon config flow."""
from unittest.mock import patch
import pytest
from toonapilib.toonapilibexceptions import (
AgreementsRetrievalError, InvalidConsumerKey, InvalidConsumerSecret,
InvalidCredentials)
from homeassistant import data_entry_flow
from homeassistant.components.toon import config_flow
from homeassistant.components.toon.const import (
CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DOMAIN)
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, MockDependency
FIXTURE_APP = {
DOMAIN: {
CONF_CLIENT_ID: '1234567890abcdef',
CONF_CLIENT_SECRET: '1234567890abcdef',
}
}
FIXTURE_CREDENTIALS = {
CONF_USERNAME: 'john.doe',
CONF_PASSWORD: 'secret',
CONF_TENANT: 'eneco'
}
FIXTURE_DISPLAY = {
CONF_DISPLAY: 'display1'
}
@pytest.fixture
def mock_toonapilib():
"""Mock toonapilib."""
with MockDependency('toonapilib') as mock_toonapilib_:
mock_toonapilib_.Toon().display_names = [FIXTURE_DISPLAY[CONF_DISPLAY]]
yield mock_toonapilib_
async def setup_component(hass):
"""Set up Toon component."""
with patch('os.path.isfile', return_value=False):
assert await async_setup_component(hass, DOMAIN, FIXTURE_APP)
await hass.async_block_till_done()
async def test_abort_if_no_app_configured(hass):
"""Test abort if no app is configured."""
flow = config_flow.ToonFlowHandler()
flow.hass = hass
result = await flow.async_step_user()
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'no_app'
async def test_show_authenticate_form(hass):
"""Test that the authentication form is served."""
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
result = await flow.async_step_user(user_input=None)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'authenticate'
@pytest.mark.parametrize('side_effect,reason',
[(InvalidConsumerKey, 'client_id'),
(InvalidConsumerSecret, 'client_secret'),
(AgreementsRetrievalError, 'no_agreements'),
(Exception, 'unknown_auth_fail')])
async def test_toon_abort(hass, mock_toonapilib, side_effect, reason):
"""Test we abort on Toon error."""
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
mock_toonapilib.Toon.side_effect = side_effect
result = await flow.async_step_authenticate(user_input=FIXTURE_CREDENTIALS)
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == reason
async def test_invalid_credentials(hass, mock_toonapilib):
"""Test we show authentication form on Toon auth error."""
mock_toonapilib.Toon.side_effect = InvalidCredentials
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'authenticate'
assert result['errors'] == {'base': 'credentials'}
async def test_full_flow_implementation(hass, mock_toonapilib):
"""Test registering an integration and finishing flow works."""
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
result = await flow.async_step_user(user_input=None)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'authenticate'
result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'display'
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result['title'] == FIXTURE_DISPLAY[CONF_DISPLAY]
assert result['data'][CONF_USERNAME] == FIXTURE_CREDENTIALS[CONF_USERNAME]
assert result['data'][CONF_PASSWORD] == FIXTURE_CREDENTIALS[CONF_PASSWORD]
assert result['data'][CONF_TENANT] == FIXTURE_CREDENTIALS[CONF_TENANT]
assert result['data'][CONF_DISPLAY] == FIXTURE_DISPLAY[CONF_DISPLAY]
async def test_no_displays(hass, mock_toonapilib):
"""Test abort when there are no displays."""
await setup_component(hass)
mock_toonapilib.Toon().display_names = []
flow = config_flow.ToonFlowHandler()
flow.hass = hass
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
result = await flow.async_step_display(user_input=None)
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'no_displays'
async def test_display_already_exists(hass, mock_toonapilib):
"""Test showing display form again if display already exists."""
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
MockConfigEntry(domain=DOMAIN, data=FIXTURE_DISPLAY).add_to_hass(hass)
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
assert result['step_id'] == 'display'
assert result['errors'] == {'base': 'display_exists'}
async def test_abort_last_minute_fail(hass, mock_toonapilib):
"""Test we abort when API communication fails in the last step."""
await setup_component(hass)
flow = config_flow.ToonFlowHandler()
flow.hass = hass
await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
mock_toonapilib.Toon.side_effect = Exception
result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'unknown_auth_fail'