feat(airconditioner): filter alarm interval on legacy ARTIK051 boards
Adds the Select the official app offers next to the filter reminder -- 180/300/500/700 hours -- which this board generation keeps as a FilterAlarmTime_ token in /mode/vs/0's options[] rather than on a /filter/* resource. Confirmed on hardware: stepping through all four radio positions in the app moved that one token and nothing else across all 19 resources, so the token carries the hour count verbatim; and a local write of 500 to a unit sitting on 700 was accepted and kept, surviving a restart. The cloud exposes none of this, so it is only available locally. Gated with the counter it belongs to, so boards carrying a real /filter/airdustfilter/vs/0 threshold keep using that one -- the gate is covered by a test that injects the token into a newer board's dump, since no non-legacy fixture carries it and the assertion would otherwise pass for the wrong reason. Also settles what filter_time measures: it counts UP -- running time accumulated since the last filter reset, not time remaining -- which an earlier revision of that comment explicitly left open. Three independent things agree: the token rising while the unit runs, FilterAlarmTime_ being the threshold it is measured against, and /alarms/vs/0's filter entry tracking the counter across two units on one site (a live unsuffixed 'FilterAlarm'/'Created' at FilterTime_5595 against the 'FilterAlarm_OFF'/'Deleted' placeholder at 1915). The alarm clearing by itself the moment the counter dropped under the threshold is the causal half of that, not just correlation. Resetting the counter is NOT solved and no reset entity is added. The descriptor records what was tried, and what the failures do and do not prove, so the next attempt starts from evidence instead of from scratch. Short version: the reset is a *command* (custom.dustFilter/resetDustFilter), not a value write, which is why nothing that writes the counter works; I could not work out how to drive that command locally. /actions/vs/0 is the obvious local command channel but publishes no schema, and I did not enumerate guessed action names against a live appliance. Written with Claude (AI), on the author's own hardware; every result quoted above is measured on the device rather than inferred.
This commit is contained in:
@@ -573,15 +573,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'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +313,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": {
|
||||
|
||||
@@ -313,6 +313,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",
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user