diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 2bc5cce..d231c52 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -555,9 +555,9 @@ CLIMATE = Capability( ), # Filter time in tenths of an hour, counting UP since last filter # reset; scale and direction confirmed against the Samsung app and - # the /alarms/vs/0 threshold crossing (500h). No reset entity: no - # local write path has been found -- see - # docs/investigations/ac-filter-reset.md for what's been tried. + # the /alarms/vs/0 threshold crossing (500h). Resettable via the + # FilterCleanAlarm_Clear trigger token below -- see + # docs/investigations/ac-filter-reset.md for how that was found. SensorDesc( key="filter_time", rep_fn=_option_token_num("FilterTime", divisor=10), @@ -567,6 +567,26 @@ CLIMATE = Capability( state_class="measurement", icon="mdi:air-filter", ), + # Resets the counter above via the FilterCleanAlarm_Clear token, the + # same single-token options merge as every other /mode/vs/0 setting. + # It's a trigger, not a stored setting -- the board zeroes the + # counter on receipt and never reports this token back, which is why + # it doesn't show up in exists_fn like the others. Confirmed on an + # ARTIK051_KRAC_18K: FilterTime_95 -> FilterTime_0, stable across a + # fresh DTLS session and every poll after (see the investigation doc + # for why this was believed cloud-only until now). + ButtonDesc( + key="filter_time_reset", + field="", + payload="FilterCleanAlarm_Clear", + icon="mdi:restart", + entity_category="config", + exists_fn=_has_option_token("FilterTime"), + write_fn=lambda p, rep, href=None: ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": [p]}, + ), + ), # FilterTime_'s threshold, exposed as a static 4-way radio # (180/300/500/700h, matching the app) since options[] tokens carry # no supported-values list to read from, unlike air_filter_threshold diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index aeece88..83e5b72 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -120,6 +120,9 @@ "auto_clean_stop": { "name": "Zastavit samočištění" }, + "filter_time_reset": { + "name": "Vynulovat počítadlo filtru" + }, "pause": { "name": "Pozastavit" }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 545e8c0..d403e24 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -120,6 +120,9 @@ "auto_clean_stop": { "name": "Stop auto clean" }, + "filter_time_reset": { + "name": "Reset filter time" + }, "pause": { "name": "Pause" }, diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 729b853..ec8e6a0 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -242,6 +242,9 @@ "auto_clean_stop": { "name": "Detener la autolimpieza" }, + "filter_time_reset": { + "name": "Restablecer el contador del filtro" + }, "pause": { "name": "Pausar" }, diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index ecdb604..be69d02 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -120,6 +120,9 @@ "auto_clean_stop": { "name": "Arresta autopulizia" }, + "filter_time_reset": { + "name": "Azzera il contatore del filtro" + }, "pause": { "name": "Pausa" }, diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 625a81a..7a86ba4 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -120,6 +120,9 @@ "auto_clean_stop": { "name": "자동 청소 정지" }, + "filter_time_reset": { + "name": "필터 사용 시간 초기화" + }, "pause": { "name": "일시정지" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index e6658e9..09e15db 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -120,6 +120,9 @@ "auto_clean_stop": { "name": "Zelfreiniging stoppen" }, + "filter_time_reset": { + "name": "Filterteller op nul zetten" + }, "pause": { "name": "Pauzeren" }, diff --git a/docs/investigations/ac-filter-reset.md b/docs/investigations/ac-filter-reset.md index 858b049..e9c35e1 100644 --- a/docs/investigations/ac-filter-reset.md +++ b/docs/investigations/ac-filter-reset.md @@ -1,20 +1,28 @@ -# AC filter-time counter reset: not solved +# AC filter-time counter reset: solved `registry/capabilities/airconditioner.py`'s `filter_time` sensor -(`FilterTime_` option token, tenths of an hour) has no reset entity. This -is where the failed attempts to find one are kept, so the next attempt starts -from the evidence instead of from scratch. Not finding a mechanism is not the -same as it not existing. +(`FilterTime_` option token, tenths of an hour) has a reset entity now +(`filter_time_reset`, issue-tracked as PR #289): a single-token options write +of `FilterCleanAlarm_Clear` to `/mode/vs/0`, the same merge every other +setting on that href uses. Measured on an ARTIK051_KRAC_18K: `FilterTime_95` +(9h30m) → `FilterTime_0`, still zero on a fresh DTLS session and every poll +after; none of the other 17 tokens moved and the `/alarms/vs/0` entries +stayed `Deleted`. + +The rest of this file is kept as-is: the failed attempts below are still the +best record of what *doesn't* work on this generation, and the reasoning +that follows them explains why the reset looked cloud-only for as long as it +did — a genuine trap worth knowing about before the next reset-adjacent +mystery on this board family. ## What the reset actually is -Samsung models it as a **command**, not a value write: capability +A **command**, not a value write. Samsung's cloud models it as capability `custom.dustFilter`, command `resetDustFilter`, no arguments (implemented in -several SmartThings HA forks; not in the core integration). That reframes -every attempt below — nothing changes the counter by writing to it, because -the board zeroes it itself on receiving a command. +several SmartThings HA forks; not in the core integration) — that command +name is real, but see below for why POSTing it directly went nowhere. -## Tried, all against a live unit, all failed +## Tried, all against a live unit, all failed (before the token above was found) - `FilterTime_0` via the single-token options merge that works for every other setting on this href — accepted with no error, then discarded. Tried @@ -50,7 +58,18 @@ on `/rm/state/vs/0` was accepted (2.04 Changed, value held, restored afterwards), and `FilterAlarmTime_` is written through the very same options merge and kept. Writes work; this one value just isn't driven that way. -## Where to look next +## The token, and why the dead ends below missed it + +`FilterCleanAlarm_Clear` is not derived from anything in this file's earlier +attempts — how it was originally identified isn't recorded here. What is +recorded is why the standard technique (diff the appliance's reported state +before/after triggering the action in Samsung's app) couldn't have found it +on its own: the token is a trigger, never stored and never echoed back in +`x.com.samsung.da.options`, so a before/after diff of stored state shows +only the *effects* (counter zeroing, alarm clearing) and never the token +that caused them. + +## Dead ends tried before the token was known (kept for the next unrelated mystery) - The `/actions/vs/0` action vocabulary from an independent source (a firmware image, or a capture of what the cloud sends the device). diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 1034c94..5738b9e 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -261,6 +261,28 @@ def test_auto_clean_stop_stays_off_boards_without_the_token(): assert "auto_clean_progress_legacy" not in _state("airconditioner_tp1x_rac") +def test_filter_time_reset_writes_the_appliance_s_own_trigger_token(): + """FilterCleanAlarm_Clear, measured on hardware: 2.04 Changed and + FilterTime_95 -> FilterTime_0, still zero on a fresh session and every poll + after. The token is a trigger the board acts on rather than a value it + stores -- it never appears in options[] -- so the button is gated on the + counter's own token being present, which is what says this board has a + filter timer at all.""" + desc = _desc(_load_device(FIXTURE), "filter_time_reset") + assert desc is not None + assert desc.write_fn(desc.payload, {}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}, + ) + + +def test_filter_time_reset_stays_off_boards_without_the_counter(): + """No FilterTime_ token, no counter to reset. Newer boards report filter + usage through their own resource and would need a different mechanism, so + offering a button that writes a legacy token there would be a guess.""" + assert _desc(_load_device("airconditioner_tp1x_rac"), "filter_time_reset") is None + + 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.