Compare commits

...

2 Commits

6 changed files with 244 additions and 17 deletions

View File

@@ -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(

View File

@@ -213,6 +213,7 @@
entity:
- domain: sensor
device_class: ozone
- domain: weather
.target_voc: &target_voc
entity:

View File

@@ -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(

View File

@@ -217,6 +217,7 @@
entity:
- domain: sensor
device_class: ozone
- domain: weather
.target_voc: &target_voc
entity:

View File

@@ -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,

View File

@@ -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()