diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index a7a9c6a..39c401b 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -12,6 +12,7 @@ import cbor2 from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.device_registry import DeviceInfo @@ -509,13 +510,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None: - """Write a value to the device. Fire-and-forget style.""" + """Write a value to the device. Fire-and-forget style. + + A description-level validate_fn (currently SwitchDesc only) runs + here rather than per-platform, so rejecting a write with a + user-facing message -- as opposed to write_fn's silent no-op below + -- is available to every platform for free.""" desc = bound_entity.desc write_fn = getattr(desc, 'write_fn', None) if write_fn is None: return href = bound_entity.href rep = self._cache.get(href or '') or {} + validate_fn = getattr(desc, 'validate_fn', None) + if validate_fn is not None: + error = validate_fn(payload, rep, self._cache.snapshot()) + if error: + raise ServiceValidationError(error) result = write_fn(payload, rep, href) if result is None: self._log.warning("write_fn rejected payload %r for %s", payload, href) 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 e997afb..04b52b5 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -17,7 +17,10 @@ from datetime import datetime, timezone from ..capability import Capability from ..entities import BinarySensorDesc, SelectDesc, SensorDesc -from .laundry import cycle_select, hex_pairs, option_value, replace_in_options +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,9 +236,58 @@ def _dosing_low(prefix): rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off') -def _dosing_alarm_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 +# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape +# already used by AiOption and KidsLockBypass in this same array, so +# PreWashSetting/IntensiveSetting are assumed to follow suit. +# +# Each also has a differently-named hex-pair availability field that lines up +# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet, +# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of +# 24), all three read 'F0' at that position and the toggle was writable -- +# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak +# Off') decodes to '00' for that course, matching the app graying the +# control out there. 'F0'/'00' is treated as available/unavailable on that +# evidence. exists_fn (device-level presence) still only runs once, against +# the setup-time snapshot, so it isn't a fit for this per-course check -- +# 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. 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 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 + back to allowing the write whenever the availability data can't be + resolved (unrecognized course, missing/mismatched-length bitmap) + rather than guessing -- a false rejection is worse than an + occasional no-op write.""" + if p != 'On': + return None + opts = rep.get('x.com.samsung.da.options') or [] + current = option_value(opts, 'Course') + courses = cycle_options(resources) + if not current or current not in courses: + return None + raw = option_value(opts, availability_field) + if raw is None: + return None + pairs = hex_pairs(raw) + if len(pairs) != len(courses): + return None + if pairs[courses.index(current)] != 'F0': + return f"{name} isn't available on the selected cycle." + return None + + return bool_option_switch( + key, name, icon, prefix, + entity_category='config', gate_on_presence=True, validate_fn=validate) WASHER_COURSE = Capability( @@ -288,11 +340,17 @@ 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=_dosing_alarm_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=_dosing_alarm_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'), + _bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine', + 'PreWashSetting', 'PreWashAvailableSet'), + _bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine', + 'IntensiveSetting', 'IntensiveAvailableSet'), ), ) diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index a22d056..e1b98fc 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -2,7 +2,9 @@ Frozen dataclasses so the future native HA component can consume them as EntityDescription subclasses unchanged. Read transforms live in value_fn; -presence gating in exists_fn; write logic in write_fn on command platforms. +presence gating in exists_fn; write logic in write_fn on command platforms; +pre-write rejection (surfaced to the user, not just logged) in validate_fn +where a description declares one. """ from __future__ import annotations @@ -10,6 +12,11 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] +# (payload, rep, resources) -> a human-readable rejection message, or None to +# allow the write. resources is the coordinator's full href->rep snapshot, for +# the same cross-resource lookups exists_fn needs (e.g. reading a sibling +# href's live option list). +ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] def _identity(v: Any) -> Any: @@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription): class SwitchDesc(SamsungEntityDescription): device_class: Optional[str] = None write_fn: WriteFn = None + validate_fn: ValidateFn = None @dataclass(frozen=True, kw_only=True) diff --git a/tests/fixtures/golden/washer.json b/tests/fixtures/golden/washer.json index 69ac2a8..d3a99a1 100644 --- a/tests/fixtures/golden/washer.json +++ b/tests/fixtures/golden/washer.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "buzzer_sound", "child_lock", "cycle", @@ -14,9 +15,11 @@ "energy_saved_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/fixtures/golden/washer_dryer_combo.json b/tests/fixtures/golden/washer_dryer_combo.json index a228067..d2bab38 100644 --- a/tests/fixtures/golden/washer_dryer_combo.json +++ b/tests/fixtures/golden/washer_dryer_combo.json @@ -1,6 +1,7 @@ { "state_keys": [ "alarm_code", + "bubble_soak", "child_lock", "cycle", "cycle_active", @@ -12,9 +13,11 @@ "energy_kwh", "finish_time", "firmware_update", + "intensive", "job_beginning_status", "machine_state", "power_switch", + "pre_wash", "progress", "progress_percentage", "remote_control", diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index ac143d8..0a1a3b4 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -251,6 +251,134 @@ class TestDetergentSoftenerDosing: assert self._desc('detergent_low').exists_fn(rep, {}) is True +class TestWashOptionToggles: + """Bubble soak / pre-wash / intensive-wash switches, from the same + options[] array as the cycle select (issue #22 follow-up). Confirmed + On/Off shape from a dump with Bubble Soak toggled on in the app.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + @staticmethod + def _keys(): + return ('bubble_soak', 'pre_wash', 'intensive') + + @staticmethod + def _prefix(key): + return {'bubble_soak': 'BubbleSoak', + 'pre_wash': 'PreWashSetting', + 'intensive': 'IntensiveSetting'}[key] + + def test_exists_only_when_field_present(self): + for key in self._keys(): + desc = self._desc(key) + assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert desc.exists_fn(rep, {}) is True + + def test_reads_off(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).rep_fn(rep) is False + + def test_reads_on(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + assert self._desc(key).rep_fn(rep) is True + + def test_write_on_and_off(self): + """write_fn receives the same 'On'/'Off' string switch.py sends + (not a bool) -- covers a bug where an earlier `'On' if p else 'Off'` + implementation always wrote 'On', since any non-empty string + (including 'Off') is truthy.""" + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} + path, body = self._desc(key).write_fn('On', rep) + assert path == ['course', 'vs', '0'] + assert f'{prefix}_On' in body['x.com.samsung.da.options'] + assert 'GMT_02' in body['x.com.samsung.da.options'] + + rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + path, body = self._desc(key).write_fn('Off', rep) + assert f'{prefix}_Off' in body['x.com.samsung.da.options'] + assert f'{prefix}_On' not in body['x.com.samsung.da.options'] + + def test_write_rejects_non_on_off_payload(self): + for key in self._keys(): + prefix = self._prefix(key) + rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + assert self._desc(key).write_fn('bogus', rep) is None + + +# editCourseList and availability bitmaps from the reporter's issue #22 +# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just +# turned on in the app): 24 courses, course '30' at position 1 reads 'F0' +# (available) on all three bitmaps; course '1C' at position 0 reads '00' on +# BubbleSoakSet (matching the app graying that control out for Eco 40-60). +_EDIT_COURSE_RESOURCES = { + '/wm/editcourse/vs/0': { + 'x.com.samsung.da.editCourseList': + 'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729', + }, +} +_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000' +_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' +_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' + + +class TestWashOptionToggleValidation: + """validate_fn rejects turning a toggle on for a course whose byte in + its availability bitmap isn't 'F0', with a user-facing message switch.py + raises as ServiceValidationError -- distinct from write_fn's silent + no-op for a malformed payload.""" + + @staticmethod + def _desc(key): + return next(e for e in washer.WASHER_COURSE.entities if e.key == key) + + def test_allowed_on_a_supported_course(self): + rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_rejected_on_an_unsupported_course(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) + assert msg == "Bubble soak isn't available on the selected cycle." + + def test_pre_wash_and_intensive_use_their_own_availableset_field(self): + rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} + assert self._desc('pre_wash').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]} + assert self._desc('intensive').validate_fn( + 'On', rep, _EDIT_COURSE_RESOURCES) is None + + def test_turning_off_is_never_blocked(self): + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert self._desc('bubble_soak').validate_fn( + 'Off', rep, _EDIT_COURSE_RESOURCES) is None + + def test_allows_write_when_course_unresolvable(self): + """No editCourseList, no Course_ token, or a bitmap whose length + doesn't match editCourseList -- in every case, fail open rather than + block a write we can't actually verify.""" + desc = self._desc('bubble_soak') + rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, {}) is None + + rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + + rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']} + assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + + class TestFlexWashAndComboFixturesHaveCompleteCoverage: """FlexWash (issue #19, previously unrecognized entirely) and washer/dryer combo (issue #22, dry_level) dumps must both resolve to