From 9cbeb6a2b2aecfd105869ecb1604d0d677a06b98 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 28 Jun 2026 15:01:20 -0500 Subject: [PATCH] feat: add refrigerator capabilities with multi-instance support --- .../registry/capabilities/__init__.py | 4 +- .../registry/capabilities/fridge.py | 342 ++++++++++++++++++ tests/test_fridge_multi_instance.py | 34 ++ 3 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 samsung_appliance/registry/capabilities/fridge.py create mode 100644 tests/test_fridge_multi_instance.py diff --git a/samsung_appliance/registry/capabilities/__init__.py b/samsung_appliance/registry/capabilities/__init__.py index 154dc34..0f5016f 100644 --- a/samsung_appliance/registry/capabilities/__init__.py +++ b/samsung_appliance/registry/capabilities/__init__.py @@ -1,4 +1,4 @@ -from . import common, laundry, operational +from . import common, fridge, laundry, operational from ..capability import Capability @@ -6,5 +6,5 @@ def _is_capability(v): return isinstance(v, Capability) -ALL = [v for mod in (common, operational, laundry) +ALL = [v for mod in (common, operational, laundry, fridge) for v in vars(mod).values() if _is_capability(v)] diff --git a/samsung_appliance/registry/capabilities/fridge.py b/samsung_appliance/registry/capabilities/fridge.py new file mode 100644 index 0000000..a8c3be2 --- /dev/null +++ b/samsung_appliance/registry/capabilities/fridge.py @@ -0,0 +1,342 @@ +"""Capabilities for the refrigerator family (Samsung RF9000B-class). + +Resources verified against the dump at local-tools/dumps/10.0.0.254.json. + +Temperature fields in all /temperature/* resources are in Fahrenheit on this +model. Setpoints are NumberDesc with direct-write write_fn (no RMW needed +because the individual /temperature/desired/* resources take a PUT with just +the 'temperature' field, unlike the aggregated /temperatures/vs/0 path). + +Multi-instance note: the two door resources (/door/cooler/0 and +/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and +/icemaker/two/vs/0) use named path segments, so they are modelled as separate +Capability objects with distinct entity keys rather than a single capability +replicated via instance_suffix. +""" +from ..capability import Capability +from ..entities import ( + BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, +) + +# Setpoint bounds (from /temperatures/vs/0 items on 2026-06-27) +FREEZER_MIN_F = -8.0 +FREEZER_MAX_F = 5.0 +FRIDGE_MIN_F = 34.0 +FRIDGE_MAX_F = 44.0 + +# Whiskey ball ice sizes. +ICE2_MODES = ('Off', 'WHISKEY_ICEBALL_3', 'WHISKEY_ICEBALL_6', 'WHISKEY_ICEBALL_9') + +# Beverage zone flex modes. +BZONE_MODES = ('SP_TTYPE_BEER_DRINKS', 'SP_TTYPE_WINE_DESSERT') + + +def _int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# Temperatures +# --------------------------------------------------------------------------- + +def _fridge_setpoint_write(p, rep): + try: + temp = int(round(float(p))) + except (TypeError, ValueError): + return None + if not (FRIDGE_MIN_F <= temp <= FRIDGE_MAX_F): + return None + return ['temperature', 'desired', 'cooler', '0'], {'temperature': temp} + + +def _freezer_setpoint_write(p, rep): + try: + temp = int(round(float(p))) + except (TypeError, ValueError): + return None + if not (FREEZER_MIN_F <= temp <= FREEZER_MAX_F): + return None + return ['temperature', 'desired', 'freezer', '0'], {'temperature': temp} + + +TEMP_FRIDGE_CURRENT = Capability( + href='/temperature/current/cooler/0', + poll_tier='warm', + entities=( + SensorDesc(key='fridge_temp_f', field='temperature', + name='Fridge temperature', device_class='temperature', + state_class='measurement', unit='°F', value_fn=_int), + ), +) + +TEMP_FREEZER_CURRENT = Capability( + href='/temperature/current/freezer/0', + poll_tier='warm', + entities=( + SensorDesc(key='freezer_temp_f', field='temperature', + name='Freezer temperature', device_class='temperature', + state_class='measurement', unit='°F', value_fn=_int), + ), +) + +TEMP_FRIDGE_SETPOINT = Capability( + href='/temperature/desired/cooler/0', + poll_tier='warm', + entities=( + NumberDesc(key='fridge_setpoint_f', field='temperature', + name='Fridge setpoint', device_class='temperature', + unit='°F', native_min=FRIDGE_MIN_F, native_max=FRIDGE_MAX_F, + step=1.0, icon='mdi:thermometer-chevron-up', + value_fn=_int, write_fn=_fridge_setpoint_write), + ), +) + +TEMP_FREEZER_SETPOINT = Capability( + href='/temperature/desired/freezer/0', + poll_tier='warm', + entities=( + NumberDesc(key='freezer_setpoint_f', field='temperature', + name='Freezer setpoint', device_class='temperature', + unit='°F', native_min=FREEZER_MIN_F, native_max=FREEZER_MAX_F, + step=1.0, icon='mdi:thermometer-chevron-down', + value_fn=_int, write_fn=_freezer_setpoint_write), + ), +) + +# --------------------------------------------------------------------------- +# Doors +# --------------------------------------------------------------------------- + +DOOR_FRIDGE = Capability( + href='/door/cooler/0', + poll_tier='hot', + entities=( + BinarySensorDesc(key='door_fridge_open', field='openState', + name='Fridge door', device_class='door', + value_fn=lambda v: v == 'Open'), + ), +) + +DOOR_FREEZER = Capability( + href='/door/freezer/0', + poll_tier='hot', + entities=( + BinarySensorDesc(key='door_freezer_open', field='openState', + name='Freezer door', device_class='door', + value_fn=lambda v: v == 'Open'), + ), +) + +DOORS_STATUS = Capability( + href='/doors/vs/0', + poll_tier='hot', + entities=( + BinarySensorDesc( + key='any_door_open', + field='x.com.samsung.da.items', + name='Any door open', + device_class='door', + value_fn=lambda items: any( + it.get('x.com.samsung.da.openState') == 'Open' + for it in (items or []) + ), + ), + ), +) + +# --------------------------------------------------------------------------- +# Ice makers +# --------------------------------------------------------------------------- + +def _ice1_write(p, rep): + if p not in ('On', 'Off'): + return None + return ['icemaker', 'one', 'vs', '0'], {'x.com.samsung.da.iceMaker.state': p} + + +def _ice2_type_write(p, rep): + if p not in ICE2_MODES: + return None + return ['icemaker', 'two', 'vs', '0'], {'x.com.samsung.da.iceType.desired': p} + + +ICEMAKER_ONE = Capability( + href='/icemaker/one/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='ice1_state', field='x.com.samsung.da.iceMaker.state', + name='Ice maker (cubed) state', icon='mdi:cube'), + BinarySensorDesc(key='ice1_on', field='x.com.samsung.da.iceMaker.state', + name='Ice maker (cubed) on', + device_class='running', + value_fn=lambda v: v == 'On'), + SensorDesc(key='ice1_making_status', + field='x.com.samsung.da.iceMaker.iceMakingStatus', + name='Ice maker (cubed) status', icon='mdi:cube'), + SwitchDesc(key='ice1', field='x.com.samsung.da.iceMaker.state', + name='Ice maker (cubed)', icon='mdi:cube', + value_fn=lambda v: v == 'On', + write_fn=_ice1_write), + ), +) + +ICEMAKER_TWO = Capability( + href='/icemaker/two/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='ice2_making_status', + field='x.com.samsung.da.iceMaker.iceMakingStatus', + name='Ice maker (whiskey) status', icon='mdi:circle'), + SelectDesc(key='ice2_type', field='x.com.samsung.da.iceType.desired', + name='Whiskey ball ice type', icon='mdi:circle', + options=ICE2_MODES, write_fn=_ice2_type_write), + ), +) + +ICEMAKER_STATUS = Capability( + href='/icemaker/status/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker', + name='Ice maker enabled', icon='mdi:snowflake'), + ), +) + +# --------------------------------------------------------------------------- +# Refrigeration modes (rapid cooling) +# --------------------------------------------------------------------------- + +def _refrigeration_write(field_name): + def _write(p, rep): + if p not in ('On', 'Off'): + return None + return ['refrigeration', 'vs', '0'], {field_name: p} + return _write + + +REFRIGERATION = Capability( + href='/refrigeration/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge', + name='Rapid fridge', icon='mdi:fridge-industrial', + value_fn=lambda v: v == 'On', + write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')), + SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing', + name='Rapid freezing', icon='mdi:snowflake', + value_fn=lambda v: v == 'On', + write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')), + ), +) + +# --------------------------------------------------------------------------- +# Autofill +# --------------------------------------------------------------------------- + +def _autofill_write(p, rep): + if p not in ('On', 'Off'): + return None + return ['autofill', 'vs', '0'], {'x.com.samsung.da.autofill': p} + + +AUTOFILL = Capability( + href='/autofill/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='autofill', field='x.com.samsung.da.autofill', + name='Autofill', icon='mdi:cup-water', + value_fn=lambda v: v == 'On', + write_fn=_autofill_write), + ), +) + +# --------------------------------------------------------------------------- +# Cabinet light +# --------------------------------------------------------------------------- + +def _cabinet_light_write(p, rep): + if p not in ('On', 'Off'): + return None + return ['cabinet', 'light', 'total', 'vs', '0'], { + 'x.com.samsung.da.lightControl': p, + } + + +CABINET_LIGHT = Capability( + href='/cabinet/light/total/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cabinet_light', field='x.com.samsung.da.lightControl', + name='Cabinet light level', icon='mdi:fridge-outline'), + BinarySensorDesc(key='cabinet_light_on', field='x.com.samsung.da.lightControl', + name='Cabinet light on', icon='mdi:fridge-outline', + value_fn=lambda v: v == 'On'), + SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl', + name='Cabinet light', icon='mdi:fridge-outline', + value_fn=lambda v: v == 'On', + write_fn=_cabinet_light_write), + ), +) + +# --------------------------------------------------------------------------- +# Sabbath mode +# --------------------------------------------------------------------------- + +def _sabbath_write(p, rep): + if p not in ('On', 'Off'): + return None + return ['sabbath', 'vs', '0'], {'x.com.samsung.da.sabbathMode': p} + + +SABBATH = Capability( + href='/sabbath/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode', + name='Sabbath mode', icon='mdi:star-david', + value_fn=lambda v: v == 'On', + write_fn=_sabbath_write), + ), +) + +# --------------------------------------------------------------------------- +# Beverage zone +# --------------------------------------------------------------------------- + +def _bzone_write(p, rep): + if p not in BZONE_MODES: + return None + return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p} + + +BEVERAGE_ZONE = Capability( + href='/specialzone/one/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='beverage_zone_mode', field='roomDesiredMode', + name='Beverage zone mode', icon='mdi:glass-wine', + options=BZONE_MODES, write_fn=_bzone_write), + ), +) + +# --------------------------------------------------------------------------- +# Firmware update +# --------------------------------------------------------------------------- + +FIRMWARE_UPDATE = Capability( + href='/otninformation/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc( + key='firmware_update', + field='x.com.samsung.da.newVersionAvailable', + name='Firmware update available', + device_class='update', + entity_category='diagnostic', + value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, + ), + ), +) diff --git a/tests/test_fridge_multi_instance.py b/tests/test_fridge_multi_instance.py new file mode 100644 index 0000000..a238b3e --- /dev/null +++ b/tests/test_fridge_multi_instance.py @@ -0,0 +1,34 @@ +"""Verify that multi-instance fridge resources produce distinct entity keys. + +The two physical door resources (/door/cooler/0, /door/freezer/0) and the two +ice-maker resources (/icemaker/one/vs/0, /icemaker/two/vs/0) use named path +segments rather than numeric indices, so they are modelled as separate +Capability objects with distinct entity keys. +""" +import json + +from samsung_appliance.registry.registry import CAPABILITIES +from samsung_appliance.registry.discovery import discover + + +def test_two_doors_get_distinct_keys(): + res = json.load(open('local-tools/dumps/10.0.0.254.json'))['resources'] + bound = discover(res, CAPABILITIES) + door_keys = sorted(f"{b.desc.key}{b.instance}" for b in bound + if b.desc.key.startswith('door')) + # at least the two physical doors produce distinct keys + assert len(set(door_keys)) >= 2, f"Expected >= 2 distinct door keys, got: {door_keys}" + + +def test_two_iceMakers_get_distinct_keys(): + res = json.load(open('local-tools/dumps/10.0.0.254.json'))['resources'] + bound = discover(res, CAPABILITIES) + ice_keys = sorted(f"{b.desc.key}{b.instance}" for b in bound + if b.desc.key.startswith('ice')) + # ice1_* and ice2_* are distinct + ice1_keys = [k for k in ice_keys if k.startswith('ice1')] + ice2_keys = [k for k in ice_keys if k.startswith('ice2')] + assert ice1_keys, "No ice1_* keys found" + assert ice2_keys, "No ice2_* keys found" + assert set(ice1_keys).isdisjoint(set(ice2_keys)), \ + f"ice1 and ice2 keys overlap: {ice1_keys} vs {ice2_keys}"