feat: add refrigerator capabilities with multi-instance support
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user