diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index ca5c8d5..887d11d 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -8,7 +8,7 @@ wash, auto release dry) are read locally here. """ from ..capability import Capability from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_select, option_value, replace_in_options +from .laundry import bool_option_switch, cycle_select # --------------------------------------------------------------------------- # /dishwasher/vs/0 — cycle wash/dry settings @@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability( # --------------------------------------------------------------------------- # /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the # dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the -# same options array. Course display names live in translations under +# same options array (shared laundry.bool_option_switch, same options[] +# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches +# use). Course display names live in translations under # entity.select.dishwasher_cycle (see laundry.cycle_select). # --------------------------------------------------------------------------- - -def _storm_wash_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p), - } - - -def _auto_release_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p), - } - - CYCLE_OPTIONS = Capability( href='/course/vs/0', entities=( cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'), - SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy', - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On', - write_fn=_storm_wash_write), - SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open', - exists_fn=lambda rep, resources: any( - isinstance(o, str) and o.startswith('AutoDoorRelease_') - for o in (rep.get('x.com.samsung.da.options') or []) - ), - rep_fn=lambda rep: option_value( - rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On', - write_fn=_auto_release_write), + bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy', + 'StormWashZone'), + bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open', + 'AutoDoorRelease', gate_on_presence=True), ), ) diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index a84c8f2..820a2ad 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon): ) +# --------------------------------------------------------------------------- +# Plain boolean toggles over /course/vs/0's options[] array: a +# '_On'/'_Off' token, read-modify-written the same way as +# the 'Course' token above. Shared by washer (bubble soak, pre-wash, +# intensive -- issue #22) and dishwasher (storm wash, auto release dry) -- +# both families ride this exact contract, just with different prefixes and +# different presence/validation needs on top. +# --------------------------------------------------------------------------- + + +def bool_option_write(prefix): + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + opts = list(rep.get('x.com.samsung.da.options') or []) + if not opts: + return None + return ['course', 'vs', '0'], { + 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), + } + return write + + +def bool_option_value(prefix): + return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On' + + +def bool_option_exists(prefix): + return lambda rep, resources: option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + +def bool_option_switch(key, name, icon, prefix, *, entity_category=None, + gate_on_presence=False, validate_fn=None): + """A SwitchDesc over a '_On'/'_Off' options[] token. + + gate_on_presence self-gates the entity off on models that never report + the token at all (washer's bubble soak/pre-wash/intensive); leave False + for a toggle every device in the family reports (dishwasher's storm + wash). validate_fn is passed straight through to SwitchDesc for callers + that need to reject a write against live device state (e.g. washer's + per-course availability check) -- this factory has no opinion on it and + building one, if needed, is the caller's job. + """ + return SwitchDesc( + key=key, name=name, icon=icon, entity_category=entity_category, + exists_fn=bool_option_exists(prefix) if gate_on_presence else None, + rep_fn=bool_option_value(prefix), + write_fn=bool_option_write(prefix), + validate_fn=validate_fn, + ) + + # --------------------------------------------------------------------------- # /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason # (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index b145e95..04b52b5 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -16,8 +16,11 @@ array. from datetime import datetime, timezone from ..capability import Capability -from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc -from .laundry import cycle_options, cycle_select, hex_pairs, option_value, replace_in_options +from ..entities import BinarySensorDesc, SelectDesc, SensorDesc +from .laundry import ( + bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value, + replace_in_options, +) # --------------------------------------------------------------------------- # Course_XX hex codes. 23 of the codes named in strings.json/translations @@ -233,11 +236,6 @@ def _dosing_low(prefix): rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') -def _option_exists(prefix): - return lambda rep, resources: option_value( - rep.get('x.com.samsung.da.options'), prefix) is not None - - # Bubble soak / pre-wash / intensive-wash toggles, from the same options[] # array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a # plain '_On'/'_Off' token, confirmed by a dump taken with @@ -257,18 +255,12 @@ def _option_exists(prefix): # validate_fn runs on every write attempt instead (dispatched from # coordinator.async_send_command, ahead of write_fn), rejecting an on-write # for a course whose byte isn't 'F0' with a user-facing error rather than -# silently no-opping against the device. +# silently no-opping against the device. The read/write/presence machinery +# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/ +# auto-release-dry toggles -- only this per-course gating is washer-only, so +# it stays here rather than in laundry.py (see laundry.bool_option_switch's +# docstring: it takes a prebuilt validate_fn and has no opinion on it). def _bool_option_switch(key, name, icon, prefix, availability_field): - def write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - opts = list(rep.get('x.com.samsung.da.options') or []) - if not opts: - return None - return ['course', 'vs', '0'], { - 'x.com.samsung.da.options': replace_in_options(opts, prefix, p), - } - def validate(p, rep, resources): """Reject turning on when the selected course's byte in `availability_field` isn't 'F0'. Turning off is never blocked. Falls @@ -293,13 +285,9 @@ def _bool_option_switch(key, name, icon, prefix, availability_field): return f"{name} isn't available on the selected cycle." return None - return SwitchDesc( - key=key, name=name, icon=icon, entity_category='config', - exists_fn=_option_exists(prefix), - rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On', - write_fn=write, - validate_fn=validate, - ) + return bool_option_switch( + key, name, icon, prefix, + entity_category='config', gate_on_presence=True, validate_fn=validate) WASHER_COURSE = Capability( @@ -352,11 +340,11 @@ WASHER_COURSE = Capability( write_fn=_level_write('SoftenerLevel2Ctrl')), BinarySensorDesc(key='detergent_low', name='Detergent low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_option_exists('DetergentAlarm'), + exists_fn=bool_option_exists('DetergentAlarm'), rep_fn=_dosing_low('DetergentAlarm')), BinarySensorDesc(key='softener_low', name='Softener low', icon='mdi:alert-circle-outline', device_class='problem', - exists_fn=_option_exists('SoftenerAlarm'), + exists_fn=bool_option_exists('SoftenerAlarm'), rep_fn=_dosing_low('SoftenerAlarm')), _bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble', 'BubbleSoak', 'BubbleSoakSet'),