diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 1841a167a50..4968e86bcf5 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,4 +1,5 @@ """Constants for the Awair component.""" +from __future__ import annotations from dataclasses import dataclass from datetime import timedelta @@ -6,9 +7,8 @@ import logging from python_awair.devices import AwairDevice +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, @@ -36,10 +36,6 @@ API_VOC = "volatile_organic_compounds" ATTRIBUTION = "Awair air quality sensor" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIQUE_ID = "unique_id" - DOMAIN = "awair" DUST_ALIASES = [API_PM25, API_PM10] @@ -48,71 +44,89 @@ LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL = timedelta(minutes=5) -SENSOR_TYPES = { - API_SCORE: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Awair score", - ATTR_UNIQUE_ID: "score", # matches legacy format - }, - API_HUMID: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_UNIT: PERCENTAGE, - ATTR_LABEL: "Humidity", - ATTR_UNIQUE_ID: "HUMID", # matches legacy format - }, - API_LUX: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, - ATTR_ICON: None, - ATTR_UNIT: LIGHT_LUX, - ATTR_LABEL: "Illuminance", - ATTR_UNIQUE_ID: "illuminance", - }, - API_SPL_A: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:ear-hearing", - ATTR_UNIT: SOUND_PRESSURE_WEIGHTED_DBA, - ATTR_LABEL: "Sound level", - ATTR_UNIQUE_ID: "sound_level", - }, - API_VOC: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_LABEL: "Volatile organic compounds", - ATTR_UNIQUE_ID: "VOC", # matches legacy format - }, - API_TEMP: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_UNIT: TEMP_CELSIUS, - ATTR_LABEL: "Temperature", - ATTR_UNIQUE_ID: "TEMP", # matches legacy format - }, - API_PM25: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM2.5", - ATTR_UNIQUE_ID: "PM25", # matches legacy format - }, - API_PM10: { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_LABEL: "PM10", - ATTR_UNIQUE_ID: "PM10", # matches legacy format - }, - API_CO2: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2, - ATTR_ICON: "mdi:cloud", - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_LABEL: "Carbon dioxide", - ATTR_UNIQUE_ID: "CO2", # matches legacy format - }, -} + +@dataclass +class AwairRequiredKeysMixin: + """Mixinf for required keys.""" + + unique_id_tag: str + + +@dataclass +class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): + """Describes Awair sensor entity.""" + + +SENSOR_TYPE_SCORE = AwairSensorEntityDescription( + key=API_SCORE, + icon="mdi:blur", + native_unit_of_measurement=PERCENTAGE, + name="Awair score", + unique_id_tag="score", # matches legacy format +) + +SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_HUMID, + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + unique_id_tag="HUMID", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_LUX, + device_class=DEVICE_CLASS_ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + unique_id_tag="illuminance", + ), + AwairSensorEntityDescription( + key=API_SPL_A, + icon="mdi:ear-hearing", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + name="Sound level", + unique_id_tag="sound_level", + ), + AwairSensorEntityDescription( + key=API_VOC, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="Volatile organic compounds", + unique_id_tag="VOC", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_TEMP, + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + unique_id_tag="TEMP", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_CO2, + device_class=DEVICE_CLASS_CO2, + icon="mdi:cloud", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="Carbon dioxide", + unique_id_tag="CO2", # matches legacy format + ), +) + +SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( + AwairSensorEntityDescription( + key=API_PM25, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM2.5", + unique_id_tag="PM25", # matches legacy format + ), + AwairSensorEntityDescription( + key=API_PM10, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + name="PM10", + unique_id_tag="PM10", # matches legacy format + ), +) @dataclass diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 3b46d3b2317..80591e36f2d 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN +from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv @@ -22,15 +22,14 @@ from .const import ( API_SCORE, API_TEMP, API_VOC, - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIQUE_ID, - ATTR_UNIT, ATTRIBUTION, DOMAIN, DUST_ALIASES, LOGGER, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, + AwairSensorEntityDescription, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -60,16 +59,20 @@ async def async_setup_entry( ): """Set up Awair sensor entity based on a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] - sensors = [] + entities = [] data: list[AwairResult] = coordinator.data.values() for result in data: if result.air_data: - sensors.append(AwairSensor(API_SCORE, result.device, coordinator)) + entities.append(AwairSensor(result.device, coordinator, SENSOR_TYPE_SCORE)) device_sensors = result.air_data.sensors.keys() - for sensor in device_sensors: - if sensor in SENSOR_TYPES: - sensors.append(AwairSensor(sensor, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in (*SENSOR_TYPES, *SENSOR_TYPES_DUST) + if description.key in device_sensors + ] + ) # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only # present on first-gen devices in lieu of separate pm2.5/pm10 sensors. @@ -78,45 +81,53 @@ async def async_setup_entry( # that data - because we can't really tell what kind of particles the # "DUST" sensor actually detected. However, it's still useful data. if API_DUST in device_sensors: - for alias_kind in DUST_ALIASES: - sensors.append(AwairSensor(alias_kind, result.device, coordinator)) + entities.extend( + [ + AwairSensor(result.device, coordinator, description) + for description in SENSOR_TYPES_DUST + ] + ) - async_add_entities(sensors) + async_add_entities(entities) class AwairSensor(CoordinatorEntity, SensorEntity): """Defines an Awair sensor entity.""" + entity_description: AwairSensorEntityDescription + def __init__( self, - kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator, + description: AwairSensorEntityDescription, ) -> None: """Set up an individual AwairSensor.""" super().__init__(coordinator) - self._kind = kind + self.entity_description = description self._device = device @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the sensor.""" - name = SENSOR_TYPES[self._kind][ATTR_LABEL] if self._device.name: - name = f"{self._device.name} {name}" + return f"{self._device.name} {self.entity_description.name}" - return name + return self.entity_description.name @property def unique_id(self) -> str: """Return the uuid as the unique_id.""" - unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID] + unique_id_tag = self.entity_description.unique_id_tag # This integration used to create a sensor that was labelled as a "PM2.5" # sensor for first-gen Awair devices, but its unique_id reflected the truth: # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id # for users with first-gen devices that are upgrading. - if self._kind == API_PM25 and API_DUST in self._air_data.sensors: + if ( + self.entity_description.key == API_PM25 + and API_DUST in self._air_data.sensors + ): unique_id_tag = "DUST" return f"{self._device.uuid}_{unique_id_tag}" @@ -127,16 +138,17 @@ class AwairSensor(CoordinatorEntity, SensorEntity): # If the last update was successful... if self.coordinator.last_update_success and self._air_data: # and the results included our sensor type... - if self._kind in self._air_data.sensors: + sensor_type = self.entity_description.key + if sensor_type in self._air_data.sensors: # then we are available. return True # or, we're a dust alias - if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + if sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: return True # or we are API_SCORE - if self._kind == API_SCORE: + if sensor_type == API_SCORE: # then we are available. return True @@ -147,38 +159,24 @@ class AwairSensor(CoordinatorEntity, SensorEntity): def native_value(self) -> float: """Return the state, rounding off to reasonable values.""" state: float + sensor_type = self.entity_description.key # Special-case for "SCORE", which we treat as the AQI - if self._kind == API_SCORE: + if sensor_type == API_SCORE: state = self._air_data.score - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.sensors: state = self._air_data.sensors.dust else: - state = self._air_data.sensors[self._kind] + state = self._air_data.sensors[sensor_type] - if self._kind == API_VOC or self._kind == API_SCORE: + if sensor_type in {API_VOC, API_SCORE}: return round(state) - if self._kind == API_TEMP: + if sensor_type == API_TEMP: return round(state, 1) return round(state, 2) - @property - def icon(self) -> str: - """Return the icon.""" - return SENSOR_TYPES[self._kind][ATTR_ICON] - - @property - def device_class(self) -> str: - """Return the device_class.""" - return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self._kind][ATTR_UNIT] - @property def extra_state_attributes(self) -> dict: """Return the Awair Index alongside state attributes. @@ -201,10 +199,11 @@ class AwairSensor(CoordinatorEntity, SensorEntity): https://docs.developer.getawair.com/?version=latest#awair-score-and-index """ + sensor_type = self.entity_description.key attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - if self._kind in self._air_data.indices: - attrs["awair_index"] = abs(self._air_data.indices[self._kind]) - elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices: + if sensor_type in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices[sensor_type]) + elif sensor_type in DUST_ALIASES and API_DUST in self._air_data.indices: attrs["awair_index"] = abs(self._air_data.indices.dust) return attrs diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index b37e8dbf5d2..658ba802e8e 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -11,9 +11,10 @@ from homeassistant.components.awair.const import ( API_SPL_A, API_TEMP, API_VOC, - ATTR_UNIQUE_ID, DOMAIN, + SENSOR_TYPE_SCORE, SENSOR_TYPES, + SENSOR_TYPES_DUST, ) from homeassistant.const import ( ATTR_ICON, @@ -44,6 +45,10 @@ from .const import ( from tests.common import MockConfigEntry +SENSOR_TYPES_MAP = { + desc.key: desc for desc in (SENSOR_TYPE_SCORE, *SENSOR_TYPES, *SENSOR_TYPES_DUST) +} + async def setup_awair(hass, fixtures): """Add Awair devices to hass, using specified fixtures for data.""" @@ -80,7 +85,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -89,7 +94,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_temperature", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_TEMP][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_TEMP].unique_id_tag}", "21.8", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "awair_index": 1.0}, ) @@ -98,7 +103,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_humidity", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_HUMID].unique_id_tag}", "41.59", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, "awair_index": 0.0}, ) @@ -107,7 +112,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_carbon_dioxide", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_CO2][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_CO2].unique_id_tag}", "654.0", { ATTR_ICON: "mdi:cloud", @@ -120,7 +125,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_volatile_organic_compounds", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_VOC][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_VOC].unique_id_tag}", "366", { ATTR_ICON: "mdi:cloud", @@ -147,7 +152,7 @@ async def test_awair_gen1_sensors(hass): hass, registry, "sensor.living_room_pm10", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM10][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM10].unique_id_tag}", "14.3", { ATTR_ICON: "mdi:blur", @@ -176,7 +181,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "97", {ATTR_ICON: "mdi:blur"}, ) @@ -185,7 +190,7 @@ async def test_awair_gen2_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "2.0", { ATTR_ICON: "mdi:blur", @@ -210,7 +215,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "98", {ATTR_ICON: "mdi:blur"}, ) @@ -219,7 +224,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_pm2_5", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_PM25].unique_id_tag}", "1.0", { ATTR_ICON: "mdi:blur", @@ -232,7 +237,7 @@ async def test_awair_mint_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "441.7", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -252,7 +257,7 @@ async def test_awair_glow_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "93", {ATTR_ICON: "mdi:blur"}, ) @@ -272,7 +277,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "99", {ATTR_ICON: "mdi:blur"}, ) @@ -281,7 +286,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_sound_level", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SPL_A][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SPL_A].unique_id_tag}", "47.0", {ATTR_ICON: "mdi:ear-hearing", ATTR_UNIT_OF_MEASUREMENT: "dBa"}, ) @@ -290,7 +295,7 @@ async def test_awair_omni_sensors(hass): hass, registry, "sensor.living_room_illuminance", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_LUX].unique_id_tag}", "804.9", {ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX}, ) @@ -325,7 +330,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "88", {ATTR_ICON: "mdi:blur"}, ) @@ -338,7 +343,7 @@ async def test_awair_unavailable(hass): hass, registry, "sensor.living_room_awair_score", - f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + f"{AWAIR_UUID}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", STATE_UNAVAILABLE, {ATTR_ICON: "mdi:blur"}, )