From 92e19f6001566d098b5ed4c570950aad2420e093 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 10 Dec 2018 18:44:45 +0100 Subject: [PATCH] TelldusLive config flow (#18758) * update TelldusLive to use config flow * fixes from Martin * Update homeassistant/components/tellduslive/config_flow.py Co-Authored-By: fredrike * revert changes in entry.py * tox tests * tox fixes * woof woof (fix for hound) * lint ignore * unload entry * coverall toxtests * fix some toxtests --- .../components/binary_sensor/tellduslive.py | 31 +- homeassistant/components/cover/tellduslive.py | 28 +- homeassistant/components/discovery.py | 2 +- homeassistant/components/light/tellduslive.py | 29 +- .../components/sensor/tellduslive.py | 28 +- .../components/switch/tellduslive.py | 30 +- .../tellduslive/.translations/en.json | 24 ++ .../components/tellduslive/__init__.py | 320 +++++++----------- .../components/tellduslive/config_flow.py | 150 ++++++++ homeassistant/components/tellduslive/const.py | 29 ++ .../components/tellduslive/strings.json | 24 ++ homeassistant/config_entries.py | 1 + tests/components/tellduslive/__init__.py | 1 + .../tellduslive/test_config_flow.py | 223 ++++++++++++ 14 files changed, 685 insertions(+), 235 deletions(-) create mode 100644 homeassistant/components/tellduslive/.translations/en.json create mode 100644 homeassistant/components/tellduslive/config_flow.py create mode 100644 homeassistant/components/tellduslive/strings.json create mode 100644 tests/components/tellduslive/__init__.py create mode 100644 tests/components/tellduslive/test_config_flow.py diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py index 7f60e40c68b..f6ed85db132 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -9,22 +9,35 @@ https://home-assistant.io/components/binary_sensor.tellduslive/ """ import logging -from homeassistant.components import tellduslive +from homeassistant.components import binary_sensor, tellduslive from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.tellduslive.entry import TelldusLiveEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tellstick sensors.""" - if discovery_info is None: - return - client = hass.data[tellduslive.DOMAIN] - add_entities( - TelldusLiveSensor(client, binary_sensor) - for binary_sensor in discovery_info - ) + """Old way of setting up TelldusLive. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tellduslive sensors dynamically.""" + async def async_discover_binary_sensor(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[tellduslive.DOMAIN] + async_add_entities([TelldusLiveSensor(client, device_id)]) + + async_dispatcher_connect( + hass, + tellduslive.TELLDUS_DISCOVERY_NEW.format(binary_sensor.DOMAIN, + tellduslive.DOMAIN), + async_discover_binary_sensor) class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice): diff --git a/homeassistant/components/cover/tellduslive.py b/homeassistant/components/cover/tellduslive.py index 67affdae04e..1879c88c83c 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -8,20 +8,36 @@ https://home-assistant.io/components/cover.tellduslive/ """ import logging -from homeassistant.components import tellduslive +from homeassistant.components import cover, tellduslive from homeassistant.components.cover import CoverDevice from homeassistant.components.tellduslive.entry import TelldusLiveEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Telldus Live covers.""" - if discovery_info is None: - return + """Old way of setting up TelldusLive. - client = hass.data[tellduslive.DOMAIN] - add_entities(TelldusLiveCover(client, cover) for cover in discovery_info) + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tellduslive sensors dynamically.""" + async def async_discover_cover(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[tellduslive.DOMAIN] + async_add_entities([TelldusLiveCover(client, device_id)]) + + async_dispatcher_connect( + hass, + tellduslive.TELLDUS_DISCOVERY_NEW.format(cover.DOMAIN, + tellduslive.DOMAIN), + async_discover_cover, + ) class TelldusLiveCover(TelldusLiveEntity, CoverDevice): diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index bbf40c73070..00805bd76b8 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -49,6 +49,7 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DECONZ: 'deconz', 'google_cast': 'cast', SERVICE_HUE: 'hue', + SERVICE_TELLDUSLIVE: 'tellduslive', SERVICE_IKEA_TRADFRI: 'tradfri', 'sonos': 'sonos', } @@ -62,7 +63,6 @@ SERVICE_HANDLERS = { SERVICE_APPLE_TV: ('apple_tv', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), - SERVICE_TELLDUSLIVE: ('tellduslive', None), SERVICE_DAIKIN: ('daikin', None), SERVICE_SABNZBD: ('sabnzbd', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), diff --git a/homeassistant/components/light/tellduslive.py b/homeassistant/components/light/tellduslive.py index 8601fe3cf1f..3f14b34ea78 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -8,20 +8,37 @@ https://home-assistant.io/components/light.tellduslive/ """ import logging -from homeassistant.components import tellduslive +from homeassistant.components import light, tellduslive from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) from homeassistant.components.tellduslive.entry import TelldusLiveEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Tellstick Net lights.""" - if discovery_info is None: - return - client = hass.data[tellduslive.DOMAIN] - add_entities(TelldusLiveLight(client, light) for light in discovery_info) + """Old way of setting up TelldusLive. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tellduslive sensors dynamically.""" + async def async_discover_light(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[tellduslive.DOMAIN] + async_add_entities([TelldusLiveLight(client, device_id)]) + + async_dispatcher_connect( + hass, + tellduslive.TELLDUS_DISCOVERY_NEW.format(light.DOMAIN, + tellduslive.DOMAIN), + async_discover_light, + ) class TelldusLiveLight(TelldusLiveEntity, Light): diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 4afff115b9d..7d67dcfb38f 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -6,11 +6,12 @@ https://home-assistant.io/components/sensor.tellduslive/ """ import logging -from homeassistant.components import tellduslive +from homeassistant.components import sensor, tellduslive from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -46,12 +47,25 @@ SENSOR_TYPES = { def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Tellstick sensors.""" - if discovery_info is None: - return - client = hass.data[tellduslive.DOMAIN] - add_entities( - TelldusLiveSensor(client, sensor) for sensor in discovery_info) + """Old way of setting up TelldusLive. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tellduslive sensors dynamically.""" + async def async_discover_sensor(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[tellduslive.DOMAIN] + async_add_entities([TelldusLiveSensor(client, device_id)]) + + async_dispatcher_connect( + hass, + tellduslive.TELLDUS_DISCOVERY_NEW.format( + sensor.DOMAIN, tellduslive.DOMAIN), async_discover_sensor) class TelldusLiveSensor(TelldusLiveEntity): diff --git a/homeassistant/components/switch/tellduslive.py b/homeassistant/components/switch/tellduslive.py index ed4f825f5ac..5c04e872623 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -9,20 +9,36 @@ https://home-assistant.io/components/switch.tellduslive/ """ import logging -from homeassistant.components import tellduslive +from homeassistant.components import switch, tellduslive from homeassistant.components.tellduslive.entry import TelldusLiveEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up Tellstick switches.""" - if discovery_info is None: - return - client = hass.data[tellduslive.DOMAIN] - add_entities( - TelldusLiveSwitch(client, switch) for switch in discovery_info) + """Old way of setting up TelldusLive. + + Can only be called when a user accidentally mentions the platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up tellduslive sensors dynamically.""" + async def async_discover_switch(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[tellduslive.DOMAIN] + async_add_entities([TelldusLiveSwitch(client, device_id)]) + + async_dispatcher_connect( + hass, + tellduslive.TELLDUS_DISCOVERY_NEW.format(switch.DOMAIN, + tellduslive.DOMAIN), + async_discover_switch, + ) class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): diff --git a/homeassistant/components/tellduslive/.translations/en.json b/homeassistant/components/tellduslive/.translations/en.json new file mode 100644 index 00000000000..ef7db098419 --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Telldus Live", + "step": { + "user": { + "title": "Pick endpoint.", + "description": "", + "data": { + "host": "Host" + } + }, + "auth": { + "title": "Authenticate against TelldusLive", + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})" + } + }, + "abort": { + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "all_configured": "TelldusLive is already configured", + "unknown": "Unknown error occurred" + } + } + } diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index 89e74464489..b17c2cb3c46 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -4,20 +4,22 @@ Support for Telldus Live. For more details about this component, please refer to the documentation at https://home-assistant.io/components/tellduslive/ """ +import asyncio from datetime import timedelta import logging import voluptuous as vol -from homeassistant.components.discovery import SERVICE_TELLDUSLIVE -from homeassistant.const import CONF_HOST, CONF_TOKEN -from homeassistant.helpers import discovery +from homeassistant import config_entries import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import track_time_interval -from homeassistant.util.json import load_json, save_json +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, SIGNAL_UPDATE_ENTITY +from . import config_flow # noqa pylint_disable=unused-import +from .const import ( + CONF_HOST, CONF_UPDATE_INTERVAL, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, + KEY_SESSION, MIN_UPDATE_INTERVAL, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, + SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, TELLDUS_DISCOVERY_NEW) APPLICATION_NAME = 'Home Assistant' @@ -25,229 +27,149 @@ REQUIREMENTS = ['tellduslive==0.10.4'] _LOGGER = logging.getLogger(__name__) -TELLLDUS_CONFIG_FILE = 'tellduslive.conf' -KEY_CONFIG = 'tellduslive_config' +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Optional(CONF_HOST, default=DOMAIN): + cv.string, + vol.Optional(CONF_UPDATE_INTERVAL, default=SCAN_INTERVAL): + (vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))) + }), + }, + extra=vol.ALLOW_EXTRA, +) -CONF_TOKEN_SECRET = 'token_secret' -CONF_UPDATE_INTERVAL = 'update_interval' +DATA_CONFIG_ENTRY_LOCK = 'tellduslive_config_entry_lock' +CONFIG_ENTRY_IS_SETUP = 'telldus_config_entry_is_setup' -PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA' -NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS' - -MIN_UPDATE_INTERVAL = timedelta(seconds=5) -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): ( - vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))) - }), -}, extra=vol.ALLOW_EXTRA) - -CONFIG_INSTRUCTIONS = """ -To link your TelldusLive account: - -1. Click the link below - -2. Login to Telldus Live - -3. Authorize {app_name}. - -4. Click the Confirm button. - -[Link TelldusLive account]({auth_url}) -""" +INTERVAL_TRACKER = '{}_INTERVAL'.format(DOMAIN) -def setup(hass, config, session=None): - """Set up the Telldus Live component.""" - from tellduslive import Session, supports_local_api - config_filename = hass.config.path(TELLLDUS_CONFIG_FILE) - conf = load_json(config_filename) +async def async_setup_entry(hass, entry): + """Create a tellduslive session.""" + from tellduslive import Session + conf = entry.data[KEY_SESSION] - def request_configuration(host=None): - """Request TelldusLive authorization.""" - configurator = hass.components.configurator - hass.data.setdefault(KEY_CONFIG, {}) - data_key = host or DOMAIN - - # Configuration already in progress - if hass.data[KEY_CONFIG].get(data_key): - return - - _LOGGER.info('Configuring TelldusLive %s', - 'local client: {}'.format(host) if host else - 'cloud service') - - session = Session(public_key=PUBLIC_KEY, - private_key=NOT_SO_PRIVATE_KEY, - host=host, - application=APPLICATION_NAME) - - auth_url = session.authorize_url - if not auth_url: - _LOGGER.warning('Failed to retrieve authorization URL') - return - - _LOGGER.debug('Got authorization URL %s', auth_url) - - def configuration_callback(callback_data): - """Handle the submitted configuration.""" - session.authorize() - res = setup(hass, config, session) - if not res: - configurator.notify_errors( - hass.data[KEY_CONFIG].get(data_key), - 'Unable to connect.') - return - - conf.update( - {host: {CONF_HOST: host, - CONF_TOKEN: session.access_token}} if host else - {DOMAIN: {CONF_TOKEN: session.access_token, - CONF_TOKEN_SECRET: session.access_token_secret}}) - save_json(config_filename, conf) - # Close all open configurators: for now, we only support one - # tellstick device, and configuration via either cloud service - # or via local API, not both at the same time - for instance in hass.data[KEY_CONFIG].values(): - configurator.request_done(instance) - - hass.data[KEY_CONFIG][data_key] = \ - configurator.request_config( - 'TelldusLive ({})'.format( - 'LocalAPI' if host - else 'Cloud service'), - configuration_callback, - description=CONFIG_INSTRUCTIONS.format( - app_name=APPLICATION_NAME, - auth_url=auth_url), - submit_caption='Confirm', - entity_picture='/static/images/logo_tellduslive.png', - ) - - def tellstick_discovered(service, info): - """Run when a Tellstick is discovered.""" - _LOGGER.info('Discovered tellstick device') - - if DOMAIN in hass.data: - _LOGGER.debug('Tellstick already configured') - return - - host, device = info[:2] - - if not supports_local_api(device): - _LOGGER.debug('Tellstick does not support local API') - # Configure the cloud service - hass.add_job(request_configuration) - return - - _LOGGER.debug('Tellstick does support local API') - - # Ignore any known devices - if conf and host in conf: - _LOGGER.debug('Discovered already known device: %s', host) - return - - # Offer configuration of both live and local API - request_configuration() - request_configuration(host) - - discovery.listen(hass, SERVICE_TELLDUSLIVE, tellstick_discovered) - - if session: - _LOGGER.debug('Continuing setup configured by configurator') - elif conf and CONF_HOST in next(iter(conf.values())): - # For now, only one local device is supported - _LOGGER.debug('Using Local API pre-configured by configurator') - session = Session(**next(iter(conf.values()))) - elif DOMAIN in conf: - _LOGGER.debug('Using TelldusLive cloud service ' - 'pre-configured by configurator') - session = Session(PUBLIC_KEY, NOT_SO_PRIVATE_KEY, - application=APPLICATION_NAME, **conf[DOMAIN]) - elif config.get(DOMAIN): - _LOGGER.info('Found entry in configuration.yaml. ' - 'Requesting TelldusLive cloud service configuration') - request_configuration() - - if CONF_HOST in config.get(DOMAIN, {}): - _LOGGER.info('Found TelldusLive host entry in configuration.yaml. ' - 'Requesting Telldus Local API configuration') - request_configuration(config.get(DOMAIN).get(CONF_HOST)) - - return True + if KEY_HOST in conf: + session = Session(**conf) else: - _LOGGER.info('Tellstick discovered, awaiting discovery callback') - return True + session = Session( + PUBLIC_KEY, + NOT_SO_PRIVATE_KEY, + application=APPLICATION_NAME, + **conf, + ) if not session.is_authorized: _LOGGER.error('Authentication Error') return False - client = TelldusLiveClient(hass, config, session) + hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[CONFIG_ENTRY_IS_SETUP] = set() + + client = TelldusLiveClient(hass, entry, session) hass.data[DOMAIN] = client - client.update() - interval = config.get(DOMAIN, {}).get(CONF_UPDATE_INTERVAL, - DEFAULT_UPDATE_INTERVAL) + await client.update() + + interval = timedelta(seconds=entry.data[KEY_SCAN_INTERVAL]) _LOGGER.debug('Update interval %s', interval) - track_time_interval(hass, client.update, interval) + hass.data[INTERVAL_TRACKER] = async_track_time_interval( + hass, client.update, interval) return True +async def async_setup(hass, config): + """Set up the Telldus Live component.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data={ + KEY_HOST: config[DOMAIN].get(CONF_HOST), + KEY_SCAN_INTERVAL: config[DOMAIN].get(CONF_UPDATE_INTERVAL), + })) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + interval_tracker = hass.data.pop(INTERVAL_TRACKER) + interval_tracker() + await asyncio.wait([ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in hass.data.pop(CONFIG_ENTRY_IS_SETUP) + ]) + del hass.data[DOMAIN] + del hass.data[DATA_CONFIG_ENTRY_LOCK] + return True + + class TelldusLiveClient: """Get the latest data and update the states.""" - def __init__(self, hass, config, session): + def __init__(self, hass, config_entry, session): """Initialize the Tellus data object.""" self._known_devices = set() self._hass = hass - self._config = config + self._config_entry = config_entry self._client = session - def update(self, *args): - """Update local list of devices.""" + @staticmethod + def identify_device(device): + """Find out what type of HA component to create.""" + if device.is_sensor: + return 'sensor' + from tellduslive import (DIM, UP, TURNON) + if device.methods & DIM: + return 'light' + if device.methods & UP: + return 'cover' + if device.methods & TURNON: + return 'switch' + if device.methods == 0: + return 'binary_sensor' + _LOGGER.warning("Unidentified device type (methods: %d)", + device.methods) + return 'switch' + + async def _discover(self, device_id): + """Discover the component.""" + device = self._client.device(device_id) + component = self.identify_device(device) + async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: + if component not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component) + self._hass.data[CONFIG_ENTRY_IS_SETUP].add(component) + device_ids = [] + if device.is_sensor: + for item in device.items: + device_ids.append((device.device_id, item.name, item.scale)) + else: + device_ids.append(device_id) + for _id in device_ids: + async_dispatcher_send( + self._hass, TELLDUS_DISCOVERY_NEW.format(component, DOMAIN), + _id) + + async def update(self, *args): + """Periodically poll the servers for current state.""" _LOGGER.debug('Updating') if not self._client.update(): _LOGGER.warning('Failed request') - def identify_device(device): - """Find out what type of HA component to create.""" - from tellduslive import (DIM, UP, TURNON) - if device.methods & DIM: - return 'light' - if device.methods & UP: - return 'cover' - if device.methods & TURNON: - return 'switch' - if device.methods == 0: - return 'binary_sensor' - _LOGGER.warning( - "Unidentified device type (methods: %d)", device.methods) - return 'switch' - - def discover(device_id, component): - """Discover the component.""" - discovery.load_platform( - self._hass, component, DOMAIN, [device_id], self._config) - - for device in self._client.devices: - if device.device_id in self._known_devices: - continue - if device.is_sensor: - for item in device.items: - discover((device.device_id, item.name, item.scale), - 'sensor') - else: - discover(device.device_id, - identify_device(device)) - self._known_devices.add(device.device_id) - - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + dev_ids = {dev.device_id for dev in self._client.devices} + new_devices = dev_ids - self._known_devices + await asyncio.gather(*[self._discover(d_id) for d_id in new_devices]) + self._known_devices |= new_devices + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): """Return device representation.""" diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py new file mode 100644 index 00000000000..64260b6047c --- /dev/null +++ b/homeassistant/components/tellduslive/config_flow.py @@ -0,0 +1,150 @@ +"""Config flow for Tellduslive.""" +import asyncio +import logging +import os + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.util.json import load_json + +from .const import ( + APPLICATION_NAME, CLOUD_NAME, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, + KEY_SESSION, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, SCAN_INTERVAL, + TELLDUS_CONFIG_FILE) + +KEY_TOKEN = 'token' +KEY_TOKEN_SECRET = 'token_secret' + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register('tellduslive') +class FlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Init config flow.""" + self._hosts = [CLOUD_NAME] + self._host = None + self._session = None + self._scan_interval = SCAN_INTERVAL + + def _get_auth_url(self): + return self._session.authorize_url + + async def async_step_user(self, user_input=None): + """Let user select host or cloud.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if user_input is not None or len(self._hosts) == 1: + if user_input is not None and user_input[KEY_HOST] != CLOUD_NAME: + self._host = user_input[KEY_HOST] + return await self.async_step_auth() + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(KEY_HOST): + vol.In(list(self._hosts)) + })) + + async def async_step_auth(self, user_input=None): + """Handle the submitted configuration.""" + if not self._session: + from tellduslive import Session + self._session = Session( + public_key=PUBLIC_KEY, + private_key=NOT_SO_PRIVATE_KEY, + host=self._host, + application=APPLICATION_NAME, + ) + + if user_input is not None and self._session.authorize(): + host = self._host or CLOUD_NAME + if self._host: + session = { + KEY_HOST: host, + KEY_TOKEN: self._session.access_token + } + else: + session = { + KEY_TOKEN: self._session.access_token, + KEY_TOKEN_SECRET: self._session.access_token_secret + } + return self.async_create_entry( + title=host, data={ + KEY_HOST: host, + KEY_SCAN_INTERVAL: self._scan_interval.seconds, + KEY_SESSION: session, + }) + + try: + with async_timeout.timeout(10): + auth_url = await self.hass.async_add_executor_job( + self._get_auth_url) + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error generating auth url") + return self.async_abort(reason='authorize_url_fail') + + _LOGGER.debug('Got authorization URL %s', auth_url) + return self.async_show_form( + step_id='auth', + description_placeholders={ + 'app_name': APPLICATION_NAME, + 'auth_url': auth_url, + }, + ) + + async def async_step_discovery(self, user_input): + """Run when a Tellstick is discovered.""" + from tellduslive import supports_local_api + _LOGGER.info('Discovered tellstick device: %s', user_input) + # Ignore any known devices + for entry in self._async_current_entries(): + if entry.data[KEY_HOST] == user_input[0]: + return self.async_abort(reason='already_configured') + + if not supports_local_api(user_input[1]): + _LOGGER.debug('Tellstick does not support local API') + # Configure the cloud service + return await self.async_step_auth() + + self._hosts.append(user_input[0]) + return await self.async_step_user() + + async def async_step_import(self, user_input): + """Import a config entry.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + self._scan_interval = user_input[KEY_SCAN_INTERVAL] + if user_input[KEY_HOST] != DOMAIN: + self._hosts.append(user_input[KEY_HOST]) + + if not await self.hass.async_add_executor_job( + os.path.isfile, self.hass.config.path(TELLDUS_CONFIG_FILE)): + return await self.async_step_user() + + conf = await self.hass.async_add_executor_job( + load_json, self.hass.config.path(TELLDUS_CONFIG_FILE)) + host = next(iter(conf)) + + if user_input[KEY_HOST] != host: + return await self.async_step_user() + + host = CLOUD_NAME if host == 'tellduslive' else host + return self.async_create_entry( + title=host, + data={ + KEY_HOST: host, + KEY_SCAN_INTERVAL: self._scan_interval.seconds, + KEY_SESSION: next(iter(conf.values())), + }) diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py index a4ef33af518..81b3abefdee 100644 --- a/homeassistant/components/tellduslive/const.py +++ b/homeassistant/components/tellduslive/const.py @@ -1,5 +1,34 @@ """Consts used by TelldusLive.""" +from datetime import timedelta + +from homeassistant.const import ( # noqa pylint: disable=unused-import + ATTR_BATTERY_LEVEL, CONF_HOST, CONF_TOKEN, DEVICE_DEFAULT_NAME) + +APPLICATION_NAME = 'Home Assistant' DOMAIN = 'tellduslive' +TELLDUS_CONFIG_FILE = 'tellduslive.conf' +KEY_CONFIG = 'tellduslive_config' + SIGNAL_UPDATE_ENTITY = 'tellduslive_update' + +KEY_HOST = 'host' +KEY_SESSION = 'session' +KEY_SCAN_INTERVAL = 'scan_interval' + +CONF_TOKEN_SECRET = 'token_secret' +CONF_UPDATE_INTERVAL = 'update_interval' + +PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA' +NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS' + +MIN_UPDATE_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(minutes=1) + +ATTR_LAST_UPDATED = 'time_last_updated' + +SIGNAL_UPDATE_ENTITY = 'tellduslive_update' +TELLDUS_DISCOVERY_NEW = 'telldus_new_{}_{}' + +CLOUD_NAME = 'Cloud API' diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json new file mode 100644 index 00000000000..7be98213222 --- /dev/null +++ b/homeassistant/components/tellduslive/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Telldus Live", + "step": { + "user": { + "title": "Pick endpoint.", + "description": "", + "data": { + "host": "Host" + } + }, + "auth": { + "title": "Authenticate against TelldusLive", + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})" + } + }, + "abort": { + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "all_configured": "TelldusLive is already configured", + "unknown": "Unknown error occurred" + } + } +} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 5c6ced5756f..39270b36108 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -155,6 +155,7 @@ FLOWS = [ 'simplisafe', 'smhi', 'sonos', + 'tellduslive', 'tradfri', 'twilio', 'unifi', diff --git a/tests/components/tellduslive/__init__.py b/tests/components/tellduslive/__init__.py new file mode 100644 index 00000000000..4ed4babc1c8 --- /dev/null +++ b/tests/components/tellduslive/__init__.py @@ -0,0 +1 @@ +"""Tests for the TelldusLive component.""" diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py new file mode 100644 index 00000000000..ba569a9f149 --- /dev/null +++ b/tests/components/tellduslive/test_config_flow.py @@ -0,0 +1,223 @@ +# flake8: noqa pylint: skip-file +"""Tests for the TelldusLive config flow.""" +import asyncio +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.tellduslive import ( + APPLICATION_NAME, DOMAIN, KEY_HOST, KEY_SCAN_INTERVAL, SCAN_INTERVAL, + config_flow) + +from tests.common import MockConfigEntry, MockDependency, mock_coro + + +def init_config_flow(hass, side_effect=None): + """Init a configuration flow.""" + flow = config_flow.FlowHandler() + flow.hass = hass + if side_effect: + flow._get_auth_url = Mock(side_effect=side_effect) + return flow + + +@pytest.fixture +def supports_local_api(): + """Set TelldusLive supports_local_api.""" + return True + + +@pytest.fixture +def authorize(): + """Set TelldusLive authorize.""" + return True + + +@pytest.fixture +def mock_tellduslive(supports_local_api, authorize): + """Mock tellduslive.""" + with MockDependency('tellduslive') as mock_tellduslive_: + mock_tellduslive_.supports_local_api.return_value = supports_local_api + mock_tellduslive_.Session().authorize.return_value = authorize + mock_tellduslive_.Session().access_token = 'token' + mock_tellduslive_.Session().access_token_secret = 'token_secret' + mock_tellduslive_.Session().authorize_url = 'https://example.com' + yield mock_tellduslive_ + + +async def test_abort_if_already_setup(hass): + """Test we abort if TelldusLive is already setup.""" + flow = init_config_flow(hass) + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_import(None) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow_implementation(hass, mock_tellduslive): + """Test registering an implementation and finishing flow works.""" + flow = init_config_flow(hass) + result = await flow.async_step_discovery(['localhost', 'tellstick']) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user({'host': 'localhost'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + assert result['description_placeholders'] == { + 'auth_url': 'https://example.com', + 'app_name': APPLICATION_NAME, + } + + result = await flow.async_step_auth('') + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'localhost' + assert result['data']['host'] == 'localhost' + assert result['data']['scan_interval'] == 60 + assert result['data']['session'] == {'token': 'token', 'host': 'localhost'} + + +async def test_step_import(hass, mock_tellduslive): + """Test that we trigger auth when configuring from import.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import({ + KEY_HOST: DOMAIN, + KEY_SCAN_INTERVAL: 0, + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_step_import_add_host(hass, mock_tellduslive): + """Test that we add host and trigger user when configuring from import.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import({ + KEY_HOST: 'localhost', + KEY_SCAN_INTERVAL: 0, + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import_no_config_file(hass, mock_tellduslive): + """Test that we trigger user with no config_file configuring from import.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import({ KEY_HOST: 'localhost', KEY_SCAN_INTERVAL: 0, }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import_load_json_matching_host(hass, mock_tellduslive): + """Test that we add host and trigger user when configuring from import.""" + flow = init_config_flow(hass) + + with patch('homeassistant.components.tellduslive.config_flow.load_json', + return_value={'tellduslive': {}}), \ + patch('os.path.isfile'): + result = await flow.async_step_import({ KEY_HOST: 'Cloud API', KEY_SCAN_INTERVAL: 0, }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import_load_json(hass, mock_tellduslive): + """Test that we create entry when configuring from import.""" + flow = init_config_flow(hass) + + with patch('homeassistant.components.tellduslive.config_flow.load_json', + return_value={'localhost': {}}), \ + patch('os.path.isfile'): + result = await flow.async_step_import({ KEY_HOST: 'localhost', KEY_SCAN_INTERVAL: SCAN_INTERVAL, }) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'localhost' + assert result['data']['host'] == 'localhost' + assert result['data']['scan_interval'] == 60 + assert result['data']['session'] == {} + + +@pytest.mark.parametrize('supports_local_api', [False]) +async def test_step_disco_no_local_api(hass, mock_tellduslive): + """Test that we trigger when configuring from discovery, not supporting local api.""" + flow = init_config_flow(hass) + + result = await flow.async_step_discovery(['localhost', 'tellstick']) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_step_auth(hass, mock_tellduslive): + """Test that create cloud entity from auth.""" + flow = init_config_flow(hass) + + result = await flow.async_step_auth(['localhost', 'tellstick']) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == 'Cloud API' + assert result['data']['host'] == 'Cloud API' + assert result['data']['scan_interval'] == 60 + assert result['data']['session'] == { + 'token': 'token', + 'token_secret': 'token_secret', + } + + +@pytest.mark.parametrize('authorize', [False]) +async def test_wrong_auth_flow_implementation(hass, mock_tellduslive): + """Test wrong auth.""" + flow = init_config_flow(hass) + + await flow.async_step_user() + result = await flow.async_step_auth('') + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_not_pick_host_if_only_one(hass, mock_tellduslive): + """Test not picking host if we have just one.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_abort_if_timeout_generating_auth_url(hass, mock_tellduslive): + """Test abort if generating authorize url timeout.""" + flow = init_config_flow(hass, side_effect=asyncio.TimeoutError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' + + +async def test_abort_if_exception_generating_auth_url(hass, mock_tellduslive): + """Test we abort if generating authorize url blows up.""" + flow = init_config_flow(hass, side_effect=ValueError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_fail' + +async def test_discovery_already_configured(hass, mock_tellduslive): + """Test abort if alredy configured fires from discovery.""" + MockConfigEntry( + domain='tellduslive', + data={'host': 'some-host'} + ).add_to_hass(hass) + flow = init_config_flow(hass) + + result = await flow.async_step_discovery(['some-host', '']) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_configured'