Merge pull request #218 from perseus177/ac-filter-reset
feat(airconditioner): filter dust alarm interval on legacy ARTIK051 boards
This commit is contained in:
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",
|
||||
|
||||
@@ -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