Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Notion Home Monitoring #24634

Merged
merged 31 commits into from Jul 9, 2019
Merged
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
9b17584
Add support for Notion Home Monitoring
bachya Jun 19, 2019
42c804f
Updated coverage
bachya Jun 19, 2019
281f406
Removed auto-generated translations
bachya Jun 19, 2019
4ba9b2d
Stale docstrings
bachya Jun 19, 2019
4b366fc
Corrected hardware version
bachya Jun 20, 2019
6ab4044
Fixed binary sensor representation
bachya Jun 20, 2019
71aeb5d
Cleanup and update protection
bachya Jun 21, 2019
e941cd9
Updated log message
bachya Jun 21, 2019
808b10b
Cleaned up is_on
bachya Jun 23, 2019
dfcc9fa
Updated docstring
bachya Jun 23, 2019
21a4ec3
Modified which data is updated during async_update
bachya Jun 28, 2019
8068b8c
Added more checks during update
bachya Jun 28, 2019
68925ee
More cleanup
bachya Jun 28, 2019
f94aadf
Fixed unhandled exception
bachya Jun 28, 2019
90d89af
Owner-requested changes (round 1)
bachya Jul 2, 2019
10b123b
Fixed incorrect scan interval retrieval
bachya Jul 3, 2019
4160a10
Ugh
bachya Jul 3, 2019
70acb5a
Removed unnecessary import
bachya Jul 3, 2019
943cca2
Simplified everything via dict lookups
bachya Jul 3, 2019
e41c4ee
Ensure bridges are properly registered
bachya Jul 3, 2019
128581f
Fixed tests
bachya Jul 3, 2019
546d53a
Added catch for invalid credentials
bachya Jul 3, 2019
e739974
Ensure bridge ID is updated as necessary
bachya Jul 3, 2019
f24734f
Updated method name
bachya Jul 3, 2019
e8fede1
Simplified bridge update
bachya Jul 3, 2019
3cc14c2
Add support for updating bridge via_device_id
bachya Jul 5, 2019
06e337d
Device update guard clause
bachya Jul 5, 2019
0deb16e
Removed excess whitespace
bachya Jul 5, 2019
4a1d939
Whitespace
bachya Jul 5, 2019
ac502ed
Owner comments
bachya Jul 8, 2019
2e76d7e
Member comments
bachya Jul 9, 2019
File filter...
Filter file types
Jump to…
Jump to file or symbol
Failed to load files and symbols.

Always

Just for now

@@ -406,6 +406,8 @@ omit =
homeassistant/components/nissan_leaf/*
homeassistant/components/nmap_tracker/device_tracker.py
homeassistant/components/nmbs/sensor.py
homeassistant/components/notion/binary_sensor.py
homeassistant/components/notion/sensor.py
homeassistant/components/noaa_tides/sensor.py
homeassistant/components/norway_air/air_quality.py
homeassistant/components/nsw_fuel_station/sensor.py
@@ -181,6 +181,7 @@ homeassistant/components/nissan_leaf/* @filcole
homeassistant/components/nmbs/* @thibmaek
homeassistant/components/no_ip/* @fabaff
homeassistant/components/notify/* @home-assistant/core
homeassistant/components/notion/* @bachya
homeassistant/components/nsw_fuel_station/* @nickw444
homeassistant/components/nuki/* @pschmitt
homeassistant/components/ohmconnect/* @robbiet480
@@ -0,0 +1,19 @@
{
"config": {
"error": {
"identifier_exists": "Username already registered",
"invalid_credentials": "Invalid username or password",
"no_devices": "No devices found in account"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Username/Email Address"
},
"title": "Fill in your information"
}
},
"title": "Notion"
}
}
@@ -0,0 +1,307 @@
"""Support for Notion."""
import asyncio
import logging

from aionotion import async_get_client
from aionotion.errors import InvalidCredentialsError, NotionError
import voluptuous as vol

from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import (
aiohttp_client, config_validation as cv, device_registry as dr)
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 .config_flow import configured_instances
from .const import (
DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_DATA_UPDATE)

_LOGGER = logging.getLogger(__name__)

ATTR_SYSTEM_MODE = 'system_mode'
ATTR_SYSTEM_NAME = 'system_name'

DATA_LISTENER = 'listener'

DEFAULT_ATTRIBUTION = 'Data provided by Notion'

SENSOR_BATTERY = 'low_battery'
SENSOR_DOOR = 'door'
SENSOR_GARAGE_DOOR = 'garage_door'
SENSOR_LEAK = 'leak'
SENSOR_MISSING = 'missing'
SENSOR_SAFE = 'safe'
SENSOR_SLIDING = 'sliding'
SENSOR_SMOKE_CO = 'alarm'
SENSOR_TEMPERATURE = 'temperature'
SENSOR_WINDOW_HINGED_HORIZONTAL = 'window_hinged_horizontal'
SENSOR_WINDOW_HINGED_VERTICAL = 'window_hinged_vertical'

BINARY_SENSOR_TYPES = {
SENSOR_BATTERY: ('Low Battery', 'battery'),
SENSOR_DOOR: ('Door', 'door'),
SENSOR_GARAGE_DOOR: ('Garage Door', 'garage_door'),
SENSOR_LEAK: ('Leak Detector', 'moisture'),
SENSOR_MISSING: ('Missing', 'connectivity'),
SENSOR_SAFE: ('Safe', 'door'),
SENSOR_SLIDING: ('Sliding Door/Window', 'door'),
SENSOR_SMOKE_CO: ('Smoke/Carbon Monoxide Detector', 'smoke'),
SENSOR_WINDOW_HINGED_HORIZONTAL: ('Hinged Window', 'window'),
SENSOR_WINDOW_HINGED_VERTICAL: ('Hinged Window', 'window'),
}
SENSOR_TYPES = {
SENSOR_TEMPERATURE: ('Temperature', 'temperature', '°C'),
}

CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
})
}, extra=vol.ALLOW_EXTRA)


async def async_setup(hass, config):
"""Set up the Notion component."""
hass.data[DOMAIN] = {}
hass.data[DOMAIN][DATA_CLIENT] = {}
hass.data[DOMAIN][DATA_LISTENER] = {}

if DOMAIN not in config:
return True

conf = config[DOMAIN]

if conf[CONF_USERNAME] in configured_instances(hass):
return True

hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={'source': SOURCE_IMPORT},
data={
CONF_USERNAME: conf[CONF_USERNAME],
CONF_PASSWORD: conf[CONF_PASSWORD]
}))

return True


async def async_setup_entry(hass, config_entry):
"""Set up Notion as a config entry."""
session = aiohttp_client.async_get_clientsession(hass)

try:
client = await async_get_client(
config_entry.data[CONF_USERNAME],
config_entry.data[CONF_PASSWORD],
session)
except InvalidCredentialsError:
_LOGGER.error('Invalid username and/or password')
return
This conversation was marked as resolved by bachya

This comment has been minimized.

Copy link
@MartinHjelmare

MartinHjelmare Jul 9, 2019

Member

return False

except NotionError as err:
_LOGGER.error('Config entry failed: %s', err)
raise ConfigEntryNotReady
This conversation was marked as resolved by bachya

This comment has been minimized.

Copy link
@balloob

balloob Jul 1, 2019

Member

Can you differentiate between authentication and connection errors ? Authentication errors are non-recoverable and would probably require an update from the user. Connection errors are recoverable.

(this can be in a future PR)

This comment has been minimized.

Copy link
@bachya

bachya Jul 2, 2019

Author Contributor

Great point. I'll need to update aionotion with a new exception, so I'll take your advice and do this as a separate PR.

EDIT: since this ended up being a fairly small change, going to include it in this PR. 37a796d


notion = Notion(hass, client, config_entry.entry_id)
await notion.async_update()
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = notion

for component in ('binary_sensor', 'sensor'):
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(
config_entry, component))

async def refresh(event_time):
"""Refresh Notion sensor data."""
_LOGGER.debug('Refreshing Notion sensor data')
await notion.async_update()
async_dispatcher_send(hass, TOPIC_DATA_UPDATE)

hass.data[DOMAIN][DATA_LISTENER][
config_entry.entry_id] = async_track_time_interval(
hass,
refresh,
DEFAULT_SCAN_INTERVAL)

return True


async def async_unload_entry(hass, config_entry):
"""Unload a Notion config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
cancel = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id)
cancel()

for component in ('binary_sensor', 'sensor'):
await hass.config_entries.async_forward_entry_unload(
config_entry, component)

return True


async def register_new_bridge(hass, bridge, config_entry_id):
"""Register a new bridge."""
device_registry = await dr.async_get_registry(hass)
device_registry.async_get_or_create(
config_entry_id=config_entry_id,
identifiers={
(DOMAIN, bridge['hardware_id'])
},
manufacturer='Silicon Labs',
model=bridge['hardware_revision'],
name=bridge['name'] or bridge['id'],
sw_version=bridge['firmware_version']['wifi']
)


class Notion:
"""Define a class to handle the Notion API."""

def __init__(self, hass, client, config_entry_id):
"""Initialize."""
self._client = client
self._config_entry_id = config_entry_id
self._hass = hass
self.bridges = {}
self.sensors = {}
self.tasks = {}

async def async_update(self):
"""Get the latest Notion data."""
tasks = {
'bridges': self._client.bridge.async_all(),
'sensors': self._client.sensor.async_all(),
'tasks': self._client.task.async_all(),
}

results = await asyncio.gather(*tasks.values(), return_exceptions=True)
for attr, result in zip(tasks, results):
if isinstance(result, NotionError):
_LOGGER.error(
'There was an error while updating %s: %s', attr, result)
continue

holding_pen = getattr(self, attr)
for item in result:
if attr == 'bridges' and item['id'] not in holding_pen:
# If a new bridge is discovered, register it:
self._hass.async_create_task(
register_new_bridge(
self._hass, item, self._config_entry_id))
holding_pen[item['id']] = item


class NotionEntity(Entity):
"""Define a base Notion entity."""

def __init__(
self,
notion,
task_id,
sensor_id,
bridge_id,
system_id,
name,
device_class):
"""Initialize the entity."""
self._async_unsub_dispatcher_connect = None
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._bridge_id = bridge_id
self._device_class = device_class
self._name = name
self._notion = notion
self._sensor_id = sensor_id
self._state = None
self._system_id = system_id
self._task_id = task_id

@property
def available(self):
"""Return True if entity is available."""
return self._task_id in self._notion.tasks

@property
def device_class(self):
"""Return the device class."""
return self._device_class

@property
def device_state_attributes(self) -> dict:
"""Return the state attributes."""
return self._attrs

@property
def device_info(self):
"""Return device registry information for this entity."""
bridge = self._notion.bridges[self._bridge_id]
sensor = self._notion.sensors[self._sensor_id]

return {
'identifiers': {
(DOMAIN, sensor['hardware_id'])
},
'manufacturer': 'Silicon Labs',
'model': sensor['hardware_revision'],
'name': sensor['name'],
'sw_version': sensor['firmware_version'],
'via_device': (DOMAIN, bridge['hardware_id'])
}

@property
def name(self):
"""Return the name of the sensor."""
return '{0}: {1}'.format(
self._notion.sensors[self._sensor_id]['name'], self._name)

@property
def should_poll(self):
"""Disable entity polling."""
return False

@property
def unique_id(self):
"""Return a unique, unchanging string that represents this sensor."""
return self._task_id

async def _update_bridge_id(self):
"""Update the entity's bridge ID if it has changed.
Sensors can move to other bridges based on signal strength, etc.
"""
sensor = self._notion.sensors[self._sensor_id]
if self._bridge_id == sensor['bridge']['id']:
return

self._bridge_id = sensor['bridge']['id']

device_registry = await dr.async_get_registry(self.hass)
bridge = self._notion.bridges[self._bridge_id]
bridge_device = device_registry.async_get_device(
{DOMAIN: bridge['hardware_id']}, set())
this_device = device_registry.async_get_device(
{DOMAIN: sensor['hardware_id']})

device_registry.async_update_device(
this_device.id, via_device_id=bridge_device.id)

async def async_added_to_hass(self):
"""Register callbacks."""
@callback
def update():
"""Update the entity."""
self.hass.async_create_task(self._update_bridge_id())
self.async_schedule_update_ha_state(True)

self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, TOPIC_DATA_UPDATE, update)

async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
ProTip! Use n and p to navigate between commits in a pull request.
You can’t perform that action at this time.