Support for Point component (#17466)
* Minut Point support * tox tests for Point * config flow fixes * fixes requested by @MartinHjelmare * swedish translation :) * fix tests
This commit is contained in:
parent
84fd66c8a1
commit
c1ca7beea1
13 changed files with 935 additions and 0 deletions
|
@ -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
|
||||
|
||||
|
|
103
homeassistant/components/binary_sensor/point.py
Normal file
103
homeassistant/components/binary_sensor/point.py
Normal file
|
@ -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
|
33
homeassistant/components/point/.translations/en.json
Normal file
33
homeassistant/components/point/.translations/en.json
Normal file
|
@ -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 <b>Accept</b> access to your Minut account, then come back and press <b>Submit</b> 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
33
homeassistant/components/point/.translations/sv.json
Normal file
33
homeassistant/components/point/.translations/sv.json
Normal file
|
@ -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 <b>Accept</b> f\u00f6r att tilll\u00e5ta tillg\u00e5ng till ditt Minut konto, kom d\u00f6refter tillbaka hit och kicka p\u00e5 <b>Submit</b> 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
306
homeassistant/components/point/__init__.py
Normal file
306
homeassistant/components/point/__init__.py
Normal file
|
@ -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
|
189
homeassistant/components/point/config_flow.py
Normal file
189
homeassistant/components/point/config_flow.py
Normal file
|
@ -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!"
|
15
homeassistant/components/point/const.py
Normal file
15
homeassistant/components/point/const.py
Normal file
|
@ -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'
|
32
homeassistant/components/point/strings.json
Normal file
32
homeassistant/components/point/strings.json
Normal file
|
@ -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 <b>Accept</b> access to your Minut account, then come back and press <b>Submit</b> 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."
|
||||
}
|
||||
}
|
||||
}
|
68
homeassistant/components/sensor/point.py
Normal file
68
homeassistant/components/sensor/point.py
Normal file
|
@ -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]
|
|
@ -149,6 +149,7 @@ FLOWS = [
|
|||
'mqtt',
|
||||
'nest',
|
||||
'openuv',
|
||||
'point',
|
||||
'rainmachine',
|
||||
'simplisafe',
|
||||
'smhi',
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
1
tests/components/point/__init__.py
Normal file
1
tests/components/point/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Point component."""
|
147
tests/components/point/test_config_flow.py
Normal file
147
tests/components/point/test_config_flow.py
Normal file
|
@ -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'
|
Loading…
Add table
Reference in a new issue