From f3c9327ccf6e03f359d78c35b220cdd54f1eb40e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 26 Feb 2019 19:18:09 +0100 Subject: [PATCH] Rewrite of Toon component (#21186) * :tractor: Rewrite of Toon component * :fire: Removed manual state from list * :shirt: Addresses code review comments * :fire: Removes a log line that should not have been left behind * :shirt: Addresses linting warnings * :shirt: Addresses Hound CI warning * :shirt: Fixes small code styling issues * :sparkles: Sets an appropriate SCAN_INTERVAL * :sparkles: Sets min/max temperature for climate platform * :shirt: Makes imports more consistent with codebase * :ambulance: Fixes incorrect SCAN_INTERVAL value in climate platform * :ambulance: Uses OrderedDict for config_flow schema * :shirt: Adds return types for min/max temp * :tractor: Refactors entities into their actual devices * :arrow_up: Updates toonapilib to 3.0.7 * :tractor: Refactors binary sensor state inversion * :ambulance: Fixes states of OpenTherm connection and Hot Tap Water * :sparkles: Adds Boiler Preheat binary sensor * :sparkles: Adds Toon Thermostat Program binary sensor * :sparkles: Adds Boiler Modulation Level sensor * :sparkles: Adds Daily Power Cost sensor * :fire: Cleanup of Toon Thermostat climate attributes * :tractor: Adjusts config_flow with Tenant selection * :raising_hand: Adds myself to codeowners file as maintainer * :arrow_up: Gen requirements * :arrow_up: Updates toonapilib to 3.0.9 * :umbrella: Adds config_flow tests --- CODEOWNERS | 1 + .../components/toon/.translations/en.json | 34 ++ homeassistant/components/toon/__init__.py | 261 +++++++++------ .../components/toon/binary_sensor.py | 127 +++++++ homeassistant/components/toon/climate.py | 127 ++++--- homeassistant/components/toon/config_flow.py | 158 +++++++++ homeassistant/components/toon/const.py | 21 ++ homeassistant/components/toon/sensor.py | 316 ++++++++---------- homeassistant/components/toon/strings.json | 34 ++ homeassistant/components/toon/switch.py | 68 ---- homeassistant/config_entries.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/toon/__init__.py | 1 + tests/components/toon/test_config_flow.py | 177 ++++++++++ 16 files changed, 943 insertions(+), 389 deletions(-) create mode 100644 homeassistant/components/toon/.translations/en.json create mode 100644 homeassistant/components/toon/binary_sensor.py create mode 100644 homeassistant/components/toon/config_flow.py create mode 100644 homeassistant/components/toon/const.py create mode 100644 homeassistant/components/toon/strings.json delete mode 100644 homeassistant/components/toon/switch.py create mode 100644 tests/components/toon/__init__.py create mode 100644 tests/components/toon/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 6fb00f382ea..9d0476e7a37 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/toon/.translations/en.json b/homeassistant/components/toon/.translations/en.json new file mode 100644 index 00000000000..80d71d4e421 --- /dev/null +++ b/homeassistant/components/toon/.translations/en.json @@ -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/)." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 96d8b4e6d15..fce0bc4ed2a 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -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'), + } diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py new file mode 100644 index 00000000000..891a72daeed --- /dev/null +++ b/homeassistant/components/toon/binary_sensor.py @@ -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 diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index c07ccf79d26..2e564b8457a 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -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 diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py new file mode 100644 index 00000000000..cdb8a0f2257 --- /dev/null +++ b/homeassistant/components/toon/config_flow.py @@ -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] + } + ) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py new file mode 100644 index 00000000000..762374eb41c --- /dev/null +++ b/homeassistant/components/toon/const.py @@ -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' diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index ebd25e02cde..2a5921b78eb 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -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 diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json new file mode 100644 index 00000000000..80d71d4e421 --- /dev/null +++ b/homeassistant/components/toon/strings.json @@ -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/)." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py deleted file mode 100644 index 08ccec588b4..00000000000 --- a/homeassistant/components/toon/switch.py +++ /dev/null @@ -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() diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 34b30cf422a..7b22c2e197c 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -172,6 +172,7 @@ FLOWS = [ 'smhi', 'sonos', 'tellduslive', + 'toon', 'tplink', 'tradfri', 'twilio', diff --git a/requirements_all.txt b/requirements_all.txt index 48a2e6990c7..e4899715228 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba283d1d2c6..0093aa87cac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 24d081349a0..1355b71b7b1 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -118,6 +118,7 @@ TEST_REQUIREMENTS = ( 'sqlalchemy', 'srpenergy', 'statsd', + 'toonapilib', 'uvcclient', 'vsure', 'warrant', diff --git a/tests/components/toon/__init__.py b/tests/components/toon/__init__.py new file mode 100644 index 00000000000..96de853baff --- /dev/null +++ b/tests/components/toon/__init__.py @@ -0,0 +1 @@ +"""Tests for the Toon component.""" diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py new file mode 100644 index 00000000000..44cb54fc98e --- /dev/null +++ b/tests/components/toon/test_config_flow.py @@ -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'