Merge pull request #218 from perseus177/ac-filter-reset

feat(airconditioner): filter dust alarm interval on legacy ARTIK051 boards
This commit is contained in:
Marc Billow authored and GitHub committed 2026-07-31 20:14:31 -05:00
commit d639aa693f
7 files changed
+199 -8

No files matched your search

@@ -596,15 +596,109 @@ CLIMATE = Capability(
unit='°C', icon='mdi:home-thermometer-outline'),
# Filter time in tenths of an hour: the token read 1710 while the official
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
# it counts up or down is NOT established -- it was seen rising (171.0 ->
# 171.5) while the unit ran, which contradicts the app's "remaining"
# wording, so the entity is deliberately named neutrally.
# unit, and the .0 matching the app's "0 minutes" pins the scale.
#
# It counts UP -- running time accumulated since the last filter reset,
# not time remaining. An earlier revision of this comment called the
# direction unestablished; three independent things now settle it. It was
# seen rising while the unit ran (171.0 -> 171.5). FilterAlarmTime_ in the
# same options[] blob is the threshold it is measured against (500 on
# every unit on record). And across two units on one site, the alarm
# tracks the counter in the right direction: at FilterTime_5595 the
# /alarms/vs/0 filter entry is live (unsuffixed code 'FilterAlarm', state
# 'Created'), while at FilterTime_1915 it is still the
# 'FilterAlarm_OFF'/'Deleted' placeholder. That also matches what the app
# does at 500 hours -- ask the user to clean the filter and reset it.
#
# The key stays 'filter_time' rather than becoming something like
# filter_time_elapsed: renaming it would change every existing unit's
# entity_id and unique_id for a wording improvement.
#
# (The alarm clearing on its own is the causal proof of the direction
# above: the board recomputes it the moment the counter crosses the
# threshold, rather than the app clearing counter and alarm separately.)
#
# ---------------------------------------------------------------------
# RESETTING THIS COUNTER: NOT SOLVED YET -- no reset entity here, and
# the notes below are so the next attempt starts from the evidence
# rather than from scratch. I could not work out how to drive the reset
# locally; that is not the same as it being impossible, and none of the
# results below rule out a mechanism I simply haven't found.
#
# What the reset actually is: a *command*, not a value write. Samsung
# models it as capability `custom.dustFilter`, command
# `resetDustFilter`, no arguments (implemented in several SmartThings HA
# forks; not in the core integration). That reframes every failed
# attempt below -- nothing changes the counter by writing to it,
# because the board zeroes it itself on receiving a command.
#
# Tried, all against a live unit, all failed:
# * FilterTime_0 via the single-token options merge that works for
# every other setting on this href -- accepted with no error, then
# discarded. Two units, opposite power states, to rule out the
# obvious confound: 5595 -> back to 5595 after 69 s (powered off,
# alarm active) and 1925 -> back to 1925 after 65 s (actively
# cooling).
# * A full options[] read-modify-write with FilterTime_0 substituted,
# instead of the single-token merge -- zero fields changed anywhere.
# * A write to /consumable/vs/0, the board's own filter resource
# (items[{name: FilterProgress, state: N}]) -- discarded. /oic/res
# declares that resource oic.if.s, i.e. read-only, which fits.
# * /actions/vs/0 (x.com.samsung.da.actions, oic.if.a) is the obvious
# local command channel, but publishes no schema: GET returns {} on
# baseline and on oic.if.a, and five POSTs probing the *shape*
# (empty map, empty string, empty array, invalid value, items shape)
# all returned 4.00 with an empty body -- no echo of accepted field
# names, unlike the laundry firmware's "Control fail, <...>". I
# deliberately did not enumerate guessed action names against a live
# appliance: an unknown vocabulary on a channel called "actions" can
# hold a factory reset next to the one we want.
# * /hass/state/vs/0 and /hass/command/vs/0 (advertised in /oic/res,
# and /opt/data/hass.db exists in /file/list) -> 4.04 on every
# interface, so unimplemented scaffolding on this firmware.
# * /file/transfer/vs/0 serves only /mnt/usage.db; selecting another
# path returns 4.05/4.00, so the firmware can't be pulled that way
# to read the action vocabulary out of it.
# * /rm/micomdata/vs/0 (channel toward the MICOM board the physical
# panel talks to) stays empty even after successfully enabling
# remote management.
#
# What the failures are NOT: a transport, permission or cert problem.
# A control write of rmState on /rm/state/vs/0 was accepted (2.04
# Changed, value held, restored afterwards), and FilterAlarmTime_ below
# is written through the very same options merge and kept. So writes
# work; this one value just isn't driven that way.
#
# Where I'd look next: the action vocabulary for /actions/vs/0 from an
# independent source (firmware image, or a capture of what the cloud
# sends the device), or the IR path -- the physical remote has a filter
# reset (Options -> Filter Reset -> SET), and IRremoteESP8266 decodes
# this AC family, though issue #1277's dump doesn't include that button.
# ---------------------------------------------------------------------
SensorDesc(key='filter_time',
rep_fn=_option_token_num('FilterTime', divisor=10),
exists_fn=_has_option_token('FilterTime'),
device_class='duration', unit='h',
state_class='measurement', icon='mdi:air-filter'),
# The interval FilterTime_ is measured against -- the app offers it as a
# four-way radio (180/300/500/700 hours, default 500) next to the filter
# reminder. The token value is the hour count verbatim: captured by
# watching all 19 resources while the owner stepped through every radio
# position, one field changing per step and nothing else moving.
#
# Static options here, unlike air_filter_threshold on the newer boards
# which reads supportedFilterDesiredUsage: options[] tokens carry no
# supported-values list anywhere in this board's dump, so there is
# nothing to read them from. The four values are the app's own radio
# rather than a guess extrapolated from one observed value -- but a
# board offering different steps would need this revisited, which is
# why the tuple is documented rather than just written down.
SelectDesc(key='filter_alarm_time',
rep_fn=lambda rep: _option_token(rep, 'FilterAlarmTime'),
exists_fn=_has_option_token('FilterAlarmTime'),
options=('180', '300', '500', '700'),
write_fn=_option_switch_write('FilterAlarmTime'),
icon='mdi:alarm', entity_category='config'),
# Odor-controller ("Smart Cool Clean") state + progress -- see
# _odor_controller_active's docstring for the cloud-capability
# correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's
@@ -328,6 +328,15 @@
"favorite_hotwater_temperature": {
"name": "Favorite hot water temperature"
},
"filter_alarm_time": {
"name": "Filter alarm interval",
"state": {
"180": "180 hours",
"300": "300 hours",
"500": "500 hours",
"700": "700 hours"
}
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -328,6 +328,15 @@
"favorite_hotwater_temperature": {
"name": "Favoriete temperatuur warm water"
},
"filter_alarm_time": {
"name": "Filteralarminterval",
"state": {
"180": "180 uur",
"300": "300 uur",
"500": "500 uur",
"700": "700 uur"
}
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
@@ -9,6 +9,7 @@
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_alarm_time",
"filter_time",
"good_sleep",
"humidity",
+80 -4
View File
@@ -9,8 +9,8 @@ differs from all of them in three ways, each covered below:
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
token in ``/mode/vs/0``'s ``options`` array.
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
outdoor temperature, filter time) are ``options`` tokens too, where newer
boards have dedicated resources.
outdoor temperature, filter time and its alarm interval) are ``options``
tokens too, where newer boards have dedicated resources.
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
by read-back on the unit this fixture is dumped from. The issue #136 unit is
@@ -103,8 +103,8 @@ def test_token_entities_present_with_calibrated_values():
state = _state()
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
# official app on this unit, which pins the scale (the .5 here is a later
# reading). Whether it counts up or down is deliberately not asserted --
# see the descriptor comment.
# reading). It counts up -- see the descriptor comment and
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
assert state['filter_time'] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
@@ -180,6 +180,82 @@ def test_option_writes_carry_one_token():
)
# -- filter counter and its reset ---------------------------------------------
def _desc(resources, key):
"""The bound descriptor for `key`, or None when its exists_fn declines it.
flatten() gives values, not descriptors, so this is how a test reaches a
descriptor's own write_fn and options without standing up an HA entity.
"""
bound, _ = _discover(resources)
for item in bound:
if item.desc.key == key and (
item.desc.exists_fn is None
or item.desc.exists_fn(resources.get(item.href) or {}, resources)
):
return item.desc
return None
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
"""The interval FilterTime_ is measured against, offered by the app as a
180/300/500/700 hour radio. All four were walked on hardware while watching
all 19 resources: the token carries the hour count verbatim and each step
moved that one field and nothing else, which is also what makes a static
options tuple defensible here (the board advertises no supported-values
list for options[] tokens)."""
state = _state()
assert state['filter_alarm_time'] == '500' # the fixture's own value
desc = _desc(_load_device(FIXTURE), 'filter_alarm_time')
assert desc.options == ('180', '300', '500', '700')
assert desc.write_fn('180', {}) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': ['FilterAlarmTime_180']},
)
def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
"""Newer boards carry air_filter_threshold off supportedFilterDesiredUsage;
two thresholds on one device would be a coin flip for the user.
No non-legacy fixture carries a FilterAlarmTime_ token today (only the two
KRAC dumps have one at all), so asserting on an unmodified newer board
would pass for the wrong reason -- absent token rather than the
board-generation gate. The token is injected to exercise the gate itself,
test_absent_token_yields_no_entity's technique in reverse."""
newer = _load_device('airconditioner_tp1x_rac')
assert _desc(newer, 'filter_alarm_time') is None
mode = newer['/mode/vs/0']
mode['x.com.samsung.da.options'] = [
*(mode.get('x.com.samsung.da.options') or []), 'FilterAlarmTime_500',
]
assert is_legacy_board(newer) is False
assert _desc(newer, 'filter_alarm_time') is None
def test_filter_alarm_tracks_the_counter_against_its_threshold():
"""Why filter_time is read as elapsed rather than remaining: the same
options blob carries the threshold, and /alarms/vs/0's filter entry is a
'FilterAlarm_OFF'/'Deleted' placeholder below it. The sibling unit on the
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
resources = _load_device(FIXTURE)
options = resources['/mode/vs/0']['x.com.samsung.da.options']
assert 'FilterTime_1715' in options
assert 'FilterAlarmTime_500' in options
alarms = resources['/alarms/vs/0']['x.com.samsung.da.items']
filter_alarm = next(
item for item in alarms
if item['x.com.samsung.da.code'].startswith('FilterAlarm')
)
assert filter_alarm['x.com.samsung.da.code'] == 'FilterAlarm_OFF'
assert filter_alarm['x.com.samsung.da.state'] == 'Deleted'
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
def test_fan_mode_reads_the_airflow_speed_level():