From c1ca7beea11a6a9ee5722f4afb9e1a8ac420df8c Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 19 Nov 2018 12:52:21 +0100 Subject: [PATCH] Support for Point component (#17466) * Minut Point support * tox tests for Point * config flow fixes * fixes requested by @MartinHjelmare * swedish translation :) * fix tests --- .coveragerc | 4 + .../components/binary_sensor/point.py | 103 ++++++ .../components/point/.translations/en.json | 33 ++ .../components/point/.translations/sv.json | 33 ++ homeassistant/components/point/__init__.py | 306 ++++++++++++++++++ homeassistant/components/point/config_flow.py | 189 +++++++++++ homeassistant/components/point/const.py | 15 + homeassistant/components/point/strings.json | 32 ++ homeassistant/components/sensor/point.py | 68 ++++ homeassistant/config_entries.py | 1 + requirements_all.txt | 3 + tests/components/point/__init__.py | 1 + tests/components/point/test_config_flow.py | 147 +++++++++ 13 files changed, 935 insertions(+) create mode 100644 homeassistant/components/binary_sensor/point.py create mode 100644 homeassistant/components/point/.translations/en.json create mode 100644 homeassistant/components/point/.translations/sv.json create mode 100644 homeassistant/components/point/__init__.py create mode 100644 homeassistant/components/point/config_flow.py create mode 100644 homeassistant/components/point/const.py create mode 100644 homeassistant/components/point/strings.json create mode 100644 homeassistant/components/sensor/point.py create mode 100644 tests/components/point/__init__.py create mode 100644 tests/components/point/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 2762dffbeb1..a4fd6ea1c2e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,6 +262,10 @@ omit = homeassistant/components/pilight.py homeassistant/components/*/pilight.py + homeassistant/components/point/__init__.py + homeassistant/components/point/const.py + homeassistant/components/*/point.py + homeassistant/components/switch/qwikswitch.py homeassistant/components/light/qwikswitch.py diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py new file mode 100644 index 00000000000..a2ed9eabebf --- /dev/null +++ b/homeassistant/components/binary_sensor/point.py @@ -0,0 +1,103 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.point/ +""" + +import logging + +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +EVENTS = { + 'battery': # On means low, Off means normal + ('battery_low', ''), + 'button_press': # On means the button was pressed, Off means normal + ('short_button_press', ''), + 'cold': # On means cold, Off means normal + ('temperature_low', 'temperature_risen_normal'), + 'connectivity': # On means connected, Off means disconnected + ('device_online', 'device_offline'), + 'dry': # On means too dry, Off means normal + ('humidity_low', 'humidity_risen_normal'), + 'heat': # On means hot, Off means normal + ('temperature_high', 'temperature_dropped_normal'), + 'moisture': # On means wet, Off means dry + ('humidity_high', 'humidity_dropped_normal'), + 'sound': # On means sound detected, Off means no sound (clear) + ('avg_sound_high', 'sound_level_dropped_normal'), + 'tamper': # On means the point was removed or attached + ('tamper', ''), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's binary sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointBinarySensor(client, device_id, device_class) + for device_class in EVENTS), True) + + +class MinutPointBinarySensor(MinutPointEntity): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + + self._async_unsub_hook_dispatcher_connect = None + self._events = EVENTS[device_class] + self._is_on = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_WEBHOOK, self._webhook_event) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + await super().async_will_remove_from_hass() + if self._async_unsub_hook_dispatcher_connect: + self._async_unsub_hook_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if not self.is_updated: + return + if self._events[0] in self.device.ongoing_events: + self._is_on = True + else: + self._is_on = None + self.async_schedule_update_ha_state() + + @callback + def _webhook_event(self, data, webhook): + """Process new event from the webhook.""" + if self.device.webhook != webhook: + return + _type = data.get('event', {}).get('type') + if _type not in self._events: + return + _LOGGER.debug("Recieved webhook: %s", _type) + if _type == self._events[0]: + self._is_on = True + if _type == self._events[1]: + self._is_on = None + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return the state of the binary sensor.""" + if self.device_class == 'connectivity': + # connectivity is the other way around. + return not self._is_on + return self._is_on diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json new file mode 100644 index 00000000000..fed892113c3 --- /dev/null +++ b/homeassistant/components/point/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Point.", + "data": { + "flow_impl": "Provider" + } + }, + "auth": { + "title": "Authenticate Point", + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "no_token": "Not authenticated with Minut", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "You can only configure a Point account.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} + \ No newline at end of file diff --git a/homeassistant/components/point/.translations/sv.json b/homeassistant/components/point/.translations/sv.json new file mode 100644 index 00000000000..6464434eda4 --- /dev/null +++ b/homeassistant/components/point/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Autentiseringsleverant\u00f6r", + "description": "V\u00e4lj den autentiseringsleverant\u00f6r som du vill autentisera med mot Point.", + "data": { + "flow_impl": "Leverant\u00f6r" + } + }, + "auth": { + "title": "Autentisera Point", + "description": "F\u00f6lj l\u00e4nken nedan och klicka p\u00e5 Accept f\u00f6r att tilll\u00e5ta tillg\u00e5ng till ditt Minut konto, kom d\u00f6refter tillbaka hit och kicka p\u00e5 Submit nedan.\n\n[L\u00e4nk]({authorization_url})" + } + }, + "create_entry": { + "default": "Autentiserad med Minut f\u00f6r era Point enheter." + }, + "error": { + "no_token": "Inte autentiserad hos Minut", + "follow_link": "F\u00f6lj l\u00e4nken och autentisera innan du kickar på Submit" + }, + "abort": { + "already_setup": "Du kan endast konfigurera ett Point-konto.", + "external_setup": "Point har lyckats konfigureras fr\u00e5n ett annat fl\u00f6de.", + "no_flows": "Du m\u00e5ste konfigurera Nest innan du kan autentisera med det. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress.", + "authorize_url_fail": "Ok\u00e4nt fel vid generering av autentisieringsadress." + } + } +} + \ No newline at end of file diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py new file mode 100644 index 00000000000..fcbd5ddb064 --- /dev/null +++ b/homeassistant/components/point/__init__.py @@ -0,0 +1,306 @@ +""" +Support for Minut Point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/point/ +""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp + +from . import config_flow # noqa pylint_disable=unused-import +from .const import ( + CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, + SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) + +REQUIREMENTS = ['pypoint==1.0.5'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + }) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Minut Point component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + config_flow.register_flow_implementation( + hass, DOMAIN, conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET]) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + )) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Point from a config entry.""" + from pypoint import PointSession + + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + hass.config_entries.async_update_entry(entry, data={**entry.data}) + + # Force token update. + entry.data[CONF_TOKEN]['expires_in'] = -1 + session = PointSession( + entry.data['refresh_args']['client_id'], + token=entry.data[CONF_TOKEN], + auto_refresh_kwargs=entry.data['refresh_args'], + token_saver=token_saver, + ) + + if not session.is_authorized: + _LOGGER.error('Authentication Error') + return False + + await async_setup_webhook(hass, entry, session) + client = MinutPointClient(hass, entry, session) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) + await client.update() + + return True + + +async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, + session): + """Set up a webhook to handle binary sensor events.""" + if CONF_WEBHOOK_ID not in entry.data: + entry.data[CONF_WEBHOOK_ID] = \ + hass.components.webhook.async_generate_id() + entry.data[CONF_WEBHOOK_URL] = \ + hass.components.webhook.async_generate_url( + entry.data[CONF_WEBHOOK_ID]) + _LOGGER.info('Registering new webhook at: %s', + entry.data[CONF_WEBHOOK_URL]) + hass.config_entries.async_update_entry( + entry, data={ + **entry.data, + }) + session.update_webhook(entry.data[CONF_WEBHOOK_URL], + entry.data[CONF_WEBHOOK_ID]) + + hass.components.webhook.async_register(entry.data[CONF_WEBHOOK_ID], + handle_webhook) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + client = hass.data[DOMAIN].pop(entry.entry_id) + client.remove_webhook() + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + for component in ('binary_sensor', 'sensor'): + await hass.config_entries.async_forward_entry_unload( + entry, component) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + try: + data = await request.json() + _LOGGER.debug("Webhook %s: %s", webhook_id, data) + except ValueError: + return None + + if isinstance(data, dict): + data['webhook_id'] = webhook_id + async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get('hook_id')) + hass.bus.async_fire(EVENT_RECEIVED, data) + + +class MinutPointClient(): + """Get the latest data and update the states.""" + + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, + session): + """Initialize the Minut data object.""" + self._known_devices = [] + self._hass = hass + self._config_entry = config_entry + self._is_available = True + self._client = session + + async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) + + async def update(self, *args): + """Periodically poll the cloud for current state.""" + await self._sync() + + async def _sync(self): + """Update local list of devices.""" + if not self._client.update() and self._is_available: + self._is_available = False + _LOGGER.warning("Device is unavailable") + return + + self._is_available = True + for device in self._client.devices: + if device.device_id not in self._known_devices: + # A way to communicate the device_id to entry_setup, + # can this be done nicer? + self._config_entry.data[NEW_DEVICE] = device.device_id + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor') + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'binary_sensor') + self._known_devices.append(device.device_id) + del self._config_entry.data[NEW_DEVICE] + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + + def device(self, device_id): + """Return device representation.""" + return self._client.device(device_id) + + def is_available(self, device_id): + """Return device availability.""" + return device_id in self._client.device_ids + + def remove_webhook(self): + """Remove the session webhook.""" + return self._client.remove_webhook() + + +class MinutPointEntity(Entity): + """Base Entity used by the sensors.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + self._async_unsub_dispatcher_connect = None + self._client = point_client + self._id = device_id + self._name = self.device.name + self._device_class = device_class + self._updated = utc_from_timestamp(0) + self._value = None + + def __str__(self): + """Return string representation of device.""" + return "MinutPoint {}".format(self.name) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug('Created device %s', self) + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + self._update_callback() + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + pass + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def device_state_attributes(self): + """Return status of device.""" + attrs = self.device.device_status + attrs['last_heard_from'] = \ + as_local(self.last_update).strftime("%Y-%m-%d %H:%M:%S") + return attrs + + @property + def device_info(self): + """Return a device description for device registry.""" + device = self.device.device + return { + 'connections': {('mac', device['device_mac'])}, + 'identifieres': device['device_id'], + 'manufacturer': 'Minut', + 'model': 'Point v{}'.format(device['hardware_version']), + 'name': device['description'], + 'sw_version': device['firmware']['installed'], + } + + @property + def name(self): + """Return the display name of this device.""" + return "{} {}".format(self._name, self.device_class.capitalize()) + + @property + def is_updated(self): + """Return true if sensor have been updated.""" + return self.last_update > self._updated + + @property + def last_update(self): + """Return the last_update time for the device.""" + last_update = parse_datetime(self.device.last_update) + return last_update + + @property + def should_poll(self): + """No polling needed for point.""" + return False + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return 'point.{}-{}'.format(self._id, self.device_class) + + @property + def value(self): + """Return the sensor value.""" + return self._value diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py new file mode 100644 index 00000000000..8cda30c7171 --- /dev/null +++ b/homeassistant/components/point/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Minut Point.""" +import asyncio +from collections import OrderedDict +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback + +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/api/minut' +AUTH_CALLBACK_NAME = 'api:minut' + +DATA_FLOW_IMPL = 'point_flow_implementation' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, domain, client_id, client_secret): + """Register a flow implementation. + + domain: Domain of the component responsible for the implementation. + name: Name of the component. + client_id: Client id. + client_secret: Client secret. + """ + if DATA_FLOW_IMPL not in hass.data: + hass.data[DATA_FLOW_IMPL] = OrderedDict() + + hass.data[DATA_FLOW_IMPL][domain] = { + CLIENT_ID: client_id, + CLIENT_SECRET: client_secret, + } + + +@config_entries.HANDLERS.register('point') +class PointFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNETION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self.flow_impl = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + self.flow_impl = DOMAIN + + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + flows = self.hass.data.get(DATA_FLOW_IMPL, {}) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if not flows: + _LOGGER.debug("no flows") + return self.async_abort(reason='no_flows') + + if len(flows) == 1: + self.flow_impl = list(flows)[0] + return await self.async_step_auth() + + if user_input is not None: + self.flow_impl = user_input['flow_impl'] + return await self.async_step_auth() + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required('flow_impl'): + vol.In(list(flows)) + })) + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='external_setup') + + errors = {} + + if user_input is not None: + errors['base'] = 'follow_link' + + try: + with async_timeout.timeout(10): + url = await self._get_authorization_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') + + return self.async_show_form( + step_id='auth', + description_placeholders={'authorization_url': url}, + errors=errors, + ) + + async def _get_authorization_url(self): + """Create Minut Point session and get authorization url.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, client_secret=client_secret) + + self.hass.http.register_view(MinutAuthCallbackView()) + + return point_session.get_authorization_url + + async def async_step_code(self, code=None): + """Received code for authentication.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if code is None: + return self.async_abort(reason='no_code') + + _LOGGER.debug("Should close all flows below %s", + self.hass.config_entries.flow.async_progress()) + # Remove notification if no other discovery config entries in progress + + return await self._async_create_session(code) + + async def _async_create_session(self, code): + """Create point session and entries.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, + client_secret=client_secret, + ) + token = await self.hass.async_add_executor_job( + point_session.get_access_token, code) + _LOGGER.debug("Got new token") + if not point_session.is_authorized: + _LOGGER.error('Authentication Error') + return self.async_abort(reason='auth_error') + + _LOGGER.info('Successfully authenticated Point') + user_email = point_session.user().get('email') or "" + + return self.async_create_entry( + title=user_email, + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class MinutAuthCallbackView(HomeAssistantView): + """Minut Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + hass = request.app['hass'] + if 'code' in request.query: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': 'code'}, + data=request.query['code'], + )) + return "OK!" diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py new file mode 100644 index 00000000000..4ef21b57cd9 --- /dev/null +++ b/homeassistant/components/point/const.py @@ -0,0 +1,15 @@ +"""Define constants for the Point component.""" +from datetime import timedelta + +DOMAIN = 'point' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' + + +SCAN_INTERVAL = timedelta(minutes=1) + +CONF_WEBHOOK_URL = 'webhook_url' +EVENT_RECEIVED = 'point_webhook_received' +SIGNAL_UPDATE_ENTITY = 'point_update' +SIGNAL_WEBHOOK = 'point_webhook' +NEW_DEVICE = 'new_device' diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json new file mode 100644 index 00000000000..642a61a5f9d --- /dev/null +++ b/homeassistant/components/point/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Point.", + "data": { + "flow_impl": "Provider" + } + }, + "auth": { + "title": "Authenticate Point", + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "no_token": "Not authenticated with Minut", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "You can only configure a Point account.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} diff --git a/homeassistant/components/sensor/point.py b/homeassistant/components/sensor/point.py new file mode 100644 index 00000000000..0c099c8873e --- /dev/null +++ b/homeassistant/components/sensor/point.py @@ -0,0 +1,68 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.point/ +""" +import logging + +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE) +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.util.dt import parse_datetime + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CLASS_SOUND = 'sound_level' + +SENSOR_TYPES = { + DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), + DEVICE_CLASS_PRESSURE: (None, 0, 'hPa'), + DEVICE_CLASS_HUMIDITY: (None, 1, '%'), + DEVICE_CLASS_SOUND: ('mdi:ear-hearing', 1, 'dBa'), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointSensor(client, device_id, sensor_type) + for sensor_type in SENSOR_TYPES), True) + + +class MinutPointSensor(MinutPointEntity): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + self._device_prop = SENSOR_TYPES[device_class] + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if self.is_updated: + _LOGGER.debug('Update sensor value for %s', self) + self._value = self.device.sensor(self.device_class) + self._updated = parse_datetime(self.device.last_update) + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon representation.""" + return self._device_prop[0] + + @property + def state(self): + """Return the state of the sensor.""" + return round(self.value, self._device_prop[1]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._device_prop[2] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 6669d5240d8..42bc8b089da 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'point', 'rainmachine', 'simplisafe', 'smhi', diff --git a/requirements_all.txt b/requirements_all.txt index 7d5e1afed06..bd63bf2480f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1095,6 +1095,9 @@ pyowm==2.9.0 # homeassistant.components.media_player.pjlink pypjlink2==1.2.0 +# homeassistant.components.point +pypoint==1.0.5 + # homeassistant.components.sensor.pollen pypollencom==2.2.2 diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py new file mode 100644 index 00000000000..9fb6eea9ac7 --- /dev/null +++ b/tests/components/point/__init__.py @@ -0,0 +1 @@ +"""Tests for the Point component.""" diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py new file mode 100644 index 00000000000..cf9f3b2dbdd --- /dev/null +++ b/tests/components/point/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Point config flow.""" +import asyncio +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.point import DOMAIN, config_flow + +from tests.common import MockDependency, mock_coro + + +def init_config_flow(hass, side_effect=None): + """Init a configuration flow.""" + config_flow.register_flow_implementation(hass, DOMAIN, 'id', 'secret') + flow = config_flow.PointFlowHandler() + flow._get_authorization_url = Mock( # pylint: disable=W0212 + return_value=mock_coro('https://example.com'), + side_effect=side_effect) + flow.hass = hass + return flow + + +@pytest.fixture +def is_authorized(): + """Set PointSession authorized.""" + return True + + +@pytest.fixture +def mock_pypoint(is_authorized): # pylint: disable=W0621 + """Mock pypoint.""" + with MockDependency('pypoint') as mock_pypoint_: + mock_pypoint_.PointSession().get_access_token.return_value = { + 'access_token': 'boo' + } + mock_pypoint_.PointSession().is_authorized = is_authorized + mock_pypoint_.PointSession().user.return_value = { + 'email': 'john.doe@example.com' + } + yield mock_pypoint_ + + +async def test_abort_if_no_implementation_registered(hass): + """Test we abort if no implementation is registered.""" + flow = config_flow.PointFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_flows' + + +async def test_abort_if_already_setup(hass): + """Test we abort if Point 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() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test registering an implementation and finishing flow works.""" + config_flow.register_flow_implementation(hass, 'test-other', None, None) + flow = init_config_flow(hass) + + 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({'flow_impl': 'test'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + assert result['description_placeholders'] == { + 'authorization_url': 'https://example.com', + } + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': 'id', + 'client_secret': 'secret' + } + assert result['title'] == 'john.doe@example.com' + assert result['data']['token'] == {'access_token': 'boo'} + + +async def test_step_import(hass, mock_pypoint): # pylint: disable=W0621 + """Test that we trigger import when configuring with client.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +@pytest.mark.parametrize('is_authorized', [False]) +async def test_wrong_code_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test wrong code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'auth_error' + + +async def test_not_pick_implementation_if_only_one(hass): + """Test we allow picking implementation if we have one flow_imp.""" + 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): + """Test we abort if generating authorize url fails.""" + 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): + """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_abort_no_code(hass): + """Test if no code is given to step_code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_code'