Skip to content

Commit c64eaa3

Browse files
authored
Merge pull request #66 from graphras-com/feat/air-quality-domain
feat(domains): add AirQuality domain (#34)
2 parents f7f6289 + f8e48f2 commit c64eaa3

3 files changed

Lines changed: 310 additions & 0 deletions

File tree

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
unavailable; this is the basis for opt-in / opt-out loading.
66
"""
77

8+
from haclient.domains.air_quality import AirQuality
89
from haclient.domains.binary_sensor import BinarySensor
910
from haclient.domains.climate import Climate
1011
from haclient.domains.cover import Cover
@@ -19,6 +20,7 @@
1920
from haclient.domains.valve import Valve
2021

2122
__all__ = [
23+
"AirQuality",
2224
"BinarySensor",
2325
"Climate",
2426
"Cover",
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""``air_quality`` domain implementation (read-only)."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from haclient.core.plugins import DomainSpec, register_domain
8+
from haclient.entity.base import Entity
9+
10+
11+
def _coerce_numeric(value: Any) -> float | int | None:
12+
"""Coerce an air-quality attribute value to a number.
13+
14+
Parameters
15+
----------
16+
value : Any
17+
Raw attribute value from Home Assistant.
18+
19+
Returns
20+
-------
21+
float, int, or None
22+
``int`` when the value is an integer literal, ``float`` for any
23+
other numeric value (including numeric strings), and ``None``
24+
when the value is missing, non-numeric, or one of HA's sentinel
25+
unavailability strings.
26+
"""
27+
if value is None:
28+
return None
29+
if isinstance(value, bool):
30+
# bool is a subclass of int; treat it as not a real measurement.
31+
return None
32+
if isinstance(value, int):
33+
return value
34+
if isinstance(value, float):
35+
return value
36+
if isinstance(value, str):
37+
if value in ("unknown", "unavailable", ""):
38+
return None
39+
try:
40+
return float(value)
41+
except ValueError:
42+
return None
43+
return None
44+
45+
46+
class AirQuality(Entity):
47+
"""A read-only Home Assistant air quality entity.
48+
49+
Exposes typed properties for the pollutant metrics the underlying
50+
integration chooses to report. Every metric returns ``None`` when
51+
the sensor does not provide that reading, so unsupported metrics
52+
degrade silently rather than raising. The entity is read-only; the
53+
HA ``air_quality`` domain exposes no service actions.
54+
55+
The Home Assistant ``air_quality`` platform reports its overall Air
56+
Quality Index (AQI) as the entity *state* and individual pollutants
57+
as attributes. `air_quality_index` therefore prefers an explicit
58+
``air_quality_index`` attribute when present and falls back to the
59+
state string. All other metrics are read directly from attributes.
60+
"""
61+
62+
domain = "air_quality"
63+
64+
# -- Listener decorators ------------------------------------------
65+
66+
def on_aqi_change(self, func: Any) -> Any:
67+
"""Register a listener for Air Quality Index changes.
68+
69+
Fires whenever the entity *state* string changes, which mirrors
70+
the HA convention of reporting the AQI as the entity state.
71+
72+
Parameters
73+
----------
74+
func : callable
75+
Sync or async callable receiving ``(old_state, new_state)``.
76+
77+
Returns
78+
-------
79+
callable
80+
The same *func*, returned for decorator use.
81+
"""
82+
return self._register_state_value_listener(func)
83+
84+
def on_pm25_change(self, func: Any) -> Any:
85+
"""Register a listener for PM2.5 attribute changes.
86+
87+
Parameters
88+
----------
89+
func : callable
90+
Callable receiving ``(old_value, new_value)`` for the
91+
``particulate_matter_2_5`` attribute.
92+
93+
Returns
94+
-------
95+
callable
96+
The same *func*, returned for decorator use.
97+
"""
98+
return self._register_attr_listener("particulate_matter_2_5", func)
99+
100+
def on_co2_change(self, func: Any) -> Any:
101+
"""Register a listener for CO2 attribute changes.
102+
103+
Parameters
104+
----------
105+
func : callable
106+
Callable receiving ``(old_value, new_value)`` for the
107+
``carbon_dioxide`` attribute.
108+
109+
Returns
110+
-------
111+
callable
112+
The same *func*, returned for decorator use.
113+
"""
114+
return self._register_attr_listener("carbon_dioxide", func)
115+
116+
# -- State properties ---------------------------------------------
117+
118+
@property
119+
def air_quality_index(self) -> float | int | None:
120+
"""Overall Air Quality Index, if reported.
121+
122+
Returns
123+
-------
124+
float, int, or None
125+
The explicit ``air_quality_index`` attribute when present,
126+
otherwise the numeric coercion of the entity state, or
127+
``None`` when neither yields a number.
128+
"""
129+
explicit = _coerce_numeric(self.attributes.get("air_quality_index"))
130+
if explicit is not None:
131+
return explicit
132+
return _coerce_numeric(self.state)
133+
134+
@property
135+
def particulate_matter_2_5(self) -> float | int | None:
136+
"""PM2.5 reading, typically in µg/m³."""
137+
return _coerce_numeric(self.attributes.get("particulate_matter_2_5"))
138+
139+
@property
140+
def particulate_matter_10(self) -> float | int | None:
141+
"""PM10 reading, typically in µg/m³."""
142+
return _coerce_numeric(self.attributes.get("particulate_matter_10"))
143+
144+
@property
145+
def carbon_dioxide(self) -> float | int | None:
146+
"""CO2 concentration, typically in ppm."""
147+
return _coerce_numeric(self.attributes.get("carbon_dioxide"))
148+
149+
@property
150+
def carbon_monoxide(self) -> float | int | None:
151+
"""CO concentration, typically in ppm."""
152+
return _coerce_numeric(self.attributes.get("carbon_monoxide"))
153+
154+
@property
155+
def volatile_organic_compounds(self) -> float | int | None:
156+
"""Volatile organic compound concentration, if reported."""
157+
return _coerce_numeric(self.attributes.get("volatile_organic_compounds"))
158+
159+
@property
160+
def nitrogen_dioxide(self) -> float | int | None:
161+
"""NO2 concentration, if reported."""
162+
return _coerce_numeric(self.attributes.get("nitrogen_dioxide"))
163+
164+
@property
165+
def ozone(self) -> float | int | None:
166+
"""Ozone concentration, if reported."""
167+
return _coerce_numeric(self.attributes.get("ozone"))
168+
169+
170+
SPEC: DomainSpec[AirQuality] = register_domain(
171+
DomainSpec(name="air_quality", entity_cls=AirQuality)
172+
)
173+
"""The `DomainSpec` registered with the shared `DomainRegistry`."""

tests/test_domains.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,3 +985,138 @@ def _mode(old: Any, new: Any) -> None:
985985
assert turned_off == [("on", "off")]
986986
assert humidity_events == [(40, 55)]
987987
assert mode_events == [("auto", "sleep")]
988+
989+
990+
# ---------------------------------------------------------------------------
991+
# Air quality
992+
# ---------------------------------------------------------------------------
993+
994+
995+
async def test_air_quality_full_metrics() -> None:
996+
"""Every typed metric is read from its underlying attribute."""
997+
ha = HAClient.from_url("http://x", token="t", load_plugins=False)
998+
try:
999+
aq = ha.air_quality("bedroom")
1000+
aq._apply_state(
1001+
{
1002+
"state": "42",
1003+
"attributes": {
1004+
"particulate_matter_2_5": 12.3,
1005+
"particulate_matter_10": 20,
1006+
"carbon_dioxide": 800,
1007+
"carbon_monoxide": 1.5,
1008+
"volatile_organic_compounds": 0.4,
1009+
"nitrogen_dioxide": 5,
1010+
"ozone": 18.0,
1011+
},
1012+
}
1013+
)
1014+
# AQI falls back to the state when no explicit attribute is set.
1015+
assert aq.air_quality_index == 42.0
1016+
assert aq.particulate_matter_2_5 == 12.3
1017+
assert aq.particulate_matter_10 == 20
1018+
assert aq.carbon_dioxide == 800
1019+
assert aq.carbon_monoxide == 1.5
1020+
assert aq.volatile_organic_compounds == 0.4
1021+
assert aq.nitrogen_dioxide == 5
1022+
assert aq.ozone == 18.0
1023+
finally:
1024+
await ha.close()
1025+
1026+
1027+
async def test_air_quality_degrades_when_metrics_missing() -> None:
1028+
"""Unsupported metrics return ``None`` rather than raising."""
1029+
ha = HAClient.from_url("http://x", token="t", load_plugins=False)
1030+
try:
1031+
aq = ha.air_quality("minimal")
1032+
aq._apply_state({"state": "unknown", "attributes": {}})
1033+
assert aq.air_quality_index is None
1034+
assert aq.particulate_matter_2_5 is None
1035+
assert aq.particulate_matter_10 is None
1036+
assert aq.carbon_dioxide is None
1037+
assert aq.carbon_monoxide is None
1038+
assert aq.volatile_organic_compounds is None
1039+
assert aq.nitrogen_dioxide is None
1040+
assert aq.ozone is None
1041+
finally:
1042+
await ha.close()
1043+
1044+
1045+
async def test_air_quality_index_prefers_explicit_attribute() -> None:
1046+
"""An explicit ``air_quality_index`` attribute overrides the state."""
1047+
ha = HAClient.from_url("http://x", token="t", load_plugins=False)
1048+
try:
1049+
aq = ha.air_quality("outdoor")
1050+
aq._apply_state(
1051+
{
1052+
"state": "99",
1053+
"attributes": {"air_quality_index": 55},
1054+
}
1055+
)
1056+
assert aq.air_quality_index == 55
1057+
finally:
1058+
await ha.close()
1059+
1060+
1061+
async def test_air_quality_coercion_handles_strings_and_bad_values() -> None:
1062+
"""Numeric strings coerce to ``float``; junk values yield ``None``."""
1063+
ha = HAClient.from_url("http://x", token="t", load_plugins=False)
1064+
try:
1065+
aq = ha.air_quality("noisy")
1066+
aq._apply_state(
1067+
{
1068+
"state": "unavailable",
1069+
"attributes": {
1070+
"particulate_matter_2_5": "12.5",
1071+
"carbon_dioxide": "not-a-number",
1072+
"carbon_monoxide": True, # bools are not real readings
1073+
"ozone": "",
1074+
"nitrogen_dioxide": [1, 2, 3], # unsupported type -> None
1075+
},
1076+
}
1077+
)
1078+
assert aq.air_quality_index is None
1079+
assert aq.particulate_matter_2_5 == 12.5
1080+
assert aq.carbon_dioxide is None
1081+
assert aq.carbon_monoxide is None
1082+
assert aq.ozone is None
1083+
assert aq.nitrogen_dioxide is None
1084+
finally:
1085+
await ha.close()
1086+
1087+
1088+
async def test_air_quality_listeners(client: HAClient, fake_ha: FakeHA) -> None:
1089+
"""``on_aqi_change`` / ``on_pm25_change`` / ``on_co2_change`` fire as expected."""
1090+
aq = client.air_quality("bedroom")
1091+
aqi_events: list[tuple[Any, Any]] = []
1092+
pm25_events: list[tuple[Any, Any]] = []
1093+
co2_events: list[tuple[Any, Any]] = []
1094+
1095+
@aq.on_aqi_change
1096+
def _aqi(old: Any, new: Any) -> None:
1097+
aqi_events.append((old, new))
1098+
1099+
@aq.on_pm25_change
1100+
def _pm25(old: Any, new: Any) -> None:
1101+
pm25_events.append((old, new))
1102+
1103+
@aq.on_co2_change
1104+
def _co2(old: Any, new: Any) -> None:
1105+
co2_events.append((old, new))
1106+
1107+
await fake_ha.push_state_changed(
1108+
"air_quality.bedroom",
1109+
{
1110+
"state": "55",
1111+
"attributes": {"particulate_matter_2_5": 14.0, "carbon_dioxide": 950},
1112+
},
1113+
{
1114+
"state": "42",
1115+
"attributes": {"particulate_matter_2_5": 10.0, "carbon_dioxide": 800},
1116+
},
1117+
)
1118+
await asyncio.sleep(0.05)
1119+
1120+
assert aqi_events == [("42", "55")]
1121+
assert pm25_events == [(10.0, 14.0)]
1122+
assert co2_events == [(800, 950)]

0 commit comments

Comments
 (0)