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
@@ -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 /mode/vs/0's x.com.samsung.da.options array packs multiple independent
than modeled as real controls, per the "don't guess" rule: '<Prefix>_<value>' flags into one list -- the same packed-list/RMW contract
laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this family's
own href). Per issue #56's follow-up (five diagnostics dumps captured with
the physical unit set to Auto/Sleep/Low/Medium/High):
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
real switch, RMW-replacing just that one entry.
Comode_Off -- read 'Off' on *every* one of the five dumps,
including High/Low/Medium/Auto -- confirms this
is NOT the fan-speed selector (ruling out the
original guess); exposed read-only since its
actual purpose is still unconfirmed.
OptionCode_60282 -- confirmed opaque/not user-facing in the
SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the same
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, /airflow/vs/0 -- OCF-standard + vendor pair for fan speed/ /airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
direction, both zeroed/'Off' on every dump seen (device was off in both). fan-speed control: across the same five dumps it read 0 for both Auto *and*
No supportedSpeed/supportedModes list is present anywhere in either dump High, and 3 for Low/Medium/*and* Sleep -- not a monotonic mapping to any
to confirm the valid range, so a write-capable fan/select isn't safe to selectable level, and the dumps were all captured within about three minutes
build yet -- see the issue #56 request for a running-state dump. 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
/mode/vs/0's x.com.samsung.da.options array packs multiple independent diagnostics snapshot was taken. Exposed read-only pending a confirmed,
'<Prefix>_<value>' flags into one list -- the same packed-list/RMW stable capture -- see the issue #56 discussion for what's needed.
contract laundry.py's option_value/replace_in_options already model for
/course/vs/0's options[] (reused directly below, just against this
family's own href). Of the tokens seen:
Light_On / Light_Off -- read as a plain on/off flag; MODE below
models it as a real switch, RMW-
replacing just that one list entry.
Comode_Off -- never seen non-'Off' on these dumps;
likely the fan operating mode the issue
describes (Auto/Sleep/1/2/3), but
unconfirmed -- exposed read-only.
Blooming_0 / Blooming_6 -- meaning unconfirmed; exposed read-only.
OptionCode_60282 -- opaque, unchanged across both dumps;
not modeled (same treatment as
range_hood's OptionCode_* token on the
same href).
""" """
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)."""