Files
localthings/custom_components/localthings/registry/capabilities/washer.py
T
Marc Billow 9264b912f9 feat: full dryer support + consistent laundry capability surface
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.

Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
  fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
  /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).

Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
  pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
  /course/vs/0 cycle-select machinery) into laundry.py; washer and
  dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
  instantaneousPower reading no longer shows a misleading 0 W (fixes it
  on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
  every dump actually carries; the dryer sensor was previously blank.

Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
2026-07-21 01:15:12 +00:00

246 lines
13 KiB
Python

"""Capabilities specific to washer appliances (Samsung DA_WM_TP1-class
front-load washers).
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer-
specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
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
# ---------------------------------------------------------------------------
# Course_XX hex codes. The 23 codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
# positionally against a Slovak-UI user's screenshots of their app's course
# list (same order, same count -- see issue #2) and cross-checked against
# the printed user manual's course table (confirming e.g. '8F' as 'Intense
# Cold', not the position-adjacent-looking but distinct 'Mixed Load', a
# cycle the manual marks "applicable models only" and that does not appear
# in this device's editCourseList -- nor does 'AI Wash', also "applicable
# models only"). FixedCourseList_1C29 (the two courses always pinned in the
# app) maps to '1C'/'29' = Eco 40-60 and Drum Clean+, which matches what
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
# Load' -- both "applicable models only" per the manual -- would have yet
# another set), so hardcoding one device's list would show/hide the wrong
# options on a different model. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; if a device doesn't populate that
# resource, the cycle select isn't created at all (see cycle_select's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
# fallback source (its first byte reliably equals the currently-selected
# Course_XX on both dumps we have), but the bytes after that don't
# correspond to any confirmed course code on either device -- e.g. dump 1's
# MostUsed_1C8410923FA67F00000000000000 decodes to
# ['1C','84','10','92','3F','A6','7F',...] and only '1C' is a real code --
# so it isn't trustworthy as a list of selectable courses and isn't used.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
# bound to '/washer/vs/0') -- an artifact of Samsung reusing the same OCF
# path for different device families. Only one of the two ever binds for a
# given device, since dryer and washer are separate by_type registries.
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability(
href='/washer/vs/0',
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
name='Wash temperature', icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
name='Spin speed', icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
name='Rinse cycles', icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
# drum-clean and dispenser-dosing entities below are washer-specific reads off
# the same options array.
# ---------------------------------------------------------------------------
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
# recommended cleans; WashingTimes_<N> is the count since the last one --
# their difference is exactly the "N cycles until due" figure the Samsung
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
# is the last-clean timestamp (verified against the same screenshot's "10
# days ago"); no explicit timezone field accompanies it on this resource,
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
return max(int(proposal) - int(washed), 0)
except ValueError:
return None
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
except ValueError:
return None
# Detergent/softener auto-dispense dosing, from the same options[] array
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
# detergent, concentration for softener -- matching the SmartThings app's
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
#
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
# water_hardness/washer_softener_concentration in strings.json) is an
# assumed, not cross-device-verified, reading of the single issue #9 dump +
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
# on-screen equivalent -- the app's Quantité picker only offers
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
# 1x/2x/3x for softener concentration does *not* cleanly match
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
# setting the user changed in the app between the dump (issue body) and the
# screenshots (a later comment), not a different code scheme, since it's
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
return hex_pairs(raw) if raw else []
def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _level_write(prefix):
def write(p, rep, href=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 _dosing_low(prefix):
return lambda rep: option_value(
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
WASHER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
icon='mdi:calendar-clock', device_class='timestamp',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
translation_key='washer_dosing_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
translation_key='washer_detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
translation_key='washer_softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
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'),
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'),
rep_fn=_dosing_low('SoftenerAlarm')),
),
)