From 07c20e82aac06cb660741e287296e6e16afd6caf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 9 Aug 2026 00:37:09 +0000 Subject: [PATCH] Stop double-converting filterUsage on AIR_FILTER/HEPA_FILTER (#330) filterUsage is already a 0-100 percentage on every confirmed family, including ARTIK051_PRAC: filterStatus flips to 'wash' at filterUsage == '100' regardless of filterCapacity (60/224/500 across other fixtures), which only holds if filterUsage is already a percent. filter_usage_percent() divided by filterCapacity again, reading a filter due for washing as 20% fresh. air_filter_usage_hours had the mirror problem: it read filterUsage directly as an hour count with device_class=duration, when the field is a percent. It's now derived from the percentage and filterCapacity (new filter_usage_hours() in common.py) instead. --- .../registry/capabilities/airconditioner.py | 12 +++--- .../registry/capabilities/common.py | 24 ++++++++--- tests/test_air_purifier_avt_ww.py | 2 +- tests/test_airconditioner_capabilities.py | 43 +++++++++++++------ 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 0f0d32b..4eac2be 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -22,7 +22,7 @@ from ..entities import ( SwitchDesc, ) from . import common -from .common import filter_usage_percent, normalize_temp_unit +from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -854,16 +854,17 @@ AIR_FILTER = Capability( icon="mdi:air-filter", entity_category="diagnostic", ), - # Lifetime hour counter, resets only on filter replacement. + # Lifetime hour counter, resets only on filter replacement. Derived + # from the percentage and filterCapacity (issue #330) -- filterUsage + # itself is not an hour count. SensorDesc( key="air_filter_usage_hours", - field="x.com.samsung.da.filterUsage", + rep_fn=filter_usage_hours, device_class="duration", state_class="total_increasing", unit_fn=_filter_unit, icon="mdi:air-filter", entity_category="diagnostic", - value_fn=_int, ), # Locally writable alarm threshold (see _threshold_write); only # surfaces where supportedFilterDesiredUsage is advertised. @@ -925,13 +926,12 @@ AIR_FILTER_PM1 = Capability( ), SensorDesc( key="air_filter_pm1_usage_hours", - field="x.com.samsung.da.filterUsage", + rep_fn=filter_usage_hours, device_class="duration", state_class="total_increasing", unit_fn=_filter_unit, icon="mdi:air-filter", entity_category="diagnostic", - value_fn=_int, exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"), ), SelectDesc( diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 56c9166..17a3057 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -72,15 +72,25 @@ def epoch_to_utc(value): def filter_usage_percent(rep): - """Filter usage as a percentage of rated capacity. Several families - report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of - a 500-hour capacity), so a plain value with a '%' unit would be wrong. - Returns None when capacity is missing/zero.""" - used = _num(rep.get("x.com.samsung.da.filterUsage")) + """Filter usage as a percentage. `filterUsage` is already 0-100 on every + family confirmed so far, including ARTIK051_PRAC (issue #330): its own + fixture and three live heads all show `filterStatus == 'wash'` at + `filterUsage == '100'` regardless of `filterCapacity` (60/224/500 across + other families' fixtures), which only holds if `filterUsage` is already + a percent -- dividing by capacity again would read that filter as fresh + at 20%.""" + return int_or_none(rep.get("x.com.samsung.da.filterUsage")) + + +def filter_usage_hours(rep): + """Elapsed filter hours, derived from the percentage and capacity rather + than read off `filterUsage` directly -- `filterUsage` is a percent, not + an hour count (issue #330). Returns None when capacity is missing/zero.""" + pct = filter_usage_percent(rep) cap = _num(rep.get("x.com.samsung.da.filterCapacity")) - if used is None or not cap: + if pct is None or not cap: return None - return round(used / cap * 100) + return round(pct / 100 * cap, 1) def normalize_temp_unit(raw, default="°F"): diff --git a/tests/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py index 9a8fc00..784688b 100644 --- a/tests/test_air_purifier_avt_ww.py +++ b/tests/test_air_purifier_avt_ww.py @@ -59,5 +59,5 @@ def test_air_quality_and_filter_sensors_present(): assert state["dust"] == 10 assert state["fine_dust"] == 9 assert state["odor"] == 1 - assert state["hepa_filter_usage"] == 0 + assert state["hepa_filter_usage"] == 30 assert state["hepa_filter_status"] == "normal" diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index f4a719e..f0881d5 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -95,10 +95,13 @@ def test_power_and_convenient_folded_into_climate(): assert "convenient_mode" not in state -def test_air_filter_usage_is_percentage_of_capacity(): - """filterUsage is a raw count in the capacity unit (100 of 500), surfaced as - a percentage rather than the misleading raw value.""" - assert _state()["air_filter_usage"] == 20 +def test_air_filter_usage_is_already_a_percentage(): + """filterUsage is already 0-100, not a raw count to divide by + filterCapacity (issue #330): this fixture (ARTIK051_PRAC_20K) reports + filterStatus == 'wash' at filterUsage == '100', which only holds if + filterUsage is already a percent -- dividing by the 500 capacity again + would read a filter due for washing as 20% fresh.""" + assert _state()["air_filter_usage"] == 100 def test_climate_write_targets(): @@ -771,19 +774,33 @@ def test_tropical_night_state_levels_across_fixtures(): assert level("airconditioner_tp2x_rac_20k") == 16 -def test_air_filter_usage_hours_reads_raw_count(): - """filterUsage is a lifetime hour counter (41 of 500) that resets on - filter replacement -- total_increasing, not measurement. Unit comes from +def test_air_filter_usage_hours_derived_from_percent_and_capacity(): + """filterUsage is a percent, not an hour count (issue #330): the lifetime + hour reading is derived from percent * capacity rather than read off + filterUsage directly. total_increasing, not measurement; unit comes from filterCapacityUnit via unit_fn, not a hardcoded 'h'.""" desc = next( e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc) ) - assert desc.value_fn("41") == 41 - assert desc.value_fn(41) == 41 - assert desc.value_fn(None) is None - assert desc.value_fn("not-a-number") is None + assert ( + desc.rep_fn( + {"x.com.samsung.da.filterUsage": "91", "x.com.samsung.da.filterCapacity": "500"} + ) + == 455.0 + ) + assert ( + desc.rep_fn( + { + "x.com.samsung.da.filterUsage": "not-a-number", + "x.com.samsung.da.filterCapacity": "500", + } + ) + is None + ) + assert desc.rep_fn({"x.com.samsung.da.filterCapacity": "500"}) is None + assert desc.rep_fn({"x.com.samsung.da.filterUsage": "91"}) is None assert desc.device_class == "duration" assert desc.state_class == "total_increasing" assert desc.unit_fn is not None @@ -830,8 +847,8 @@ def test_air_filter_threshold_absent_without_supported_enum(): reg, resources = _ac_windfree() state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) assert "air_filter_threshold" not in state - assert state["air_filter_usage_hours"] == 41 - assert state["air_filter_usage"] == 8 # 41/500 -> 8% + assert state["air_filter_usage"] == 41 + assert state["air_filter_usage_hours"] == 205.0 # 41% of 500 def test_air_filter_threshold_binds_on_enum_board():