diff --git a/samsung_appliance/registry/adapter.py b/samsung_appliance/registry/adapter.py index 5e9c3fd..e966850 100644 --- a/samsung_appliance/registry/adapter.py +++ b/samsung_appliance/registry/adapter.py @@ -118,7 +118,7 @@ def _make_command_handlers(bound): def handler(payload, links, _fn=fn, _href=href): rep = (links or {}).get(_href) or {} - return _fn(payload, rep) + return _fn(payload, rep, _href) out[f"cmd/{_key(b)}"] = handler return out diff --git a/samsung_appliance/registry/by_type/refrigerator.py b/samsung_appliance/registry/by_type/refrigerator.py index 45d7323..400242d 100644 --- a/samsung_appliance/registry/by_type/refrigerator.py +++ b/samsung_appliance/registry/by_type/refrigerator.py @@ -11,13 +11,7 @@ REGISTRY = DeviceRegistry( common.ALARMS, common.ENERGY_METER, common.WATER_FILTER, - fridge.TEMP_FRIDGE_CURRENT, - fridge.TEMP_FREEZER_CURRENT, - fridge.TEMP_FRIDGE_SETPOINT, - fridge.TEMP_FREEZER_SETPOINT, fridge.DOORS_STATUS, - fridge.ICEMAKER_ONE, - fridge.ICEMAKER_TWO, fridge.ICEMAKER_STATUS, fridge.REFRIGERATION, fridge.AUTOFILL, @@ -26,5 +20,10 @@ REGISTRY = DeviceRegistry( fridge.BEVERAGE_ZONE, fridge.FIRMWARE_UPDATE, ]), - pattern_capabilities=[fridge.DOOR_GENERIC], + pattern_capabilities=[ + fridge.TEMP_CURRENT_GENERIC, + fridge.TEMP_SETPOINT_GENERIC, + fridge.ICEMAKER_GENERIC, + fridge.DOOR_GENERIC, + ], ) diff --git a/samsung_appliance/registry/capabilities/common.py b/samsung_appliance/registry/capabilities/common.py index b942c0e..6eeb23d 100644 --- a/samsung_appliance/registry/capabilities/common.py +++ b/samsung_appliance/registry/capabilities/common.py @@ -74,7 +74,7 @@ POWER = Capability( SwitchDesc(key='power_switch', field='x.com.samsung.da.power', name='Power', value_fn=lambda v: v == 'On', - write_fn=lambda p, rep: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), + write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})), SensorDesc(key='power_state', field='x.com.samsung.da.power', name='Power state'), BinarySensorDesc(key='power_state_binary', field='x.com.samsung.da.power', diff --git a/samsung_appliance/registry/capabilities/dryer.py b/samsung_appliance/registry/capabilities/dryer.py index a167e4a..911328f 100644 --- a/samsung_appliance/registry/capabilities/dryer.py +++ b/samsung_appliance/registry/capabilities/dryer.py @@ -34,13 +34,13 @@ _COURSE_NAMES = { _COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()} -def _wrinkle_write(p, rep): +def _wrinkle_write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} -def _course_write(p, rep): +def _course_write(p, rep, href=None): """Encode a human course name to the Samsung hex-encoded course string.""" code = _COURSE_CODE_BY_NAME.get(p) if code is None: diff --git a/samsung_appliance/registry/capabilities/fridge.py b/samsung_appliance/registry/capabilities/fridge.py index 939816e..afab787 100644 --- a/samsung_appliance/registry/capabilities/fridge.py +++ b/samsung_appliance/registry/capabilities/fridge.py @@ -3,30 +3,19 @@ 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). +model. Setpoints are NumberDesc with direct-write write_fn — generic caps +derive the CoAP PUT path from href at write time. 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. +/icemaker/two/vs/0) use named path segments, so they are modelled via +pattern capabilities that auto-derive distinct entity keys from href segments. """ 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') @@ -39,70 +28,67 @@ def _int(v): # --------------------------------------------------------------------------- -# Temperatures +# Temperature (generic — covers /temperature/current/* and /temperature/desired/*) # --------------------------------------------------------------------------- -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', +TEMP_CURRENT_GENERIC = Capability( + href=None, + href_prefix='/temperature/current/', 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), + SensorDesc(key='temp_f', field='temperature', + name=None, icon='mdi:thermometer'), ), ) -TEMP_FREEZER_CURRENT = Capability( - href='/temperature/current/freezer/0', +TEMP_SETPOINT_GENERIC = Capability( + href=None, + href_prefix='/temperature/desired/', 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), + NumberDesc(key='temp_f', field='temperature', + name=None, native_min=-20.0, native_max=50.0, + write_fn=lambda p, rep, href=None: ( + [s for s in href.strip('/').split('/') if s], + {'temperature': int(round(float(p)))} + ) if href else None), ), ) -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), - ), -) +# --------------------------------------------------------------------------- +# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0) +# /icemaker/status/vs/0 is kept as exact-href cap and binds first. +# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state). +# --------------------------------------------------------------------------- -TEMP_FREEZER_SETPOINT = Capability( - href='/temperature/desired/freezer/0', +def _icemaker_write(field): + return lambda p, rep, href=None: ( + [s for s in href.strip('/').split('/') if s], + {field: p} + ) if href else None + +ICEMAKER_GENERIC = Capability( + href=None, + href_prefix='/icemaker/', + match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep, 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), + SensorDesc(key='state', field='x.com.samsung.da.iceMaker.state', + name=None, icon='mdi:cube-outline'), + BinarySensorDesc(key='on', field='x.com.samsung.da.iceMaker.state', + name=None, device_class='running', + value_fn=lambda v: v == 'On'), + SensorDesc(key='making_status', + field='x.com.samsung.da.iceMaker.iceMakingStatus', + name=None, icon='mdi:cube-outline'), + SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state', + name=None, icon='mdi:cube-outline', + value_fn=lambda v: v == 'On', + write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')), + SelectDesc(key='type', field='x.com.samsung.da.iceType.desired', + name=None, icon='mdi:cube-outline', + options=(), + write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')), ), ) @@ -110,26 +96,6 @@ TEMP_FREEZER_SETPOINT = Capability( # 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', @@ -148,54 +114,9 @@ DOORS_STATUS = Capability( ) # --------------------------------------------------------------------------- -# Ice makers +# Ice maker status (exact-href — binds before ICEMAKER_GENERIC pattern) # --------------------------------------------------------------------------- -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', @@ -210,7 +131,7 @@ ICEMAKER_STATUS = Capability( # --------------------------------------------------------------------------- def _refrigeration_write(field_name): - def _write(p, rep): + def _write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['refrigeration', 'vs', '0'], {field_name: p} @@ -236,7 +157,7 @@ REFRIGERATION = Capability( # Autofill # --------------------------------------------------------------------------- -def _autofill_write(p, rep): +def _autofill_write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['autofill', 'vs', '0'], {'x.com.samsung.da.autofill': p} @@ -257,7 +178,7 @@ AUTOFILL = Capability( # Cabinet light # --------------------------------------------------------------------------- -def _cabinet_light_write(p, rep): +def _cabinet_light_write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['cabinet', 'light', 'total', 'vs', '0'], { @@ -285,7 +206,7 @@ CABINET_LIGHT = Capability( # Sabbath mode # --------------------------------------------------------------------------- -def _sabbath_write(p, rep): +def _sabbath_write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['sabbath', 'vs', '0'], {'x.com.samsung.da.sabbathMode': p} @@ -306,7 +227,7 @@ SABBATH = Capability( # Beverage zone # --------------------------------------------------------------------------- -def _bzone_write(p, rep): +def _bzone_write(p, rep, href=None): if p not in BZONE_MODES: return None return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p} diff --git a/samsung_appliance/registry/capabilities/laundry.py b/samsung_appliance/registry/capabilities/laundry.py index 68a301f..a8cc49f 100644 --- a/samsung_appliance/registry/capabilities/laundry.py +++ b/samsung_appliance/registry/capabilities/laundry.py @@ -11,19 +11,19 @@ _LED_LEVELS = ('Low', 'High') _SOUND_MODES = ('voice', 'tone', 'mute') -def _led_brightness_write(p, rep): +def _led_brightness_write(p, rep, href=None): if p not in _LED_LEVELS: return None return ['doorled', 'light', 'vs', '0'], {'setBrightness': p} -def _led_night_write(p, rep): +def _led_night_write(p, rep, href=None): if p not in ('On', 'Off'): return None return ['doorled', 'light', 'vs', '0'], {'setNightLight': p} -def _sound_mode_write(p, rep): +def _sound_mode_write(p, rep, href=None): if p not in _SOUND_MODES: return None return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p} diff --git a/samsung_appliance/registry/capabilities/oven.py b/samsung_appliance/registry/capabilities/oven.py index 8adfa93..5c26cfa 100644 --- a/samsung_appliance/registry/capabilities/oven.py +++ b/samsung_appliance/registry/capabilities/oven.py @@ -119,7 +119,7 @@ def _replace_in_options(options, prefix, new_value): # Write functions # --------------------------------------------------------------------------- -def _oven_setpoint_write(p, rep): +def _oven_setpoint_write(p, rep, href=None): """RMW write to /temperatures/vs/0 items array.""" try: temp = float(p) @@ -136,7 +136,7 @@ def _oven_setpoint_write(p, rep): return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items} -def _cook_time_write(p, rep): +def _cook_time_write(p, rep, href=None): """Write operationTime + remainingTime (H:MM:SS) from minutes.""" try: minutes = int(round(float(p))) @@ -152,19 +152,19 @@ def _cook_time_write(p, rep): } -def _stop_write(p, rep): +def _stop_write(p, rep, href=None): return ['operational', 'state', 'vs', '0'], { 'x.com.samsung.da.state': 'Ready', } -def _oven_mode_write(p, rep): +def _oven_mode_write(p, rep, href=None): if p not in _OVEN_MODES: return None return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} -def _lamp_write(p, rep): +def _lamp_write(p, rep, href=None): if p not in ('On', 'Off'): return None opts = list(rep.get('x.com.samsung.da.options') or []) @@ -175,7 +175,7 @@ def _lamp_write(p, rep): } -def _sound_write(p, rep): +def _sound_write(p, rep, href=None): if p not in ('On', 'Off'): return None opts = list(rep.get('x.com.samsung.da.options') or []) @@ -186,7 +186,7 @@ def _sound_write(p, rep): } -def _fastpreheat_write(p, rep): +def _fastpreheat_write(p, rep, href=None): if p not in ('On', 'Off'): return None opts = list(rep.get('x.com.samsung.da.options') or []) @@ -197,7 +197,7 @@ def _fastpreheat_write(p, rep): } -def _naturalsteam_write(p, rep): +def _naturalsteam_write(p, rep, href=None): if p not in ('On', 'Off'): return None opts = list(rep.get('x.com.samsung.da.options') or []) diff --git a/samsung_appliance/registry/discovery.py b/samsung_appliance/registry/discovery.py index 8f1e84c..f742f25 100644 --- a/samsung_appliance/registry/discovery.py +++ b/samsung_appliance/registry/discovery.py @@ -69,7 +69,7 @@ def discover( continue inst = instance_suffix(href) # Auto-derive key prefix from href: strip digits, join non-empty segs with '_' - segs = [s for s in href.strip('/').split('/') if s and not s.isdigit()] + segs = [s for s in href.strip('/').split('/') if s and not s.isdigit() and s != 'vs'] for desc in cap.entities: key_override = '_'.join(segs) + '_' + desc.key out.append(BoundEntity(href=href, capability=cap, desc=desc, diff --git a/tests/fixtures/golden/refrigerator.json b/tests/fixtures/golden/refrigerator.json index da27fb0..a46fe9a 100644 --- a/tests/fixtures/golden/refrigerator.json +++ b/tests/fixtures/golden/refrigerator.json @@ -13,21 +13,25 @@ "filter_status", "filter_usage", "firmware_update", - "freezer_setpoint_f", - "freezer_temp_f", - "fridge_setpoint_f", - "fridge_temp_f", - "ice1", - "ice1_making_status", - "ice1_on", - "ice1_state", - "ice2_making_status", - "ice2_type", "ice_maker_enabled", + "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", "power_watts", "rapid_freezing", "rapid_fridge", - "sabbath_mode" + "sabbath_mode", + "temperature_current_cooler_temp_f", + "temperature_current_freezer_temp_f", + "temperature_desired_cooler_temp_f", + "temperature_desired_freezer_temp_f" ], "discovery_unique_ids": [ "samsung_refrigerator_alarm_code", @@ -43,20 +47,24 @@ "samsung_refrigerator_filter_status", "samsung_refrigerator_filter_usage", "samsung_refrigerator_firmware_update", - "samsung_refrigerator_freezer_setpoint_f", - "samsung_refrigerator_freezer_temp_f", - "samsung_refrigerator_fridge_setpoint_f", - "samsung_refrigerator_fridge_temp_f", - "samsung_refrigerator_ice1", - "samsung_refrigerator_ice1_making_status", - "samsung_refrigerator_ice1_on", - "samsung_refrigerator_ice1_state", - "samsung_refrigerator_ice2_making_status", - "samsung_refrigerator_ice2_type", "samsung_refrigerator_ice_maker_enabled", + "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_power_watts", "samsung_refrigerator_rapid_freezing", "samsung_refrigerator_rapid_fridge", - "samsung_refrigerator_sabbath_mode" + "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" ] } diff --git a/tests/test_fridge_multi_instance.py b/tests/test_fridge_multi_instance.py index 58cdc78..01244ae 100644 --- a/tests/test_fridge_multi_instance.py +++ b/tests/test_fridge_multi_instance.py @@ -25,12 +25,11 @@ def test_two_iceMakers_get_distinct_keys(): resources = _load_resources('10.0.0.254') reg = refrigerator.REGISTRY bound = discover(resources, reg.capabilities, reg.pattern_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}" + ice_keys = sorted(_key(b) for b in bound if _key(b).startswith('icemaker_')) + # icemaker_one_* and icemaker_two_* are distinct + ice_one_keys = [k for k in ice_keys if k.startswith('icemaker_one_')] + ice_two_keys = [k for k in ice_keys if k.startswith('icemaker_two_')] + assert ice_one_keys, "No icemaker_one_* keys found" + assert ice_two_keys, "No icemaker_two_* keys found" + assert set(ice_one_keys).isdisjoint(set(ice_two_keys)), \ + f"icemaker_one and icemaker_two keys overlap: {ice_one_keys} vs {ice_two_keys}"