From 1b5e39ba460e589942cf93023a9b2cf5ed6d401f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sat, 11 Jul 2026 12:35:47 -0500 Subject: [PATCH] feat(washer): prefer OCF-native power/kidslock/remotectrl hrefs, fall back to -vs --- .../registry/capabilities/washer.py | 83 ++++++++++++++++++- tests/test_washer_capabilities.py | 56 +++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index 587eb51..96010bf 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -7,7 +7,7 @@ family). Washers never report `oneUiVersion` -- see detection this device type requires. """ from ..capability import Capability -from ..entities import SelectDesc, SensorDesc +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc # --------------------------------------------------------------------------- # /washer/vs/0 -- wash temperature, spin speed, rinse cycle count @@ -108,3 +108,84 @@ WASHER_JOB_BEGINNING_STATUS = Capability( entity_category='diagnostic'), ), ) + +# --------------------------------------------------------------------------- +# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control. +# +# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF +# href (/power/0, plain boolean 'value') is preferred when present; the +# vendor '-vs' href (richer historically, but for these three controls just +# a string-encoded duplicate) only binds when the generic href is absent +# from this device's resource set, via match_fn. Scoped to washer.py, not +# common.py -- no other device type has been confirmed to expose the +# generic hrefs, so this must not change behavior for dishwasher/dryer/ +# oven/refrigerator. +# --------------------------------------------------------------------------- + +POWER_GENERIC = Capability( + href='/power/0', + entities=( + SwitchDesc(key='power_switch', field='value', + name='Power', + value_fn=lambda v: bool(v), + write_fn=lambda p, rep, href=None: ( + ['power', '0'], {'value': p == 'On'})), + ), +) + +POWER_VS_FALLBACK = Capability( + href='/power/vs/0', + match_fn=lambda rep, resources: '/power/0' not in resources, + entities=( + SwitchDesc(key='power_switch', field='x.com.samsung.da.power', + name='Power', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['power', 'vs', '0'], + {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), + ), +) + +KIDS_LOCK_GENERIC = Capability( + href='/kidslock/0', + entities=( + SwitchDesc(key='child_lock', field='value', + name='Child lock', device_class='lock', + value_fn=lambda v: bool(v), + write_fn=lambda p, rep, href=None: ( + ['kidslock', '0'], {'value': p == 'On'})), + ), +) + +KIDS_LOCK_VS_FALLBACK = Capability( + href='/kidslock/vs/0', + match_fn=lambda rep, resources: '/kidslock/0' not in resources, + entities=( + SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock', + name='Child lock', device_class='lock', + value_fn=lambda v: v != 'Ready', + write_fn=lambda p, rep, href=None: ( + ['kidslock', 'vs', '0'], + {'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})), + ), +) + +REMOTE_CONTROL_GENERIC = Capability( + href='/remotectrl/0', + entities=( + BinarySensorDesc(key='remote_control', field='value', + name='Smart Control', device_class='connectivity', + value_fn=lambda v: bool(v)), + ), +) + +REMOTE_CONTROL_VS_FALLBACK = Capability( + href='/remotectrl/vs/0', + match_fn=lambda rep, resources: '/remotectrl/0' not in resources, + entities=( + BinarySensorDesc(key='remote_control', + field='x.com.samsung.da.remoteControlEnabled', + name='Smart Control', device_class='connectivity', + value_fn=lambda v: str(v).lower() == 'true'), + ), +) diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index 7ab32c4..1404435 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -81,3 +81,59 @@ class TestJobBeginningStatus: assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0' desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0] assert desc.field == 'x.com.samsung.da.currentStatus' + + +class TestPowerFallback: + def test_generic_href(self): + assert washer.POWER_GENERIC.href == '/power/0' + + def test_generic_read_write(self): + desc = washer.POWER_GENERIC.entities[0] + assert desc.value_fn(True) is True + assert desc.value_fn(False) is False + path, body = desc.write_fn('On', {}) + assert path == ['power', '0'] + assert body == {'value': True} + path, body = desc.write_fn('Off', {}) + assert body == {'value': False} + + def test_vs_fallback_binds_only_when_generic_absent(self): + assert washer.POWER_VS_FALLBACK.href == '/power/vs/0' + assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True + assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/0': {}, '/power/vs/0': {}}) is False + + def test_vs_fallback_read_write(self): + desc = washer.POWER_VS_FALLBACK.entities[0] + assert desc.value_fn('On') is True + assert desc.value_fn('Off') is False + path, body = desc.write_fn('On', {}) + assert path == ['power', 'vs', '0'] + assert body == {'x.com.samsung.da.power': 'On'} + + +class TestKidsLockFallback: + def test_generic_read_write(self): + assert washer.KIDS_LOCK_GENERIC.href == '/kidslock/0' + desc = washer.KIDS_LOCK_GENERIC.entities[0] + assert desc.value_fn(True) is True + path, body = desc.write_fn('On', {}) + assert path == ['kidslock', '0'] + assert body == {'value': True} + + def test_vs_fallback_gated(self): + assert washer.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True + assert washer.KIDS_LOCK_VS_FALLBACK.match_fn( + {}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False + + +class TestRemoteControlFallback: + def test_generic_read(self): + assert washer.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0' + desc = washer.REMOTE_CONTROL_GENERIC.entities[0] + assert desc.value_fn(True) is True + assert desc.value_fn(False) is False + + def test_vs_fallback_gated(self): + assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True + assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn( + {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False