Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood

# Conflicts:
#	README.md
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
#	tests/test_golden_regression.py
This commit is contained in:
themaanda
2026-07-22 21:53:45 -05:00
64 changed files with 5282 additions and 352 deletions
+5 -4
View File
@@ -16,6 +16,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Cooktop (read-only burner/hood status) | `by_type/cooktop.py` |
@@ -101,7 +102,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
registry/
@@ -112,9 +113,9 @@ custom_components/localthings/
adapter.py Flattens bound entities into HA-ready state
identity.py Reads device identity for type detection
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, cooktop,
range_hood, dryer, oven, dishwasher, fridge, washer, laundry,
operational, ignored)
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
+84 -37
View File
@@ -30,24 +30,28 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
HREF_POWER as POWER_HREF,
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
# Sibling OCF resources the climate entity reads (the primary bound href is
# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor
# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK.
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
MODE_HREF = '/mode/vs/0'
TEMP_CURRENT_HREF = '/temperature/current/0'
TEMP_DESIRED_HREF = '/temperature/desired/0'
TEMP_CONTROL_HREF = '/temperature/control/vs/0'
WIND_STRENGTH_HREF = '/wind/strength/vs/0'
WIND_DIRECTION_HREF = '/wind/direction/vs/0'
CONVENIENT_HREF = '/mode/convenient/vs/0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
@@ -122,6 +126,22 @@ def _num(value):
return None
def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it reports current
and target under a single `/temperatures/vs/0` resource whose
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
increment/unit. Returns {} when absent, so callers fall through cleanly.
"""
items = rep.get('x.com.samsung.da.items')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner."""
@@ -159,24 +179,51 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
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)))
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]
# -- temperature --------------------------------------------------------
def _temps_vs(self) -> dict:
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
@property
def temperature_unit(self) -> str:
units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper()
return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS
raw = self._rep(TEMP_DESIRED_HREF).get('units')
if raw is None:
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
return _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
return v
@property
def target_temperature(self):
return _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.desired'))
return v
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get('range')
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
@@ -190,7 +237,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
# -- hvac mode ----------------------------------------------------------
@@ -214,30 +264,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def fan_mode(self):
return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD)))
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def fan_modes(self) -> list[str]:
return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF)
if c in _DEVICE_TO_FAN]
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def swing_mode(self):
return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD)))
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def swing_modes(self) -> list[str]:
return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF)
if c in _DEVICE_TO_SWING]
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def preset_mode(self):
return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD)))
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@property
def preset_modes(self) -> list[str]:
return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF)
if c in _DEVICE_TO_PRESET]
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
# -- writes -------------------------------------------------------------
@@ -263,17 +310,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
async def async_set_fan_mode(self, fan_mode: str) -> None:
device = _FAN_TO_DEVICE.get(fan_mode)
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
device = mapping.get(value)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
device = _SWING_TO_DEVICE.get(swing_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('swing', device))
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
device = _PRESET_TO_DEVICE.get(preset_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('preset', device))
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
+12 -1
View File
@@ -12,6 +12,7 @@ import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
@@ -511,13 +512,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style."""
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
here rather than per-platform, so rejecting a write with a
user-facing message -- as opposed to write_fn's silent no-op below
-- is available to every platform for free."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
error = validate_fn(payload, rep, self._cache.snapshot())
if error:
raise ServiceValidationError(error)
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
+6 -1
View File
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
# importlib.metadata.version() reads the installed package's metadata off
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
+11 -3
View File
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.discovery import BoundEntity
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
@@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str:
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return name.replace('_', ' ').title().strip()
return _snake_to_title(name).strip()
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
if bound.desc.name is not None:
self._attr_name = bound.desc.name
elif bound.instance_name:
# A device-given instance name (e.g. an ice maker's "Cubed
# Ice") takes the place of the href-derived instance label,
# keeping the same entity-specific suffix (issue #27).
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
else:
self._attr_name = _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
],
"version": "0.6.0"
"version": "0.9.0"
}
+27 -2
View File
@@ -69,6 +69,7 @@ class ObserveManager:
self.last_mode_change_wall = time.time()
self._settle_until: dict[str, float] = {}
self._settle_lock = threading.Lock()
self._cache_lock = threading.Lock()
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
@@ -92,11 +93,35 @@ class ObserveManager:
return True
def apply(self, href: str, rep: dict, source: str) -> bool:
"""Gate a StateCache.apply_rep call through the write-settle guard."""
"""Gate a StateCache.apply_rep call through the write-settle guard.
Merges the incoming rep onto whatever's already cached for this
href rather than handing it to StateCache.apply_rep verbatim --
apply_rep does a full replace, and Samsung devices don't always
repeat every field on every update (issue #27: a /mode/vs/0
OBSERVE notify -- and, on at least one Bespoke fridge, even the
/device/0 sweep entry for that href -- can carry just `modes`,
omitting `supportedOptions`/`supportedModes` entirely). A full
replace would silently wipe fields a select entity's
exists_fn/options_field gates on the moment one partial update
comes through, even though nothing about the device's actual
supported options changed.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
threads (DTLS reader for notifies, executor threads for poll/
sweep) -- without it, two concurrent updates for the same href
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
"""
if self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
return self.cache.apply_rep(href, rep, source=source)
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -77,6 +77,8 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'DV': 'dryer',
'DW': 'dishwasher',
}
@@ -103,6 +105,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
if key is None and '_PRAC_' in (model_num or ''):
key = 'airconditioner'
# Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain
# '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the
# 'P' sits between the underscore and 'RAC' in that token).
if key is None and '_RAC_' in (model_num or ''):
key = 'airconditioner'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
@@ -3,27 +3,28 @@
The first device whose core controls surface as a single composite HA `climate`
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
are consumed by that entity rather than exposed as separate switches/selects, so
this registry deliberately does NOT include the common POWER caps -- on/off is
the climate entity's HVACMode.OFF / TURN_ON/OFF.
this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
own comment in capabilities/common.py for why it's excluded.
Reuses common.ALARMS + common.ENERGY_METER, fridge.FIRMWARE_UPDATE (as every
registry does), and dishwasher.DIAGNOSIS for /diagnosis/vs/0.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, fridge, ignored
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
common.ALARMS,
common.ENERGY_METER,
fridge.FIRMWARE_UPDATE,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
*airconditioner.COVERAGE,
]),
)
@@ -1,6 +1,6 @@
"""Cooktop device registry."""
from ..capabilities import cooktop, fridge, ignored
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
@@ -12,6 +12,6 @@ REGISTRY = DeviceRegistry(
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
fridge.FIRMWARE_UPDATE,
common.FIRMWARE_UPDATE,
]),
)
@@ -1,19 +1,13 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
@@ -25,6 +19,5 @@ REGISTRY = DeviceRegistry(
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -9,21 +9,15 @@ objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
@@ -32,6 +26,5 @@ REGISTRY = DeviceRegistry(
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,24 +1,18 @@
"""Oven device registry."""
from ..capabilities import common, fridge, ignored, oven
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -1,6 +1,6 @@
"""Range-hood device registry."""
from ..capabilities import common, fridge, ignored, range_hood
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
@@ -10,7 +10,7 @@ REGISTRY = DeviceRegistry(
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
fridge.FIRMWARE_UPDATE,
common.FIRMWARE_UPDATE,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
@@ -1,22 +1,17 @@
"""Refrigerator device registry."""
from ..capabilities import common, fridge, ignored
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
@@ -26,13 +21,14 @@ REGISTRY = DeviceRegistry(
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.FIRMWARE_UPDATE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.SELF_CHECK,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
pattern_capabilities=[
@@ -1,26 +1,19 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, fridge, ignored, laundry, operational, washer
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
common.POWER_GENERIC,
common.POWER_VS_FALLBACK,
common.KIDS_LOCK_GENERIC,
common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC,
common.REMOTE_CONTROL_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.ALARMS,
common.ENERGY_METER,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -15,7 +15,32 @@ different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from ..capability import Capability
from ..entities import ClimateDesc, SensorDesc, SwitchDesc
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
# siblings off the coordinator snapshot; those siblings are marked covered
# (no-entity caps) so discover() reports no gap. Declared once here and
# imported by climate.py, so a new sibling read can't drift out of sync with
# its coverage entry.
# ---------------------------------------------------------------------------
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_CONVENIENT,
]
def _num(v):
@@ -71,7 +96,7 @@ def _climate_write(payload, rep, href=None):
CLIMATE = Capability(
href='/mode/vs/0',
href=HREF_MODE,
poll_tier='warm',
entities=(
ClimateDesc(key='climate', translation_key='airconditioner',
@@ -123,28 +148,76 @@ AIR_FILTER = Capability(
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: hrefs consumed by the composite climate entity, and
# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in
# the global ignored.IGNORED because several of them (/mode/vs/0 handled above,
# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A
# no-entity Capability still marks the href as bound so discover() reports no
# coverage gap.
# ---------------------------------------------------------------------------
_CONSUMED_BY_CLIMATE = [
'/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF
'/power/vs/0', # vendor fallback for on/off
'/temperature/current/0', # climate current_temperature
'/temperature/desired/0', # climate target_temperature (write target)
'/temperature/control/vs/0', # climate target_temperature_step
'/wind/strength/vs/0', # climate fan_mode
'/wind/direction/vs/0', # climate swing_mode
'/mode/convenient/vs/0', # climate preset_mode
]
DISPLAY_LIGHT = Capability(
href='/light/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='display_light', field='mode',
name='Display light', icon='mdi:led-on',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['light', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
),
)
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
# boolean field, no vendor prefix, mirroring the On/Off convention used
# throughout the rest of this API.
MUTE_ONCE = Capability(
href='/option/muteonce/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='mute_once', field='muteonce',
name='Mute once', icon='mdi:volume-mute',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'muteonce', 'vs', '0'],
{'muteonce': 'On' if p == 'On' else 'Off'})),
),
)
# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation`
# toggles the limiter and `modes` picks a level out of `supportedModes`
# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the
# dump to confirm what the levels mean (amps vs. an abstract tier) --
# exposed read-only per the 'don't guess' rule rather than risking an
# unverified write to live HVAC hardware.
CURRENT_LIMIT = Capability(
href='/electriccurrent/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='current_limit_enabled', field='operation',
name='Current limit enabled', icon='mdi:current-ac',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='current_limit_level', field='modes',
name='Current limit level', icon='mdi:current-ac',
entity_category='diagnostic'),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
# These are NOT in the global ignored.IGNORED because several of them
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
# other families' schemas. A no-entity Capability still marks the href as
# bound so discover() reports no coverage gap.
#
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
# (power, current/target temp, fan, swing, preset) -- the coordinator only
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
# so leaving these at the Capability default of 'cold' meant every state
# change was invisible until the next full /device/0 summary sweep
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
# of waiting on the summary sweep.
# ---------------------------------------------------------------------------
_AC_IGNORED = [
# Vendor superset that duplicates the OCF /temperature/current+desired pair.
'/temperatures/vs/0',
# 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',
@@ -152,7 +225,29 @@ _AC_IGNORED = [
'/humidity/vs/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
# user-actionable state or no documented write contract, so per the
# 'don't guess' rule they are ignored rather than modeled.
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
# the by_type registry, both of which have a confirmed, cleanly
# modelable contract.
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
'/mds/absencestate/vs/0', # motion-detection state (empty here)
'/remotedatacontrol/vs/0', # remote data-control session status
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/welcome/temperature/vs/0', # welcome-cooling plumbing
]
# Built as bare no-entity caps; folded into the AC registry (not global).
COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)]
COVERAGE = [
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
] + [
Capability(href=h) for h in _AC_IGNORED
]
@@ -11,7 +11,9 @@ against live device dumps:
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
)
def _num(v):
@@ -175,6 +177,38 @@ ENERGY_METER = Capability(
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
# cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. `not
# rep or` keeps the same empty-{} stub carve-out as power_watts/
# energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
name='Power energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
name='Energy saved', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
# Monthly billing-cycle totals -- the completed prior month and the
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
name='Energy (last month)', device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
name='Energy (this month)', device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
),
)
@@ -199,3 +233,162 @@ WATER_FILTER = Capability(
name='Filter status', icon='mdi:filter-check'),
),
)
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
# additional level(s) the device offers ('1' meaning just "on" on most
# hardware, but multi-level boards have been reported). Verified cross-family:
# fridge (issue #21) and washer (issue #40) both expose this href.
#
# supportedAiLevel is a single-entry list on most captured hardware, where a
# select would offer only one real choice against an implicit "off" -- shown
# as a switch instead. '0' itself is never in supportedAiLevel but has been
# observed live as the off value of aiLevel, so the select synthesizes it
# back in as an explicit option rather than leaving no way to turn off.
#
# No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
# against an unknown, growing number of future levels.
def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
be len()-checked as if it were a list."""
sl = rep.get('supportedAiLevel')
return list(sl) if isinstance(sl, (list, tuple)) else []
def _ai_energy_level_options(resources):
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# No `not rep` stub carve-out on either side, unlike most exists_fn
# gates in this file -- entity creation only ever runs once, against
# whichever snapshot happens to be current the moment platforms are
# set up (see entity._is_included / __init__.py's
# async_config_entry_first_refresh-before-forward-entry-setups
# ordering), while flatten() re-evaluates exists_fn every poll
# against live data. Both descriptors share key='ai_energy_level',
# so if a stub carve-out let one of them win at setup time while the
# other wins once real data lands, flatten() would feed the
# instantiated entity a value shaped for the other platform (e.g. a
# bool into a Select). Requiring real, populated data on both sides
# keeps the entity-creation decision and the live-value decision in
# permanent agreement -- the cost is this entity doesn't appear
# until a reload if the device's very first poll stubs this
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
entity_category='config',
value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) > 1),
write_fn=_ai_energy_level_write),
),
)
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check result', icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
name='Self-check error', icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.error' in rep),
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
payload='Start', icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
),
)
# ---------------------------------------------------------------------------
# Cross-family bundles, unpacked into every by_type registry's _build([...])
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
# discover() only binds a capability whose href is actually present in a
# given device's resource dump, so listing one here for a family that
# doesn't expose the href is a no-op, not a phantom entity -- see the
# adding-device-support skill's coverage-discipline section.
#
# UNIVERSAL holds every capability with no known family that both (a) has
# the href and (b) needs to model it some other way -- broadening one of
# these to a new family is a safe, harmless guess (issue #40's AI energy
# level: 2 of 6 families confirmed, blanket-added everywhere else).
#
# POWER is kept separate -- airconditioner is the one family that opts out
# of it. Canonical reason (see by_type/airconditioner.py and its test for
# pointers back here, not restatements): AC's climate entity already owns
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
# in UNIVERSAL.
# ---------------------------------------------------------------------------
UNIVERSAL = (
ALARMS,
ENERGY_METER,
FIRMWARE_UPDATE,
SELF_CHECK,
AI_ENERGY_LEVEL,
KIDS_LOCK_GENERIC,
KIDS_LOCK_VS_FALLBACK,
REMOTE_CONTROL_GENERIC,
REMOTE_CONTROL_VS_FALLBACK,
)
POWER = (
POWER_GENERIC,
POWER_VS_FALLBACK,
)
@@ -8,7 +8,7 @@ wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import cycle_select, option_value, replace_in_options
from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
@@ -35,49 +35,20 @@ DISHWASHER_SETTINGS = Capability(
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
# same options array. Course display names live in translations under
# same options array (shared laundry.bool_option_switch, same options[]
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
# ---------------------------------------------------------------------------
def _storm_wash_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'StormWashZone', p),
}
def _auto_release_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, 'AutoDoorRelease', p),
}
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
write_fn=_storm_wash_write),
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
exists_fn=lambda rep, resources: any(
isinstance(o, str) and o.startswith('AutoDoorRelease_')
for o in (rep.get('x.com.samsung.da.options') or [])
),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
write_fn=_auto_release_write),
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
),
)
@@ -19,7 +19,7 @@ import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc, TimeDesc,
)
from .common import normalize_temp_unit
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
# 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 are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, 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):
@@ -112,6 +118,7 @@ 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',
@@ -209,6 +216,14 @@ DEFROST_DELAY = Capability(
),
)
# 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',
@@ -220,28 +235,6 @@ DEFROST_BLOCK_STATUS = Capability(
),
)
# ---------------------------------------------------------------------------
# Self-check diagnostic
# ---------------------------------------------------------------------------
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check result', icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
payload='Start', icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
@@ -494,17 +487,64 @@ BEVERAGE_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# 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',
name='Pantry zone 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 identified by the CV_TTYPE_RF9000A_ prefix.
# 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):
modes = list(rep.get('x.com.samsung.da.modes') or [])
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
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}
@@ -514,39 +554,17 @@ FLEX_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='flex_zone_mode',
field='x.com.samsung.da.modes',
name='Flex zone mode', icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedOptions')),
value_fn=lambda modes: next(
(m for m in (modes or [])
if m.startswith('CV_TTYPE_RF9000A_')), None),
rep_fn=_flex_zone_current,
write_fn=_flex_zone_write),
),
)
# ---------------------------------------------------------------------------
# Firmware update
# ---------------------------------------------------------------------------
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
# ---------------------------------------------------------------------------
# Generic door pattern capability (href=None — use as pattern_cap only)
# ---------------------------------------------------------------------------
@@ -655,6 +673,14 @@ ICEMAKER_STATUS_FALLBACK = Capability(
),
)
# 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
@@ -85,14 +85,9 @@ IGNORED: list[Capability] = [
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
# AI energy-saving level; only one value ('1') is ever in
# supportedAiLevel on hardware seen so far, so there's no real choice
# to expose. Revisit if a device surfaces more than one supported level.
Capability(href='/energy/ailevel/vs/0'),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump), same "no real choice
# to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a
# device surfaces it toggled on.
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href='/mode/0'),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
@@ -217,6 +217,59 @@ def cycle_select(*, translation_key, icon):
)
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
# both families ride this exact contract, just with different prefixes and
# different presence/validation needs on top.
# ---------------------------------------------------------------------------
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
def bool_option_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
the token at all (washer's bubble soak/pre-wash/intensive); leave False
for a toggle every device in the family reports (dishwasher's storm
wash). validate_fn is passed straight through to SwitchDesc for callers
that need to reject a write against live device state (e.g. washer's
per-course availability check) -- this factory has no opinion on it and
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, name=name, icon=icon, entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
validate_fn=validate_fn,
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
@@ -17,10 +17,13 @@ from datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
replace_in_options,
)
# ---------------------------------------------------------------------------
# Course_XX hex codes. The 23 codes named in strings.json/translations
# Course_XX hex codes. 23 of the codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
@@ -35,6 +38,13 @@ from .laundry import cycle_select, hex_pairs, option_value, replace_in_options
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
#
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
# combo's editCourseList and were named from that user's app screenshot
# (issue #22). Combo units carry their own course set, so these codes
# don't imply anything about '1F' on a plain washer.
#
# 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
@@ -82,6 +92,19 @@ WASHER_SETTINGS = Capability(
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedDryLevel')),
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
),
)
@@ -160,13 +183,50 @@ def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
return raw
for code in hex_pairs(supported_raw) if supported_raw else []:
try:
if int(code, 16) == target:
return code
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), 'X')
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
}
return write
@@ -176,9 +236,58 @@ def _dosing_low(prefix):
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
def _dosing_alarm_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
# already used by AiOption and KidsLockBypass in this same array, so
# PreWashSetting/IntensiveSetting are assumed to follow suit.
#
# Each also has a differently-named hex-pair availability field that lines up
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
# 24), all three read 'F0' at that position and the toggle was writable --
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
# Off') decodes to '00' for that course, matching the app graying the
# control out there. 'F0'/'00' is treated as available/unavailable on that
# evidence. exists_fn (device-level presence) still only runs once, against
# the setup-time snapshot, so it isn't a fit for this per-course check --
# validate_fn runs on every write attempt instead (dispatched from
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
# for a course whose byte isn't 'F0' with a user-facing error rather than
# silently no-opping against the device. The read/write/presence machinery
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
def _bool_option_switch(key, name, icon, prefix, availability_field):
def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
back to allowing the write whenever the availability data can't be
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
return None
opts = rep.get('x.com.samsung.da.options') or []
current = option_value(opts, 'Course')
courses = cycle_options(resources)
if not current or current not in courses:
return None
raw = option_value(opts, availability_field)
if raw is None:
return None
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
return f"{name} isn't available on the selected cycle."
return None
return bool_option_switch(
key, name, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
WASHER_COURSE = Capability(
@@ -201,8 +310,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
@@ -211,8 +319,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
@@ -220,8 +327,7 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
@@ -230,16 +336,21 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', name='Softener low',
icon='mdi:alert-circle-outline', device_class='problem',
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
@@ -15,6 +15,11 @@ class Capability:
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
@@ -27,6 +27,25 @@ class BoundEntity:
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to a state key."""
return s.replace('_', ' ').title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
if not cap.name_field:
return None
raw = rep.get(cap.name_field)
if not isinstance(raw, str) or not raw:
return None
return _snake_to_title(raw)
def instance_suffix(href: str) -> str:
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
return ''
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller."""
return [
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name)
for desc in cap.entities
]
def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
@@ -58,9 +89,7 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
for desc in cap.entities:
out.append(BoundEntity(href=href, capability=cap,
desc=desc, instance=inst))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
matched = True
if matched:
@@ -78,10 +107,7 @@ def discover(
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
for desc in cap.entities:
key_override = '_'.join(segs) + '_' + desc.key
out.append(BoundEntity(href=href, capability=cap, desc=desc,
instance=inst, key_override=key_override))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
matched = True
break
@@ -2,7 +2,9 @@
Frozen dataclasses so the future native HA component can consume them as
EntityDescription subclasses unchanged. Read transforms live in value_fn;
presence gating in exists_fn; write logic in write_fn on command platforms.
presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
@@ -10,6 +12,11 @@ from dataclasses import dataclass, field
from typing import Any, Callable, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
# (payload, rep, resources) -> a human-readable rejection message, or None to
# allow the write. resources is the coordinator's full href->rep snapshot, for
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling
# href's live option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
def _identity(v: Any) -> Any:
@@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription):
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
+31 -2
View File
@@ -36,13 +36,25 @@
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"pantry_zone_mode": {
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
"cv_ttype_rf9000a_beverage": "Beverage",
"cv_fdr_wine": "Wine",
"cv_fdr_deli": "Deli",
"cv_fdr_beverage": "Beverage",
"cv_fdr_meat": "Meat",
"cv_fdr_soft_freezer": "Soft Freezer"
}
},
"dishwasher_cycle": {
@@ -83,7 +95,12 @@
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"1f": "Intense Cold"
}
},
"dryer_cycle": {
@@ -125,6 +142,18 @@
"02": "2x",
"03": "3x"
}
},
"washer_dry_level": {
"state": {
"none": "Off",
"cupboard": "Cupboard",
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr"
}
}
},
"sensor": {
@@ -36,13 +36,25 @@
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"pantry_zone_mode": {
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
"cv_ttype_rf9000a_beverage": "Beverage",
"cv_fdr_wine": "Wine",
"cv_fdr_deli": "Deli",
"cv_fdr_beverage": "Beverage",
"cv_fdr_meat": "Meat",
"cv_fdr_soft_freezer": "Soft Freezer"
}
},
"dishwasher_cycle": {
@@ -83,7 +95,12 @@
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
"96": "Less Microfiber",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"1f": "Intense Cold"
}
},
"dryer_cycle": {
@@ -125,6 +142,18 @@
"02": "2x",
"03": "3x"
}
},
"washer_dry_level": {
"state": {
"none": "Off",
"cupboard": "Cupboard",
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr"
}
}
},
"sensor": {
@@ -0,0 +1,595 @@
{
"meta": {
"note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).",
"issue": 17,
"model": "TP1X_DA-AC-RAC-01011",
"oneUiVersion": "7.0 Air conditioner"
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingInterval": "1800",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1784508715",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.autoExeState": "Alarm",
"x.com.samsung.da.supportedAutoExeState": [
"Airpurify",
"Alarm",
"Sensing"
],
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.autoExeSetting": "Off",
"x.com.samsung.da.supportedAutoExeSetting": [
"On",
"Off"
]
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C016102490F0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784704371",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "75025",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "42",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02756C25082500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR4",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"Nano",
"NanoSleep",
"DryComfort"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Heat"
],
"workingMode": "Heat",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Heat",
"Wind"
],
"x.com.samsung.da.options": [
"OptionCode_56378",
"Sleep_6",
"ArtificialWorking_Off",
"Off",
"OutdoorTemp_68",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
],
"defaultSettingStatus": "On"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "39",
"displayCondition": "Enable"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "1",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
}
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "17.0",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Africa/Johannesburg",
"offset": "+02:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Normal",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+591
View File
@@ -0,0 +1,591 @@
{
"meta": {
"model": "TP1X_DA-AC-RAC-01001_0000",
"device_type": "airconditioner",
"source": "issue #38 diagnostics (scrubbed)",
"note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEB700A0012C01610248040F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "2026-07-22T10:59:14Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "1148",
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784725078",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "160922",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "30",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_54328",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"ExtendOptionCode_230024",
"UpdateAllow_NotAllowed",
"DurationOn_120",
"OutdoorTemp_92",
"CoolCapa_50",
"WarmCapa_60",
"Autoclean_Off",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "04521026694110263641",
"versions": [
"24092600",
"10001200"
],
"visVersion": "240926"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "845210266941FFFFFFFF",
"versions": [
"24092600",
"FFFFFFFF"
],
"visVersion": "240926"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "9"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "26.0",
"x.com.samsung.da.desired": "22.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Amman",
"offset": "+03:00",
"DST": "OFF"
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
}
]
}
+380
View File
@@ -0,0 +1,380 @@
{
"meta": {
"model": "TP2X_RAC_20K",
"device_type": "airconditioner",
"source": "issue #37 diagnostics (scrubbed)",
"note": "Older/simpler RAC board reporting no oneUiVersion -- resolved via the '_RAC_' modelNum token fallback in for_device_by_model (unlike issue #38's TP1X board, which self-reports oneUiVersion)."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:42:46",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:42:46",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C01610249040F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "717060",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativePowerType": "total",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "39",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "43"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_RAC_20K|10229641|60010523001511014600003200800000",
"x.com.samsung.da.description": "TP2X_RAC_20K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "010",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02273A260324",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102296A23012700",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"MotionDirect",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Wind",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"MotionState_Normal",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_85",
"CoolCapa_35",
"WarmCapa_35",
"Light_Off",
"Volume_100",
"StopAutoClean_Idle",
"Autoclean_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"MotionMode_PowerSave",
"OptionCode_56376",
"ExtendOptionCode_230157",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_50",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "TP2X_RAC_20K",
"versions": [
"11260324"
],
"visVersion": "260324"
},
{
"type": "Micom",
"modelId": "045210229641FFFFFFFF",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
},
{
"type": "Micom",
"modelId": "04521022954110229941",
"versions": [
"21092700",
"10000300"
],
"visVersion": "210927"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "22.0",
"x.com.samsung.da.current": "26.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Amman",
"offset": "+03:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Left_And_Right",
"x.com.samsung.da.supportedModes": [
"Fix",
"All",
"Up_And_Low",
"Left_And_Right"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
}
]
}
+1
View File
@@ -8,6 +8,7 @@
"climate",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"power_watts"
]
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_purify",
"alarm_code",
"auto_clean",
"climate",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"air_purify",
"alarm_code",
"auto_clean",
"climate",
"current_limit_enabled",
"current_limit_level",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"alarm_code",
"auto_clean",
"climate",
"energy_kwh",
"firmware_update",
"mute_once",
"power_watts"
]
}
+2
View File
@@ -8,6 +8,7 @@
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"heated_dry",
@@ -39,6 +40,7 @@
"samsung_dishwasher_diagnosis_start",
"samsung_dishwasher_diagnosis_status",
"samsung_dishwasher_energy_kwh",
"samsung_dishwasher_energy_saved_kwh",
"samsung_dishwasher_finish_time",
"samsung_dishwasher_firmware_update",
"samsung_dishwasher_heated_dry",
+1
View File
@@ -7,6 +7,7 @@
"automatic_ventilation_state",
"clean_level",
"energy_kwh",
"energy_saved_kwh",
"fan",
"firmware_update",
"hood_filter_capacity",
+12
View File
@@ -1,5 +1,6 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"autofill",
@@ -16,6 +17,9 @@
"door_cooler_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
@@ -31,15 +35,18 @@
"icemaker_two_type",
"night_end",
"night_start",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"welcome_lighting"
],
"discovery_unique_ids": [
"samsung_refrigerator_ai_energy_level",
"samsung_refrigerator_alarm_code",
"samsung_refrigerator_auto_door_opener",
"samsung_refrigerator_autofill",
@@ -56,6 +63,9 @@
"samsung_refrigerator_door_cooler_open",
"samsung_refrigerator_door_freezer_open",
"samsung_refrigerator_energy_kwh",
"samsung_refrigerator_energy_last_month_kwh",
"samsung_refrigerator_energy_saved_kwh",
"samsung_refrigerator_energy_this_month_kwh",
"samsung_refrigerator_filter_status",
"samsung_refrigerator_filter_usage",
"samsung_refrigerator_firmware_update",
@@ -71,10 +81,12 @@
"samsung_refrigerator_icemaker_two_type",
"samsung_refrigerator_night_end",
"samsung_refrigerator_night_start",
"samsung_refrigerator_power_energy_kwh",
"samsung_refrigerator_power_watts",
"samsung_refrigerator_rapid_freezing",
"samsung_refrigerator_rapid_fridge",
"samsung_refrigerator_sabbath_mode",
"samsung_refrigerator_selfcheck_error",
"samsung_refrigerator_selfcheck_result",
"samsung_refrigerator_selfcheck_start",
"samsung_refrigerator_selfcheck_status",
@@ -0,0 +1,28 @@
{
"state_keys": [
"alarm_code",
"autofill",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"diagnosis_status",
"door_cooler_open",
"door_freezer_open",
"energy_last_month_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"pantry_zone_mode",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"autofill",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"door_cooler_open",
"door_cvroom_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_saved_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"flex_zone_mode",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"icemaker_two_enabled",
"icemaker_two_making_status",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"state_keys": [
"alarm_code",
"cooler_setpoint",
"cooler_temperature",
"defrost_active",
"defrost_delay",
"diagnosis_status",
"door_cooler_open",
"door_cvroom_open",
"door_freezer_open",
"energy_kwh",
"energy_last_month_kwh",
"energy_this_month_kwh",
"filter_status",
"filter_usage",
"firmware_update",
"flex_zone_mode",
"freezer_setpoint",
"freezer_temperature",
"icemaker_one_enabled",
"icemaker_one_making_status",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
]
}
+5
View File
@@ -1,6 +1,8 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"bubble_soak",
"buzzer_sound",
"child_lock",
"cycle",
@@ -11,11 +13,14 @@
"drum_clean_cycles_remaining",
"drum_clean_last_cleaned",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
+29
View File
@@ -0,0 +1,29 @@
{
"state_keys": [
"alarm_code",
"bubble_soak",
"child_lock",
"cycle",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"dry_level",
"energy_kwh",
"finish_time",
"firmware_update",
"intensive",
"job_beginning_status",
"machine_state",
"power_switch",
"pre_wash",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"softener_low",
"spin_speed",
"wash_temperature"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"child_lock",
"cycle_active",
"delay_start_hours",
"detergent_low",
"diagnosis_status",
"drum_clean_cycles_remaining",
"energy_kwh",
"finish_time",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"spin_speed",
"wash_temperature"
]
}
+351
View File
@@ -0,0 +1,351 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "-21",
"x.com.samsung.da.current": "-21",
"x.com.samsung.da.maximum": "-15",
"x.com.samsung.da.minimum": "-23",
"x.com.samsung.da.unit": "Celsius"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "2",
"x.com.samsung.da.current": "2",
"x.com.samsung.da.maximum": "7",
"x.com.samsung.da.minimum": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/freezer/0",
"rep": {
"range": [
-23.0,
-15.0
],
"units": "C",
"temperature": -21.0
}
},
{
"href": "/temperature/desired/freezer/0",
"rep": {
"range": [
-23.0,
-15.0
],
"units": "C",
"temperature": -21.0
}
},
{
"href": "/temperature/current/cooler/0",
"rep": {
"range": [
1.0,
7.0
],
"units": "C",
"temperature": 2.0
}
},
{
"href": "/temperature/desired/cooler/0",
"rep": {
"range": [
1.0,
7.0
],
"units": "C",
"temperature": 2.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "0",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "86000",
"x.com.samsung.da.thismonthlyConsumption": "64026"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": [
"WATERFILTER_ENABLE"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": [
"WATERFILTER_ENABLE"
]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"DEFROST_BLOCK_ON",
"DEFROST_BLOCK_OFF"
],
"x.com.samsung.da.modes": [
"DEFROST_BLOCK_OFF"
]
}
},
{
"href": "/sabbath/vs/0",
"rep": {
"x.com.samsung.da.sabbathMode": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_REF_17K|00117141|0002074E051311200103000010000000",
"x.com.samsung.da.description": "ARTIK051_REF_17K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02144A201124",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2003030C,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-04:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
]
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.countryCode": "US",
"x.com.samsung.da.region": ""
}
},
{
"href": "/defrost/delay/0",
"rep": {
"value": false
}
},
{
"href": "/defrost/delay/vs/0",
"rep": {
"x.com.samsung.da.delayDefrost": "Off"
}
},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{
"href": "/icemaker/status/0",
"rep": {
"status": "On"
}
},
{
"href": "/icemaker/status/vs/0",
"rep": {
"x.com.samsung.da.iceMaker": "On"
}
},
{
"href": "/refrigeration/0",
"rep": {
"defrost": false,
"rapidFreeze": false,
"rapidCool": false
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "00:00:00",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "00:00:00",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "replace",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/autofill/vs/0",
"rep": {
"x.com.samsung.da.autofill": "Off"
}
},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
},
{
"href": "/status/pantry/one/vs/0",
"rep": {
"x.com.samsung.da.controllable": "true",
"x.com.samsung.da.name": "COOL_SELECT_ZONE",
"x.com.samsung.da.mode": "FDR_DRINKS",
"x.com.samsung.da.supportedOptions": [
"FDR_WINE",
"FDR_DELI",
"FDR_DRINKS"
]
}
}
]
}
+2 -1
View File
@@ -279,7 +279,8 @@
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1"
"1",
"2"
],
"href": "/energy/ailevel/vs/0"
}
+253
View File
@@ -0,0 +1,253 @@
{
"device0": [
{
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
},
{"href": "/alarms/vs/0", "rep": {}},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "0",
"x.com.samsung.da.maximum": "5",
"x.com.samsung.da.minimum": "-8",
"x.com.samsung.da.unit": "Fahrenheit"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "39",
"x.com.samsung.da.current": "39",
"x.com.samsung.da.maximum": "44",
"x.com.samsung.da.minimum": "34",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{"href": "/temperature/current/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
{"href": "/temperature/desired/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
{"href": "/temperature/current/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
{"href": "/temperature/desired/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": ["DA_ERROR_NONE"],
"x.com.samsung.da.supportedActions": ["Start"]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "63",
"x.com.samsung.da.cumulativePower": "1831899",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "3",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "52000",
"x.com.samsung.da.thismonthlyConsumption": "38900",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"],
"x.com.samsung.da.supportedOptions": [
"CV_FDR_WINE",
"CV_FDR_DELI",
"CV_FDR_BEVERAGE",
"CV_FDR_MEAT",
"CV_FDR_SOFT_FREEZER"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": ["DEFROST_BLOCK_ON", "DEFROST_BLOCK_OFF"],
"x.com.samsung.da.modes": ["DEFROST_BLOCK_OFF"]
}
},
{"href": "/sabbath/vs/0", "rep": {"x.com.samsung.da.sabbathMode": "Off"}},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00148241|00020850031611204103000021010000",
"x.com.samsung.da.description": "TP1X_REF_21K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": ["errCode", "dump"],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "512",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "250420",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "23071923, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{"href": "/file/information/vs/0", "rep": {"x.com.samsung.timeoffset": "-05:00", "x.com.samsung.supprtedtype": 1}},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{"x.com.samsung.da.id": "0", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
{"x.com.samsung.da.id": "1", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
{"x.com.samsung.da.id": "2", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"}
]
}
},
{"href": "/door/freezer/0", "rep": {"openState": "Close"}},
{"href": "/door/cooler/0", "rep": {"openState": "Close"}},
{"href": "/door/cvroom/0", "rep": {"openState": "Close"}},
{"href": "/configuration/vs/0", "rep": {"x.com.samsung.da.countryCode": "", "x.com.samsung.da.region": ""}},
{"href": "/defrost/delay/0", "rep": {"value": false}},
{"href": "/defrost/delay/vs/0", "rep": {"x.com.samsung.da.delayDefrost": "Off"}},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{"href": "/icemaker/status/0", "rep": {"status": "On"}},
{"href": "/icemaker/status/vs/0", "rep": {"x.com.samsung.da.iceMaker": "On"}},
{"href": "/refrigeration/0", "rep": {"defrost": false, "rapidFreeze": false, "rapidCool": false}},
{
"href": "/refrigeration/vs/0",
"rep": {"x.com.samsung.da.rapidFridge": "Off", "x.com.samsung.da.rapidFreezing": "Off"}
},
{"href": "/drlc/0", "rep": {"DRLevel": 0, "override": false}},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{"href": "/bespoke/vs/0", "rep": {"x.com.samsung.da.BespokeProduct": "On"}},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "",
"otnList": [
{"type": "WIFI", "modelId": "A-RFWW-TP1-22-REV1", "versions": ["20250420"], "visVersion": "250420"},
{"type": "Micom", "modelId": "024100148241FFFFFFFF", "versions": ["23071923", "FFFFFFFF"], "visVersion": "230719"}
]
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "31",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterResetType": ["replaceable"]
}
},
{"href": "/autofill/vs/0", "rep": {"x.com.samsung.da.autofill": "Off"}},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "CUBED_ICE",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
},
{
"href": "/icemaker/two/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_BITES",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "On"
}
},
{"href": "/energy/ailevel/vs/0", "rep": {"aiLevel": "1", "supportedAiLevel": ["1"]}},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": ["ble_ocf", null],
"supportedWiFiAuthType": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"],
"supportedWiFiCryptoType": ["TKIP", "AES", "WEP-64", "WEP-128"],
"supportedWiFiFreq": ["2.4G"],
"calmConnectionCare": {"version": "1.0", "role": ["things"]}
}
},
{"href": "/timezone/vs/0", "rep": {"timezoneid": "America/Chicago", "offset": "-05:00", "DST": "ON"}},
{"href": "/wirelessinfo/vs/0", "rep": {"macaddressWiFi": "REDACTED", "macaddressBLE": "REDACTED"}},
{"href": "/quickcontrol/info/vs/0", "rep": {"supportedVersion": "1.0"}}
]
}
+351
View File
@@ -0,0 +1,351 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Freezer",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "0",
"x.com.samsung.da.maximum": "5",
"x.com.samsung.da.minimum": "-8",
"x.com.samsung.da.unit": "Fahrenheit"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Fridge",
"x.com.samsung.da.desired": "37",
"x.com.samsung.da.current": "37",
"x.com.samsung.da.maximum": "44",
"x.com.samsung.da.minimum": "34",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/temperature/current/freezer/0",
"rep": {
"range": [
-8.0,
5.0
],
"units": "F",
"temperature": 0.0
}
},
{
"href": "/temperature/desired/freezer/0",
"rep": {
"range": [
-8.0,
5.0
],
"units": "F",
"temperature": 0.0
}
},
{
"href": "/temperature/current/cooler/0",
"rep": {
"range": [
34.0,
44.0
],
"units": "F",
"temperature": 37.0
}
},
{
"href": "/temperature/desired/cooler/0",
"rep": {
"range": [
34.0,
44.0
],
"units": "F",
"temperature": 37.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "288048",
"x.com.samsung.da.cumulativePower": "1797027",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPower": "115",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.monthlyConsumption": "55400",
"x.com.samsung.da.thismonthlyConsumption": "39737"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"x.com.samsung.da.modes": [
"CVN_CONVERTIBLE_ZONE",
"CV_FDR_BEVERAGE",
"WATERFILTER_DISABLE"
],
"x.com.samsung.da.supportedOptions": [
"CV_FDR_WINE",
"CV_FDR_DELI",
"CV_FDR_BEVERAGE",
"CV_FDR_MEAT"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2",
"SUPPORT_SABBATH_CONTROL"
],
"modes": [
"CVN_CONVERTIBLE_ZONE",
"CV_FDR_BEVERAGE",
"WATERFILTER_DISABLE"
]
}
},
{
"href": "/defrost/block/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"DEFROST_BLOCK_ON",
"DEFROST_BLOCK_OFF"
],
"x.com.samsung.da.modes": [
"DEFROST_BLOCK_OFF"
]
}
},
{
"href": "/sabbath/vs/0",
"rep": {
"x.com.samsung.da.sabbathMode": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_REF_20K|00113141|0002033F051311200103000010000000",
"x.com.samsung.da.description": "TP2X_REF_20K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02144A220110",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2011030E,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close"
}
]
}
},
{
"href": "/door/freezer/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/door/cvroom/0",
"rep": {
"openState": "Close"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.countryCode": "US",
"x.com.samsung.da.region": ""
}
},
{
"href": "/defrost/delay/0",
"rep": {
"value": false
}
},
{
"href": "/defrost/delay/vs/0",
"rep": {
"x.com.samsung.da.delayDefrost": "Off"
}
},
{
"href": "/defrost/reservation/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Summer Season",
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Winter Season",
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
"x.com.samsung.da.period": "04:00:00",
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
}
]
}
},
{
"href": "/icemaker/status/0",
"rep": {
"status": "On"
}
},
{
"href": "/icemaker/status/vs/0",
"rep": {
"x.com.samsung.da.iceMaker": "On"
}
},
{
"href": "/refrigeration/0",
"rep": {
"defrost": false,
"rapidFreeze": false,
"rapidCool": false
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/icemaker/one/vs/0",
"rep": {
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
"x.com.samsung.da.iceMaker.state": "On",
"x.com.samsung.da.iceMaker.type": "toggle",
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
"x.com.samsung.da.iceType.desired": "NORMAL"
}
}
]
}
+15
View File
@@ -44,6 +44,21 @@
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/ailevel/vs/0",
"rep": {
"aiLevel": "0",
"supportedAiLevel": [
"1"
],
"energySavingInfo": [
{
"aiLevel": "1",
"maxEnergySavingRate": "70"
}
]
}
},
{
"href": "/course/vs/0",
"rep": {
+325
View File
@@ -0,0 +1,325 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "1210200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784671200",
"x.com.samsung.da.cumulativeDateUTC": "1784667600"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0167",
"UpdateAllow_NotAllowed",
"Course_1C",
"AiOption_On",
"LaundryOutTime_0",
"AddWashSet_0",
"AddWashAvailable_7",
"AddWashIndicator_On",
"SeamlessControl_Enable",
"KidsLockBypass_On",
"WashingTimes_25",
"DrumCleanProposal_40",
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
"DetergentOnce_1",
"DetergentLeft_0",
"DetergentBase_5",
"DetergentAlarm_Off",
"DetergentType_2",
"DetergentTotal_49",
"SoftenerOnce_0",
"SoftenerLeft_0",
"SoftenerBase_0",
"SoftenerAlarm_Off",
"SoftenerType_0",
"SoftenerTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_159",
"BubbleSoak_Off",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_420BF48204D4A204F5",
"SendToDevice_Off",
"GMT_02",
"LaundryPlannerMicomSetTime_1704",
"PreWashSetting_Off",
"IntensiveSetting_Off",
"BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000",
"EnergyLevelSet_05030401040104020404020503020202030301010505050405",
"MostUsed_1C8410923FA67FB01F0000000000",
"PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
"IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
"GeoFenceAlarm",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"41C8410923FA67FB01F1B847E923FA67FB01F1E811E933FA53FB01F1D841E923FA67FB01F1F8102923FA57FB01F25843E923FA57FB01F26841E920FA207B01F33857E933FA67FB00024841E930FA30FB00032833E923FA37FB00020857E943FA67FB01F22841E920FA30FB00023831E930FA57FB01F2F831E933FA30FB00021831E943FA57FB0002D841E923FA30FB00030843E923FA67FB000278000913FA67FB01F2880009000A67EB01F368410923FA640B11E3880009000A000B11E3980009000A000B11E3780009000A000B0002985209204A520B000"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "02:39:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Weightsensing",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "02:39:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WW6600R/DC92-02617A_5049",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "210",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02674A250414(F546)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20260041200911390FFFFF202617412101203104FFFF(01672026004120261741_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20091139,21012031",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2025984120072205042FFFFFFFFFFFFFFFFFFFFFFFFE(016720259841FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20072205,FFFFFFFF"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "40",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"20",
"30",
"40",
"60",
"90"
],
"x.com.samsung.da.spinLevel": "1400",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"400",
"800",
"1000",
"1200",
"1400"
],
"x.com.samsung.da.dryLevel": "None",
"x.com.samsung.da.supportedDryLevel": [
"None",
"Cupboard",
"30",
"60",
"90",
"120",
"180",
"240"
],
"x.com.samsung.da.rinseCycles": "2",
"x.com.samsung.da.supportedRinseCycles": [
"0",
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_02_Course_1C",
"x.com.samsung.da.st.courseTable": "Table_02"
}
},
{
"href": "/setting/vs/0",
"rep": {
"x.com.samsung.da.supportedSetLanguage": [
"ko_KR",
"en_US"
],
"x.com.samsung.da.setLanguage": "en_GB"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_1C3026241E293633281D27211B202F2223323825392D1F37",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1C353A29"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "true",
"x.com.samsung.da.aiCourse": "true_formobile",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {
"x.com.samsung.da.currentStatus": "None"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "GB"
}
}
]
}
+253
View File
@@ -0,0 +1,253 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "199200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784631600",
"x.com.samsung.da.cumulativeDateUTC": "1784646000"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_0146",
"UpdateAllow_NotAllowed",
"Course_A5",
"LaundryOutTime_0",
"AddGarmentIndicator_Off",
"SteamWash_Enable",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"WashingTimes_14",
"DrumCleanProposal_40",
"DetergentOnce_0",
"DetergentLeft_0",
"DetergentBase_0",
"DetergentAlarm_Off",
"DetergentType_0",
"DetergentTotal_0",
"SpecialFunction_4",
"AvailableDelayTime_124",
"LaundryPlannerUserSetTime_0",
"ProgressTimeSet_4210E08209F6A2021C",
"EnergyLevelSet_0502040502030302030105",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2023-08-11T19:17:11|2023-11-02T03:23:13|2023-12-22T22:35:54|2024-04-08T01:27:32|2024-04-22T01:07:26|2024-10-02T19:16:45|2025-02-03T02:01:33|2025-06-16T17:36:13|2026-01-04T18:34:46|2026-05-26T02:33:16",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"301833EA41BC20E9C843EA41FC30EA98520A41FC20EA2810EA207C20EAA831EA41FC20EA5830EA20FC20E9F831EA41FC10E9E830EA21FC20E9D8000A41FC000B18520A410C000"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "02:04:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "**REDACTED**",
"machineStates": "**REDACTED**",
"jobStates": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "02:04:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02198A230708(E257)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18030902,18090603",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-04:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "Warm",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"Cool",
"Warm",
"Hot",
"ExtraHot"
],
"x.com.samsung.da.spinLevel": "Low",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"Low",
"Medium",
"High"
],
"x.com.samsung.da.soilLevel": "Heavy",
"x.com.samsung.da.supportedSoilLevel": [
"None",
"Light",
"Normal",
"Heavy"
],
"x.com.samsung.da.rinseCycles": "3"
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_00_Course_A5",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": true
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "US"
}
}
]
}
+16
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import patch
import cbor2
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
@@ -150,12 +152,26 @@ class FakeObserveSession:
self.subscribed: list[str] = []
self.fail_hrefs: set[str] = set()
self.closed = False
# When set to a rep dict, subscribe() immediately delivers that rep
# as an OBSERVE notification for the href — what a real device does
# when it answers a subscription with the current representation.
# `try_enter_observe_mode` clears its notified set before it
# subscribes, so this is the only way to deliver a notify that
# reliably counts: it lands synchronously via on_notification,
# after that clear and before the post-sleep fraction check — a
# notify raced in from another thread can be wiped by the clear
# (or arrive after the check) depending on scheduling. Set to
# None to model a device that answers subscriptions but never
# notifies.
self.notify_on_subscribe: dict[str, Any] | None = None
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
if self.notify_on_subscribe is not None and self.on_notification is not None:
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
return b'\x01'
def refresh_observes(self, paths):
+25 -87
View File
@@ -1,8 +1,6 @@
"""Tests for the LocalThingsCoordinator."""
from __future__ import annotations
import threading
import time
from datetime import timedelta
from unittest.mock import AsyncMock, patch
@@ -285,21 +283,18 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
# try_enter_observe_mode clears prior notifications as soon as it
# starts, so notifies must land *during* its grace-period sleep (same
# pattern test_observe.py uses), not before the call.
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
# try_enter_observe_mode clears prior notifications before it
# subscribes, then checks the notified fraction after the grace-period
# sleep — a notify counts as long as it arrives in that window, not
# specifically during the sleep itself. The fake session delivers one
# synchronously from subscribe() for every href it subscribes to (see
# FakeObserveSession.notify_on_subscribe in conftest.py), which lands
# it in that window by construction rather than by timing.
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator._observe.mode == MODE_OBSERVE
@@ -325,18 +320,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
# Get the coordinator into observe mode the same way
# test_enters_observe_mode_when_hot_warm_hrefs_notify does.
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -349,12 +337,12 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
# Simulate the existing "poll failed, reconnecting" branch: _poll_once
# fails once (triggering the reconnect/backoff path), then succeeds.
# No fresh notifies are supplied for the immediate resubscribe attempt
# this now triggers, so its grace period (shortened for tests by the
# `_fast_coordinator_timers` autouse fixture — see conftest.py) times
# out and mode stays 'poll' — this test only asserts the tear-down half of
# the fix; test_reconnect_from_observe_mode_resubscribes_immediately
# covers the successful-immediate-resubscribe half.
# The device stops notifying on subscribe, so the immediate resubscribe
# attempt this now triggers gets nothing back and mode stays 'poll' —
# this test only asserts the tear-down half of the fix;
# test_reconnect_from_observe_mode_resubscribes_immediately covers the
# successful-immediate-resubscribe half.
fake.notify_on_subscribe = None
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
@@ -387,18 +375,11 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -433,18 +414,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -466,7 +440,10 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
assert coordinator.observe_mode == MODE_OBSERVE
# The final consecutive timeout crosses the limit and triggers the
# existing reconnect path, which then succeeds and downgrades.
# existing reconnect path, which then succeeds and downgrades. The
# device stops notifying on subscribe, so the resubscribe attempt that
# follows the reconnect finds nothing and mode stays 'poll'.
fake.notify_on_subscribe = None
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
@@ -498,18 +475,11 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -551,21 +521,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -588,10 +548,8 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
new=AsyncMock(),
),
):
notifier = _notify()
await coordinator.async_request_refresh()
await hass.async_block_till_done()
notifier.join()
assert coordinator.observe_mode == MODE_OBSERVE
@@ -612,21 +570,11 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
@@ -662,21 +610,11 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
assert len(hrefs) >= 2
def _notify(delay: float = 0.005) -> threading.Thread:
def _run():
time.sleep(delay)
for href in hrefs:
fake.on_notification(href, cbor2.dumps({'notified': True}))
t = threading.Thread(target=_run, daemon=True)
t.start()
return t
notifier = _notify()
fake.notify_on_subscribe = {'notified': True}
entered = await hass.async_add_executor_job(
coordinator._observe.try_enter_observe_mode,
fake, hrefs, 0.02, 0.8,
)
notifier.join()
assert entered is True
assert coordinator.observe_mode == MODE_OBSERVE
+26
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings import diagnostics as diagnostics_mod
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
@@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields(
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
async def test_dependency_version_read_off_the_event_loop(
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
) -> None:
"""importlib.metadata.version() does blocking disk I/O, so it must run in
the executor, not on the event loop (issue #9's logs flagged it)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
seen: dict[str, int] = {}
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['smartthings_local_version']
assert seen['thread_id'] != loop_thread_id
+22
View File
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
def test_apply_merges_partial_update_onto_prior_rep():
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
(and even a later sweep entry for that href) can carry only `modes`,
omitting `supportedOptions` entirely. A full replace would wipe
`supportedOptions` from the cache the moment that partial update
arrives, even though the device's supported options didn't change --
which is exactly what made the flex-zone select disappear."""
mgr = _manager()
full = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
}
mgr.apply('/mode/vs/0', full, source='poll')
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
mgr.apply('/mode/vs/0', partial, source='observe')
cached = mgr.cache.get('/mode/vs/0')
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
+128 -1
View File
@@ -6,7 +6,7 @@ climate entity itself lives in climate.py (imports homeassistant) and is not
importable here -- consistent with how the other HA platform files are untested.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
@@ -23,6 +23,22 @@ def _ac():
return reg, resources
def _resolve(name):
"""Mirror the coordinator's detection order: oneUiVersion first, modelNum
fallback second (needed for issue #37's board, which reports neither
oneUiVersion nor a '_PRAC_' modelNum token)."""
resources = _load_device(name)
otn = resources.get('/otninformation/vs/0', {})
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
info = resources['/information/vs/0']
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _bound():
reg, resources = _ac()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
@@ -106,3 +122,114 @@ def test_climate_consumed_hrefs_declared_as_coverage():
caps = reg.capabilities.get(href)
assert caps, href
assert all(c.entities == () for c in caps), href
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a
# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard
# /temperature/current+desired pair (temperature lives on the vendor
# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and
# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36).
# ---------------------------------------------------------------------------
def _ac_tp1x():
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion']
return for_device(one_ui), resources
def test_tp1x_resolves_to_airconditioner_registry():
reg, _ = _ac_tp1x()
assert reg is not None and reg.name == 'airconditioner'
def test_tp1x_no_unbound_hrefs():
"""Every resource in the TP1X dump binds or is covered -- including
/temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from
the ARTIK051 dump. Clears the coverage-gap repair."""
reg, resources = _ac_tp1x()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_display_light_switch_present():
"""/light/vs/0 (mode On/Off) surfaces as the display-light switch."""
reg, resources = _ac_tp1x()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state.get('display_light') is True # device reports mode == 'On'
def test_tp1x_vendor_temperature_and_light_covered():
"""The vendor temperature resource (read by the climate entity) and the
display-light resource both resolve in the registry -- no gap."""
reg, _ = _ac_tp1x()
assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0'
assert reg.capabilities.get('/light/vs/0'), '/light/vs/0'
def test_tp1x_climate_entity_is_bound():
"""The composite climate entity still binds the primary /mode/vs/0."""
reg, resources = _ac_tp1x()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
-- resolved via the '_RAC_' modelNum fallback added for this device."""
reg, _ = _resolve('airconditioner_tp2x_rac_20k')
assert reg is not None and reg.name == 'airconditioner'
def test_tp2x_rac_20k_no_unbound_hrefs():
reg, resources = _resolve('airconditioner_tp2x_rac_20k')
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_rac_model_resolves_via_one_ui_version():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion
'7.0 Air conditioner' -- resolved via for_device(), not the modelNum
fallback."""
reg, _ = _resolve('airconditioner_tp1x_rac')
assert reg is not None and reg.name == 'airconditioner'
def test_tp1x_rac_no_unbound_hrefs():
reg, resources = _resolve('airconditioner_tp1x_rac')
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_tp1x_rac_expected_state_keys_present():
reg, resources = _resolve('airconditioner_tp1x_rac')
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status',
'selfcheck_result', 'current_limit_enabled', 'current_limit_level'):
assert key in state, key
def test_mute_once_write_target():
write = airconditioner.MUTE_ONCE.entities[0].write_fn
assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'})
assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'})
def test_display_light_write_target():
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'})
assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'})
def test_current_limit_is_read_only():
"""Meaning/write contract for the current-limit levels isn't confirmed
from the dump alone -- exposed as read-only diagnostic sensors rather
than a guessed writable control."""
for desc in airconditioner.CURRENT_LIMIT.entities:
assert getattr(desc, 'write_fn', None) is None
+20
View File
@@ -209,6 +209,26 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'range_hood'
def test_washer_wf_prefix(self):
"""US front-load washers use the WF consumer-model prefix."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000',
'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE',
)
assert reg is not None
assert reg.name == 'washer'
def test_washer_wv_prefix(self):
"""FlexWash twin washers use the WV consumer-model prefix -- issue #19."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000',
'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014',
)
assert reg is not None
assert reg.name == 'washer'
def test_unknown_model_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
@@ -0,0 +1,38 @@
"""Tests for the /temperatures/vs/0 fallback helper in climate.py (PR #36).
`_temps_vs_item` is a pure module-level function (plain dict in, plain dict
out) with no coordinator/entity dependency, so -- like select.py's `_display`
in test_select_display.py -- it's testable directly even though the
LocalThingsClimate entity itself needs a coordinator/hass fixture to
exercise. This covers the actual fallback-selection logic that the
registry-level "no unbound hrefs" tests in test_airconditioner_capabilities.py
don't reach: TP1X-class firmware has no OCF-standard
/temperature/current+desired pair, so current_temperature/target_temperature
would silently be None without this fallback.
"""
from custom_components.localthings.climate import _temps_vs_item
def test_temps_vs_item_returns_first_item():
rep = {
'x.com.samsung.da.items': [
{'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'},
],
}
assert _temps_vs_item(rep) == {
'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0',
}
def test_temps_vs_item_empty_when_items_missing():
"""OCF-pair devices don't populate /temperatures/vs/0's items at all --
callers must fall through to the OCF hrefs cleanly, not raise."""
assert _temps_vs_item({}) == {}
def test_temps_vs_item_empty_when_items_is_empty_list():
assert _temps_vs_item({'x.com.samsung.da.items': []}) == {}
def test_temps_vs_item_empty_when_items_entry_is_not_a_dict():
assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {}
+199
View File
@@ -130,3 +130,202 @@ class TestEnergyMeter:
phantom power sensor (the exists_fn replaces the field-presence gate)."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False
# ---------------------------------------------------------------------------
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
# is really a binary toggle, so it's exposed as a switch instead of a
# one-choice select; multiple entries get a select with '0' synthesized back
# in as the explicit off option (supportedAiLevel never lists '0' itself, but
# it's a real, observed value of aiLevel).
# ---------------------------------------------------------------------------
def _ai_energy_level_desc(cls_name):
return next(e for e in common.AI_ENERGY_LEVEL.entities
if e.__class__.__name__ == cls_name)
class TestAiEnergyLevelSwitch:
def _desc(self):
return _ai_energy_level_desc('SwitchDesc')
def test_href(self):
assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
def test_shown_only_with_single_supported_level(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False
def test_hidden_when_supported_level_is_non_list_scalar(self):
"""A stray scalar (e.g. a string) must not be len()-checked as if it
were a list -- a 2-char string would otherwise wrongly pass `== 1`
style checks."""
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_missing(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_empty_list(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
def test_hidden_on_empty_stub_rep(self):
desc = self._desc()
assert desc.exists_fn({}, {}) is False
def test_value_fn(self):
desc = self._desc()
assert desc.value_fn('0') is False
assert desc.value_fn('1') is True
def test_write_on_uses_the_single_supported_level(self):
"""The on-value is whatever the device calls its one level, not a
hardcoded '1'."""
desc = self._desc()
path, body = desc.write_fn('On', {'supportedAiLevel': ['2']})
assert path == ['energy', 'ailevel', 'vs', '0']
assert body == {'aiLevel': '2'}
def test_write_off(self):
desc = self._desc()
path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']})
assert body == {'aiLevel': '0'}
class TestAiEnergyLevelSelect:
def _desc(self):
return _ai_energy_level_desc('SelectDesc')
def test_shown_only_with_multiple_supported_levels(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
def test_hidden_when_supported_level_is_non_list_scalar(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
def test_hidden_when_supported_level_missing(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
def test_hidden_when_supported_level_empty_list(self):
desc = self._desc()
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
def test_no_translation_key(self):
"""aiLevel's values are plain digits that render fine untranslated
(select.py's _display()) -- no strings.json entry to maintain
against an unknown number of future levels."""
desc = self._desc()
assert desc.translation_key is None
def test_options_synthesize_off(self):
"""'0' is never in supportedAiLevel but is a real, observed aiLevel
value -- synthesized back in as the explicit off option."""
desc = self._desc()
resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}}
assert desc.options(resources) == ['0', '1', '2']
def test_options_empty_when_resource_missing(self):
desc = self._desc()
assert desc.options({}) == ['0']
def test_write(self):
desc = self._desc()
path, body = desc.write_fn('2', {})
assert path == ['energy', 'ailevel', 'vs', '0']
assert body == {'aiLevel': '2'}
class TestAiEnergyLevelStubDoesNotDecideThePlatform:
"""Issue found in review: entity *creation* runs once, against whatever
snapshot is current when platforms are set up (see
__init__.py's async_config_entry_first_refresh-before-forward-entry-setups
ordering), while flatten() re-evaluates exists_fn every poll against live
data. Both descriptors share key='ai_energy_level' (see adapter._key), so
if a stub carve-out let one of them win at setup time while the other
wins once real data lands, flatten() would feed the already-instantiated
entity a value shaped for the other platform (e.g. a bool into a Select
expecting a string option). Neither side gets a `not rep` carve-out, so
an unfetched stub can never win entity creation for either platform --
the entity simply doesn't appear until a reload happens with real data,
same as any other exists_fn-gated entity in this codebase that's unlucky
on first-poll timing, instead of appearing as the wrong widget type."""
def test_neither_widget_exists_on_empty_stub_rep(self):
switch = _ai_energy_level_desc('SwitchDesc')
select = _ai_energy_level_desc('SelectDesc')
assert switch.exists_fn({}, {}) is False
assert select.exists_fn({}, {}) is False
class TestSelfCheckError:
"""Self-check diagnostic error list -- surfaced on hardware that reports
x.com.samsung.da.error, joined into a single display string."""
def _desc(self):
return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error')
def test_exists_when_field_present(self):
desc = self._desc()
assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True
def test_does_not_exist_when_field_absent(self):
desc = self._desc()
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
def test_exists_for_empty_stub_rep(self):
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
must be included-for-now, same as ENERGY_METER's fields."""
desc = self._desc()
assert desc.exists_fn({}, {}) is True
def test_value_joins_list(self):
desc = self._desc()
assert desc.value_fn(['E1', 'E2']) == 'E1, E2'
def test_value_passes_through_scalar(self):
desc = self._desc()
assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE'
def test_value_none_for_empty_list(self):
"""An empty error list means no value to show -- None (unknown),
not an empty string."""
desc = self._desc()
assert desc.value_fn([]) is None
# ---------------------------------------------------------------------------
# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type
# registry's _build([...]) call in place of the hand-duplicated capability
# lists that used to live there.
# ---------------------------------------------------------------------------
class TestUniversalAndPowerBundles:
def test_universal_contains_the_no_conflict_capabilities(self):
assert set(common.UNIVERSAL) == {
common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE,
common.SELF_CHECK, common.AI_ENERGY_LEVEL,
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
}
def test_power_kept_separate_for_airconditioners_sake(self):
"""See common.POWER's own comment for why airconditioner opts out."""
assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK}
def test_no_overlap_between_the_two_bundles(self):
assert not (set(common.UNIVERSAL) & set(common.POWER))
def test_airconditioner_registry_does_not_include_power(self):
from custom_components.localthings.registry.by_type import airconditioner
bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps}
assert common.POWER_GENERIC not in bound_caps
assert common.POWER_VS_FALLBACK not in bound_caps
+40
View File
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
assert seen == []
def test_discover_pattern_cap_reads_name_field():
"""A pattern cap's name_field normalizes a device-given instance name
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
independent of the href-derived key (issue #27)."""
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {
'/icemaker/one/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
},
'/icemaker/two/vs/0': {
'x.com.samsung.da.iceMaker.state': 'On',
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
},
}
bound = discover(resources, {}, pattern_caps=[cap])
names = {b.href: b.instance_name for b in bound}
assert names == {
'/icemaker/one/vs/0': 'Cubed Ice',
'/icemaker/two/vs/0': 'Ice Bites',
}
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
cap = Capability(
href=None,
href_prefix='/icemaker/',
name_field='x.com.samsung.da.iceMaker.name',
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
)
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
bound = discover(resources, {}, pattern_caps=[cap])
assert bound[0].instance_name is None
def test_discover_rt_filter_gates_binding():
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
oven_mode_cap = Capability(
+45
View File
@@ -0,0 +1,45 @@
"""Tests for LocalThingsEntity's display-name derivation
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import BinarySensorDesc
class _FakeCoordinator:
device_serial = 'TEST-SERIAL'
def __init__(self, last_resources=None):
self.last_resources = last_resources or {}
def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None):
capability = Capability(href=href, entities=(desc,))
bound = BoundEntity(href=href, capability=capability, desc=desc,
instance=instance, key_override=key_override,
instance_name=instance_name)
return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything():
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name'
def test_instance_name_prefixes_the_derived_suffix():
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
the href-derived instance label ("Icemaker One") as the name prefix,
keeping the same entity-specific suffix."""
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice')
assert entity._attr_name == 'Cubed Ice Enabled'
def test_no_instance_name_falls_back_to_derived_state_key():
desc = BinarySensorDesc(key='enabled')
entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled'
+156
View File
@@ -102,3 +102,159 @@ class TestRefrigerationFallback:
for key in ('rapid_fridge', 'rapid_freezing'):
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
class TestFlexZone:
"""Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone
entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class,
CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left
CV_FDR_-family fridges with an always-unknown select, and a write would
have appended a duplicate flag instead of replacing the existing one."""
def test_reads_rf9000_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'],
}
assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE'
def test_reads_cv_fdr_prefix(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT'
def test_write_replaces_rather_than_duplicates(self):
rep = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
'x.com.samsung.da.supportedOptions': [
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
}
path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep)
assert path == ['mode', 'vs', '0']
modes = payload['x.com.samsung.da.modes']
assert modes.count('CV_FDR_WINE') == 1
assert 'CV_FDR_MEAT' not in modes
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
class TestTp1xNativeDuplicateResources:
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
richer vendor resources. They must count as covered without producing
duplicate entities or guessing unverified write contracts."""
def test_duplicate_capabilities_have_no_entities(self):
assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == ()
assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == ()
def test_us_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['defrost_delay'] is False
assert 'ice_maker_enabled' not in state
assert state['icemaker_one_enabled'] is True
assert state['icemaker_two_enabled'] is True
assert state['selfcheck_error'] == 'DA_ERROR_NONE'
class TestPantryZone:
"""Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20."""
def test_href(self):
assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0'
def test_write(self):
desc = fridge.PANTRY_ZONE.entities[0]
path, body = desc.write_fn('FDR_WINE', {})
assert path == ['status', 'pantry', 'one', 'vs', '0']
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
the incomplete-capability-coverage repair; both must resolve to zero
unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for
fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native
defrost/icemaker mirrors are covered."""
def test_artik051_ref_17k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_ref_17k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['pantry_zone_mode'] == 'FDR_DRINKS'
def test_tp2x_ref_20k(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
test_common_capabilities.py) is exercised end-to-end here against the
refrigerator fixture/registry.
refrigerator_device.json's supportedAiLevel was extended to two entries
(['1', '2']) specifically to exercise the select branch -- the real
captured TP1X_REF_21K_US dump only ever reports one entry (switch
branch), so the select is synthetic-fixture-only for now."""
def test_synthetic_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] == '1'
+118
View File
@@ -97,6 +97,20 @@ def test_registry_reproduces_golden_state_keys_for_cooktop():
)
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp1x_ref_21k_us')
golden = json.loads(
(GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text()
)
state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_range_hood():
from tests.conftest import _load_device
resources = _load_device('range_hood')
@@ -109,6 +123,110 @@ def test_registry_reproduces_golden_state_keys_for_range_hood():
)
def test_registry_reproduces_golden_state_keys_for_washer_flexwash():
"""FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW)
report no oneUiVersion and previously fell through for_device_by_model's
consumer-prefix map entirely -- issue #19."""
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text())
state_keys = _new_state_keys('washer_flexwash', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, with no separate dryer resource -- issue #22."""
from tests.conftest import _load_device
resources = _load_device('washer_dryer_combo')
golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text())
state_keys = _new_state_keys('washer_dryer_combo', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
(/status/pantry/one/vs/0) -- issue #20."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_artik051_ref_17k')
golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text())
state_keys = _new_state_keys('refrigerator_artik051_ref_17k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
"""TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra
energy fields (cumulativeConsumption/monthlyConsumption/
thismonthlyConsumption) surfaced by issue #26."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_tp2x_ref_20k')
golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text())
state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011():
"""Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model
TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0
items[] resource and adds a /light/vs/0 display light, with extra vendor
housekeeping hrefs -- issue #17 for this model class (PR #36)."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k():
"""TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the
'_RAC_' modelNum token fallback in for_device_by_model."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp2x_rac_20k')
golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text())
state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display
light, self-check, mute-once, and a current-limit setting."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac')
golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text())
state_keys = _new_state_keys('airconditioner_tp1x_rac', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+245 -6
View File
@@ -40,6 +40,22 @@ class TestWasherSettings:
assert body == {'x.com.samsung.da.rinseCycles': '3'}
class TestDryLevel:
"""Washer/dryer combo units carry a writable dryLevel field on
/washer/vs/0 itself, self-gated off on plain washers -- issue #22."""
def test_exists_only_when_supported_dry_level_present(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True
assert desc.exists_fn({}, {}) is False
def test_write(self):
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
path, body = desc.write_fn('Cupboard', {})
assert path == ['washer', 'vs', '0']
assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'}
class TestWasherCourse:
def test_href(self):
assert washer.WASHER_COURSE.href == '/course/vs/0'
@@ -151,11 +167,32 @@ class TestDetergentSoftenerDosing:
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
assert self._desc('softener_quantity').rep_fn(rep) == '3'
assert self._desc('softener_concentration').rep_fn(rep) == '2'
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
@@ -181,17 +218,21 @@ class TestDetergentSoftenerDosing:
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
@@ -208,3 +249,201 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_low').exists_fn(rep, {}) is True
class TestWashOptionToggles:
"""Bubble soak / pre-wash / intensive-wash switches, from the same
options[] array as the cycle select (issue #22 follow-up). Confirmed
On/Off shape from a dump with Bubble Soak toggled on in the app."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
@staticmethod
def _keys():
return ('bubble_soak', 'pre_wash', 'intensive')
@staticmethod
def _prefix(key):
return {'bubble_soak': 'BubbleSoak',
'pre_wash': 'PreWashSetting',
'intensive': 'IntensiveSetting'}[key]
def test_exists_only_when_field_present(self):
for key in self._keys():
desc = self._desc(key)
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert desc.exists_fn(rep, {}) is True
def test_reads_off(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).rep_fn(rep) is False
def test_reads_on(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
assert self._desc(key).rep_fn(rep) is True
def test_write_on_and_off(self):
"""write_fn receives the same 'On'/'Off' string switch.py sends
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
implementation always wrote 'On', since any non-empty string
(including 'Off') is truthy."""
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
path, body = self._desc(key).write_fn('On', rep)
assert path == ['course', 'vs', '0']
assert f'{prefix}_On' in body['x.com.samsung.da.options']
assert 'GMT_02' in body['x.com.samsung.da.options']
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
path, body = self._desc(key).write_fn('Off', rep)
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
def test_write_rejects_non_on_off_payload(self):
for key in self._keys():
prefix = self._prefix(key)
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
assert self._desc(key).write_fn('bogus', rep) is None
# editCourseList and availability bitmaps from the reporter's issue #22
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
_EDIT_COURSE_RESOURCES = {
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList':
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
},
}
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
class TestWashOptionToggleValidation:
"""validate_fn rejects turning a toggle on for a course whose byte in
its availability bitmap isn't 'F0', with a user-facing message switch.py
raises as ServiceValidationError -- distinct from write_fn's silent
no-op for a malformed payload."""
@staticmethod
def _desc(key):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_allowed_on_a_supported_course(self):
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
assert msg == "Bubble soak isn't available on the selected cycle."
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
assert self._desc('pre_wash').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
assert self._desc('intensive').validate_fn(
'On', rep, _EDIT_COURSE_RESOURCES) is None
def test_turning_off_is_never_blocked(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert self._desc('bubble_soak').validate_fn(
'Off', rep, _EDIT_COURSE_RESOURCES) is None
def test_allows_write_when_course_unresolvable(self):
"""No editCourseList, no Course_ token, or a bitmap whose length
doesn't match editCourseList -- in every case, fail open rather than
block a write we can't actually verify."""
desc = self._desc('bubble_soak')
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, {}) is None
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
class TestAiEnergyLevel:
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
The capability itself (common.AI_ENERGY_LEVEL) is tested in
test_common_capabilities.py; this just confirms it's wired into the
washer registry and that the fixture's single-entry supportedAiLevel
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
def test_fixture_has_complete_coverage(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['ai_energy_level'] is False # aiLevel '0' -- off
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
"""FlexWash (issue #19, previously unrecognized entirely) and
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
zero unbound hrefs."""
def test_flexwash(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_flexwash')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert 'dry_level' not in state # plain washer -- no supportedDryLevel field
def test_dryer_combo(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import washer as washer_registry
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('washer_dryer_combo')
unbound = []
bound = discover(
resources,
washer_registry.REGISTRY.capabilities,
washer_registry.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
# the device's own "off" sentinel is the literal string 'None', not absence
assert state['dry_level'] == 'None'