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..5c6f0c0 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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"), + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 93b96bf..8f5074d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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")), ), diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 27b2503..dcb7543 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -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 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..4a939dd 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -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.""" diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 5669364..697434d 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -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(): diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 49e7526..5667a96 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -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 diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 4b6609f..c938632 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -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)