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:
@@ -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"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user