diff --git a/custom_components/localthings/ocf/registry/by_type/dishwasher.py b/custom_components/localthings/ocf/registry/by_type/dishwasher.py index 2a06b95..48b4d1a 100644 --- a/custom_components/localthings/ocf/registry/by_type/dishwasher.py +++ b/custom_components/localthings/ocf/registry/by_type/dishwasher.py @@ -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, ]), ) diff --git a/custom_components/localthings/ocf/registry/capabilities/__init__.py b/custom_components/localthings/ocf/registry/capabilities/__init__.py index 7536838..8f4bdf3 100644 --- a/custom_components/localthings/ocf/registry/capabilities/__init__.py +++ b/custom_components/localthings/ocf/registry/capabilities/__init__.py @@ -1,4 +1,4 @@ -from . import common, fridge, laundry, operational, oven +from . import common, dishwasher, fridge, laundry, operational, oven from ..capability import Capability diff --git a/custom_components/localthings/ocf/registry/capabilities/common.py b/custom_components/localthings/ocf/registry/capabilities/common.py index 867fc04..f5e9905 100644 --- a/custom_components/localthings/ocf/registry/capabilities/common.py +++ b/custom_components/localthings/ocf/registry/capabilities/common.py @@ -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', diff --git a/custom_components/localthings/ocf/registry/capabilities/dishwasher.py b/custom_components/localthings/ocf/registry/capabilities/dishwasher.py new file mode 100644 index 0000000..bf02b95 --- /dev/null +++ b/custom_components/localthings/ocf/registry/capabilities/dishwasher.py @@ -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 `_` in options array and return .""" + 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), + ), +) diff --git a/custom_components/localthings/ocf/registry/capabilities/laundry.py b/custom_components/localthings/ocf/registry/capabilities/laundry.py index 6841d07..d7516b9 100644 --- a/custom_components/localthings/ocf/registry/capabilities/laundry.py +++ b/custom_components/localthings/ocf/registry/capabilities/laundry.py @@ -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))})), + ), +) diff --git a/custom_components/localthings/ocf/registry/capabilities/operational.py b/custom_components/localthings/ocf/registry/capabilities/operational.py index cf70e54..fb37ff3 100644 --- a/custom_components/localthings/ocf/registry/capabilities/operational.py +++ b/custom_components/localthings/ocf/registry/capabilities/operational.py @@ -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, diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 9a270af..de9fdb1 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -3,7 +3,8 @@ "sensor": { "ice_making_status": { "state": { - "ICESTATUS_STOP": "Idle" + "ICESTATUS_STOP": "Idle", + "ICESTATUS_RUN": "Making ice" } } }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 9a270af..de9fdb1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -3,7 +3,8 @@ "sensor": { "ice_making_status": { "state": { - "ICESTATUS_STOP": "Idle" + "ICESTATUS_STOP": "Idle", + "ICESTATUS_RUN": "Making ice" } } }, diff --git a/tests/fixtures/golden/dishwasher.json b/tests/fixtures/golden/dishwasher.json index 9c3c4d8..891c848 100644 --- a/tests/fixtures/golden/dishwasher.json +++ b/tests/fixtures/golden/dishwasher.json @@ -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" ] } diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index a46fe9a..65ed3aa 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -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" ] } diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 2433b42..794e066 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -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