Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07d6ed706 |
@@ -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')
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Vendored
+2
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user