air_purifier: expose CO2 when the device lists it

Issue #387's TP1X_DA-AC-AIR-class board reports a CO2 item on
/sensors/vs/0. Same field/shape air_monitor.SENSORS already models
(carbon_dioxide / ppm). Gated with exists_fn so boards that don't
list the type (every current fixture) don't grow an empty entity.
This commit is contained in:
JayChickenK
2026-08-18 11:04:25 +02:00
parent 01c9dc9801
commit 33a06b9ae8
5 changed files with 89 additions and 13 deletions
@@ -7,8 +7,9 @@ only); this registry deliberately doesn't include common.POWER.
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
device shares those capabilities' catalog entries. This board reports a
CO2 reading; air_purifier.AIR_QUALITY now models the same type when a
purifier lists it (issue #387).
A second `value` list element on the particulate-matter types (Dust's
`['31', '2']`) is the device's own graded air-quality level for that
@@ -51,6 +51,18 @@ def _has_top_level_modes(rep, resources):
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
def _has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type."""
def fn(rep, resources):
return any(
isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_
for i in (rep.get("x.com.samsung.da.items") or [])
)
return fn
# Columns: key, icon, device item type, state_class, device_class, unit.
# state_class is what makes Home Assistant keep long-term statistics --
# without one, a reading is only in the short-term recorder history and
@@ -106,17 +118,33 @@ _AIR_QUALITY_SENSORS = (
AIR_QUALITY = Capability(
href="/sensors/vs/0",
poll_tier="warm",
entities=tuple(
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
),
# CO2 (issue #387) -- same field/shape air_monitor.SENSORS already
# models with device_class='carbon_dioxide'/unit='ppm'. Gated on the
# type being listed so boards that don't report it (every current
# fixture) don't grow an empty entity.
SensorDesc(
key=key,
key="co2",
field="x.com.samsung.da.items",
icon=icon,
state_class=state_class,
device_class=device_class,
unit=unit,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type, state_class, device_class, unit in _AIR_QUALITY_SENSORS
icon="mdi:molecule-co2",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
exists_fn=_has_sensor_type("CO2"),
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
+2 -2
View File
@@ -54,8 +54,8 @@ def test_no_unbound_hrefs():
def test_air_quality_sensors_read_from_shared_items_decode():
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
decode air_purifier.AIR_QUALITY already uses -- this board lists CO2
unconditionally, unlike the exists_fn-gated purifier descriptor."""
state = _state()
assert state["dust"] == 31
assert state["fine_dust"] == 23
@@ -96,6 +96,25 @@ def test_air_monitor_takes_the_pm_labels_but_not_the_state_class():
assert (desc.device_class, desc.unit) == (None, None), key
def test_co2_is_not_in_the_shared_tuple():
"""air_monitor already has its own CO2 SensorDesc. Putting CO2 in
_AIR_QUALITY_SENSORS would create a second entity with the same key."""
assert all(row[0] != "co2" for row in air_purifier._AIR_QUALITY_SENSORS)
def test_co2_matches_air_monitor_mapping():
"""ppm / carbon_dioxide is the same contract air_monitor.SENSORS already
ships for this field (issue #387), not a unit guess."""
desc = _desc("co2")
assert desc.device_class == "carbon_dioxide"
assert desc.unit == "ppm"
assert desc.state_class == "measurement"
assert desc.exists_fn is not None
assert desc.value_fn(
[{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["498"]}]
) == 498
def test_every_air_quality_sensor_still_reads_a_plain_int():
"""A state_class is only honoured for a numeric state, so the value
contract this depends on is asserted here too."""
+28
View File
@@ -69,6 +69,34 @@ def test_air_quality_sensor_values():
assert state["super_fine_dust"] == 5
assert state["odor"] == 0
assert state["clean_level"] == 0
assert "co2" not in state
def test_co2_reads_when_the_items_list_includes_the_type():
"""Issue #387 -- same {type, value} shape air_monitor.SENSORS already
models. Gated so boards that don't list CO2 don't grow an empty entity."""
reg, resources = _purifier()
resources = {
**resources,
"/sensors/vs/0": {
**resources["/sensors/vs/0"],
"x.com.samsung.da.items": [
*resources["/sensors/vs/0"]["x.com.samsung.da.items"],
{"x.com.samsung.da.type": "CO2", "x.com.samsung.da.value": ["612"]},
],
},
}
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
assert flatten(bound, resources)["co2"] == 612
desc = next(e for e in air_purifier.AIR_QUALITY.entities if e.key == "co2")
assert desc.exists_fn({"x.com.samsung.da.items": []}, {}) is False
assert (
desc.exists_fn(
{"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}
)
is True
)
def test_filter_progress_reads_named_consumable_item():