From a1eec7b55dd8dc1e271bc078e34eace86f8d3c73 Mon Sep 17 00:00:00 2001 From: Stackie Jia Date: Mon, 31 Oct 2022 00:33:06 +0800 Subject: [PATCH] Expose NO2 and VOCs sensors to homekit (#81217) Co-authored-by: J. Nick Koston --- .../components/homekit/accessories.py | 4 + homeassistant/components/homekit/const.py | 2 + .../components/homekit/type_sensors.py | 64 +++++++++- homeassistant/components/homekit/util.py | 36 +++++- .../homekit/test_get_accessories.py | 12 ++ tests/components/homekit/test_type_sensors.py | 116 ++++++++++++++++-- 6 files changed, 219 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 61c2e3cd5dd..7d0de1a5740 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -200,6 +200,10 @@ def get_accessory( # noqa: C901 or SensorDeviceClass.PM25 in state.entity_id ): a_type = "PM25Sensor" + elif device_class == SensorDeviceClass.NITROGEN_DIOXIDE: + a_type = "NitrogenDioxideSensor" + elif device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: + a_type = "VolatileOrganicCompoundsSensor" elif ( device_class == SensorDeviceClass.GAS or SensorDeviceClass.GAS in state.entity_id diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 264801c521f..58e1e13a3f3 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -196,6 +196,7 @@ CHAR_MODEL = "Model" CHAR_MOTION_DETECTED = "MotionDetected" CHAR_MUTE = "Mute" CHAR_NAME = "Name" +CHAR_NITROGEN_DIOXIDE_DENSITY = "NitrogenDioxideDensity" CHAR_OBSTRUCTION_DETECTED = "ObstructionDetected" CHAR_OCCUPANCY_DETECTED = "OccupancyDetected" CHAR_ON = "On" @@ -226,6 +227,7 @@ CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" CHAR_HOLD_POSITION = "HoldPosition" CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits" CHAR_VALVE_TYPE = "ValveType" +CHAR_VOC_DENSITY = "VOCDensity" CHAR_VOLUME = "Volume" CHAR_VOLUME_SELECTOR = "VolumeSelector" CHAR_VOLUME_CONTROL_TYPE = "VolumeControlType" diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index e877ffff07a..4e9c897dff9 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -33,10 +33,12 @@ from .const import ( CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED, CHAR_MOTION_DETECTED, + CHAR_NITROGEN_DIOXIDE_DENSITY, CHAR_OCCUPANCY_DETECTED, CHAR_PM10_DENSITY, CHAR_PM25_DENSITY, CHAR_SMOKE_DETECTED, + CHAR_VOC_DENSITY, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR, SERV_CARBON_DIOXIDE_SENSOR, @@ -55,7 +57,9 @@ from .const import ( from .util import ( convert_to_float, density_to_air_quality, + density_to_air_quality_nitrogen_dioxide, density_to_air_quality_pm10, + density_to_air_quality_voc, temperature_to_homekit, ) @@ -206,7 +210,7 @@ class PM10Sensor(AirQualitySensor): def async_update_state(self, new_state): """Update accessory after state change.""" density = convert_to_float(new_state.state) - if not density: + if density is None: return if self.char_density.value != density: self.char_density.set_value(density) @@ -233,7 +237,7 @@ class PM25Sensor(AirQualitySensor): def async_update_state(self, new_state): """Update accessory after state change.""" density = convert_to_float(new_state.state) - if not density: + if density is None: return if self.char_density.value != density: self.char_density.set_value(density) @@ -244,6 +248,62 @@ class PM25Sensor(AirQualitySensor): _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) +@TYPES.register("NitrogenDioxideSensor") +class NitrogenDioxideSensor(AirQualitySensor): + """Generate a NitrogenDioxideSensor accessory as NO2 sensor.""" + + def create_services(self): + """Override the init function for PM 2.5 Sensor.""" + serv_air_quality = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, [CHAR_NITROGEN_DIOXIDE_DENSITY] + ) + self.char_quality = serv_air_quality.configure_char(CHAR_AIR_QUALITY, value=0) + self.char_density = serv_air_quality.configure_char( + CHAR_NITROGEN_DIOXIDE_DENSITY, value=0 + ) + + @callback + def async_update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density is None: + return + if self.char_density.value != density: + self.char_density.set_value(density) + _LOGGER.debug("%s: Set density to %d", self.entity_id, density) + air_quality = density_to_air_quality_nitrogen_dioxide(density) + if self.char_quality.value != air_quality: + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + +@TYPES.register("VolatileOrganicCompoundsSensor") +class VolatileOrganicCompoundsSensor(AirQualitySensor): + """Generate a VolatileOrganicCompoundsSensor accessory as VOCs sensor.""" + + def create_services(self): + """Override the init function for PM 2.5 Sensor.""" + serv_air_quality = self.add_preload_service( + SERV_AIR_QUALITY_SENSOR, [CHAR_VOC_DENSITY] + ) + self.char_quality = serv_air_quality.configure_char(CHAR_AIR_QUALITY, value=0) + self.char_density = serv_air_quality.configure_char(CHAR_VOC_DENSITY, value=0) + + @callback + def async_update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density is None: + return + if self.char_density.value != density: + self.char_density.set_value(density) + _LOGGER.debug("%s: Set density to %d", self.entity_id, density) + air_quality = density_to_air_quality_voc(density) + if self.char_quality.value != air_quality: + self.char_quality.set_value(air_quality) + _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality) + + @TYPES.register("CarbonMonoxideSensor") class CarbonMonoxideSensor(HomeAccessory): """Generate a CarbonMonoxidSensor accessory as CO sensor.""" diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index ee02ea1a576..413786c22c4 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -413,14 +413,40 @@ def density_to_air_quality(density: float) -> int: def density_to_air_quality_pm10(density: float) -> int: - """Map PM10 density to HomeKit AirQuality level.""" - if density <= 40: + """Map PM10 µg/m3 density to HomeKit AirQuality level.""" + if density <= 54: # US AQI 0-50 (HomeKit: Excellent) return 1 - if density <= 80: + if density <= 154: # US AQI 51-100 (HomeKit: Good) return 2 - if density <= 120: + if density <= 254: # US AQI 101-150 (HomeKit: Fair) return 3 - if density <= 300: + if density <= 354: # US AQI 151-200 (HomeKit: Inferior) + return 4 + return 5 # US AQI 201+ (HomeKit: Poor) + + +def density_to_air_quality_nitrogen_dioxide(density: float) -> int: + """Map nitrogen dioxide µg/m3 to HomeKit AirQuality level.""" + if density <= 30: + return 1 + if density <= 60: + return 2 + if density <= 80: + return 3 + if density <= 90: + return 4 + return 5 + + +def density_to_air_quality_voc(density: float) -> int: + """Map VOCs µg/m3 to HomeKit AirQuality level.""" + if density <= 24: + return 1 + if density <= 48: + return 2 + if density <= 64: + return 3 + if density <= 96: return 4 return 5 diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 32f4abe98f1..12113ada5cb 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -226,6 +226,18 @@ def test_type_media_player(type_name, entity_id, state, attrs, config): "40", {ATTR_DEVICE_CLASS: "pm25"}, ), + ( + "NitrogenDioxideSensor", + "sensor.air_quality_nitrogen_dioxide", + "50", + {ATTR_DEVICE_CLASS: SensorDeviceClass.NITROGEN_DIOXIDE}, + ), + ( + "VolatileOrganicCompoundsSensor", + "sensor.air_quality_volatile_organic_compounds", + "55", + {ATTR_DEVICE_CLASS: SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS}, + ), ( "CarbonMonoxideSensor", "sensor.co", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 4997a35910d..28dfe04932f 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -15,9 +15,11 @@ from homeassistant.components.homekit.type_sensors import ( CarbonMonoxideSensor, HumiditySensor, LightSensor, + NitrogenDioxideSensor, PM10Sensor, PM25Sensor, TemperatureSensor, + VolatileOrganicCompoundsSensor, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -155,24 +157,24 @@ async def test_pm10(hass, hk_driver): assert acc.char_density.value == 0 assert acc.char_quality.value == 0 - hass.states.async_set(entity_id, "34") + hass.states.async_set(entity_id, "54") await hass.async_block_till_done() - assert acc.char_density.value == 34 + assert acc.char_density.value == 54 assert acc.char_quality.value == 1 - hass.states.async_set(entity_id, "70") + hass.states.async_set(entity_id, "154") await hass.async_block_till_done() - assert acc.char_density.value == 70 + assert acc.char_density.value == 154 assert acc.char_quality.value == 2 - hass.states.async_set(entity_id, "110") + hass.states.async_set(entity_id, "254") await hass.async_block_till_done() - assert acc.char_density.value == 110 + assert acc.char_density.value == 254 assert acc.char_quality.value == 3 - hass.states.async_set(entity_id, "200") + hass.states.async_set(entity_id, "354") await hass.async_block_till_done() - assert acc.char_density.value == 200 + assert acc.char_density.value == 354 assert acc.char_quality.value == 4 hass.states.async_set(entity_id, "400") @@ -228,6 +230,104 @@ async def test_pm25(hass, hk_driver): assert acc.char_quality.value == 5 +async def test_no2(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = "sensor.air_quality_nitrogen_dioxide" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = NitrogenDioxideSensor( + hass, hk_driver, "Nitrogen Dioxide Sensor", entity_id, 2, None + ) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, "30") + await hass.async_block_till_done() + assert acc.char_density.value == 30 + assert acc.char_quality.value == 1 + + hass.states.async_set(entity_id, "60") + await hass.async_block_till_done() + assert acc.char_density.value == 60 + assert acc.char_quality.value == 2 + + hass.states.async_set(entity_id, "80") + await hass.async_block_till_done() + assert acc.char_density.value == 80 + assert acc.char_quality.value == 3 + + hass.states.async_set(entity_id, "90") + await hass.async_block_till_done() + assert acc.char_density.value == 90 + assert acc.char_quality.value == 4 + + hass.states.async_set(entity_id, "100") + await hass.async_block_till_done() + assert acc.char_density.value == 100 + assert acc.char_quality.value == 5 + + +async def test_voc(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = "sensor.air_quality_volatile_organic_compounds" + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = VolatileOrganicCompoundsSensor( + hass, hk_driver, "Volatile Organic Compounds Sensor", entity_id, 2, None + ) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 + + hass.states.async_set(entity_id, "24") + await hass.async_block_till_done() + assert acc.char_density.value == 24 + assert acc.char_quality.value == 1 + + hass.states.async_set(entity_id, "48") + await hass.async_block_till_done() + assert acc.char_density.value == 48 + assert acc.char_quality.value == 2 + + hass.states.async_set(entity_id, "64") + await hass.async_block_till_done() + assert acc.char_density.value == 64 + assert acc.char_quality.value == 3 + + hass.states.async_set(entity_id, "96") + await hass.async_block_till_done() + assert acc.char_density.value == 96 + assert acc.char_quality.value == 4 + + hass.states.async_set(entity_id, "128") + await hass.async_block_till_done() + assert acc.char_density.value == 128 + assert acc.char_quality.value == 5 + + async def test_co(hass, hk_driver): """Test if accessory is updated after state change.""" entity_id = "sensor.co"