Compare commits

...
Author SHA1 Message Date
Marc Billow e07d6ed706 Complete dryer coverage from issue #14 screenshots (DV90BB5245AES1)
Name the two remaining unidentified course codes (0x21 Hygiene Care,
0x4C Air Refresh) by matching the SmartThings app's course-list order
against editCourseList in the reporter's dump.

Also close three entity gaps the screenshots surfaced that v0.6.0
missed: dry level was read-only despite the app exposing a writable
Level 1/2/3 picker; the app's "AI pattern" toggle (AiOption_On/Off)
and "Auto cycle link" toggle (/cycleinterface/vs/0, previously
globally ignored as always-empty on washer dumps) were both unbound.
Scope that ignore to washer.py now that the dryer binds it for real.
2026-07-24 00:43:58 +00:00
10 changed files with 121 additions and 18 deletions
@@ -26,5 +26,6 @@ REGISTRY = DeviceRegistry(
dryer.DRYER_SETTINGS, dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE, dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS, dryer.DRYER_DIAGNOSIS,
dryer.AUTO_CYCLE_LINK,
]), ]),
) )
@@ -15,5 +15,6 @@ REGISTRY = DeviceRegistry(
common.WATER_METER, common.WATER_METER,
operational.OPERATIONAL_STATE, operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS, dishwasher.DIAGNOSIS,
washer.CYCLE_INTERFACE_IGNORED,
]), ]),
) )
@@ -4,13 +4,15 @@ Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
the /course/vs/0 cycle select -- lives in laundry.py. the /course/vs/0 cycle select -- lives in laundry.py.
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent) /washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below) /course/vs/0 -> DRYER_COURSE (shared cycle select, plus the AI-pattern
/diagnosis/vs/0 -> DRYER_DIAGNOSIS toggle read off the same options[] array; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
/cycleinterface/vs/0 -> AUTO_CYCLE_LINK
""" """
from ..capability import Capability from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc from ..entities import SelectDesc, SensorDesc, SwitchDesc
from .laundry import cycle_select from .laundry import bool_option_switch, cycle_select
def _wrinkle_write(p, rep, href=None): def _wrinkle_write(p, rep, href=None):
@@ -23,8 +25,15 @@ DRYER_SETTINGS = Capability(
href='/washer/vs/0', href='/washer/vs/0',
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel', # supportedDryLevel values (e.g. 'None'/'Less'/'Normal'/'More') are
name='Dry level', icon='mdi:water-percent'), # already human-readable on this device, unlike the combo-unit
# numeric-minute codes washer.py's washer_dry_level select needs
# translations for -- no translation_key needed here.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent',
options_field='x.com.samsung.da.supportedDryLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime', SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
name='Dry time', icon='mdi:timer'), name='Dry time', icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType', SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
@@ -40,15 +49,21 @@ DRYER_SETTINGS = Capability(
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as # laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations # an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured # under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 # 2026-05-29; codes 0x21/0x4C named from the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code # SmartThings course-list screenshots on 2026-07-24). The /st/dryercourse/vs/0
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected # resource re-encodes the same selected course and is ignored (ignored.py) --
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 # the mirror of how /st/washercourse/vs/0 is ignored for washers.
# is ignored for washers. #
# The same options[] array also carries AiOption_On/Off -- the app's "AI
# pattern" toggle ("Get cycle recommendations based on your usage patterns"),
# confirmed against the issue #14 screenshots. Same On/Off token shape as
# washer.py's bubble-soak/pre-wash/intensive toggles; not previously bound.
DRYER_COURSE = Capability( DRYER_COURSE = Capability(
href='/course/vs/0', href='/course/vs/0',
entities=( entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'), cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
bool_option_switch('ai_pattern', 'AI pattern', 'mdi:creation', 'AiOption',
entity_category='config', gate_on_presence=True),
), ),
) )
@@ -60,3 +75,26 @@ DRYER_DIAGNOSIS = Capability(
name='Diagnosis', entity_category='diagnostic'), name='Diagnosis', entity_category='diagnostic'),
), ),
) )
# /cycleinterface/vs/0 -- the app's "Auto cycle link" toggle ("automatically
# set to the best drying cycle by communicating with the washer cycle"; both
# devices must stay connected). Empty ({}) on every washer dump seen so far,
# which is why it used to sit in the global ignored.IGNORED list -- the
# issue #14 dryer dump is the first to populate it. washer.py scopes its own
# ignore locally now (washer.CYCLE_INTERFACE_IGNORED) to avoid a collision.
def _auto_cycle_link_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['cycleinterface', 'vs', '0'], {'x.com.samsung.da.cycleInterfaceEnabled': p}
AUTO_CYCLE_LINK = Capability(
href='/cycleinterface/vs/0',
entities=(
SwitchDesc(key='auto_cycle_link', field='x.com.samsung.da.cycleInterfaceEnabled',
name='Auto cycle link', icon='mdi:link-variant',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_auto_cycle_link_write),
),
)
@@ -117,11 +117,15 @@ IGNORED: list[Capability] = [
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only # data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control. # real source for this control.
Capability(href='/energy/consumption/0'), Capability(href='/energy/consumption/0'),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href='/cycleinterface/vs/0'),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program # OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally. # dependency this integration doesn't support locally.
Capability(href='/drlc/0'), Capability(href='/drlc/0'),
# NOTE: /cycleinterface/vs/0 used to be listed here as empty ({}) on
# every washer dump seen so far. The issue #14 dryer dump populates it
# (cycleInterfaceEnabled) as the app's "Auto cycle link" toggle, so it's
# now a real capability there (dryer.AUTO_CYCLE_LINK) -- washer.py scopes
# its own ignore locally (washer.CYCLE_INTERFACE_IGNORED) to avoid a
# global-ignore/real-capability href collision.
# OCF-native duplicate of /operational/state/vs/0, which is already # OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable # modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control) # capability with start/pause/stop buttons and a delay-start control)
@@ -354,3 +354,10 @@ WASHER_COURSE = Capability(
'IntensiveSetting', 'IntensiveAvailableSet'), 'IntensiveSetting', 'IntensiveAvailableSet'),
), ),
) )
# /cycleinterface/vs/0 -- washer-to-dryer "Auto cycle link" pairing signal
# (dryer.AUTO_CYCLE_LINK). Empty ({}) on every washer/combo dump seen so far
# (plain washers, FlexWash, and washer/dryer combos alike), so it's scoped
# out here rather than left in the global ignored.IGNORED list, which would
# collide with the real dryer-side capability.
CYCLE_INTERFACE_IGNORED = Capability(href='/cycleinterface/vs/0')
+3 -1
View File
@@ -115,10 +115,12 @@
"1e": "Outdoor", "1e": "Outdoor",
"1f": "Mixed Load", "1f": "Mixed Load",
"20": "Iron Dry", "20": "Iron Dry",
"21": "Hygiene Care",
"23": "Quick Dry 35", "23": "Quick Dry 35",
"24": "Cool Air", "24": "Cool Air",
"25": "Warm Air", "25": "Warm Air",
"27": "Time Dry" "27": "Time Dry",
"4c": "Air Refresh"
} }
}, },
"washer_dosing_quantity": { "washer_dosing_quantity": {
@@ -115,10 +115,12 @@
"1e": "Outdoor", "1e": "Outdoor",
"1f": "Mixed Load", "1f": "Mixed Load",
"20": "Iron Dry", "20": "Iron Dry",
"21": "Hygiene Care",
"23": "Quick Dry 35", "23": "Quick Dry 35",
"24": "Cool Air", "24": "Cool Air",
"25": "Warm Air", "25": "Warm Air",
"27": "Time Dry" "27": "Time Dry",
"4c": "Air Refresh"
} }
}, },
"washer_dosing_quantity": { "washer_dosing_quantity": {
+2
View File
@@ -1,6 +1,8 @@
{ {
"state_keys": [ "state_keys": [
"ai_pattern",
"alarm_code", "alarm_code",
"auto_cycle_link",
"buzzer_sound", "buzzer_sound",
"child_lock", "child_lock",
"cycle", "cycle",
+37 -1
View File
@@ -39,10 +39,46 @@ def test_no_unbound_hrefs():
def test_expected_entities_present(): def test_expected_entities_present():
state = _state() state = _state()
for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock', for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock',
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'): 'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh',
'ai_pattern', 'auto_cycle_link'):
assert key in state, key assert key in state, key
def test_dry_level_is_a_writable_select():
"""dryLevel used to be a read-only sensor, but the app's "Dryness"
control (issue #14 screenshots) is a Level 1/2/3 picker, and the dump's
supportedDryLevel list confirms the device accepts writes -- it should
be a select, not a sensor."""
desc = next(e for e in dryer.DRYER_SETTINGS.entities if e.key == 'dry_level')
assert desc.options_field == 'x.com.samsung.da.supportedDryLevel'
assert desc.write_fn is not None
assert desc.write_fn('Less', {}) == (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': 'Less'})
assert _state()['dry_level'] == 'Normal'
def test_ai_pattern_reads_and_writes_options_token():
"""AiOption_On/Off on /course/vs/0's options[] array is the app's "AI
pattern" toggle (issue #14 screenshot: on)."""
assert _state()['ai_pattern'] is True
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'ai_pattern')
opts = ['Course_16', 'AiOption_On']
href, payload = desc.write_fn('Off', {'x.com.samsung.da.options': opts})
assert href == ['course', 'vs', '0']
assert 'AiOption_Off' in payload['x.com.samsung.da.options']
def test_auto_cycle_link_bound_to_cycleinterface():
"""cycleInterfaceEnabled on /cycleinterface/vs/0 is the app's "Auto cycle
link" toggle (issue #14 screenshot: off) -- previously globally ignored
as always-empty until this dump showed it populated on a dryer."""
assert dryer.AUTO_CYCLE_LINK.href == '/cycleinterface/vs/0'
assert _state()['auto_cycle_link'] is False
desc = next(e for e in dryer.AUTO_CYCLE_LINK.entities if e.key == 'auto_cycle_link')
assert desc.write_fn('On', {}) == (
['cycleinterface', 'vs', '0'], {'x.com.samsung.da.cycleInterfaceEnabled': 'On'})
def test_job_beginning_status_reads_current_status(): def test_job_beginning_status_reads_current_status():
"""The dump carries x.com.samsung.da.currentStatus (not the old """The dump carries x.com.samsung.da.currentStatus (not the old
jobBeginingStatus field the dryer descriptor used to read), so the sensor jobBeginingStatus field the dryer descriptor used to read), so the sensor
+11 -1
View File
@@ -68,8 +68,18 @@ class TestWasherIgnoredHrefs:
'/wm/welcomemsg/vs/0', '/wm/welcomemsg/vs/0',
'/wm/personalcourse/vs/0', '/wm/personalcourse/vs/0',
'/energy/consumption/0', '/energy/consumption/0',
'/cycleinterface/vs/0',
'/drlc/0', '/drlc/0',
'/operational/state/0', '/operational/state/0',
): ):
assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED" assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED"
def test_cycleinterface_is_scoped_to_washer_registry(self):
"""/cycleinterface/vs/0 is empty on every washer dump but real on the
dryer (issue #14's AUTO_CYCLE_LINK), so it's a washer-scoped ignore
(washer.CYCLE_INTERFACE_IGNORED) rather than a global one -- a global
entry would collide with the dryer's real capability."""
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.capabilities.ignored import IGNORED
assert '/cycleinterface/vs/0' not in {cap.href for cap in IGNORED}
assert '/cycleinterface/vs/0' in washer_registry.REGISTRY.capabilities