refactor: replace exact-href door/temp/icemaker caps with generic pattern caps

Delete dead: DOOR_FRIDGE, DOOR_FREEZER, TEMP_FRIDGE/FREEZER_CURRENT/SETPOINT,
ICEMAKER_ONE, ICEMAKER_TWO, ICE2_MODES, temperature bounds constants.

Add: TEMP_CURRENT_GENERIC (href_prefix=/temperature/current/),
TEMP_SETPOINT_GENERIC (href_prefix=/temperature/desired/, write derives
CoAP path from href), ICEMAKER_GENERIC (href_prefix=/icemaker/,
match_fn gates on iceMaker.state field).

Key derivation strips 'vs' segments alongside digits so icemaker hrefs
like /icemaker/one/vs/0 produce icemaker_one_state not icemaker_one_vs_state.

write_fn signature extended: adapter now passes href as 3rd positional arg;
all existing write_fns updated to accept href=None.

Fridge golden: old ice1_*/ice2_*/fridge_*/freezer_* keys replaced by
icemaker_one_*/icemaker_two_*/temperature_current_*/temperature_desired_*.
This commit is contained in:
Marc Billow committed 2026-06-30 16:33:36 -05:00
1 parent 9e5a169c82
commit 785154eb83
10 files changed
+116 -189

No files matched your search

+1 -1
View File
@@ -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
@@ -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,
],
)
@@ -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',
@@ -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:
+56 -135
View File
@@ -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}
@@ -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}
@@ -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 [])
+1 -1
View File
@@ -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,
+30 -22
View File
@@ -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"
]
}
+8 -9
View File
@@ -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}"