diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index b753039..d39d0c3 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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'), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 5d718da..29c1eb3 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 890ea12..65c2ca5 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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": { diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json index e525909..4008747 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -9,6 +9,7 @@ "diagnosis_status", "display_light", "energy_kwh", + "filter_alarm_time", "filter_time", "good_sleep", "humidity", diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k_slot.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k_slot.json index a92207e..3008f12 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_18k_slot.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k_slot.json @@ -9,6 +9,7 @@ "diagnosis_status", "display_light", "energy_kwh", + "filter_alarm_time", "filter_time", "good_sleep", "humidity", diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json index e525909..4008747 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json @@ -9,6 +9,7 @@ "diagnosis_status", "display_light", "energy_kwh", + "filter_alarm_time", "filter_time", "good_sleep", "humidity", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 432e517..ffe60c5 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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():