From e07d6ed70657f6e915205193a5b531ed34c6c4b1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 00:43:58 +0000 Subject: [PATCH] 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. --- .../localthings/registry/by_type/dryer.py | 1 + .../localthings/registry/by_type/washer.py | 1 + .../registry/capabilities/dryer.py | 62 +++++++++++++++---- .../registry/capabilities/ignored.py | 8 ++- .../registry/capabilities/washer.py | 7 +++ custom_components/localthings/strings.json | 4 +- .../localthings/translations/en.json | 4 +- tests/fixtures/golden/dryer.json | 2 + tests/test_dryer_capabilities.py | 38 +++++++++++- tests/test_registry_ignored.py | 12 +++- 10 files changed, 121 insertions(+), 18 deletions(-) diff --git a/custom_components/localthings/registry/by_type/dryer.py b/custom_components/localthings/registry/by_type/dryer.py index 81d2891..6d140bf 100644 --- a/custom_components/localthings/registry/by_type/dryer.py +++ b/custom_components/localthings/registry/by_type/dryer.py @@ -26,5 +26,6 @@ REGISTRY = DeviceRegistry( dryer.DRYER_SETTINGS, dryer.DRYER_COURSE, dryer.DRYER_DIAGNOSIS, + dryer.AUTO_CYCLE_LINK, ]), ) diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index c7732d8..f373a33 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -15,5 +15,6 @@ REGISTRY = DeviceRegistry( common.WATER_METER, operational.OPERATIONAL_STATE, dishwasher.DIAGNOSIS, + washer.CYCLE_INTERFACE_IGNORED, ]), ) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index d57994e..249af8a 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -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 the /course/vs/0 cycle select -- lives in laundry.py. - /washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent) - /course/vs/0 -> DRYER_COURSE (shared cycle select; see below) - /diagnosis/vs/0 -> DRYER_DIAGNOSIS + /washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent) + /course/vs/0 -> DRYER_COURSE (shared cycle select, plus the AI-pattern + 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 ..entities import SensorDesc, SwitchDesc -from .laundry import cycle_select +from ..entities import SelectDesc, SensorDesc, SwitchDesc +from .laundry import bool_option_switch, cycle_select def _wrinkle_write(p, rep, href=None): @@ -23,8 +25,15 @@ DRYER_SETTINGS = Capability( href='/washer/vs/0', poll_tier='warm', entities=( - SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel', - name='Dry level', icon='mdi:water-percent'), + # supportedDryLevel values (e.g. 'None'/'Less'/'Normal'/'More') are + # 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', name='Dry time', icon='mdi:timer'), 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 # an RMW on the options array). Course display names live in translations # under entity.select.dryer_cycle (Table_03, DV5000-class, captured -# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1 -# editCourseList but aren't identified yet -- they render as the raw code -# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected -# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0 -# is ignored for washers. +# 2026-05-29; codes 0x21/0x4C named from the issue #14 DV90BB5245AES1 +# SmartThings course-list screenshots on 2026-07-24). The /st/dryercourse/vs/0 +# resource re-encodes the same selected course and is ignored (ignored.py) -- +# the mirror of how /st/washercourse/vs/0 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( href='/course/vs/0', entities=( 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'), ), ) + +# /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), + ), +) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index b7e3cc1..90c887b 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -117,11 +117,15 @@ IGNORED: list[Capability] = [ # data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only # real source for this control. 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 # dependency this integration doesn't support locally. 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 # modeled by operational.OPERATIONAL_STATE (a richer, write-capable # capability with start/pause/stop buttons and a delay-start control) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 04b52b5..ff09c1a 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -354,3 +354,10 @@ WASHER_COURSE = Capability( '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') diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index b3e0026..3f7157c 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -115,10 +115,12 @@ "1e": "Outdoor", "1f": "Mixed Load", "20": "Iron Dry", + "21": "Hygiene Care", "23": "Quick Dry 35", "24": "Cool Air", "25": "Warm Air", - "27": "Time Dry" + "27": "Time Dry", + "4c": "Air Refresh" } }, "washer_dosing_quantity": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b3e0026..3f7157c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -115,10 +115,12 @@ "1e": "Outdoor", "1f": "Mixed Load", "20": "Iron Dry", + "21": "Hygiene Care", "23": "Quick Dry 35", "24": "Cool Air", "25": "Warm Air", - "27": "Time Dry" + "27": "Time Dry", + "4c": "Air Refresh" } }, "washer_dosing_quantity": { diff --git a/tests/fixtures/golden/dryer.json b/tests/fixtures/golden/dryer.json index 97f17c5..24642c7 100644 --- a/tests/fixtures/golden/dryer.json +++ b/tests/fixtures/golden/dryer.json @@ -1,6 +1,8 @@ { "state_keys": [ + "ai_pattern", "alarm_code", + "auto_cycle_link", "buzzer_sound", "child_lock", "cycle", diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index 9a2021d..d8cb84e 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -39,10 +39,46 @@ def test_no_unbound_hrefs(): def test_expected_entities_present(): state = _state() 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 +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(): """The dump carries x.com.samsung.da.currentStatus (not the old jobBeginingStatus field the dryer descriptor used to read), so the sensor diff --git a/tests/test_registry_ignored.py b/tests/test_registry_ignored.py index b66d80c..d15c011 100644 --- a/tests/test_registry_ignored.py +++ b/tests/test_registry_ignored.py @@ -68,8 +68,18 @@ class TestWasherIgnoredHrefs: '/wm/welcomemsg/vs/0', '/wm/personalcourse/vs/0', '/energy/consumption/0', - '/cycleinterface/vs/0', '/drlc/0', '/operational/state/0', ): 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