Files
localthings/custom_components/localthings/registry/capabilities/fridge.py
T
Marc Billow 26c9168fb7 Add internal air-filter support for TP1X_REF_21K refrigerators (issue #318)
/filter/airdustfilter/vs/0 was the dump's only unbound href -- this board's
internal deodorizing filter, same filterUsage/filterStatus field pair as
common.WATER_FILTER, but filterUsage here is already a 0-100 percentage
with no filterCapacity to divide by (confirmed by filterStatus=="wash" at
filterUsage=="100"). Uses air_-prefixed keys so a fridge with both a
water and an air filter gets two distinct entities.
2026-08-07 14:13:38 +00:00

1007 lines
32 KiB
Python

"""Capabilities for the refrigerator family (Samsung RF9000B-class).
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
Temperature unit is read live from each resource, not assumed: the RF9000B
dump reports Fahrenheit, but a TP1X_REF_21K dump (issue #7) reports the same
fields in Celsius for the exact same resources -- the device tells you which
one it is. See `_temp_unit`/`_temp_item_unit` below.
Multi-instance note: the two 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, so they are modeled via
pattern capabilities that auto-derive distinct entity keys from href
segments.
"""
import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
TimeDesc,
)
from .common import int_or_none, normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in translations/en.json, keyed by the
# lowercased raw device value.
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _temp_unit(rep):
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults
to °F if the device omits the field."""
return normalize_temp_unit(rep.get("units"))
# Temperature (generic -- covers /temperature/current/* and
# /temperature/desired/*)
TEMP_CURRENT_GENERIC = Capability(
href=None,
href_prefix="/temperature/current/",
strip_prefix_in_key=True,
poll_tier="warm",
entities=(
SensorDesc(
key="temperature",
field="temperature",
translation_key="instance_temperature",
use_instance_name=True,
icon="mdi:thermometer",
device_class="temperature",
unit_fn=_temp_unit,
state_class="measurement",
),
),
)
def _temp_setpoint_write(p, rep, href=None, resources=None):
"""Prefer vendor /temperatures/vs/0 when present, else the direct OCF
/temperature/desired/ write -- on some models only the vendor path
commits. Item IDs follow the Samsung convention: "0" = Freezer,
"1" = Fridge/Cooler."""
if not href:
return None
if resources and "/temperatures/vs/0" in resources:
if "/cooler/" in href:
item_id = "1"
elif "/freezer/" in href:
item_id = "0"
else:
return None
return (
["temperatures", "vs", "0"],
{
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": item_id,
"x.com.samsung.da.desired": str(round(float(p))),
}
]
},
)
return ([s for s in href.strip("/").split("/") if s], {"temperature": round(float(p))})
TEMP_SETPOINT = Capability(
href=None,
href_prefix="/temperature/desired/",
strip_prefix_in_key=True,
poll_tier="warm",
entities=(
NumberDesc(
key="setpoint",
field="temperature",
translation_key="instance_setpoint",
use_instance_name=True,
device_class="temperature",
unit_fn=_temp_unit,
native_min=-20.0,
native_max=50.0,
range_field="range",
entity_category="config",
write_fn=_temp_setpoint_write,
),
),
)
# Discrete cooler setpoint (issue #186): single-door "cooler only" fridges
# report no /temperature/current|desired/* pair, only this vendor resource
# bundling the live desired value with the specific values the unit
# accepts. supportedList (e.g. ['1','2','3','4','7']) is not a contiguous
# range, so this is a select reading its own live options rather than a
# NumberDesc with min/max/step.
def _definite_cooler_write(p, rep, href=None):
return (
["temperature", "definite", "cooler", "vs", "0"],
{"x.com.samsung.da.definite.desired": p},
)
DEFINITE_TEMPERATURE_COOLER = Capability(
href="/temperature/definite/cooler/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="cooler_temperature_setpoint",
field="x.com.samsung.da.definite.desired",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.definite.supportedList",
write_fn=_definite_cooler_write,
),
),
)
def _definite_freezer_write(p, rep, href=None):
return (
["temperature", "definite", "freezer", "vs", "0"],
{"x.com.samsung.da.definite.desired": p},
)
# Freezer half of the same discrete-setpoint pattern (issue #229) -- same
# shape as DEFINITE_TEMPERATURE_COOLER, negative supportedList values.
DEFINITE_TEMPERATURE_FREEZER = Capability(
href="/temperature/definite/freezer/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="freezer_temperature_setpoint",
field="x.com.samsung.da.definite.desired",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.definite.supportedList",
write_fn=_definite_freezer_write,
),
),
)
ICEMAKER_NIGHTTIME = Capability(
href="/icemaker/nighttime/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="ice_night_mode",
field="ice.night.status",
icon="mdi:weather-night",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["icemaker", "nighttime", "vs", "0"],
{"ice.night.status": "On" if p == "On" else "Off"},
),
),
),
)
# Icemaker (generic -- covers /icemaker/one/vs/0, /icemaker/two/vs/0).
# /icemaker/status/vs/0 is an exact-href cap and binds first;
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these
# two makers can both be enabled at once (issue #27), so they stay
# separate entities rather than one ice-type select.
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,
name_field="x.com.samsung.da.iceMaker.name",
poll_tier="warm",
entities=(
SensorDesc(
key="making_status",
field="x.com.samsung.da.iceMaker.iceMakingStatus",
use_instance_name=True,
icon="mdi:cube-outline",
device_class="enum",
options=("icestatus_stop", "icestatus_run"),
translation_key="ice_making_status",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
SwitchDesc(
key="enabled",
field="x.com.samsung.da.iceMaker.state",
translation_key="instance_enabled",
use_instance_name=True,
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",
use_instance_name=True,
icon="mdi:cube-outline",
translation_key="ice_type",
entity_category="config",
options_field="x.com.samsung.da.iceType.supported",
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.iceType.supported")),
write_fn=_icemaker_write("x.com.samsung.da.iceType.desired"),
),
),
)
DOOR_ALERT = Capability(
href="/settings/sound/alert/door/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="door_alert",
field="alert.door",
icon="mdi:bell-alert",
translation_key="door_alert",
entity_category="config",
options_field="supportedAlert.door",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "alert", "door", "vs", "0"],
{"alert.door": p},
),
),
),
)
# Internal deodorizing filter (issue #318, TP1X_REF_21K). Same
# filterUsage/filterStatus field pair as common.WATER_FILTER, but
# filterUsage here is already a 0-100 percentage with no filterCapacity to
# divide by (confirmed by filterStatus=="wash" at filterUsage=="100") --
# 'air_'-prefixed keys so a fridge with both a water and an air filter gets
# two distinct entities rather than a unique_id collision.
AIR_FILTER = Capability(
href="/filter/airdustfilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key="air_filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="air_filter_status",
field="x.com.samsung.da.filterStatus",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
def _status_lock_write(field):
return lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{field: "On" if p == "On" else "Off"},
)
STATUS_LOCK = Capability(
href="/status/lock/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="auto_door_opener",
field="x.com.samsung.da.ado.devicecontrol",
icon="mdi:door-open",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_status_lock_write("x.com.samsung.da.ado.devicecontrol"),
),
SwitchDesc(
key="fridge_sound",
field="x.com.samsung.da.device.sound",
icon="mdi:volume-high",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_status_lock_write("x.com.samsung.da.device.sound"),
),
),
)
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
# defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming,
# live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively
# running right now (seen with defrost_delay off) -- "block" refers to the
# evaporator/coil block being defrosted, not a prevention state.
DEFROST_DELAY = Capability(
href="/defrost/delay/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="defrost_delay",
field="x.com.samsung.da.delayDefrost",
icon="mdi:snowflake-off",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["defrost", "delay", "vs", "0"],
{"x.com.samsung.da.delayDefrost": "On" if p == "On" else "Off"},
),
),
),
)
# OCF-native boolean mirror of DEFROST_DELAY -- only the vendor resource
# above has a confirmed write contract, so bind this without another
# entity to record it as an intentional duplicate.
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
href="/defrost/delay/0",
)
DEFROST_BLOCK_STATUS = Capability(
href="/defrost/block/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key="defrost_active",
field="x.com.samsung.da.modes",
icon="mdi:snowflake-melt",
entity_category="diagnostic",
value_fn=lambda modes: bool(modes) and modes[0] == "DEFROST_BLOCK_ON",
),
),
)
def _refrigeration_write(field_name):
def _write(p, rep, href=None):
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",
icon="mdi:fridge-industrial",
entity_category="config",
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",
icon="mdi:snowflake",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_refrigeration_write("x.com.samsung.da.rapidFreezing"),
),
),
)
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}
AUTOFILL = Capability(
href="/autofill/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="autofill",
field="x.com.samsung.da.autofill",
icon="mdi:cup-water",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_autofill_write,
),
),
)
WELCOME_LIGHTING = Capability(
href="/proximity/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="welcome_lighting",
field="status",
icon="mdi:motion-sensor",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["proximity", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
),
)
# Enhanced cabinet light nighttime schedule: night.starttime is an ISO
# datetime (only the time portion matters), night.duration.minute is the
# window length. End time is derived so both time entities write back to
# the same resource without stepping on each other: writing start
# preserves duration; writing end recalculates it.
_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100")
def _tz_offset(rep) -> datetime.timedelta:
s = rep.get("timezone.offset", "+00:00")
try:
sign = 1 if s[0] == "+" else -1
h, m = map(int, s[1:].split(":"))
return datetime.timedelta(hours=h, minutes=m) * sign
except (ValueError, TypeError, IndexError):
return datetime.timedelta(0)
def _parse_night_time(iso_str, offset=datetime.timedelta(0)) -> "datetime.time | None":
"""Convert a UTC ISO datetime string to local time using offset."""
if not iso_str:
return None
try:
return (datetime.datetime.fromisoformat(iso_str) + offset).time().replace(second=0)
except (ValueError, TypeError):
return None
def _night_start_value(rep) -> "datetime.time | None":
return _parse_night_time(rep.get("night.starttime"), _tz_offset(rep))
def _night_end_value(rep) -> "datetime.time | None":
start_t = _parse_night_time(rep.get("night.starttime"), _tz_offset(rep))
duration_str = rep.get("night.duration.minute")
if start_t is None or duration_str is None:
return None
try:
duration = int(duration_str)
except (ValueError, TypeError):
return None
return (
(
datetime.datetime.combine(datetime.date.today(), start_t)
+ datetime.timedelta(minutes=duration)
)
.time()
.replace(second=0)
)
def _night_start_write(p, rep, href=None):
# p is local time; subtract offset to get UTC for storage
offset = _tz_offset(rep)
utc_dt = datetime.datetime.combine(datetime.date.today(), p) - offset
old_iso = rep.get("night.starttime") or ""
try:
new_dt = datetime.datetime.fromisoformat(old_iso).replace(
hour=utc_dt.hour, minute=utc_dt.minute, second=0
)
except (ValueError, TypeError):
new_dt = utc_dt
return ["cabinet", "light", "enhanced", "vs", "0"], {
"night.starttime": new_dt.strftime("%Y-%m-%dT%H:%M:%S"),
}
def _night_end_write(p, rep, href=None):
# duration = end_local - start_local; offset cancels so work in local time
start_t = _parse_night_time(rep.get("night.starttime"), _tz_offset(rep))
if start_t is None:
return None
start_min = start_t.hour * 60 + start_t.minute
end_min = p.hour * 60 + p.minute
duration = (end_min - start_min) % (24 * 60)
return ["cabinet", "light", "enhanced", "vs", "0"], {
"night.duration.minute": str(duration),
}
CABINET_LIGHT_ENHANCED = Capability(
href="/cabinet/light/enhanced/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="day_brightness",
field="level.brightness.daytime",
icon="mdi:brightness-5",
translation_key="day_brightness",
entity_category="config",
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
["cabinet", "light", "enhanced", "vs", "0"],
{"level.brightness.daytime": p},
),
),
SelectDesc(
key="brightness_level",
field="level.brightness.nighttime",
icon="mdi:brightness-4",
translation_key="brightness_level",
entity_category="config",
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
["cabinet", "light", "enhanced", "vs", "0"],
{"level.brightness.nighttime": p},
),
),
TimeDesc(
key="night_start",
field="",
icon="mdi:clock-start",
entity_category="config",
rep_fn=_night_start_value,
write_fn=_night_start_write,
),
TimeDesc(
key="night_end",
field="",
icon="mdi:clock-end",
entity_category="config",
rep_fn=_night_end_value,
write_fn=_night_end_write,
),
),
)
def _cabinet_light_write(p, rep, href=None):
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=(
SwitchDesc(
key="cabinet_light_switch",
field="x.com.samsung.da.lightControl",
icon="mdi:fridge-outline",
value_fn=lambda v: v == "On",
write_fn=_cabinet_light_write,
),
SwitchDesc(
key="cabinet_light_dim",
field="light.dimming.status",
icon="mdi:brightness-auto",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["cabinet", "light", "total", "vs", "0"],
{"light.dimming.status": "On" if p == "On" else "Off"},
),
),
),
)
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}
SABBATH = Capability(
href="/sabbath/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(
key="sabbath_mode",
field="x.com.samsung.da.sabbathMode",
icon="mdi:hands-pray",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_sabbath_write,
),
),
)
def _bzone_write(p, rep, href=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",
icon="mdi:glass-wine",
translation_key="beverage_zone_mode",
entity_category="config",
options_field="roomSupportedModes",
write_fn=_bzone_write,
),
),
)
# Pantry / Cool Select Zone -- a convertible compartment toggled between
# wine/deli/drinks presets (issue #20). Same shape as BEVERAGE_ZONE but a
# distinct field set (x.com.samsung.da.mode/supportedOptions vs
# roomDesiredMode/roomSupportedModes). Only a "one" instance seen; not
# generalized to a pattern cap until a second instance turns up.
def _pantry_write(p, rep, href=None):
return ["status", "pantry", "one", "vs", "0"], {"x.com.samsung.da.mode": p}
PANTRY_ZONE = Capability(
href="/status/pantry/one/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="pantry_zone_mode",
field="x.com.samsung.da.mode",
icon="mdi:glass-wine",
translation_key="pantry_zone_mode",
entity_category="config",
options_field="x.com.samsung.da.supportedOptions",
write_fn=_pantry_write,
),
),
)
# Flex zone (convertible drawer -- /mode/vs/0 on RF9000-class fridges):
# x.com.samsung.da.modes holds several orthogonal flags in one list; the
# flex-zone entry is whichever item also appears in supportedOptions (the
# other flags, WATERFILTER_*/DEFROST_BLOCK_*/CVN_*_ZONE, never do). The
# prefix on that item varies by family (CV_TTYPE_RF9000A_ vs CV_FDR_ on
# Bespoke, issues #27/#26), so match by list membership instead of a
# hardcoded prefix. Write replaces only that item.
def _flex_zone_supported(rep):
return set(rep.get("x.com.samsung.da.supportedOptions") or ())
def _flex_zone_current(rep):
# Every dump seen has at most one modes/supportedOptions overlap; a
# future device reporting two would read the first and the write below
# would drop both.
modes = rep.get("x.com.samsung.da.modes") or []
supported = _flex_zone_supported(rep)
return next((m for m in modes if m in supported), None)
def _flex_zone_write(p, rep, href=None):
supported = _flex_zone_supported(rep)
modes = [m for m in (rep.get("x.com.samsung.da.modes") or []) if m not in supported]
modes.append(p)
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": modes}
FLEX_ZONE = Capability(
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(
key="flex_zone_mode",
icon="mdi:thermostat",
translation_key="flex_zone_mode",
entity_category="config",
options_field="x.com.samsung.da.supportedOptions",
# A nonempty supportedOptions alone isn't sufficient: the
# kimchi-refrigerator family (issue #26) also populates both
# fields, but its tokens carry a "_[n]:[n]" suffix on
# supportedOptions that modes never repeats, so nothing ever
# overlaps there. Require an actual resolvable value so this
# stays absent on that family instead of stuck on "unknown".
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
rep_fn=_flex_zone_current,
write_fn=_flex_zone_write,
),
),
)
# Generic door pattern capability (href=None -- use as pattern_cap only)
def _door_open_state(rep):
"""Most /door/* resources report bare `openState`, but the
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77,
#83) reports `x.com.samsung.da.openState` instead -- check both."""
v = rep.get("openState")
if v is None:
v = rep.get("x.com.samsung.da.openState")
return v == "Open"
DOOR_GENERIC = Capability(
href=None,
href_prefix="/door/",
poll_tier="hot",
entities=(
BinarySensorDesc(
key="open",
rep_fn=_door_open_state,
translation_key="instance_open",
use_instance_name=True,
device_class="door",
),
),
)
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment
# units, issue #26): top/middle/bottom each report their own storage mode
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, modeled as a
# pattern capability the same way DOOR_GENERIC is. Only the top
# compartment's door is reported separately (kimchidoors); middle/bottom
# apparently have no contact switch, hence the narrower KIMCHI_DOOR_GENERIC
# below rather than assuming it's universal.
#
# The same state is also packed into single tokens (e.g.
# "KIMCHIT_KIMCHI_STORAGE_NORMAL") on /mode/vs/0, the resource FLEX_ZONE
# reads for RF9000-class fridges -- this binds to /status/kimchi/<slot>/
# vs/0's plain, self-describing currentMode/supportMode instead.
#
# Write path is unconfirmed on real hardware; same "write the field back to
# the entity's own href" convention as PANTRY_ZONE/BEVERAGE_ZONE.
#
# translations/en.json's kimchi_zone_mode labels were translated directly
# from the reporter's own Korean SmartThings app screenshots (not guessed),
# and cross-checked against supportMode order to confirm the on-screen
# option order matches the array order throughout -- so COLD/WARM
# consistently means Strong/Weak everywhere that suffix appears.
def _kimchi_mode_write(p, rep, href=None):
if not href or p not in (rep.get("x.com.samsung.da.supportMode") or ()):
return None
return [s for s in href.strip("/").split("/") if s], {
"x.com.samsung.da.currentMode": p,
}
KIMCHI_ZONE = Capability(
href=None,
href_prefix="/status/kimchi/",
strip_prefix_in_key=True,
poll_tier="warm",
entities=(
SelectDesc(
key="mode",
field="x.com.samsung.da.currentMode",
use_instance_name=True,
icon="mdi:fridge-outline",
translation_key="kimchi_zone_mode",
entity_category="config",
options_field="x.com.samsung.da.supportMode",
write_fn=_kimchi_mode_write,
),
SensorDesc(
key="ripening_status",
field="x.com.samsung.da.ripeStatus",
use_instance_name=True,
icon="mdi:progress-clock",
translation_key="kimchi_ripening_status",
entity_category="diagnostic",
),
SensorDesc(
key="ripening_remaining",
field="x.com.samsung.da.ripeRemaintime",
use_instance_name=True,
icon="mdi:timer-sand",
translation_key="kimchi_ripening_remaining",
entity_category="diagnostic",
# No dump has this nonzero (ripeStatus is always "Off" so far)
# -- unit unconfirmed, so this stays a bare number.
value_fn=_int,
),
SensorDesc(
key="rack_count",
field="x.com.samsung.da.rackCount",
use_instance_name=True,
icon="mdi:tray-full",
translation_key="kimchi_rack_count",
entity_category="diagnostic",
enabled_default=False,
value_fn=_int,
),
),
)
KIMCHI_DOOR_GENERIC = Capability(
href=None,
href_prefix="/kimchidoors/",
strip_prefix_in_key=True,
poll_tier="hot",
entities=(
# Not deduped against DOORS_FALLBACK below: on the one reporter
# this binds alongside, /doors/vs/0's aggregate carries a single
# generic item (id "4", no /door/<instance> siblings) that doesn't
# share this compartment's "top" numbering -- a distinct
# main-cabinet door, not this drawer's contact switch reported
# twice.
BinarySensorDesc(
key="open",
rep_fn=_door_open_state,
translation_key="instance_open",
use_instance_name=True,
device_class="door",
),
),
)
# Aggregate-resource fallbacks: /doors/vs/0, /temperatures/vs/0, and
# /icemaker/status/vs/0 each duplicate information the per-instance hrefs
# above expose more precisely, on hardware that has them -- not every
# fridge does. Each fallback's match_fn checks for the richer sibling
# hrefs and only binds when they're absent, so it's a no-op wherever the
# richer hrefs exist and a real (coarser) source where they don't.
def _any_door_generic(resources):
return any(h.startswith("/door/") for h in resources)
DOORS_FALLBACK = Capability(
href="/doors/vs/0",
match_fn=lambda rep, resources: not _any_door_generic(resources),
poll_tier="hot",
entities=(
BinarySensorDesc(
key="door_open",
field="x.com.samsung.da.items",
device_class="door",
value_fn=lambda items: any(
i.get("x.com.samsung.da.openState") == "Open" for i in (items or [])
),
),
),
)
def _any_temperature_generic(resources):
return any(h.startswith("/temperature/") for h in resources)
def _temp_item_value(items, keyword):
for item in items or []:
if keyword.lower() in (item.get("x.com.samsung.da.description") or "").lower():
return _int(item.get("x.com.samsung.da.current"))
return None
def _temp_item_unit(items, keyword):
for item in items or []:
if keyword.lower() in (item.get("x.com.samsung.da.description") or "").lower():
return normalize_temp_unit(item.get("x.com.samsung.da.unit"))
return "°F"
TEMPERATURES_FALLBACK = Capability(
href="/temperatures/vs/0",
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
poll_tier="warm",
entities=(
SensorDesc(
key="freezer_temperature",
field="x.com.samsung.da.items",
icon="mdi:thermometer",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: _temp_item_unit(rep.get("x.com.samsung.da.items"), "Freezer"),
value_fn=lambda items: _temp_item_value(items, "Freezer"),
),
SensorDesc(
key="fridge_temperature",
field="x.com.samsung.da.items",
icon="mdi:thermometer",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: _temp_item_unit(rep.get("x.com.samsung.da.items"), "Fridge"),
value_fn=lambda items: _temp_item_value(items, "Fridge"),
),
),
)
def _any_icemaker_unit_generic(resources):
return any(
h.startswith("/icemaker/")
and isinstance(r, dict)
and "x.com.samsung.da.iceMaker.state" in r
for h, r in resources.items()
)
ICEMAKER_STATUS_FALLBACK = Capability(
href="/icemaker/status/vs/0",
match_fn=lambda rep, resources: not _any_icemaker_unit_generic(resources),
poll_tier="warm",
entities=(
SwitchDesc(
key="ice_maker_enabled",
field="x.com.samsung.da.iceMaker",
icon="mdi:cube-outline",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["icemaker", "status", "vs", "0"],
{"x.com.samsung.da.iceMaker": "On" if p == "On" else "Off"},
),
),
),
)
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer
# per-unit hrefs; its write contract isn't advertised, so bind it as a
# duplicate only.
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
href="/icemaker/status/0",
)
# OCF-native /refrigeration/0 (issue #7): its three fields duplicate two
# different richer hrefs (REFRIGERATION's rapidFridge/rapidFreezing,
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so
# gating is per-entity (exists_fn) rather than one capability-level
# match_fn. No write path confirmed, so these stay read-only.
REFRIGERATION_FALLBACK = Capability(
href="/refrigeration/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key="defrost_active",
field="defrost",
icon="mdi:snowflake-melt",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: "/defrost/block/vs/0" not in resources,
),
BinarySensorDesc(
key="rapid_fridge",
field="rapidCool",
icon="mdi:fridge-industrial",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources,
),
BinarySensorDesc(
key="rapid_freezing",
field="rapidFreeze",
icon="mdi:snowflake",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources,
),
),
)