mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 20:16:00 +00:00
Compare commits
2 Commits
dev
...
air_qualit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7158ea6a | ||
|
|
6b27812476 |
@@ -5,17 +5,23 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_OZONE,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityNumericalConditionWithUnitBase,
|
||||
make_entity_numerical_condition,
|
||||
make_entity_numerical_condition_with_unit,
|
||||
make_entity_state_condition,
|
||||
@@ -49,6 +55,28 @@ def _make_cleared_condition(
|
||||
)
|
||||
|
||||
|
||||
OZONE_DOMAIN_SPECS = {
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE),
|
||||
WEATHER_DOMAIN: NumericalDomainSpec(value_source=ATTR_WEATHER_OZONE),
|
||||
}
|
||||
|
||||
|
||||
class OzoneCondition(EntityNumericalConditionWithUnitBase):
|
||||
"""Condition for ozone value across sensor and weather domains."""
|
||||
|
||||
_base_unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
_domain_specs = OZONE_DOMAIN_SPECS
|
||||
_unit_converter = OzoneConcentrationConverter
|
||||
|
||||
def _get_entity_unit(self, entity_state: State) -> str | None:
|
||||
"""Get the ozone unit of an entity from its state."""
|
||||
if entity_state.domain == WEATHER_DOMAIN:
|
||||
# Weather entities report ozone without a unit attribute;
|
||||
# assume the base unit (μg/m³) so no conversion is applied.
|
||||
return self._base_unit
|
||||
return entity_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
# Binary sensor conditions (detected/cleared)
|
||||
"is_gas_detected": _make_detected_condition(BinarySensorDeviceClass.GAS),
|
||||
@@ -63,11 +91,7 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"is_ozone_value": make_entity_numerical_condition_with_unit(
|
||||
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"is_ozone_value": OzoneCondition,
|
||||
"is_voc_value": make_entity_numerical_condition_with_unit(
|
||||
{
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
- domain: weather
|
||||
|
||||
.target_voc: &target_voc
|
||||
entity:
|
||||
|
||||
@@ -5,16 +5,24 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_OZONE,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateChangedTriggerWithUnitBase,
|
||||
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
|
||||
EntityNumericalStateTriggerWithUnitBase,
|
||||
EntityTargetStateTriggerBase,
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
@@ -54,6 +62,40 @@ def _make_cleared_trigger(
|
||||
)
|
||||
|
||||
|
||||
OZONE_DOMAIN_SPECS = {
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE),
|
||||
WEATHER_DOMAIN: NumericalDomainSpec(value_source=ATTR_WEATHER_OZONE),
|
||||
}
|
||||
|
||||
|
||||
class _OzoneTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
|
||||
"""Mixin for ozone triggers providing entity filtering, value extraction, and unit conversion."""
|
||||
|
||||
_base_unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
_domain_specs = OZONE_DOMAIN_SPECS
|
||||
_unit_converter = OzoneConcentrationConverter
|
||||
|
||||
def _get_entity_unit(self, state: State) -> str | None:
|
||||
"""Get the ozone unit of an entity from its state."""
|
||||
if state.domain == WEATHER_DOMAIN:
|
||||
# Weather entities report ozone without a unit attribute;
|
||||
# assume the base unit (μg/m³) so no conversion is applied.
|
||||
return self._base_unit
|
||||
return state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
|
||||
class OzoneChangedTrigger(
|
||||
_OzoneTriggerMixin, EntityNumericalStateChangedTriggerWithUnitBase
|
||||
):
|
||||
"""Trigger for ozone value changes across sensor and weather domains."""
|
||||
|
||||
|
||||
class OzoneCrossedThresholdTrigger(
|
||||
_OzoneTriggerMixin, EntityNumericalStateCrossedThresholdTriggerWithUnitBase
|
||||
):
|
||||
"""Trigger for ozone value crossing a threshold across sensor and weather domains."""
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
# Binary sensor triggers (detected/cleared)
|
||||
"gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS),
|
||||
@@ -73,16 +115,8 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CarbonMonoxideConcentrationConverter,
|
||||
),
|
||||
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
|
||||
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
OzoneConcentrationConverter,
|
||||
),
|
||||
"ozone_changed": OzoneChangedTrigger,
|
||||
"ozone_crossed_threshold": OzoneCrossedThresholdTrigger,
|
||||
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
|
||||
{
|
||||
SENSOR_DOMAIN: NumericalDomainSpec(
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: ozone
|
||||
- domain: weather
|
||||
|
||||
.target_voc: &target_voc
|
||||
entity:
|
||||
|
||||
@@ -5,12 +5,14 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.weather import ATTR_WEATHER_OZONE
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
@@ -22,6 +24,7 @@ from tests.components.common import (
|
||||
assert_condition_behavior_any,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
assert_numerical_condition_unit_conversion,
|
||||
create_target_condition,
|
||||
parametrize_condition_states_all,
|
||||
parametrize_condition_states_any,
|
||||
parametrize_numerical_condition_above_below_all,
|
||||
@@ -502,6 +505,76 @@ async def test_air_quality_numerical_no_unit_condition_behavior_all(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_air_quality_ozone_condition_weather_entity(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test that the ozone condition works with weather entities.
|
||||
|
||||
Weather entities report ozone via the 'ozone' attribute without a unit;
|
||||
the value is assumed to be in μg/m³.
|
||||
"""
|
||||
entity_id = "weather.test"
|
||||
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 50})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
cond = await create_target_condition(
|
||||
hass,
|
||||
condition="air_quality.is_ozone_value",
|
||||
target={CONF_ENTITY_ID: [entity_id]},
|
||||
behavior="any",
|
||||
condition_options={
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {
|
||||
"number": 40,
|
||||
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# 50 μg/m³ is above 40 — condition should be true
|
||||
assert cond(hass) is True
|
||||
|
||||
# Change ozone to 30, below threshold — condition should be false
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 30})
|
||||
await hass.async_block_till_done()
|
||||
assert cond(hass) is False
|
||||
|
||||
# Verify a sensor ozone entity is also matched by the same condition
|
||||
sensor_id = "sensor.test_ozone"
|
||||
hass.states.async_set(
|
||||
sensor_id,
|
||||
"60",
|
||||
{
|
||||
ATTR_DEVICE_CLASS: "ozone",
|
||||
ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
cond_both = await create_target_condition(
|
||||
hass,
|
||||
condition="air_quality.is_ozone_value",
|
||||
target={CONF_ENTITY_ID: [entity_id, sensor_id]},
|
||||
behavior="any",
|
||||
condition_options={
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {
|
||||
"number": 40,
|
||||
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Weather at 30 (fail), sensor at 60 (pass) — 'any' should be true
|
||||
assert cond_both(hass) is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_air_quality_condition_unit_conversion_co(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.components.weather import ATTR_WEATHER_OZONE
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
@@ -785,3 +786,96 @@ async def test_air_quality_trigger_unit_conversion_co_ppm_to_ugm3(
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_air_quality_ozone_crossed_threshold_trigger_weather_entity(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test ozone crossed_threshold trigger works with weather entities.
|
||||
|
||||
Weather entities report ozone via the 'ozone' attribute without a unit;
|
||||
the value is assumed to be in μg/m³.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
entity_id = "weather.test"
|
||||
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 30})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
hass,
|
||||
"air_quality.ozone_crossed_threshold",
|
||||
{
|
||||
"threshold": {
|
||||
"type": "above",
|
||||
"value": {
|
||||
"number": 100,
|
||||
"unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
},
|
||||
}
|
||||
},
|
||||
{CONF_ENTITY_ID: [entity_id]},
|
||||
calls,
|
||||
)
|
||||
|
||||
# 30 → 50 μg/m³, both below 100 — should NOT trigger
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# 50 → 120 μg/m³, crosses above 100 — should trigger
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 120})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
calls.clear()
|
||||
|
||||
# 120 → 150 μg/m³, still above — should NOT re-trigger (already crossed)
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 150})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
async def test_air_quality_ozone_changed_trigger_weather_entity(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test ozone changed trigger works with weather entities.
|
||||
|
||||
Weather entities report ozone via the 'ozone' attribute without a unit;
|
||||
the value is assumed to be in μg/m³.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
entity_id = "weather.test"
|
||||
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 30})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
hass,
|
||||
"air_quality.ozone_changed",
|
||||
{
|
||||
"threshold": {
|
||||
"type": "any",
|
||||
}
|
||||
},
|
||||
{CONF_ENTITY_ID: [entity_id]},
|
||||
calls,
|
||||
)
|
||||
|
||||
# 30 → 50 μg/m³ — should trigger
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
calls.clear()
|
||||
|
||||
# 50 → 50 μg/m³ (no change) — should NOT trigger
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 50})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 0
|
||||
|
||||
# 50 → 80 μg/m³ — should trigger again
|
||||
hass.states.async_set(entity_id, "sunny", {ATTR_WEATHER_OZONE: 80})
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
calls.clear()
|
||||
|
||||
Reference in New Issue
Block a user