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 authored and GitHub committed 2026-08-19 20:35:00 -05:00
commit 16dfcd4e50
9 files changed
+155 -40

No files matched your search

+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)