From 5ce4de7bbbc4d9cb7d54aa984541fcdaf3268a0c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 4 Jul 2021 13:16:06 -0500 Subject: [PATCH] Migrate AirVisual `air_quality` platform to `sensor` platform (#52349) * Migrate AirVisual air_quality platform sensors * Remove redundancy * Cleanup * Properly set available * Unwind config_entry -> entry name change * More unwinding * Rename * Update homeassistant/components/airvisual/sensor.py Co-authored-by: Franck Nijhof * Code review * Linting Co-authored-by: Franck Nijhof --- .coveragerc | 1 - .../components/airvisual/__init__.py | 36 ++-- .../components/airvisual/air_quality.py | 108 ------------ homeassistant/components/airvisual/sensor.py | 163 +++++++++++------- 4 files changed, 123 insertions(+), 185 deletions(-) delete mode 100644 homeassistant/components/airvisual/air_quality.py diff --git a/.coveragerc b/.coveragerc index 6bf9936e26b..74797f83087 100644 --- a/.coveragerc +++ b/.coveragerc @@ -35,7 +35,6 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airvisual/__init__.py - homeassistant/components/airvisual/air_quality.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/* homeassistant/components/alarmdecoder/__init__.py diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 8a1e0ad9655..89963bff623 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -22,7 +22,11 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + entity_registry, +) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -42,7 +46,7 @@ from .const import ( LOGGER, ) -PLATFORMS = ["air_quality", "sensor"] +PLATFORMS = ["sensor"] DATA_LISTENER = "listener" @@ -124,12 +128,6 @@ def async_sync_geo_coordinator_update_intervals(hass, api_key): coordinator.update_interval = update_interval -async def async_setup(hass, config): - """Set up the AirVisual component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}, DATA_LISTENER: {}} - return True - - @callback def _standardize_geography_config_entry(hass, config_entry): """Ensure that geography config entries have appropriate properties.""" @@ -183,6 +181,8 @@ def _standardize_node_pro_config_entry(hass, config_entry): async def async_setup_entry(hass, config_entry): """Set up AirVisual as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}, DATA_LISTENER: {}}) + if CONF_API_KEY in config_entry.data: _standardize_geography_config_entry(hass, config_entry) @@ -227,6 +227,19 @@ async def async_setup_entry(hass, config_entry): config_entry.entry_id ] = config_entry.add_update_listener(async_reload_entry) else: + # Remove outdated air_quality entities from the entity registry if they exist: + ent_reg = entity_registry.async_get(hass) + for entity_entry in [ + e + for e in ent_reg.entities.values() + if e.config_entry_id == config_entry.entry_id + and e.entity_id.startswith("air_quality") + ]: + LOGGER.debug( + 'Removing deprecated air_quality entity: "%s"', entity_entry.entity_id + ) + ent_reg.async_remove(entity_entry.entity_id) + _standardize_node_pro_config_entry(hass, config_entry) async def async_update_data(): @@ -336,12 +349,7 @@ class AirVisualEntity(CoordinatorEntity): def __init__(self, coordinator): """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/airvisual/air_quality.py b/homeassistant/components/airvisual/air_quality.py deleted file mode 100644 index 175c129068f..00000000000 --- a/homeassistant/components/airvisual/air_quality.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Support for AirVisual Node/Pro units.""" -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.core import callback - -from . import AirVisualEntity -from .const import ( - CONF_INTEGRATION_TYPE, - DATA_COORDINATOR, - DOMAIN, - INTEGRATION_TYPE_NODE_PRO, -) - -ATTR_HUMIDITY = "humidity" -ATTR_SENSOR_LIFE = "{0}_sensor_life" -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.""" - # Geography-based AirVisual integrations don't utilize this platform: - if config_entry.data[CONF_INTEGRATION_TYPE] != INTEGRATION_TYPE_NODE_PRO: - return - - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - - async_add_entities([AirVisualNodeProSensor(coordinator)], True) - - -class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity): - """Define a sensor for a AirVisual Node/Pro.""" - - def __init__(self, airvisual): - """Initialize.""" - super().__init__(airvisual) - - self._attr_icon = "mdi:chemical-weapon" - - @property - def air_quality_index(self): - """Return the Air Quality Index (AQI).""" - if self.coordinator.data["settings"]["is_aqi_usa"]: - return self.coordinator.data["measurements"]["aqi_us"] - return self.coordinator.data["measurements"]["aqi_cn"] - - @property - def available(self): - """Return True if entity is available.""" - return bool(self.coordinator.data) - - @property - def carbon_dioxide(self): - """Return the CO2 (carbon dioxide) level.""" - return self.coordinator.data["measurements"].get("co2") - - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, - "name": self.coordinator.data["settings"]["node_name"], - "manufacturer": "AirVisual", - "model": f'{self.coordinator.data["status"]["model"]}', - "sw_version": ( - f'Version {self.coordinator.data["status"]["system_version"]}' - f'{self.coordinator.data["status"]["app_version"]}' - ), - } - - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["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.coordinator.data["measurements"].get("pm2_5") - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self.coordinator.data["measurements"].get("pm1_0") - - @property - def particulate_matter_0_1(self): - """Return the particulate matter 0.1 level.""" - return self.coordinator.data["measurements"].get("pm0_1") - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self.coordinator.data["serial_number"] - - @callback - def update_from_latest_data(self): - """Update the entity from the latest data.""" - self._attrs.update( - { - ATTR_VOC: self.coordinator.data["measurements"].get("voc"), - **{ - ATTR_SENSOR_LIFE.format(pollutant): lifespan - for pollutant, lifespan in self.coordinator.data["status"][ - "sensor_life" - ].items() - }, - } - ) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index c5d6621a329..fb6cc0c5e26 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, @@ -36,12 +37,18 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_REGION = "region" -SENSOR_KIND_LEVEL = "air_pollution_level" SENSOR_KIND_AQI = "air_quality_index" -SENSOR_KIND_POLLUTANT = "main_pollutant" SENSOR_KIND_BATTERY_LEVEL = "battery_level" +SENSOR_KIND_CO2 = "carbon_dioxide" SENSOR_KIND_HUMIDITY = "humidity" +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_PM_0_1 = "particulate_matter_0_1" +SENSOR_KIND_PM_1_0 = "particulate_matter_1_0" +SENSOR_KIND_PM_2_5 = "particulate_matter_2_5" +SENSOR_KIND_POLLUTANT = "main_pollutant" +SENSOR_KIND_SENSOR_LIFE = "sensor_life" SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_VOC = "voc" GEOGRAPHY_SENSORS = [ (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), @@ -51,9 +58,51 @@ GEOGRAPHY_SENSORS = [ GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} NODE_PRO_SENSORS = [ - (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, PERCENTAGE), - (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE), - (SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS), + (SENSOR_KIND_AQI, "Air Quality Index", None, "mdi:chart-line", "AQI"), + (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), + ( + SENSOR_KIND_CO2, + "C02", + DEVICE_CLASS_CO2, + None, + CONCENTRATION_PARTS_PER_MILLION, + ), + (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, None, PERCENTAGE), + ( + SENSOR_KIND_PM_0_1, + "PM 0.1", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_PM_1_0, + "PM 1.0", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_PM_2_5, + "PM 2.5", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_TEMPERATURE, + "Temperature", + DEVICE_CLASS_TEMPERATURE, + None, + TEMP_CELSIUS, + ), + ( + SENSOR_KIND_VOC, + "VOC", + None, + "mdi:sprinkler", + CONCENTRATION_PARTS_PER_MILLION, + ), ] POLLUTANT_LABELS = { @@ -107,8 +156,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ] else: sensors = [ - AirVisualNodeProSensor(coordinator, kind, name, device_class, unit) - for kind, name, device_class, unit in NODE_PRO_SENSORS + AirVisualNodeProSensor(coordinator, kind, name, device_class, icon, unit) + for kind, name, device_class, icon, unit in NODE_PRO_SENSORS ] async_add_entities(sensors, True) @@ -121,46 +170,25 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): """Initialize.""" super().__init__(coordinator) - self._attrs.update( + self._attr_extra_state_attributes.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._attr_icon = icon + self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {name}" + self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{kind}" + self._attr_unit_of_measurement = unit self._config_entry = config_entry self._kind = kind self._locale = locale - self._name = name - self._state = None - - self._attr_icon = icon - self._attr_unit_of_measurement = unit @property - def available(self): - """Return True if entity is available.""" - try: - return self.coordinator.last_update_success and bool( - self.coordinator.data["current"]["pollution"] - ) - except KeyError: - return False - - @property - def name(self): - """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._config_entry.unique_id}_{self._locale}_{self._kind}" + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data["current"]["pollution"] @callback def update_from_latest_data(self): @@ -172,17 +200,17 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): if self._kind == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] - [(self._state, self._attr_icon)] = [ + [(self._attr_state, self._attr_icon)] = [ (name, icon) for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() if floor <= aqi <= ceiling ] elif self._kind == SENSOR_KIND_AQI: - self._state = data[f"aqi{self._locale}"] + self._attr_state = data[f"aqi{self._locale}"] elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._state = POLLUTANT_LABELS[symbol] - self._attrs.update( + self._attr_state = POLLUTANT_LABELS[symbol] + self._attr_extra_state_attributes.update( { ATTR_POLLUTANT_SYMBOL: symbol, ATTR_POLLUTANT_UNIT: POLLUTANT_UNITS[symbol], @@ -206,30 +234,29 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ) if self._config_entry.options[CONF_SHOW_ON_MAP]: - self._attrs[ATTR_LATITUDE] = latitude - self._attrs[ATTR_LONGITUDE] = longitude - self._attrs.pop("lati", None) - self._attrs.pop("long", None) + self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude + self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude + self._attr_extra_state_attributes.pop("lati", None) + self._attr_extra_state_attributes.pop("long", None) else: - self._attrs["lati"] = latitude - self._attrs["long"] = longitude - self._attrs.pop(ATTR_LATITUDE, None) - self._attrs.pop(ATTR_LONGITUDE, None) + self._attr_extra_state_attributes["lati"] = latitude + self._attr_extra_state_attributes["long"] = longitude + self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None) + self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None) class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" - def __init__(self, coordinator, kind, name, device_class, unit): + def __init__(self, coordinator, kind, name, device_class, icon, unit): """Initialize.""" super().__init__(coordinator) + self._attr_device_class = device_class + self._attr_icon = icon + self._attr_unit_of_measurement = unit self._kind = kind self._name = name - self._state = None - - self._attr_device_class = device_class - self._attr_unit_of_measurement = unit @property def device_info(self): @@ -251,11 +278,6 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): node_name = self.coordinator.data["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.""" @@ -264,9 +286,26 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): @callback def update_from_latest_data(self): """Update the entity from the latest data.""" - if self._kind == SENSOR_KIND_BATTERY_LEVEL: - self._state = self.coordinator.data["status"]["battery"] + if self._kind == SENSOR_KIND_AQI: + if self.coordinator.data["settings"]["is_aqi_usa"]: + self._attr_state = self.coordinator.data["measurements"]["aqi_us"] + else: + self._attr_state = self.coordinator.data["measurements"]["aqi_cn"] + elif self._kind == SENSOR_KIND_BATTERY_LEVEL: + self._attr_state = self.coordinator.data["status"]["battery"] + elif self._kind == SENSOR_KIND_CO2: + self._attr_state = self.coordinator.data["measurements"].get("co2") elif self._kind == SENSOR_KIND_HUMIDITY: - self._state = self.coordinator.data["measurements"].get("humidity") + self._attr_state = self.coordinator.data["measurements"].get("humidity") + elif self._kind == SENSOR_KIND_PM_0_1: + self._attr_state = self.coordinator.data["measurements"].get("pm0_1") + elif self._kind == SENSOR_KIND_PM_1_0: + self._attr_state = self.coordinator.data["measurements"].get("pm1_0") + elif self._kind == SENSOR_KIND_PM_2_5: + self._attr_state = self.coordinator.data["measurements"].get("pm2_5") elif self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinator.data["measurements"].get("temperature_C") + self._attr_state = self.coordinator.data["measurements"].get( + "temperature_C" + ) + elif self._kind == SENSOR_KIND_VOC: + self._attr_state = self.coordinator.data["measurements"].get("voc")