Merge pull request #390 from JayChickenK/air-purifier-co2

air_purifier: expose CO2 when the device lists it
This commit is contained in:
Marc Billow
2026-08-19 20:35:00 -05:00
committed by GitHub
9 changed files with 155 additions and 40 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
@@ -34,7 +34,13 @@ from ..entities import (
SwitchDesc,
TimeDesc,
)
from .common import epoch_to_utc, filter_usage_percent, int_or_none, sensor_item_value
from .common import (
epoch_to_utc,
filter_usage_percent,
has_sensor_type,
int_or_none,
sensor_item_value,
)
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
# Newer TP1X_DA-AC-AIR-class boards (issue #130) report fan modes directly
@@ -106,17 +112,35 @@ _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. Disabled by default for the
# same reason as airconditioner.AIR_QUALITY (issue #166).
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"),
enabled_default=False,
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
@@ -23,7 +23,7 @@ from ..entities import (
SwitchDesc,
)
from . import common
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
from .common import filter_usage_hours, filter_usage_percent, has_sensor_type, normalize_temp_unit
from .laundry import option_write
@@ -119,26 +119,6 @@ def _sensor_item_value(items, type_):
return None
def _has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type.
This only proves the type is *listed*, not that the reading is real:
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
values on units the reporter confirmed don't have the hardware. So
entities gated on this stay disabled by default (see AIR_QUALITY) rather
than existence-gated further, to avoid silently dropping real readings
on hardware not yet seen.
"""
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
# Canonical AC resource hrefs. climate.py binds HREF_MODE and reads the
# CLIMATE_CONSUMED_HREFS siblings off the coordinator snapshot; declared once
# here so climate.py and the coverage list below can't drift out of sync.
@@ -1570,7 +1550,7 @@ WINDSLEEP = Capability(
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
# so it's a measurement; the others stay string diagnostics (see
# _sensor_item_value). All disabled by default: _has_sensor_type only proves
# _sensor_item_value). All disabled by default: has_sensor_type only proves
# the item type is listed, not that the sensor is real (see its docstring).
AIR_QUALITY = Capability(
href="/sensors/vs/0",
@@ -1582,7 +1562,7 @@ AIR_QUALITY = Capability(
icon="mdi:broom",
entity_category="diagnostic",
state_class="measurement",
exists_fn=_has_sensor_type("CleanLevel"),
exists_fn=has_sensor_type("CleanLevel"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")),
),
@@ -1592,7 +1572,7 @@ AIR_QUALITY = Capability(
field="x.com.samsung.da.items",
icon=icon,
entity_category="diagnostic",
exists_fn=_has_sensor_type(type_),
exists_fn=has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t),
)
@@ -1618,7 +1598,7 @@ AIR_QUALITY = Capability(
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
exists_fn=_has_sensor_type("CO2"),
exists_fn=has_sensor_type("CO2"),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
),
@@ -299,6 +299,30 @@ def sensor_item_value(items, sensor_type, index=0):
return None
def has_sensor_type(type_):
"""True when /sensors/vs/0's items[] lists an item of this type.
This only proves the type is *listed*, not that the reading is real:
issue #166 (ARTIK051_PRAC_20K) lists all five types with permanent-zero
values on units the reporter confirmed don't have the hardware. So
entities gated on this stay disabled by default rather than
existence-gated further, to avoid silently dropping real readings on
hardware not yet seen.
is_stub_rep(rep) keeps the stub carve-out (see entity._is_included /
ENERGY_METER, issue #127): an explicit exists_fn otherwise bypasses it
and would drop the entity when /device/0 returns a not-yet-fetched stub.
"""
def fn(rep, resources):
return is_stub_rep(rep) or 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
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote
# control: each exists as both a standard OCF resource (/power/0,
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor
+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,26 @@ 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.enabled_default is False
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."""
+30
View File
@@ -69,6 +69,36 @@ 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 is not None
assert desc.enabled_default is False
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
)
# /device/0 stub must not drop co2 while its field-gated siblings still
# register -- platforms enumerate bound once (issue #127).
assert desc.exists_fn({"href": "/sensors/vs/0"}, {}) is True
def test_filter_progress_reads_named_consumable_item():
+12 -1
View File
@@ -986,7 +986,7 @@ def test_air_quality_disabled_by_default():
SuperFineDust readings with no such scalar, so requiring it would
silently drop real readings on hardware this repo hasn't seen yet on an
AC. These stay bound whenever the item type is listed (see
_has_sensor_type) and disabled by default instead, same precedent as
has_sensor_type) and disabled by default instead, same precedent as
fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode --
units that do have the sensor can enable it themselves."""
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"):
@@ -994,6 +994,17 @@ def test_air_quality_disabled_by_default():
assert desc.enabled_default is False, key
def test_air_quality_included_on_stub_rep():
"""exists_fn would otherwise drop every gated sensor when /device/0
returns a not-yet-fetched stub, while field-gated siblings still
register (issue #127)."""
stub = {"href": "/sensors/vs/0"}
for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust", "co2"):
desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key)
assert desc.exists_fn is not None
assert desc.exists_fn(stub, {}) is True, key
def test_air_quality_absent_when_no_sensor_items():
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
RAC variant) binds no air-quality entities -- exists_fn gates each on its
+25
View File
@@ -417,6 +417,31 @@ class TestEnergyMeter:
assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False
# ---------------------------------------------------------------------------
# has_sensor_type. Presence gate for /sensors/vs/0 items[] types, shared by
# airconditioner.AIR_QUALITY and air_purifier.AIR_QUALITY. The stub carve-out
# is the same contract ENERGY_METER documents for issue #127.
# ---------------------------------------------------------------------------
class TestHasSensorType:
def test_true_when_type_is_listed(self):
fn = common.has_sensor_type("CO2")
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "CO2"}]}, {}) is True
def test_false_when_type_is_absent(self):
fn = common.has_sensor_type("CO2")
assert fn({"x.com.samsung.da.items": [{"x.com.samsung.da.type": "Dust"}]}, {}) is False
assert fn({"x.com.samsung.da.items": []}, {}) is False
assert fn({}, {}) is False
def test_true_on_stub_rep(self):
"""A true stub -- /device/0's {"href": "..."} "not fetched yet"
marker -- must keep the entity so sub-polls can populate it."""
fn = common.has_sensor_type("CO2")
assert fn({"href": "/sensors/vs/0"}, {}) is True
# ---------------------------------------------------------------------------
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)