Update AirVisual to use DataUpdateCoordinator (#34796)

* Update AirVisual to use DataUpdateCoordinator

* Empty commit to re-trigger build

* Don't include history or trends in config flow

* Code review
pull/34903/head
Aaron Bach 2020-05-01 11:29:58 -06:00 committed by GitHub
parent e6be297fba
commit 8661cf463a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 159 additions and 201 deletions

View File

@ -19,30 +19,23 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, 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.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_CITY,
CONF_COUNTRY,
CONF_GEOGRAPHIES,
CONF_INTEGRATION_TYPE,
DATA_CLIENT,
DATA_COORDINATOR,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
INTEGRATION_TYPE_NODE_PRO,
LOGGER,
TOPIC_UPDATE,
)
PLATFORMS = ["air_quality", "sensor"]
DATA_LISTENER = "listener"
DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
DEFAULT_GEOGRAPHY_SCAN_INTERVAL = timedelta(minutes=10)
DEFAULT_NODE_PRO_SCAN_INTERVAL = timedelta(minutes=1)
@ -97,7 +90,7 @@ def async_get_geography_id(geography_dict):
async def async_setup(hass, config):
"""Set up the AirVisual component."""
hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_LISTENER: {}}
hass.data[DOMAIN] = {DATA_COORDINATOR: {}}
if DOMAIN not in config:
return True
@ -167,35 +160,71 @@ async def async_setup_entry(hass, config_entry):
if CONF_API_KEY in config_entry.data:
_standardize_geography_config_entry(hass, config_entry)
airvisual = AirVisualGeographyData(
client = Client(api_key=config_entry.data[CONF_API_KEY], session=websession)
async def async_update_data():
"""Get new data from the API."""
if CONF_CITY in config_entry.data:
api_coro = client.api.city(
config_entry.data[CONF_CITY],
config_entry.data[CONF_STATE],
config_entry.data[CONF_COUNTRY],
)
else:
api_coro = client.api.nearest_city(
config_entry.data[CONF_LATITUDE], config_entry.data[CONF_LONGITUDE],
)
try:
return await api_coro
except AirVisualError as err:
raise UpdateFailed(f"Error while retrieving data: {err}")
coordinator = DataUpdateCoordinator(
hass,
Client(api_key=config_entry.data[CONF_API_KEY], session=websession),
config_entry,
LOGGER,
name="geography data",
update_interval=DEFAULT_GEOGRAPHY_SCAN_INTERVAL,
update_method=async_update_data,
)
# Only geography-based entries have options:
config_entry.add_update_listener(async_update_options)
else:
_standardize_node_pro_config_entry(hass, config_entry)
airvisual = AirVisualNodeProData(hass, Client(session=websession), config_entry)
await airvisual.async_update()
client = Client(session=websession)
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = airvisual
async def async_update_data():
"""Get new data from the API."""
try:
return await client.node.from_samba(
config_entry.data[CONF_IP_ADDRESS],
config_entry.data[CONF_PASSWORD],
include_history=False,
include_trends=False,
)
except NodeProError as err:
raise UpdateFailed(f"Error while retrieving data: {err}")
coordinator = DataUpdateCoordinator(
hass,
LOGGER,
name="Node/Pro data",
update_interval=DEFAULT_NODE_PRO_SCAN_INTERVAL,
update_method=async_update_data,
)
await coordinator.async_refresh()
hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] = coordinator
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
async def refresh(event_time):
"""Refresh data from AirVisual."""
await airvisual.async_update()
hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval(
hass, refresh, airvisual.scan_interval
)
return True
@ -248,28 +277,31 @@ async def async_unload_entry(hass, config_entry):
)
)
if unload_ok:
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id)
remove_listener()
hass.data[DOMAIN][DATA_COORDINATOR].pop(config_entry.entry_id)
return unload_ok
async def async_update_options(hass, config_entry):
"""Handle an options update."""
airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
airvisual.async_update_options(config_entry.options)
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id]
await coordinator.async_request_refresh()
class AirVisualEntity(Entity):
"""Define a generic AirVisual entity."""
def __init__(self, airvisual):
def __init__(self, coordinator):
"""Initialize."""
self._airvisual = airvisual
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._icon = None
self._unit = None
self.coordinator = coordinator
@property
def available(self):
"""Return if entity is available."""
return self.coordinator.last_update_success
@property
def device_state_attributes(self):
@ -295,9 +327,7 @@ class AirVisualEntity(Entity):
self.update_from_latest_data()
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(self.hass, self._airvisual.topic_update, update)
)
self.async_on_remove(self.coordinator.async_add_listener(update))
self.update_from_latest_data()
@ -305,76 +335,3 @@ class AirVisualEntity(Entity):
def update_from_latest_data(self):
"""Update the entity from the latest data."""
raise NotImplementedError
class AirVisualGeographyData:
"""Define a class to manage data from the AirVisual cloud API."""
def __init__(self, hass, client, config_entry):
"""Initialize."""
self._client = client
self._hass = hass
self.data = {}
self.geography_data = config_entry.data
self.geography_id = config_entry.unique_id
self.integration_type = INTEGRATION_TYPE_GEOGRAPHY
self.options = config_entry.options
self.scan_interval = DEFAULT_GEOGRAPHY_SCAN_INTERVAL
self.topic_update = TOPIC_UPDATE.format(config_entry.unique_id)
async def async_update(self):
"""Get new data for all locations from the AirVisual cloud API."""
if CONF_CITY in self.geography_data:
api_coro = self._client.api.city(
self.geography_data[CONF_CITY],
self.geography_data[CONF_STATE],
self.geography_data[CONF_COUNTRY],
)
else:
api_coro = self._client.api.nearest_city(
self.geography_data[CONF_LATITUDE], self.geography_data[CONF_LONGITUDE],
)
try:
self.data[self.geography_id] = await api_coro
except AirVisualError as err:
LOGGER.error("Error while retrieving data: %s", err)
self.data[self.geography_id] = {}
LOGGER.debug("Received new geography data")
async_dispatcher_send(self._hass, self.topic_update)
@callback
def async_update_options(self, options):
"""Update the data manager's options."""
self.options = options
async_dispatcher_send(self._hass, self.topic_update)
class AirVisualNodeProData:
"""Define a class to manage data from an AirVisual Node/Pro."""
def __init__(self, hass, client, config_entry):
"""Initialize."""
self._client = client
self._hass = hass
self._password = config_entry.data[CONF_PASSWORD]
self.data = {}
self.integration_type = INTEGRATION_TYPE_NODE_PRO
self.ip_address = config_entry.data[CONF_IP_ADDRESS]
self.scan_interval = DEFAULT_NODE_PRO_SCAN_INTERVAL
self.topic_update = TOPIC_UPDATE.format(config_entry.data[CONF_IP_ADDRESS])
async def async_update(self):
"""Get new data from the Node/Pro."""
try:
self.data = await self._client.node.from_samba(
self.ip_address, self._password, include_history=False
)
except NodeProError as err:
LOGGER.error("Error while retrieving Node/Pro data: %s", err)
self.data = {}
return
LOGGER.debug("Received new Node/Pro data")
async_dispatcher_send(self._hass, self.topic_update)

View File

@ -4,7 +4,12 @@ from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import callback
from . import AirVisualEntity
from .const import DATA_CLIENT, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY
from .const import (
CONF_INTEGRATION_TYPE,
DATA_COORDINATOR,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
)
ATTR_HUMIDITY = "humidity"
ATTR_SENSOR_LIFE = "{0}_sensor_life"
@ -13,13 +18,13 @@ ATTR_VOC = "voc"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AirVisual air quality entities based on a config entry."""
airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id]
# Geography-based AirVisual integrations don't utilize this platform:
if airvisual.integration_type == INTEGRATION_TYPE_GEOGRAPHY:
if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY:
return
async_add_entities([AirVisualNodeProSensor(airvisual)], True)
async_add_entities([AirVisualNodeProSensor(coordinator)], True)
class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity):
@ -35,69 +40,71 @@ class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity):
@property
def air_quality_index(self):
"""Return the Air Quality Index (AQI)."""
if self._airvisual.data["current"]["settings"]["is_aqi_usa"]:
return self._airvisual.data["current"]["measurements"]["aqi_us"]
return self._airvisual.data["current"]["measurements"]["aqi_cn"]
if self.coordinator.data["current"]["settings"]["is_aqi_usa"]:
return self.coordinator.data["current"]["measurements"]["aqi_us"]
return self.coordinator.data["current"]["measurements"]["aqi_cn"]
@property
def available(self):
"""Return True if entity is available."""
return bool(self._airvisual.data)
return bool(self.coordinator.data)
@property
def carbon_dioxide(self):
"""Return the CO2 (carbon dioxide) level."""
return self._airvisual.data["current"]["measurements"].get("co2_ppm")
return self.coordinator.data["current"]["measurements"].get("co2")
@property
def device_info(self):
"""Return device registry information for this entity."""
return {
"identifiers": {(DOMAIN, self._airvisual.data["current"]["serial_number"])},
"name": self._airvisual.data["current"]["settings"]["node_name"],
"identifiers": {
(DOMAIN, self.coordinator.data["current"]["serial_number"])
},
"name": self.coordinator.data["current"]["settings"]["node_name"],
"manufacturer": "AirVisual",
"model": f'{self._airvisual.data["current"]["status"]["model"]}',
"model": f'{self.coordinator.data["current"]["status"]["model"]}',
"sw_version": (
f'Version {self._airvisual.data["current"]["status"]["system_version"]}'
f'{self._airvisual.data["current"]["status"]["app_version"]}'
f'Version {self.coordinator.data["current"]["status"]["system_version"]}'
f'{self.coordinator.data["current"]["status"]["app_version"]}'
),
}
@property
def name(self):
"""Return the name."""
node_name = self._airvisual.data["current"]["settings"]["node_name"]
node_name = self.coordinator.data["current"]["settings"]["node_name"]
return f"{node_name} Node/Pro: Air Quality"
@property
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
return self._airvisual.data["current"]["measurements"].get("pm2_5")
return self.coordinator.data["current"]["measurements"].get("pm2_5")
@property
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
return self._airvisual.data["current"]["measurements"].get("pm1_0")
return self.coordinator.data["current"]["measurements"].get("pm1_0")
@property
def particulate_matter_0_1(self):
"""Return the particulate matter 0.1 level."""
return self._airvisual.data["current"]["measurements"].get("pm0_1")
return self.coordinator.data["current"]["measurements"].get("pm0_1")
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return self._airvisual.data["current"]["serial_number"]
return self.coordinator.data["current"]["serial_number"]
@callback
def update_from_latest_data(self):
"""Update from the Node/Pro's data."""
"""Update the entity from the latest data."""
self._attrs.update(
{
ATTR_VOC: self._airvisual.data["current"]["measurements"].get("voc"),
ATTR_VOC: self.coordinator.data["current"]["measurements"].get("voc"),
**{
ATTR_SENSOR_LIFE.format(pollutant): lifespan
for pollutant, lifespan in self._airvisual.data["current"][
for pollutant, lifespan in self.coordinator.data["current"][
"status"
]["sensor_life"].items()
},

View File

@ -146,7 +146,10 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try:
await client.node.from_samba(
user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD]
user_input[CONF_IP_ADDRESS],
user_input[CONF_PASSWORD],
include_history=False,
include_trends=False,
)
except NodeProError as err:
LOGGER.error("Error connecting to Node/Pro unit: %s", err)

View File

@ -12,6 +12,4 @@ CONF_COUNTRY = "country"
CONF_GEOGRAPHIES = "geographies"
CONF_INTEGRATION_TYPE = "integration_type"
DATA_CLIENT = "client"
TOPIC_UPDATE = f"airvisual_update_{0}"
DATA_COORDINATOR = "coordinator"

View File

@ -24,7 +24,8 @@ from . import AirVisualEntity
from .const import (
CONF_CITY,
CONF_COUNTRY,
DATA_CLIENT,
CONF_INTEGRATION_TYPE,
DATA_COORDINATOR,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
)
@ -92,68 +93,53 @@ POLLUTANT_MAPPING = {
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AirVisual sensors based on a config entry."""
airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id]
if airvisual.integration_type == INTEGRATION_TYPE_GEOGRAPHY:
if config_entry.data[CONF_INTEGRATION_TYPE] == INTEGRATION_TYPE_GEOGRAPHY:
sensors = [
AirVisualGeographySensor(
airvisual, kind, name, icon, unit, locale, geography_id,
coordinator, config_entry, kind, name, icon, unit, locale,
)
for geography_id in airvisual.data
for locale in GEOGRAPHY_SENSOR_LOCALES
for kind, name, icon, unit in GEOGRAPHY_SENSORS
]
else:
sensors = [
AirVisualNodeProSensor(airvisual, kind, name, device_class, unit)
AirVisualNodeProSensor(coordinator, kind, name, device_class, unit)
for kind, name, device_class, unit in NODE_PRO_SENSORS
]
async_add_entities(sensors, True)
class AirVisualSensor(AirVisualEntity):
"""Define a generic AirVisual sensor."""
class AirVisualGeographySensor(AirVisualEntity):
"""Define an AirVisual sensor related to geography data via the Cloud API."""
def __init__(self, airvisual, kind, name, unit):
def __init__(self, coordinator, config_entry, kind, name, icon, unit, locale):
"""Initialize."""
super().__init__(airvisual)
super().__init__(coordinator)
self._attrs.update(
{
ATTR_CITY: config_entry.data.get(CONF_CITY),
ATTR_STATE: config_entry.data.get(CONF_STATE),
ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY),
}
)
self._config_entry = config_entry
self._icon = icon
self._kind = kind
self._locale = locale
self._name = name
self._state = None
self._unit = unit
@property
def state(self):
"""Return the state."""
return self._state
class AirVisualGeographySensor(AirVisualSensor):
"""Define an AirVisual sensor related to geography data via the Cloud API."""
def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id):
"""Initialize."""
super().__init__(airvisual, kind, name, unit)
self._attrs.update(
{
ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY),
ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE),
ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY),
}
)
self._geography_id = geography_id
self._icon = icon
self._locale = locale
@property
def available(self):
"""Return True if entity is available."""
try:
return bool(
self._airvisual.data[self._geography_id]["current"]["pollution"]
return self.coordinator.last_update_success and bool(
self.coordinator.data["current"]["pollution"]
)
except KeyError:
return False
@ -163,16 +149,21 @@ class AirVisualGeographySensor(AirVisualSensor):
"""Return the name."""
return f"{GEOGRAPHY_SENSOR_LOCALES[self._locale]} {self._name}"
@property
def state(self):
"""Return the state."""
return self._state
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._geography_id}_{self._locale}_{self._kind}"
return f"{self._config_entry.unique_id}_{self._locale}_{self._kind}"
@callback
def update_from_latest_data(self):
"""Update the sensor."""
"""Update the entity from the latest data."""
try:
data = self._airvisual.data[self._geography_id]["current"]["pollution"]
data = self.coordinator.data["current"]["pollution"]
except KeyError:
return
@ -197,36 +188,31 @@ class AirVisualGeographySensor(AirVisualSensor):
}
)
if CONF_LATITUDE in self._airvisual.geography_data:
if self._airvisual.options[CONF_SHOW_ON_MAP]:
self._attrs[ATTR_LATITUDE] = self._airvisual.geography_data[
CONF_LATITUDE
]
self._attrs[ATTR_LONGITUDE] = self._airvisual.geography_data[
CONF_LONGITUDE
]
if CONF_LATITUDE in self._config_entry.data:
if self._config_entry.options[CONF_SHOW_ON_MAP]:
self._attrs[ATTR_LATITUDE] = self._config_entry.data[CONF_LATITUDE]
self._attrs[ATTR_LONGITUDE] = self._config_entry.data[CONF_LONGITUDE]
self._attrs.pop("lati", None)
self._attrs.pop("long", None)
else:
self._attrs["lati"] = self._airvisual.geography_data[CONF_LATITUDE]
self._attrs["long"] = self._airvisual.geography_data[CONF_LONGITUDE]
self._attrs["lati"] = self._config_entry.data[CONF_LATITUDE]
self._attrs["long"] = self._config_entry.data[CONF_LONGITUDE]
self._attrs.pop(ATTR_LATITUDE, None)
self._attrs.pop(ATTR_LONGITUDE, None)
class AirVisualNodeProSensor(AirVisualSensor):
class AirVisualNodeProSensor(AirVisualEntity):
"""Define an AirVisual sensor related to a Node/Pro unit."""
def __init__(self, airvisual, kind, name, device_class, unit):
def __init__(self, coordinator, kind, name, device_class, unit):
"""Initialize."""
super().__init__(airvisual, kind, name, unit)
super().__init__(coordinator)
self._device_class = device_class
@property
def available(self):
"""Return True if entity is available."""
return bool(self._airvisual.data)
self._kind = kind
self._name = name
self._state = None
self._unit = unit
@property
def device_class(self):
@ -237,37 +223,44 @@ class AirVisualNodeProSensor(AirVisualSensor):
def device_info(self):
"""Return device registry information for this entity."""
return {
"identifiers": {(DOMAIN, self._airvisual.data["current"]["serial_number"])},
"name": self._airvisual.data["current"]["settings"]["node_name"],
"identifiers": {
(DOMAIN, self.coordinator.data["current"]["serial_number"])
},
"name": self.coordinator.data["current"]["settings"]["node_name"],
"manufacturer": "AirVisual",
"model": f'{self._airvisual.data["current"]["status"]["model"]}',
"model": f'{self.coordinator.data["current"]["status"]["model"]}',
"sw_version": (
f'Version {self._airvisual.data["current"]["status"]["system_version"]}'
f'{self._airvisual.data["current"]["status"]["app_version"]}'
f'Version {self.coordinator.data["current"]["status"]["system_version"]}'
f'{self.coordinator.data["current"]["status"]["app_version"]}'
),
}
@property
def name(self):
"""Return the name."""
node_name = self._airvisual.data["current"]["settings"]["node_name"]
node_name = self.coordinator.data["current"]["settings"]["node_name"]
return f"{node_name} Node/Pro: {self._name}"
@property
def state(self):
"""Return the state."""
return self._state
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._airvisual.data['current']['serial_number']}_{self._kind}"
return f"{self.coordinator.data['current']['serial_number']}_{self._kind}"
@callback
def update_from_latest_data(self):
"""Update from the Node/Pro's data."""
"""Update the entity from the latest data."""
if self._kind == SENSOR_KIND_BATTERY_LEVEL:
self._state = self._airvisual.data["current"]["status"]["battery"]
self._state = self.coordinator.data["current"]["status"]["battery"]
elif self._kind == SENSOR_KIND_HUMIDITY:
self._state = self._airvisual.data["current"]["measurements"].get(
self._state = self.coordinator.data["current"]["measurements"].get(
"humidity"
)
elif self._kind == SENSOR_KIND_TEMPERATURE:
self._state = self._airvisual.data["current"]["measurements"].get(
self._state = self.coordinator.data["current"]["measurements"].get(
"temperature_C"
)