Files
localthings/custom_components/localthings/registry/capabilities/fridge.py
T
Marc Billow daf7e3787f Add ruff (lint + format) and ty (type checking) to the project
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.

Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).

Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
2026-08-02 23:56:38 +00:00

1067 lines
38 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 ("units": "F" / "x.com.samsung.da.unit": "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, it's just never
been read before. See `_temp_unit`/`_temp_item_unit` below. 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 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 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 — select.py and SensorDesc.options
# normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device.
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 (this module's original assumption) 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):
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
fall back to direct OCF /temperature/desired/ write otherwise.
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
/temperatures/vs/0. On some models only the vendor path commits the change;
on others both work. Using the vendor path when present is always correct.
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) -- some single-door ("cooler only")
# fridges report no /temperature/current|desired/* pair at all (this href
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
# or '/temperature/desired/' prefixes), only this one vendor resource that
# bundles the live desired value together with the *specific* values the
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
# user pick an unsupported value; modeled as a select reading its own live
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
# ---------------------------------------------------------------------------
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,
),
),
)
# ---------------------------------------------------------------------------
# Icemaker nighttime quiet mode
# ---------------------------------------------------------------------------
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 kept as 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") -- read via name_field, reaching the translated name as the
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
# ice makers are independent on/off toggles that can both be enabled at once
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
# ---------------------------------------------------------------------------
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 tone
# ---------------------------------------------------------------------------
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},
),
),
),
)
# ---------------------------------------------------------------------------
# Status/lock — auto door opener and fridge sound
# ---------------------------------------------------------------------------
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 / active-defrost status
#
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
# status: despite its "block" naming (originally assumed to mean "defrost
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
# defrost cycle is *actively running* right now, seen with defrost_delay
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
# not a blocking/prevention state. Exposed as a read-only diagnostic
# binary sensor.
# ---------------------------------------------------------------------------
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. The captured TP1X_REF_21K
# firmware publishes the same state on both hrefs, but only the vendor resource
# above has a confirmed write contract. Bind the native mirror without another
# entity so discovery records 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",
),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
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"),
),
),
)
# ---------------------------------------------------------------------------
# Autofill
# ---------------------------------------------------------------------------
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 (proximity-triggered cabinet light)
# ---------------------------------------------------------------------------
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 lighting schedule
#
# night.starttime is an ISO datetime; only the time portion is meaningful.
# night.duration.minute encodes 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 duration.
# ---------------------------------------------------------------------------
_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,
),
),
)
# ---------------------------------------------------------------------------
# Cabinet light
# ---------------------------------------------------------------------------
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"},
),
),
),
)
# ---------------------------------------------------------------------------
# Sabbath mode
# ---------------------------------------------------------------------------
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,
),
),
)
# ---------------------------------------------------------------------------
# Beverage zone
# ---------------------------------------------------------------------------
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 temperature presets (issue #20). Same shape as
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
# instance has been 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 multiple orthogonal flags in one list; the
# flex-zone entry is whichever item is also a member of supportedOptions --
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
# never appear there. The prefix on that item varies by fridge family
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
# stuck on None), so match by list membership instead of a hardcoded prefix.
# Write replaces only that item; other flags are preserved.
# ---------------------------------------------------------------------------
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, so
# "first match" and "strip all matches" (in the write below) agree. If a
# future device ever reports two, this reads the first and the write
# would drop both -- revisit if that turns up.
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
# /mode/vs/0's modes/supportedOptions with real data, but
# its tokens carry a "_[n]:[n]" parameter suffix on
# supportedOptions that modes never repeats, so no item
# ever overlaps -- the RF9000/Bespoke-class overlap this
# capability was built for never happens there. Require an
# actual resolvable value instead of just a populated
# list, so this stays absent on that family rather than
# showing a select permanently 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 the vendor-prefixed `x.com.samsung.da.openState` instead. This
capability still binds either way (href_prefix match doesn't care about
field names), but a plain `field=` lookup against the wrong key means
the entity exists and is permanently unavailable -- 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 kimchi
# units, issue #26) -- top/middle/bottom each report their own storage mode
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
# an identical shape; modeled as a pattern capability the same way
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
# key and {instance_name} from the href's top/middle/bottom segment. Only
# the top compartment's door has been seen reported separately (kimchidoors);
# middle/bottom apparently have no contact switch of their own, so that's
# its own narrower pattern cap rather than assumed universal.
#
# The same state is also mirrored -- packed into single tokens like
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
# supportMode fields are unpacked and self-describing, so that's what this
# binds to instead.
#
# Write path is unconfirmed (no live write against a real unit) -- same
# "write the same field back to the entity's own href" convention as
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
#
# translations/en.json's kimchi_zone_mode state labels were translated
# directly from the reporter's own (Korean-language) SmartThings app
# screenshots, not guessed from the codes or from their English paraphrase.
# Cross-checking the screenshots against supportMode confirms the on-screen
# option order matches the array order everywhere it's verifiable: the top
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
# and BUY (구입, "purchased") are also confirmed exact matches, not
# abbreviation guesses.
# ---------------------------------------------------------------------------
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) -- device-reported unit unconfirmed, so this stays
# a bare number rather than asserting minutes or hours.
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
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
# /doors/vs/0 aggregate carries a single generic item (id "4", no
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
# that doesn't share this compartment's "top" instance numbering --
# a distinct main-cabinet door, not this kimchi drawer's own 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 exposed more precisely by per-instance hrefs (DOOR_GENERIC,
# TEMP_CURRENT_GENERIC/TEMP_SETPOINT_GENERIC, ICEMAKER_GENERIC) on hardware
# that has them. Not every fridge does — a simpler model may only ever
# advertise the aggregate resource. Each fallback's match_fn checks the
# full resource set for the richer sibling hrefs and only binds when
# they're absent, so it's a no-op (not a gap — see discovery.py) wherever
# the richer hrefs exist, and a real (if coarser) source of the same data
# 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
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
href="/icemaker/status/0",
)
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
# in this section: its three fields duplicate two *different* richer
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
# single capability-level match_fn can't express it. Gated per-entity
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
# REFRIGERATION's href is present; defrost_active only when
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
# these are read-only, unlike REFRIGERATION's switches.
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="config",
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="config",
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources,
),
),
)