Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 64 additions & 107 deletions homeassistant/components/airvisual/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,23 @@
)
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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):
Expand All @@ -295,86 +327,11 @@ def update():
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()

Comment thread
bachya marked this conversation as resolved.
@callback
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)
51 changes: 29 additions & 22 deletions homeassistant/components/airvisual/air_quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
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"
Expand All @@ -13,13 +18,13 @@

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):
Expand All @@ -35,69 +40,71 @@ def __init__(self, airvisual):
@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()
},
Expand Down
5 changes: 4 additions & 1 deletion homeassistant/components/airvisual/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ async def async_step_node_pro(self, user_input=None):

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)
Expand Down
4 changes: 1 addition & 3 deletions homeassistant/components/airvisual/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,4 @@
CONF_GEOGRAPHIES = "geographies"
CONF_INTEGRATION_TYPE = "integration_type"

DATA_CLIENT = "client"

TOPIC_UPDATE = f"airvisual_update_{0}"
DATA_COORDINATOR = "coordinator"
Loading