feat: dishwasher cycle/settings entities, stale-state fixes, LED/sound controls

Dishwasher:
- Revert remote_control to BinarySensorDesc ("Smart Control", read-only)
- Add cycle SelectDesc on /course/vs/0 via options-array RMW (AI Wash,
  Pre blast, Self clean, Normal, Express 60, Heavy, Pots and pans,
  Delicate, Plastic, Baby Care)
- Add Storm Wash+ and Auto release dry SwitchDescs on /course/vs/0
- Add Sanitize SwitchDesc and Smart Dry SelectDesc on /dishwasher/vs/0
- Add Sound volume NumberDesc (0–15 step 5) on /settings/sound/volume/vs/0
- Add Door LED night brightness, night start/end time to DOOR_LED capability
- Add SOUND_VOLUME capability to laundry module

Stale-state fixes (firmware leaves values set after cycle ends):
- finish_time: suppress when machine state is not active
- progress: return 'Idle' when machine state is not active
- water filter: gate WATER_FILTER capability on filterStatus != 'notused'

Translations:
- Add ICESTATUS_RUN → "Making ice" to strings.json and en.json

Tests:
- Update dishwasher and refrigerator golden fixtures to reflect current entity set
- Remove dead test_project_extrapolates_remaining_time (on_observation removed)
This commit is contained in:
Marc Billow
2026-07-01 00:36:24 -05:00
parent 800b113495
commit db2148467b
11 changed files with 257 additions and 72 deletions
@@ -1,5 +1,5 @@
"""Dishwasher device registry."""
from ..capabilities import common, fridge, laundry, operational
from ..capabilities import common, dishwasher, fridge, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
@@ -13,8 +13,11 @@ REGISTRY = DeviceRegistry(
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,4 +1,4 @@
from . import common, fridge, laundry, operational, oven
from . import common, dishwasher, fridge, laundry, operational, oven
from ..capability import Capability
@@ -11,7 +11,7 @@ against live device dumps:
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
def _num(v):
@@ -58,13 +58,10 @@ KIDS_LOCK = Capability(
REMOTE_CONTROL = Capability(
href='/remotectrl/vs/0',
entities=(
SwitchDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Remote control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true',
write_fn=lambda p, rep, href=None: (
['remotectrl', 'vs', '0'],
{'x.com.samsung.da.remoteControlEnabled': 'true' if p == 'On' else 'false'})),
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
@@ -112,6 +109,7 @@ WATER_METER = Capability(
WATER_FILTER = Capability(
href='/filter/waterfilter/vs/0',
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
name='Filter usage', unit='%', state_class='measurement',
@@ -0,0 +1,121 @@
"""Capabilities specific to dishwasher appliances (DW9000F-class).
Resources verified against the live device dump at 10.0.0.129.
"""
from ..capability import Capability
from ..entities import SelectDesc, SwitchDesc
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
# ---------------------------------------------------------------------------
DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
name='Sanitize', icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
name='Smart Dry', icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.heatedDry': p})),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection and course options (RMW on options array)
# ---------------------------------------------------------------------------
# Course IDs in editCourseList byte order, matched to Samsung app display names.
# IDs are uppercase hex strings matching the Course_XX encoding in options[].
_COURSE_ID_TO_NAME: dict[str, str] = {
'0E': 'AI Wash',
'07': 'Pre blast',
'90': 'Self clean',
'86': 'Normal',
'83': 'Express 60',
'84': 'Heavy',
'8D': 'Pots and pans',
'80': 'Delicate',
'8E': 'Plastic',
'8F': 'Baby Care',
}
_COURSE_NAME_TO_ID = {v: k for k, v in _COURSE_ID_TO_NAME.items()}
_CYCLE_OPTIONS = tuple(_COURSE_ID_TO_NAME.values())
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _cycle_write(p, rep, href=None):
course_id = _COURSE_NAME_TO_ID.get(p)
if not course_id:
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, 'Course', course_id),
}
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=(
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
options=_CYCLE_OPTIONS,
rep_fn=lambda rep: _COURSE_ID_TO_NAME.get(
_option_value(rep.get('x.com.samsung.da.options'), 'Course')
),
write_fn=_cycle_write),
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: 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),
),
)
@@ -4,8 +4,10 @@ Resources verified against the DW9000F-class dump at 10.0.0.129.
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import SelectDesc, SwitchDesc
from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
@@ -23,6 +25,16 @@ def _led_night_write(p, rep, href=None):
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
def _parse_hm(v):
if not v:
return None
try:
h, m = v.split(':')
return dt_time(int(h), int(m))
except Exception:
return None
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
@@ -41,6 +53,27 @@ DOOR_LED = Capability(
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
name='Door LED night brightness', icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
name='Door LED night start', icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
name='Door LED night end', icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
),
)
@@ -53,3 +86,17 @@ SOUND_MODE = Capability(
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
entities=(
NumberDesc(key='sound_volume', field='level',
name='Sound volume', icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
),
)
@@ -64,18 +64,33 @@ OPERATIONAL_STATE = Capability(
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress', field='x.com.samsung.da.progress',
name='Progress', icon='mdi:progress-wrench', value_fn=_progress),
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication.
BinarySensorDesc(key='cycle_active', device_class='running',
name='Cycle active',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', name='Progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
name='Estimated finish', device_class='timestamp',
value_fn=_finish_time),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
name='Estimated finish',
rep_fn=lambda rep: (
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
or rep.get('x.com.samsung.da.progress') == 'Finish'
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
)),
TimeDesc(key='delay_start_time', field='x.com.samsung.da.delayStartTime',
name='Delay start',
value_fn=_parse_hms,
+2 -1
View File
@@ -3,7 +3,8 @@
"sensor": {
"ice_making_status": {
"state": {
"ICESTATUS_STOP": "Idle"
"ICESTATUS_STOP": "Idle",
"ICESTATUS_RUN": "Making ice"
}
}
},
@@ -3,7 +3,8 @@
"sensor": {
"ice_making_status": {
"state": {
"ICESTATUS_STOP": "Idle"
"ICESTATUS_STOP": "Idle",
"ICESTATUS_RUN": "Making ice"
}
}
},
+20 -16
View File
@@ -1,57 +1,61 @@
{
"state_keys": [
"alarm_code",
"auto_release_dry",
"child_lock",
"child_lock_binary",
"completion_minutes",
"completion_time",
"cycle",
"cycle_active",
"delay_start_time",
"energy_kwh",
"filter_status",
"filter_usage",
"finish_time",
"firmware_update",
"heated_dry",
"led_brightness",
"led_night_brightness",
"led_night_end",
"led_night_light",
"led_night_start",
"machine_state",
"power_state",
"power_state_binary",
"power_switch",
"power_watts",
"progress",
"progress_percentage",
"remote_control",
"remote_control_binary",
"sanitize",
"sound_mode",
"sound_volume",
"storm_wash",
"water_liters"
],
"discovery_unique_ids": [
"samsung_dishwasher_alarm_code",
"samsung_dishwasher_auto_release_dry",
"samsung_dishwasher_child_lock",
"samsung_dishwasher_child_lock_binary",
"samsung_dishwasher_completion_minutes",
"samsung_dishwasher_completion_time",
"samsung_dishwasher_cycle",
"samsung_dishwasher_cycle_active",
"samsung_dishwasher_delay_start_time",
"samsung_dishwasher_energy_kwh",
"samsung_dishwasher_filter_status",
"samsung_dishwasher_filter_usage",
"samsung_dishwasher_finish_time",
"samsung_dishwasher_firmware_update",
"samsung_dishwasher_heated_dry",
"samsung_dishwasher_led_brightness",
"samsung_dishwasher_led_night_brightness",
"samsung_dishwasher_led_night_end",
"samsung_dishwasher_led_night_light",
"samsung_dishwasher_led_night_start",
"samsung_dishwasher_machine_state",
"samsung_dishwasher_pause",
"samsung_dishwasher_power_state",
"samsung_dishwasher_power_state_binary",
"samsung_dishwasher_power_switch",
"samsung_dishwasher_power_watts",
"samsung_dishwasher_progress",
"samsung_dishwasher_progress_percentage",
"samsung_dishwasher_remote_control",
"samsung_dishwasher_remote_control_binary",
"samsung_dishwasher_sanitize",
"samsung_dishwasher_sound_mode",
"samsung_dishwasher_sound_volume",
"samsung_dishwasher_start",
"samsung_dishwasher_stop",
"samsung_dishwasher_storm_wash",
"samsung_dishwasher_water_liters"
]
}
+30 -26
View File
@@ -1,70 +1,74 @@
{
"state_keys": [
"alarm_code",
"any_door_open",
"auto_door_opener",
"autofill",
"beverage_zone_mode",
"cabinet_light",
"cabinet_light_on",
"brightness_level",
"cabinet_light_dim",
"cabinet_light_switch",
"cooler_setpoint",
"cooler_temperature",
"day_brightness",
"door_alert",
"door_cooler_open",
"door_freezer_open",
"energy_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"ice_maker_enabled",
"flex_zone_mode",
"freezer_setpoint",
"freezer_temperature",
"fridge_sound",
"ice_night_mode",
"icemaker_one_enabled",
"icemaker_one_making_status",
"icemaker_one_on",
"icemaker_one_state",
"icemaker_one_type",
"icemaker_two_enabled",
"icemaker_two_making_status",
"icemaker_two_on",
"icemaker_two_state",
"icemaker_two_type",
"night_end",
"night_start",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"temperature_current_cooler_temp_f",
"temperature_current_freezer_temp_f",
"temperature_desired_cooler_temp_f",
"temperature_desired_freezer_temp_f"
"welcome_lighting"
],
"discovery_unique_ids": [
"samsung_refrigerator_alarm_code",
"samsung_refrigerator_any_door_open",
"samsung_refrigerator_auto_door_opener",
"samsung_refrigerator_autofill",
"samsung_refrigerator_beverage_zone_mode",
"samsung_refrigerator_cabinet_light",
"samsung_refrigerator_cabinet_light_on",
"samsung_refrigerator_brightness_level",
"samsung_refrigerator_cabinet_light_dim",
"samsung_refrigerator_cabinet_light_switch",
"samsung_refrigerator_cooler_setpoint",
"samsung_refrigerator_cooler_temperature",
"samsung_refrigerator_day_brightness",
"samsung_refrigerator_door_alert",
"samsung_refrigerator_door_cooler_open",
"samsung_refrigerator_door_freezer_open",
"samsung_refrigerator_energy_kwh",
"samsung_refrigerator_filter_status",
"samsung_refrigerator_filter_usage",
"samsung_refrigerator_firmware_update",
"samsung_refrigerator_ice_maker_enabled",
"samsung_refrigerator_flex_zone_mode",
"samsung_refrigerator_freezer_setpoint",
"samsung_refrigerator_freezer_temperature",
"samsung_refrigerator_fridge_sound",
"samsung_refrigerator_ice_night_mode",
"samsung_refrigerator_icemaker_one_enabled",
"samsung_refrigerator_icemaker_one_making_status",
"samsung_refrigerator_icemaker_one_on",
"samsung_refrigerator_icemaker_one_state",
"samsung_refrigerator_icemaker_one_type",
"samsung_refrigerator_icemaker_two_enabled",
"samsung_refrigerator_icemaker_two_making_status",
"samsung_refrigerator_icemaker_two_on",
"samsung_refrigerator_icemaker_two_state",
"samsung_refrigerator_icemaker_two_type",
"samsung_refrigerator_night_end",
"samsung_refrigerator_night_start",
"samsung_refrigerator_power_watts",
"samsung_refrigerator_rapid_freezing",
"samsung_refrigerator_rapid_fridge",
"samsung_refrigerator_sabbath_mode",
"samsung_refrigerator_temperature_current_cooler_temp_f",
"samsung_refrigerator_temperature_current_freezer_temp_f",
"samsung_refrigerator_temperature_desired_cooler_temp_f",
"samsung_refrigerator_temperature_desired_freezer_temp_f"
"samsung_refrigerator_welcome_lighting"
]
}
-9
View File
@@ -1,4 +1,3 @@
import time
from custom_components.localthings.ocf.registry.capabilities.operational import OPERATIONAL_STATE
@@ -12,11 +11,3 @@ def test_machine_state_maps_samsung_to_ocf():
def test_active_when_true_only_when_running():
assert OPERATIONAL_STATE.active_when({'x.com.samsung.da.state': 'Run'}) is True
assert OPERATIONAL_STATE.active_when({'x.com.samsung.da.state': 'Ready'}) is False
def test_project_extrapolates_remaining_time():
state = {}
OPERATIONAL_STATE.on_observation(state, {'x.com.samsung.da.remainingTime': '1:00:00'})
assert 'remaining_anchor' in state
out = OPERATIONAL_STATE.project(state, {'machine_state': 'active', 'completion_minutes': 60})
assert out['completion_minutes'] <= 60