From 31e0ddaec5adc4d23cd4b3009d8a054e1640d413 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 21 Aug 2020 08:10:12 -0600 Subject: [PATCH] Update Notion to use a DataUpdateCoordinator (#38978) * Update Notion to use a DataUpdateCoordinator * isort * Bug --- homeassistant/components/notion/__init__.py | 266 ++++++++---------- .../components/notion/binary_sensor.py | 55 ++-- homeassistant/components/notion/const.py | 21 +- homeassistant/components/notion/sensor.py | 48 ++-- 4 files changed, 191 insertions(+), 199 deletions(-) diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 258a939a07e..1c8fc650803 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -1,68 +1,35 @@ """Support for Notion.""" import asyncio +from datetime import timedelta 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, - TEMP_CELSIUS, -) -from homeassistant.core import callback +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_DATA_UPDATE +from .const import DATA_COORDINATOR, DOMAIN _LOGGER = logging.getLogger(__name__) +PLATFORMS = ["binary_sensor", "sensor"] + 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", TEMP_CELSIUS)} +DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = vol.Schema( { @@ -77,11 +44,9 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Notion component.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CLIENT] = {} - hass.data[DOMAIN][DATA_LISTENER] = {} + hass.data[DOMAIN] = {DATA_COORDINATOR: {}} if DOMAIN not in config: return True @@ -102,18 +67,18 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" - if not config_entry.unique_id: + if not entry.unique_id: hass.config_entries.async_update_entry( - config_entry, unique_id=config_entry.data[CONF_USERNAME] + entry, unique_id=entry.data[CONF_USERNAME] ) session = aiohttp_client.async_get_clientsession(hass) try: client = await async_get_client( - config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], session + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session ) except InvalidCredentialsError: _LOGGER.error("Invalid username and/or password") @@ -122,49 +87,77 @@ async def async_setup_entry(hass, config_entry): _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 + async def async_update(): + """Get the latest data from the Notion API.""" + data = {"bridges": {}, "sensors": {}, "tasks": {}} + tasks = { + "bridges": client.bridge.async_all(), + "sensors": client.sensor.async_all(), + "tasks": client.task.async_all(), + } - for component in ("binary_sensor", "sensor"): - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, component) - ) + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for attr, result in zip(tasks, results): + if isinstance(result, NotionError): + raise UpdateFailed( + f"There was a Notion error while updating {attr}: {result}" + ) + if isinstance(result, Exception): + raise UpdateFailed( + f"There was an unknown error while updating {attr}: {result}" + ) - 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) + for item in result: + if attr == "bridges" and item["id"] not in data["bridges"]: + # If a new bridge is discovered, register it: + hass.async_create_task(async_register_new_bridge(hass, item, entry)) + data[attr][item["id"]] = item - hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( - hass, refresh, DEFAULT_SCAN_INTERVAL + return data + + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][ + entry.entry_id + ] = DataUpdateCoordinator( + hass, + _LOGGER, + name=entry.data[CONF_USERNAME], + update_interval=DEFAULT_SCAN_INTERVAL, + update_method=async_update, ) + await coordinator.async_refresh() + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """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() + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) - tasks = [ - hass.config_entries.async_forward_entry_unload(config_entry, component) - for component in ("binary_sensor", "sensor") - ] - - await asyncio.gather(*tasks) - - return True + return unload_ok -async def register_new_bridge(hass, bridge, config_entry_id): +async def async_register_new_bridge( + hass: HomeAssistant, bridge: dict, entry: ConfigEntry +): """Register a new bridge.""" device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( - config_entry_id=config_entry_id, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, bridge["hardware_id"])}, manufacturer="Silicon Labs", model=bridge["hardware_revision"], @@ -173,73 +166,40 @@ async def register_new_bridge(hass, bridge, config_entry_id): ) -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 a Notion error while updating %s: %s", attr, result - ) - continue - if isinstance(result, Exception): - _LOGGER.error( - "There was an unknown 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 + self, + coordinator: DataUpdateCoordinator, + task_id: str, + sensor_id: str, + bridge_id: str, + system_id: str, + name: str, + device_class: str, ): """Initialize the entity.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._bridge_id = bridge_id + self._coordinator = coordinator 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): + def available(self) -> bool: """Return True if entity is available.""" - return self._task_id in self._notion.tasks + return ( + self._coordinator.last_update_success + and self._task_id in self._coordinator.data["tasks"] + ) @property - def device_class(self): + def device_class(self) -> str: """Return the device class.""" return self._device_class @@ -249,10 +209,10 @@ class NotionEntity(Entity): return self._attrs @property - def device_info(self): + def device_info(self) -> dict: """Return device registry information for this entity.""" - bridge = self._notion.bridges.get(self._bridge_id, {}) - sensor = self._notion.sensors[self._sensor_id] + bridge = self._coordinator.data["bridges"].get(self._bridge_id, {}) + sensor = self._coordinator.data["sensors"][self._sensor_id] return { "identifiers": {(DOMAIN, sensor["hardware_id"])}, @@ -264,41 +224,42 @@ class NotionEntity(Entity): } @property - def name(self): - """Return the name of the sensor.""" - return f"{self._notion.sensors[self._sensor_id]['name']}: {self._name}" + def name(self) -> str: + """Return the name of the entity.""" + sensor = self._coordinator.data["sensors"][self._sensor_id] + return f'{sensor["name"]}: {self._name}' @property - def should_poll(self): + def should_poll(self) -> bool: """Disable entity polling.""" return False @property - def unique_id(self): - """Return a unique, unchanging string that represents this sensor.""" - task = self._notion.tasks[self._task_id] - return f"{self._sensor_id}_{task['task_type']}" + def unique_id(self) -> str: + """Return a unique, unchanging string that represents this entity.""" + task = self._coordinator.data["tasks"][self._task_id] + return f'{self._sensor_id}_{task["task_type"]}' - async def _update_bridge_id(self): + async def _async_update_bridge_id(self) -> None: """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] + sensor = self._coordinator.data["sensors"][self._sensor_id] # If the sensor's bridge ID is the same as what we had before or if it points # to a bridge that doesn't exist (which can happen due to a Notion API bug), # return immediately: if ( self._bridge_id == sensor["bridge"]["id"] - or sensor["bridge"]["id"] not in self._notion.bridges + or sensor["bridge"]["id"] not in self._coordinator.data["bridges"] ): return self._bridge_id = sensor["bridge"]["id"] device_registry = await dr.async_get_registry(self.hass) - bridge = self._notion.bridges[self._bridge_id] + bridge = self._coordinator.data["bridges"][self._bridge_id] bridge_device = device_registry.async_get_device( {DOMAIN: bridge["hardware_id"]}, set() ) @@ -310,23 +271,20 @@ class NotionEntity(Entity): this_device.id, via_device_id=bridge_device.id ) - async def async_added_to_hass(self): + @callback + def _async_update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback def update(): - """Update the entity.""" - self.hass.async_create_task(self._update_bridge_id()) - self.update_from_latest_data() + """Update the state.""" + self._async_update_from_latest_data() self.async_write_ha_state() - self.async_on_remove( - async_dispatcher_connect(self.hass, TOPIC_DATA_UPDATE, update) - ) + self.async_on_remove(self._coordinator.async_add_listener(update)) - self.update_from_latest_data() - - @callback - def update_from_latest_data(self): - """Update the entity from the latest data.""" - raise NotImplementedError + self._async_update_from_latest_data() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 8d60ef0901a..0be4a5336cb 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -1,11 +1,15 @@ """Support for Notion binary sensors.""" import logging +from typing import Callable from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback -from . import ( - BINARY_SENSOR_TYPES, +from . import NotionEntity +from .const import ( + DATA_COORDINATOR, + DOMAIN, SENSOR_BATTERY, SENSOR_DOOR, SENSOR_GARAGE_DOOR, @@ -16,28 +20,41 @@ from . import ( SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED_HORIZONTAL, SENSOR_WINDOW_HINGED_VERTICAL, - NotionEntity, ) -from .const import DATA_CLIENT, DOMAIN _LOGGER = logging.getLogger(__name__) +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"), +} -async def async_setup_entry(hass, entry, async_add_entities): + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable +): """Set up Notion sensors based on a config entry.""" - notion = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] sensor_list = [] - for task_id, task in notion.tasks.items(): + for task_id, task in coordinator.data["tasks"].items(): if task["task_type"] not in BINARY_SENSOR_TYPES: continue name, device_class = BINARY_SENSOR_TYPES[task["task_type"]] - sensor = notion.sensors[task["sensor_id"]] + sensor = coordinator.data["sensors"][task["sensor_id"]] sensor_list.append( NotionBinarySensor( - notion, + coordinator, task_id, sensor["id"], sensor["bridge"]["id"], @@ -47,16 +64,21 @@ async def async_setup_entry(hass, entry, async_add_entities): ) ) - async_add_entities(sensor_list, True) + async_add_entities(sensor_list) class NotionBinarySensor(NotionEntity, BinarySensorEntity): """Define a Notion sensor.""" + @callback + def _async_update_from_latest_data(self) -> None: + """Fetch new state data for the sensor.""" + self._state = self._coordinator.data["tasks"][self._task_id]["status"]["value"] + @property - def is_on(self): + def is_on(self) -> bool: """Return whether the sensor is on or off.""" - task = self._notion.tasks[self._task_id] + task = self._coordinator.data["tasks"][self._task_id] if task["task_type"] == SENSOR_BATTERY: return self._state != "battery_good" @@ -75,10 +97,3 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): return self._state == "not_missing" if task["task_type"] == SENSOR_SMOKE_CO: return self._state != "no_alarm" - - @callback - def update_from_latest_data(self): - """Fetch new state data for the sensor.""" - task = self._notion.tasks[self._task_id] - - self._state = task["status"]["value"] diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 6ce5c4e5bc7..6a6da180374 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -1,13 +1,16 @@ """Define constants for the Notion integration.""" -from datetime import timedelta - DOMAIN = "notion" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) +DATA_COORDINATOR = "coordinator" -DATA_CLIENT = "client" - -TOPIC_DATA_UPDATE = f"{DOMAIN}_data_update" - -TYPE_BINARY_SENSOR = "binary_sensor" -TYPE_SENSOR = "sensor" +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" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 918ee8c5f95..f810245b7e2 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,29 +1,37 @@ """Support for Notion sensors.""" import logging +from typing import Callable -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import SENSOR_TEMPERATURE, SENSOR_TYPES, NotionEntity -from .const import DATA_CLIENT, DOMAIN +from . import NotionEntity +from .const import DATA_COORDINATOR, DOMAIN, SENSOR_TEMPERATURE _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = {SENSOR_TEMPERATURE: ("Temperature", "temperature", TEMP_CELSIUS)} -async def async_setup_entry(hass, entry, async_add_entities): + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable +): """Set up Notion sensors based on a config entry.""" - notion = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] sensor_list = [] - for task_id, task in notion.tasks.items(): + for task_id, task in coordinator.data["tasks"].items(): if task["task_type"] not in SENSOR_TYPES: continue name, device_class, unit = SENSOR_TYPES[task["task_type"]] - sensor = notion.sensors[task["sensor_id"]] + sensor = coordinator.data["sensors"][task["sensor_id"]] sensor_list.append( NotionSensor( - notion, + coordinator, task_id, sensor["id"], sensor["bridge"]["id"], @@ -34,42 +42,50 @@ async def async_setup_entry(hass, entry, async_add_entities): ) ) - async_add_entities(sensor_list, True) + async_add_entities(sensor_list) class NotionSensor(NotionEntity): """Define a Notion sensor.""" def __init__( - self, notion, task_id, sensor_id, bridge_id, system_id, name, device_class, unit + self, + coordinator: DataUpdateCoordinator, + task_id: str, + sensor_id: str, + bridge_id: str, + system_id: str, + name: str, + device_class: str, + unit: str, ): """Initialize the entity.""" super().__init__( - notion, task_id, sensor_id, bridge_id, system_id, name, device_class + coordinator, task_id, sensor_id, bridge_id, system_id, name, device_class ) self._unit = unit @property - def state(self): + def state(self) -> str: """Return the state of the sensor.""" return self._state @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return self._unit @callback - def update_from_latest_data(self): + def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self._notion.tasks[self._task_id] + task = self._coordinator.data["tasks"][self._task_id] if task["task_type"] == SENSOR_TEMPERATURE: self._state = round(float(task["status"]["value"]), 1) else: _LOGGER.error( "Unknown task type: %s: %s", - self._notion.sensors[self._sensor_id], + self._coordinator.data["sensors"][self._sensor_id], task["task_type"], )