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'