diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index 71c382b..7bc3cd4 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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 diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index f479970..5b42fd4 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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"), + ), ), ) diff --git a/tests/test_air_monitor_capabilities.py b/tests/test_air_monitor_capabilities.py index cff6a59..8179f06 100644 --- a/tests/test_air_monitor_capabilities.py +++ b/tests/test_air_monitor_capabilities.py @@ -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 diff --git a/tests/test_air_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 3bec9cb..bfa1cb1 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -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.""" diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 5669364..4ca6a54 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -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():