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:
@@ -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"):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user