Merge branch 'main' into claude/device-support-issue-90-water-purifier

This commit is contained in:
Marc Billow
2026-07-26 22:06:20 -05:00
committed by GitHub
39 changed files with 2590 additions and 33 deletions
+68 -4
View File
@@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
import logging
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
@@ -53,6 +55,8 @@ from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
@@ -68,6 +72,19 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto'
# would if it were ever mapped there) and a dedicated 'ai_comfort' preset
# carries the distinction a bare hvac_mode can't. Not reachable via
# async_set_hvac_mode -- entered/left only through the preset, since there's
# no dedicated HVACMode value for it to write back to.
_AI_COMFORT_MODE = 'AIComfort'
PRESET_AI_COMFORT = 'ai_comfort'
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
@@ -84,6 +101,7 @@ _DEVICE_TO_SWING: dict[str, str] = {
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
'Left_And_Right': 'horizontal', # issue #75
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
@@ -164,6 +182,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
# (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every coordinator refresh, so an
# un-deduped warning would spam the log for any device with a
# genuinely unrecognized code.
self._warned_unmapped: set[tuple[str, str]] = set()
# -- resource helpers ---------------------------------------------------
@@ -180,13 +203,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces
in the log instead of silently vanishing (issue #93)."""
key = (href, code)
if key in self._warned_unmapped:
return
self._warned_unmapped.add(key)
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code, href,
)
def _read_mode(self, href: str, mapping: dict):
"""Current mode of a wind/convenient resource, mapped to its HA value."""
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
raw = _first(self._rep(href).get(_MODES_FIELD))
if raw is not None and raw not in mapping:
self._warn_unmapped(href, raw)
return mapping.get(raw)
def _read_modes(self, href: str, mapping: dict) -> list[str]:
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
return [mapping[c] for c in self._supported(href) if c in mapping]
supported = self._supported(href)
for c in supported:
if c not in mapping:
self._warn_unmapped(href, c)
return [mapping[c] for c in supported if c in mapping]
# -- temperature --------------------------------------------------------
@@ -250,14 +294,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
if not self._is_on():
return HVACMode.OFF
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
if device == _AI_COMFORT_MODE:
return HVACMode.AUTO
if device is not None and device not in _DEVICE_TO_HVAC:
self._warn_unmapped(MODE_HREF, device)
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@property
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
if m == _AI_COMFORT_MODE:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is not None and mapped not in modes:
if mapped is None:
self._warn_unmapped(MODE_HREF, m)
continue
if mapped not in modes:
modes.append(mapped)
return modes
@@ -281,11 +334,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def preset_mode(self):
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
return PRESET_AI_COMFORT
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@property
def preset_modes(self) -> list[str]:
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
modes.append(PRESET_AI_COMFORT)
return modes
# -- writes -------------------------------------------------------------
@@ -324,4 +382,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
return
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
+13 -1
View File
@@ -197,6 +197,18 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
return _order_candidates(live)
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
"""
return serial.strip().lower().startswith('nothing')
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2
@@ -261,7 +273,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
one_ui_version = (
resources
+35 -5
View File
@@ -22,7 +22,11 @@ from smartthings_local.ocf.state_cache import StateCache
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
from .registry.capabilities.common import merge_options_field, remote_control_enabled
from .registry.capabilities.common import (
merge_options_field,
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.discovery import discover, BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -49,6 +53,22 @@ class _NoOpDescriptor:
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
"""
return serial.strip().lower().startswith('nothing')
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages one Samsung appliance: session, discovery, polling."""
@@ -312,7 +332,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._unbound_hrefs = unbound
serial = info.get('x.com.samsung.da.serialNum', '')
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
@@ -531,7 +551,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
#54: some devices accept certain writes, e.g. a washer's default
dosing levels, even while reporting remote control off, so the
block's assumption doesn't hold for every model)."""
block's assumption doesn't hold for every model), or the laundry
firmware flag isModelSettingWithoutSC declares settings writable
without Smart Control (cycle start/pause/stop on /operational/state
still require it)."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
@@ -540,7 +563,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
rep = self._cache.get(href or '') or {}
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if not bypass_remote_control and not remote_control_enabled(resources):
if (
not bypass_remote_control
and remote_control_required_for_write(resources, href or '')
and not remote_control_enabled(resources)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
@@ -553,7 +580,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_domain=DOMAIN,
translation_key=error,
)
result = write_fn(payload, rep, href)
try:
result = write_fn(payload, rep, href, resources)
except TypeError:
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
return
@@ -88,6 +88,26 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
}
def _consumer_model_key(description: str) -> Optional[str]:
"""Registry key from the consumer-model token in `description`, or None.
Usually that token is the last '_'-delimited segment before any
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
But issue #79's dryer pairs two model numbers in one description --
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
('DVE50A8800') sits one segment *before* the actual last segment
('8600', a bare second model number with no recognizable prefix). Scan
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
"""
segments = (description or '').split('/', 1)[0].split('_')
for segment in reversed(segments):
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
if key is not None:
return key
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Fallback device-type detection for hardware that never reports
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
@@ -101,8 +121,7 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
DeviceRegistry if the consumer-model code or modelNum resolves to a
known type, None otherwise.
"""
token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1]
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
key = _consumer_model_key(description)
if key is None and '_REF_' in (model_num or ''):
key = 'refrigerator'
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
@@ -123,6 +142,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# resource (see airconditioner.py's _AC_IGNORED).
if key is None and '-CAWW-' in (model_num or '').upper():
key = 'airconditioner'
# Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87)
# report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner)
# token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface
# as the room-AC models above (mode/convenient/wind/temperature/power/
# filter/humidity all confirmed against the issue #87 dump binding
# cleanly against the existing airconditioner registry once routed
# here) -- no WAC-specific resources needed.
if key is None and '_WAC_' in (model_num or ''):
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
if key is None and '_TVTL_' in (model_num or ''):
@@ -175,4 +203,18 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
# Oven/range-combo boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS -- the resource is simply absent from the
# dump, not just empty) can't be matched via for_device_by_model's
# modelNum tokens either. 'Bake' is oven/range cook-mode vocabulary that
# no other family's /mode/vs/0 uses (confirmed against the microwave,
# cooktop, and every laundry fixture), so its presence alongside the
# oven cavity resource is a safe signature. Distinguish range (has a
# cooktop half) from a plain wall oven by which cooktop-status resource,
# if any, is also present.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources and 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return None
@@ -25,6 +25,9 @@ REGISTRY = DeviceRegistry(
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]),
)
@@ -25,5 +25,6 @@ REGISTRY = DeviceRegistry(
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
)
@@ -33,7 +33,7 @@ REGISTRY = DeviceRegistry(
]),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT_GENERIC,
fridge.TEMP_SETPOINT,
fridge.ICEMAKER_GENERIC,
fridge.DOOR_GENERIC,
],
@@ -16,6 +16,7 @@ by_type registry.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
@@ -50,6 +51,25 @@ def _num(v):
return None
def _temps_vs_item(rep):
"""First item of the vendor `/temperatures/vs/0` items[] array -- the
Tizen Lite board's only current-temperature source (see climate.py's
identical helper; duplicated rather than imported to avoid a
capabilities<->platform import cycle)."""
items = rep.get('x.com.samsung.da.items')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
def _temps_vs_current(rep):
return _num(_temps_vs_item(rep).get('x.com.samsung.da.current'))
def _temps_vs_unit(rep):
return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C')
def _filter_usage_percent(rep):
"""Filter usage as a percentage of rated capacity. The device reports
`filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g.
@@ -199,6 +219,48 @@ CURRENT_LIMIT = Capability(
),
)
# The climate entity already surfaces current_temperature as a card
# attribute, but that's not enough for history graphs/automations/
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK
# pair: both share key='current_temperature_c' so only one ever binds
# (match_fn gates the vendor one off when the OCF resource is present).
CURRENT_TEMPERATURE = Capability(
href=HREF_TEMP_CURRENT,
poll_tier='warm',
entities=(
SensorDesc(key='current_temperature_c', field='temperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')),
),
)
CURRENT_TEMPERATURE_VS = Capability(
href=HREF_TEMPS_VS,
poll_tier='warm',
match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources,
entities=(
SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current,
device_class='temperature', state_class='measurement',
unit_fn=_temps_vs_unit),
),
)
# Only the vendor resource's `fivepercentHumidity` (current reading, rounded
# to the nearest 5%) has live data on the issue #75 dump -- its `humidity`
# field, and the OCF-standard /humidity/0 resource entirely, both read a
# stuck "0" there and stay ignored per the 'don't guess' rule (see
# _AC_IGNORED below).
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity',
device_class='humidity', state_class='measurement', unit='%',
value_fn=_num),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
@@ -221,8 +283,10 @@ _AC_IGNORED = [
# All-zero and ambiguously encoded on this model (2-value arrays); the
# 'don't guess' rule -- leave unmodeled rather than invent entities.
'/sensors/vs/0',
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
# resource's usable fivepercentHumidity field instead; this OCF-standard
# one has no corresponding live value confirmed yet.
'/humidity/0',
'/humidity/vs/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
@@ -251,8 +315,11 @@ _AC_IGNORED = [
]
# Built as bare no-entity caps; folded into the AC registry (not global).
# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE
# / CURRENT_TEMPERATURE_VS above already cover those two with real entities.
COVERAGE = [
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS)
] + [
Capability(href=h) for h in _AC_IGNORED
]
@@ -59,9 +59,21 @@ def _ml_to_l(v):
def _active_alarm_codes(items):
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted.
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
condition clears (see WD7000B diagnostics). Surface only live alarms so
HA doesn't stick on a stale ErrorCode. Range-hood has its own stricter
helper that also drops ErrorCode_OFF.
"""
if not items or not isinstance(items, list):
return 'none'
codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')]
codes = [
i.get('x.com.samsung.da.code')
for i in items
if i.get('x.com.samsung.da.code')
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
]
return ', '.join(codes) if codes else 'none'
@@ -92,6 +104,52 @@ def merge_options_field(cached, new_tokens):
return merged
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
# life of a given board, so reading them from the /device/0 seed (no dedicated
# poll_tier) is enough.
_SETINFO_HREF = '/wm/setinfo/vs/0'
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
def model_allows_power_on_off(resources: dict) -> bool:
"""True unless firmware explicitly declares remote power on/off unsupported.
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
the writable switch -- current behavior.
"""
setinfo = resources.get(_SETINFO_HREF)
if setinfo is None:
return True
flag = setinfo.get(_POWER_ON_OFF_FIELD)
if flag is None:
return True
return str(flag).lower() != 'false'
def model_setting_without_sc(resources: dict) -> bool:
"""True when firmware declares settings writable without Smart Control.
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
that accept temperature/spin/cycle-option writes while remote control is
off. Cycle start/pause/stop still need Smart Control on those boards --
the flag name is settings-specific, not a blanket remote-control bypass.
"""
setinfo = resources.get(_SETINFO_HREF) or {}
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
def _power_switch_exists(rep, resources):
return model_allows_power_on_off(resources)
def _power_sensor_exists(rep, resources):
return not model_allows_power_on_off(resources)
def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
@@ -128,10 +186,17 @@ def sensor_item_value(items, sensor_type, index=0):
POWER_GENERIC = Capability(
href='/power/0',
entities=(
# Writable when firmware allows remote power; otherwise a read-only
# binary_sensor with the same key keeps HA state without a dead switch.
SwitchDesc(key='power_switch', field='value',
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
BinarySensorDesc(key='power_switch', field='value',
device_class='power',
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists),
),
)
@@ -141,9 +206,14 @@ POWER_VS_FALLBACK = Capability(
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
value_fn=lambda v: v == 'On',
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
device_class='power',
value_fn=lambda v: v == 'On',
exists_fn=_power_sensor_exists),
),
)
@@ -192,6 +262,20 @@ def remote_control_enabled(resources: dict) -> bool:
return True
def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
"""Whether a write to bound_href should be gated on Smart Control.
When isModelSettingWithoutSC is true, laundry firmware accepts settings
writes (wash temp, spin, course options, buzzer, ...) with remote
control off, but cycle start/pause/stop on /operational/state still
need Smart Control. Absent that flag, keep the historical blanket gate.
"""
if not model_setting_without_sc(resources):
return True
href = bound_href or ''
return href.startswith('/operational/state')
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
@@ -42,7 +42,10 @@ DRYER_SETTINGS = Capability(
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
# appliance and reading back the raw code from the entity's state (issue
# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
@@ -62,7 +62,38 @@ TEMP_CURRENT_GENERIC = Capability(
),
)
TEMP_SETPOINT_GENERIC = Capability(
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(int(round(float(p))))}
]}
)
return (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
)
TEMP_SETPOINT = Capability(
href=None,
href_prefix='/temperature/desired/',
strip_prefix_in_key=True,
@@ -73,10 +104,7 @@ TEMP_SETPOINT_GENERIC = Capability(
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=lambda p, rep, href=None: (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
) if href else None),
write_fn=_temp_setpoint_write),
),
)
@@ -133,3 +133,22 @@ COOKTOP_SAFETY = Capability(
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
# burner array at all -- their local API only exposes this coarse
# monitoring resource for the cooktop half, with no per-burner detail.
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
# `warmingCenterState`'s full value set aren't confirmed, so both are
# exposed as plain sensors rather than guessed at as a switch/select --
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
),
)
@@ -45,6 +45,14 @@ from .laundry import (
# (issue #22). Combo units carry their own course set, so these codes
# don't imply anything about '1F' on a plain washer.
#
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
# WF50A8600AV/US, verified directly rather than by inference: the reporter
# selected each cycle on the physical appliance and read back the resulting
# raw code from the cycle_select entity's state (issue #80). '54' shares a
# display name with the existing '24' Towels -- a different code on a
# different course table legitimately landing on the same label, not a typo
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
@@ -61,6 +61,9 @@
"power_state": {
"name": "Power state"
},
"power_switch": {
"name": "Power"
},
"rapid_freezing": {
"name": "Rapid freezing"
},
@@ -101,6 +104,7 @@
},
"preset_mode": {
"state": {
"ai_comfort": "AI Comfort",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed"
@@ -213,6 +217,8 @@
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"18": "Synthetics",
"19": "Delicates",
@@ -397,30 +403,37 @@
"27": "Rinse+Spin",
"28": "Drain/Spin",
"29": "Drum Clean+",
"2a": "Jeans",
"2b": "AI Wash",
"2d": "Silent Wash",
"2e": "Baby Care",
"2f": "Activewear",
"30": "Cloudy Day",
"32": "Shirts",
"33": "Bedding",
"34": "Mixed",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"3a": "Drum Clean",
"52": "Eco Cold",
"53": "Heavy Duty",
"54": "Towels",
"55": "Activewear",
"57": "Delicate",
"5e": "Rinse+Spin",
"60": "Self Clean+",
"65": "Colors",
"66": "Denim",
"7c": "Whites",
"7d": "Bedding/Waterproof",
"7e": "Self-Clean",
"7f": "Wool/Delicate",
"86": "Deep Wash",
"87": "Download",
"8f": "Intense Cold",
"96": "Less Microfiber",
"2b": "AI Wash",
"2a": "Jeans"
"96": "Less Microfiber"
}
},
"washer_dry_level": {
@@ -466,6 +479,9 @@
"poll": "Polling"
}
},
"cooktop_running_state": {
"name": "Cooktop running state"
},
"cooktop_state": {
"name": "Cooktop state"
},
@@ -475,6 +491,9 @@
"current_temp_c": {
"name": "Temperature"
},
"current_temperature_c": {
"name": "Temperature"
},
"diagnosis": {
"name": "Diagnosis"
},
@@ -547,6 +566,9 @@
"hood_filter_usage": {
"name": "Filter usage"
},
"humidity": {
"name": "Humidity"
},
"instance_temperature": {
"name": "{instance_name} temperature"
},
@@ -625,6 +647,9 @@
"super_fine_dust": {
"name": "Super fine dust"
},
"warming_center_state": {
"name": "Warming center state"
},
"water_liters": {
"name": "Water consumption"
},
@@ -61,6 +61,9 @@
"power_state": {
"name": "Voedingsstatus"
},
"power_switch": {
"name": "Voeding"
},
"rapid_freezing": {
"name": "Snelvriezen"
},
@@ -101,6 +104,7 @@
},
"preset_mode": {
"state": {
"ai_comfort": "AI-comfort",
"quiet": "Stil",
"smart": "Slim",
"speed": "Snel"
@@ -213,6 +217,8 @@
"dryer_cycle_table_03": {
"name": "Programma",
"state": {
"01": "Normaal",
"06": "Tijdprogramma",
"16": "Katoen",
"18": "Synthetisch",
"19": "Fijne was",
@@ -397,30 +403,37 @@
"27": "Spoelen+centrifugeren",
"28": "Afpompen/centrifugeren",
"29": "Drum Clean+",
"2a": "Spijkerbroek",
"2b": "AI Wash",
"2d": "Stille was",
"2e": "Babyverzorging",
"2f": "Sportkleding",
"30": "Bewolkte dag",
"32": "Overhemden",
"33": "Beddengoed",
"34": "Gemengd",
"36": "Wassen+drogen",
"37": "Air Wash",
"38": "Katoen drogen",
"39": "Synthetisch drogen",
"3a": "Trommel reinigen",
"52": "Eco koud",
"53": "Intensief",
"54": "Handdoeken",
"55": "Sportkleding",
"57": "Fijn",
"5e": "Spoelen+centrifugeren",
"60": "Zelfreinigend+",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"7f": "Wol/Fijn",
"86": "Diep wassen",
"87": "Downloaden",
"8f": "Intensief koud",
"96": "Minder microvezels",
"2b": "AI Wash",
"2a": "Spijkerbroek"
"96": "Minder microvezels"
}
},
"washer_dry_level": {
@@ -466,6 +479,9 @@
"poll": "Polling"
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
},
@@ -475,6 +491,9 @@
"current_temp_c": {
"name": "Temperatuur"
},
"current_temperature_c": {
"name": "Temperatuur"
},
"diagnosis": {
"name": "Diagnose"
},
@@ -547,6 +566,9 @@
"hood_filter_usage": {
"name": "Filterverbruik"
},
"humidity": {
"name": "Luchtvochtigheid"
},
"instance_temperature": {
"name": "Temperatuur {instance_name}"
},
@@ -625,6 +647,9 @@
"super_fine_dust": {
"name": "Ultrafijnstof"
},
"warming_center_state": {
"name": "Status warmhoudzone"
},
"water_liters": {
"name": "Waterverbruik"
},