Compare commits

...
Author SHA1 Message Date
Marc Billow 5ee72bebb8 Apply issue #56 field reports: filter direction, drop Blooming_*
Incorporates confirmed answers from the issue thread:
- FilterProgress counts down as the filter wears (100 = fresh), so the
  sensor is renamed filter_progress -> filter_life to match the direction
  instead of implying "usage" like the AC/range_hood filterUsage sensors.
- Blooming_* has no corresponding SmartThings app setting, so it's dropped
  entirely rather than kept as an unexplained diagnostic.
- OptionCode_60282 and the missing humidity sensor are confirmed correct
  as already modeled (not user-facing / genuinely absent on this model).

Five running-state diagnostics dumps (Auto/Sleep/Low/Medium/High) rule out
the original guess that Comode_* is the fan-speed selector -- it reads
'Off' on every one of them. /airflow's speed field doesn't map monotonically
to the five settings either (0 for both Auto and High, 3 for Low/Medium/
Sleep), and all five dumps were captured within about three minutes of each
other -- one poll cycle at this integration's 30s summary interval -- so
fan-speed modeling stays unbuilt pending a cleaner capture.
2026-07-23 23:56:11 +00:00
3 changed files with 71 additions and 57 deletions

No files matched your search

@@ -1,6 +1,5 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family """Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56 -- verified against two independent (model AX60R5080WD/SE, issue #56).
diagnostics dumps of the same internal model).
Power, kids-lock, remote-control, alarms, and the energy meter are the shared Power, kids-lock, remote-control, alarms, and the energy meter are the shared
common.py capabilities (this family exposes the standard /power/0+/power/vs/0 common.py capabilities (this family exposes the standard /power/0+/power/vs/0
@@ -8,32 +7,39 @@ pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
dishwasher.DIAGNOSIS -- identical field/write contract dishwasher.DIAGNOSIS -- identical field/write contract
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps). (x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
Two things are deliberately left as raw, unwritable diagnostic sensors rather
than modeled as real controls, per the "don't guess" rule:
/airflow/0, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/
direction, both zeroed/'Off' on every dump seen (device was off in both).
No supportedSpeed/supportedModes list is present anywhere in either dump
to confirm the valid range, so a write-capable fan/select isn't safe to
build yet -- see the issue #56 request for a running-state dump.
/mode/vs/0's x.com.samsung.da.options array packs multiple independent /mode/vs/0's x.com.samsung.da.options array packs multiple independent
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW '<Prefix>_<value>' flags into one list -- the same packed-list/RMW contract
contract laundry.py's option_value/replace_in_options already model for laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this /course/vs/0's options[] (reused directly below, just against this family's
family's own href). Of the tokens seen: own href). Per issue #56's follow-up (five diagnostics dumps captured with
Light_On / Light_Off -- read as a plain on/off flag; MODE below the physical unit set to Auto/Sleep/Low/Medium/High):
models it as a real switch, RMW- Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
replacing just that one list entry. real switch, RMW-replacing just that one entry.
Comode_Off -- never seen non-'Off' on these dumps; Comode_Off -- read 'Off' on *every* one of the five dumps,
likely the fan operating mode the issue including High/Low/Medium/Auto -- confirms this
describes (Auto/Sleep/1/2/3), but is NOT the fan-speed selector (ruling out the
unconfirmed -- exposed read-only. original guess); exposed read-only since its
Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only. actual purpose is still unconfirmed.
OptionCode_60282 -- opaque, unchanged across both dumps; OptionCode_60282 -- confirmed opaque/not user-facing in the
not modeled (same treatment as SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the range_hood's OptionCode_* token on the same
same href). href).
Blooming_* -- confirmed to have no corresponding SmartThings
app setting; dropped entirely rather than kept
as an unexplained diagnostic (it did track 1:1
with Sleep mode across the five dumps -- 0 in
Sleep, 6 otherwise -- so it's plausibly an
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
fan-speed control: across the same five dumps it read 0 for both Auto *and*
High, and 3 for Low/Medium/*and* Sleep -- not a monotonic mapping to any
selectable level, and the dumps were all captured within about three minutes
of each other (only one poll cycle apart at this integration's 30s summary
interval), so the values may not have settled after each change before the
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
stable capture -- see the issue #56 discussion for what's needed.
""" """
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
@@ -68,16 +74,18 @@ def _consumable_state(items, name):
return None return None
# FilterProgress is a raw 0-100 percentage in both dumps (100 and 62); which # FilterProgress is a 0-100 percentage counting down as the filter wears --
# end of that scale means "replace me" isn't confirmed from the dump alone, # confirmed via issue #56: the SmartThings app shows "Filter needs changing"
# so the entity is named after the raw field rather than asserting a # once this reaches the low end, so 100 means a fresh filter, not "100% worn".
# direction (see issue #56 follow-up questions). # Named 'Filter life' (matching the direction) rather than the 'Filter usage'
# convention used elsewhere in this codebase (AC/range_hood's filterUsage
# counts up instead), so the two aren't confused.
FILTER = Capability( FILTER = Capability(
href='/consumable/vs/0', href='/consumable/vs/0',
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items', SensorDesc(key='filter_life', field='x.com.samsung.da.items',
name='Filter progress', unit='%', state_class='measurement', name='Filter life', unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic', icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none( value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))), _consumable_state(items, 'FilterProgress'))),
@@ -142,22 +150,20 @@ MODE = Capability(
rep_fn=bool_option_value('Light'), rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'), exists_fn=bool_option_exists('Light'),
write_fn=_light_write), write_fn=_light_write),
# Read-only pending issue #56 follow-up -- see module docstring. # Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan', SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
entity_category='diagnostic', entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'), rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')), exists_fn=bool_option_exists('Comode')),
SensorDesc(key='blooming_level', name='Blooming level', icon='mdi:flower',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Blooming'),
exists_fn=bool_option_exists('Blooming')),
), ),
) )
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has # /humidity/0 and /humidity/vs/0 are empty {} on every dump seen, and issue
# been verified against -- covered here (not globally, per ignored.py's # #56 confirms this model has no humidity sensor at all (dust/odor only) --
# module docstring) since those hrefs collide with fridge/AC schemas # covered here (not globally, per ignored.py's module docstring) since those
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED. # hrefs collide with fridge/AC schemas elsewhere. Same two hrefs and
# reasoning as airconditioner.py's _AC_IGNORED.
COVERAGE = [ COVERAGE = [
Capability(href='/humidity/0'), Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'), Capability(href='/humidity/vs/0'),
+1 -2
View File
@@ -1,7 +1,6 @@
{ {
"state_keys": [ "state_keys": [
"alarm_code", "alarm_code",
"blooming_level",
"clean_level", "clean_level",
"device_active", "device_active",
"diagnosis_status", "diagnosis_status",
@@ -13,7 +12,7 @@
"energy_this_month_kwh", "energy_this_month_kwh",
"fan_direction", "fan_direction",
"fan_speed_level", "fan_speed_level",
"filter_progress", "filter_life",
"fine_dust", "fine_dust",
"odor", "odor",
"operating_mode", "operating_mode",
+22 -13
View File
@@ -40,9 +40,9 @@ def test_expected_entities_present():
state = _state() state = _state()
for key in ( for key in (
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust', 'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active', 'odor', 'clean_level', 'filter_life', 'device_active',
'diagnosis_status', 'fan_speed_level', 'fan_direction', 'diagnosis_status', 'fan_speed_level', 'fan_direction',
'display_light', 'operating_mode', 'blooming_level', 'display_light', 'operating_mode',
): ):
assert key in state, key assert key in state, key
@@ -58,8 +58,10 @@ def test_air_quality_sensor_values():
assert state['clean_level'] == 0 assert state['clean_level'] == 0
def test_filter_progress_reads_named_consumable_item(): def test_filter_life_reads_named_consumable_item():
assert _state()['filter_progress'] == 100 """FilterProgress is confirmed (issue #56) to count down as the filter
wears -- 100 means fresh, not "100% used"."""
assert _state()['filter_life'] == 100
def test_diagnosis_reuses_dishwasher_capability(): def test_diagnosis_reuses_dishwasher_capability():
@@ -76,29 +78,36 @@ def test_light_switch_write_contract():
the other flags and the list order untouched.""" the other flags and the list order untouched."""
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light') desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
rep = {'x.com.samsung.da.options': [ rep = {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282', 'Comode_Off', 'Light_On', 'OptionCode_60282',
]} ]}
assert desc.rep_fn(rep) is True assert desc.rep_fn(rep) is True
assert desc.write_fn('Off', rep) == ( assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'], ['mode', 'vs', '0'],
{'x.com.samsung.da.options': [ {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_Off', 'OptionCode_60282', 'Comode_Off', 'Light_Off', 'OptionCode_60282',
]}, ]},
) )
def test_mode_tokens_are_read_only_diagnostics(): def test_operating_mode_is_a_read_only_diagnostic():
"""Comode_/Blooming_ tokens surface as raw diagnostic sensors rather than """Comode_* surfaces as a raw diagnostic sensor rather than a select/
a select/control -- their valid value ranges aren't confirmed yet (see control -- issue #56's five running-state dumps confirmed it reads 'Off'
the air_purifier.py module docstring and the issue #56 follow-up).""" regardless of the device's actual fan setting, ruling out the original
guess that it was the fan-speed selector; its real purpose is still
unconfirmed (see the air_purifier.py module docstring)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode') operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
blooming = next(e for e in air_purifier.MODE.entities if e.key == 'blooming_level') rep = {'x.com.samsung.da.options': ['Comode_Off']}
rep = {'x.com.samsung.da.options': ['Comode_Off', 'Blooming_6']}
assert operating_mode.rep_fn(rep) == 'Off' assert operating_mode.rep_fn(rep) == 'Off'
assert blooming.rep_fn(rep) == '6'
assert not hasattr(operating_mode, 'write_fn') assert not hasattr(operating_mode, 'write_fn')
def test_blooming_not_modeled():
"""Confirmed (issue #56) to have no corresponding SmartThings app
setting -- dropped entirely rather than kept as an unexplained
diagnostic."""
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
def test_airflow_vs_fallback_only_binds_without_generic(): def test_airflow_vs_fallback_only_binds_without_generic():
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the """/airflow/vs/0 is a match_fn fallback -- it must not bind when the
OCF-standard /airflow/0 is also present (both are on every dump seen).""" OCF-standard /airflow/0 is also present (both are on every dump seen)."""