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.
This commit is contained in:
Marc Billow
2026-08-09 00:37:09 +00:00
parent efea9e9888
commit 07c20e82aa
4 changed files with 54 additions and 27 deletions
@@ -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(
@@ -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"):
+1 -1
View File
@@ -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"
+30 -13
View File
@@ -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():