diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5b42fd4..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 @@ -51,18 +57,6 @@ 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 @@ -134,7 +128,8 @@ AIR_QUALITY = Capability( # 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. + # fixture) don't grow an empty entity. Disabled by default for the + # same reason as airconditioner.AIR_QUALITY (issue #166). SensorDesc( key="co2", field="x.com.samsung.da.items", @@ -142,7 +137,8 @@ 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: 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_purifier_air_quality_statistics.py b/tests/test_air_purifier_air_quality_statistics.py index 6e8cace..4a939dd 100644 --- a/tests/test_air_purifier_air_quality_statistics.py +++ b/tests/test_air_purifier_air_quality_statistics.py @@ -110,6 +110,7 @@ def test_co2_matches_air_monitor_mapping(): 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 ) diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index e394568..697434d 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -91,10 +91,14 @@ def test_co2_reads_when_the_items_list_includes_the_type(): 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)