forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for Notion Home Monitoring (home-assistant#24634)
* Add support for Notion Home Monitoring * Updated coverage * Removed auto-generated translations * Stale docstrings * Corrected hardware version * Fixed binary sensor representation * Cleanup and update protection * Updated log message * Cleaned up is_on * Updated docstring * Modified which data is updated during async_update * Added more checks during update * More cleanup * Fixed unhandled exception * Owner-requested changes (round 1) * Fixed incorrect scan interval retrieval * Ugh * Removed unnecessary import * Simplified everything via dict lookups * Ensure bridges are properly registered * Fixed tests * Added catch for invalid credentials * Ensure bridge ID is updated as necessary * Updated method name * Simplified bridge update * Add support for updating bridge via_device_id * Device update guard clause * Removed excess whitespace * Whitespace * Owner comments * Member comments
- Loading branch information
1 parent
7a5fca6
commit c2e843c
Showing
16 changed files
with
700 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Validating CODEOWNERS rules …
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 False | ||
except NotionError as err: | ||
_LOGGER.error('Config entry failed: %s', err) | ||
raise ConfigEntryNotReady | ||
|
||
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() |
Oops, something went wrong.