diff --git a/README.md b/README.md
index e9881b3..3ac35d0 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
# LocalThings
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
@@ -25,7 +38,8 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Air purifier | `by_type/air_purifier.py` |
| Dehumidifier | `by_type/dehumidifier.py` |
| Dryer | `by_type/dryer.py` |
-| Oven (including combi microwaves) | `by_type/oven.py` |
+| Oven | `by_type/oven.py` |
+| Microwave | `by_type/microwave.py` |
| Gas cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
| Range | `by_type/range.py` |
diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py
index 43e9aff..5ba2137 100644
--- a/custom_components/localthings/climate.py
+++ b/custom_components/localthings/climate.py
@@ -50,6 +50,7 @@ from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
+ HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
@@ -228,8 +229,45 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# -- resource helpers ---------------------------------------------------
+ # Preset codes on legacy ARTIK051 boards, learned by driving the same unit
+ # through its cloud integration and reading the local token back each time:
+ # Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
+ _LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
+
+ def _legacy_convenient(self) -> dict:
+ """A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
+ /mode/vs/0's options, for boards that have no convenient resource."""
+ options = (self.coordinator.resource(MODE_HREF) or {}).get(
+ 'x.com.samsung.da.options') or []
+ for option in options:
+ if isinstance(option, str) and option.startswith('Comode_'):
+ return {_MODES_FIELD: [option.split('_', 1)[1]],
+ _SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
+ return {}
+
+ def _legacy_airflow(self) -> dict:
+ """The /airflow/vs/0 rep, but only when it is the fan/swing channel to
+ use -- i.e. this board has no /wind/strength/vs/0."""
+ if self.coordinator.resource(WIND_STRENGTH_HREF):
+ return {}
+ return self.coordinator.resource(AIRFLOW_HREF) or {}
+
+ def _legacy_preset(self) -> bool:
+ """Whether presets come from the Comode_* token rather than a resource.
+
+ Gated on the same board test as _legacy_airflow, not on the convenient
+ rep being empty alone: newer boards carry Comode tokens too, so a
+ momentarily empty /mode/convenient/vs/0 there must not silently switch
+ the preset read (and write) over to the token path.
+ """
+ return (not self.coordinator.resource(CONVENIENT_HREF)
+ and bool(self._legacy_airflow()))
+
def _rep(self, href: str) -> dict:
- return self.coordinator.resource(href) or {}
+ rep = self.coordinator.resource(href) or {}
+ if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
+ return self._legacy_convenient()
+ return rep
def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 (present on every observed board and
@@ -373,10 +411,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def fan_mode(self):
+ airflow = self._legacy_airflow()
+ if airflow:
+ return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def fan_modes(self) -> list[str]:
+ if self._legacy_airflow():
+ # This resource carries no supportedModes, so the full scale is offered.
+ return list(_DEVICE_TO_FAN.values())
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
def _swing_via_direction(self) -> bool:
@@ -387,12 +431,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def swing_mode(self):
+ airflow = self._legacy_airflow()
+ if airflow:
+ return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@property
def swing_modes(self) -> list[str]:
+ if self._legacy_airflow():
+ return list(_SWING_TO_DEVICE.keys())
if self._swing_via_direction():
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._rep(WIND_OSCILLATION_HREF):
@@ -468,9 +517,21 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
+ if self._legacy_airflow():
+ level = _FAN_TO_DEVICE.get(fan_mode)
+ if level is not None:
+ await self.coordinator.async_send_command(
+ self._bound, ('fan_legacy', level))
+ return
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
+ if self._legacy_airflow():
+ code = _SWING_TO_DEVICE.get(swing_mode)
+ if code is not None:
+ await self.coordinator.async_send_command(
+ self._bound, ('swing_legacy', code))
+ return
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
return
@@ -489,5 +550,6 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
- await self.coordinator.async_send_command(self._bound, ('preset', code))
+ kind = 'preset_legacy' if self._legacy_preset() else 'preset'
+ await self.coordinator.async_send_command(self._bound, (kind, code))
return
diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py
index 63bffbd..10c04df 100644
--- a/custom_components/localthings/const.py
+++ b/custom_components/localthings/const.py
@@ -42,6 +42,13 @@ LIVENESS_PROBE_TIMEOUT_S = 1.5
# without stalling setup; it matches the per-resource read timeout elsewhere.
PROBE_GET_TIMEOUT_S = 10.0
+# Base for the local (client-side) DTLS source port, distinct from the
+# destination probe ports above. See coordinator._local_source_port for why a
+# fixed per-device source port matters and how the per-device offset is
+# derived. Base mirrors the upstream smartthings-local reference bridge.
+# Requires smartthings-local >= 0.1.1.
+DTLS_LOCAL_PORT_BASE = 49700
+
SUMMARY_INTERVAL_S = 30.0
DEVICE_SUPPORT_ISSUE_URL = (
diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py
index 27e6c7e..d7591f1 100644
--- a/custom_components/localthings/coordinator.py
+++ b/custom_components/localthings/coordinator.py
@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
+import ipaddress
import logging
import threading
import time
+import zlib
from datetime import timedelta
from typing import Any
@@ -37,6 +39,7 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
+ DTLS_LOCAL_PORT_BASE,
)
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +57,29 @@ class _NoOpDescriptor:
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
+def _local_source_port(host: str) -> int:
+ """Deterministic UDP source port for this device's DTLS socket.
+
+ Binding the same source port on every (re)connect keeps the client on one
+ 5-tuple, so the appliance evicts an orphaned session left by a previous run
+ (unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
+ §4.2.8, instead of holding it for 5-15 min while the new session's reads
+ hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
+ >= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
+
+ The port must be stable across restarts and unique per device on this HA
+ host: the library's socket is unconnected (recvfrom), so two devices
+ sharing a source port would mis-demux each other's datagrams. For the usual
+ dotted-IPv4 host we use the last octet as the offset (unique on a /24);
+ anything else folds a stable CRC32 into the same 256-wide window.
+ """
+ try:
+ offset = int(ipaddress.IPv4Address(host)) & 0xFF
+ except (ipaddress.AddressValueError, ValueError):
+ offset = zlib.crc32(host.encode()) & 0xFF
+ return DTLS_LOCAL_PORT_BASE + offset
+
+
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
@@ -182,7 +208,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
- on_notification=self._observe.on_notification)
+ on_notification=self._observe.on_notification,
+ local_port=_local_source_port(host))
sess.connect()
sess.start_reader()
self._session = sess
diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py
index e8c0b9b..cbdbca1 100644
--- a/custom_components/localthings/entity.py
+++ b/custom_components/localthings/entity.py
@@ -8,6 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
+from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
@@ -21,9 +22,17 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
require that field to be present in the resource rep so that optional
fields on shared resources don't create phantom entities.
- An empty rep ({}) means /device/0 returned a stub for this resource —
- the resource exists on the device but data hasn't been fetched yet.
- In that case we include the entity so it can be populated by sub-polls.
+ A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
+ yet" marker) is included anyway so it can be populated by sub-polls. A
+ genuinely empty {} rep is included too by this default gate -- whether
+ empty means "not populated yet" or "permanently unsupported" needs
+ per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
+ {} is fridge.py's documented *normal* no-alarm state (see
+ _active_alarm_codes), not an absence signal, and it's far from the only
+ resource like that. Only a capability whose author has actually verified
+ a field is genuinely never populated on unsupported hardware opts into
+ stricter gating with its own is_stub_rep-based exists_fn (see
+ common.ENERGY_METER, issue #127) -- this default stays permissive.
"""
rep = coordinator.last_resources.get(bound.href)
if rep is None:
@@ -31,7 +40,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.last_resources)
if bound.desc.field:
- if not rep: # stub — resource known to exist, data not yet fetched
+ if not rep or is_stub_rep(rep):
return True
return bound.desc.field in rep
return True # rep_fn or no-field entities (ButtonDesc) are always included
diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py
index 15ac111..9c9d623 100644
--- a/custom_components/localthings/fan.py
+++ b/custom_components/localthings/fan.py
@@ -1,10 +1,15 @@
"""Fan platform for Samsung range hoods and air purifiers.
-Two FanDesc-bound hrefs exist, one per family, dispatched by href in
-async_setup_entry below since they need different HA fan semantics: the
-range hood's fan speed is an ordered set of numeric levels (SET_SPEED), while
-the newer air-purifier board family's modes (Smart/Max/Mid/WindFree/Sleep,
-issue #130) are named behaviors with no linear order (PRESET_MODE)."""
+Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
+below since each needs different HA fan semantics: the range hood's fan
+speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
+Medium/High (issue #56) are both an ordered set of numeric levels
+(SET_SPEED) -- the latter confirmed monotonic in capabilities/
+air_purifier.py's module docstring, with no named-mode list to preserve
+since this board never self-reports one. The newer TP1X air-purifier
+family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named
+behaviors with no linear order (PRESET_MODE), reported directly by that
+board's own supportedModes."""
from __future__ import annotations
@@ -22,6 +27,7 @@ from homeassistant.util.percentage import (
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
+from .registry.capabilities.air_purifier import HREF_AIRFLOW
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
from .registry.entities import FanDesc
@@ -31,6 +37,8 @@ POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
+_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
+_OFF_SPEED_CODE = '0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
@@ -48,13 +56,32 @@ async def async_setup_entry(
continue
if bound.href == AIR_PURIFIER_FAN_HREF:
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
+ elif bound.href == HREF_AIRFLOW:
+ entities.append(LocalThingsAirflowFan(coordinator, bound))
else:
entities.append(LocalThingsRangeHoodFan(coordinator, bound))
async_add_entities(entities)
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
- """A hood fan combining sibling power and fan-speed resources."""
+ """A hood fan combining sibling power and fan-speed resources.
+
+ Some boards that reuse this capability (built-in microwave vent fans,
+ issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
+ resource at all -- fan speed 0 is itself the off state there, with no
+ separate power toggle to write. `_speed_zero_is_off` detects that
+ shape from the hood resource's own settableMinFanSpeed/
+ supportedFanSpeed fields and switches every method below to drive
+ off/on purely through the fanSpeed field, including '0' in the
+ ordered speed codes as the off step instead of assuming every
+ advertised code is an active speed.
+
+ This is deliberately not the same question as `_has_separate_power`,
+ which only proves *some* power resource exists on the device -- on a
+ combi appliance (e.g. an over-the-range microwave) that resource can
+ belong to the cavity, not the vent fan, and toggling it from here
+ would turn off the whole appliance instead of just the fan.
+ """
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
@@ -70,15 +97,35 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
+ def _has_separate_power(self) -> bool:
+ return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
+
+ def _speed_zero_is_off(self) -> bool:
+ """Whether fan speed '0' is itself this hood's off step, with no
+ separate power resource to toggle. The board says so directly:
+ settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
+ standalone hood's codes start at 14 and it carries a real /power
+ resource instead, so this is False there."""
+ rep = self._rep(self._bound.href)
+ return (
+ str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
+ or _OFF_SPEED_CODE in self._all_speed_codes()
+ )
+
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
def _active_speed_codes(self) -> list[str]:
- # Power is carried by the separate /power resource. fanSpeed retains
- # the selected setting while power is off (as the lamp's `current`
- # field does), so every advertised code is an active ordered speed.
- return self._all_speed_codes()
+ codes = self._all_speed_codes()
+ if self._speed_zero_is_off():
+ # No separate power resource: '0' is the off step, not a speed.
+ return [code for code in codes if code != _OFF_SPEED_CODE]
+ # Power is carried by the separate /power resource. fanSpeed
+ # retains the selected setting while power is off (as the
+ # lamp's `current` field does), so every advertised code is an
+ # active ordered speed.
+ return codes
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
@@ -88,6 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
@property
def is_on(self) -> bool:
+ if self._speed_zero_is_off():
+ current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
+ return current not in ('', _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
@@ -113,6 +163,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
+ if self._speed_zero_is_off():
+ if percentage is not None:
+ await self.async_set_percentage(percentage)
+ return
+ if self.is_on:
+ # Already running: no percentage given means "just turn on",
+ # not "reset to the lowest speed".
+ return
+ codes = self._active_speed_codes()
+ if codes:
+ await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
+ return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
@@ -120,6 +182,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
+ if self._speed_zero_is_off():
+ await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
+ return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
)
@@ -131,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
codes = self._active_speed_codes()
if not codes:
return
- if not self.is_on:
+ if not self._speed_zero_is_off() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
@@ -214,3 +279,87 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
"%s: %r is not a valid preset mode (supported: %s)",
self.entity_id, preset_mode, self.preset_modes,
)
+
+
+_AIRFLOW_SPEED_FIELD = 'speed'
+# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
+# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
+# as plain strings, same as _all_speed_codes above, so
+# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
+# it exactly like the range hood's numeric levels -- no named-preset table
+# needed since this board never reports mode names to hang one off of.
+_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
+
+
+class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
+ """ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric
+ speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above."""
+
+ _enable_turn_on_off_backwards_compatibility = False
+ _attr_supported_features = (
+ FanEntityFeature.SET_SPEED
+ | FanEntityFeature.TURN_ON
+ | FanEntityFeature.TURN_OFF
+ )
+
+ def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
+ super().__init__(coordinator, bound)
+ self._attr_name = None
+
+ def _rep(self, href: str) -> dict:
+ return self.coordinator.resource(href) or {}
+
+ def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
+ """Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
+ LocalThingsAirPurifierFan's vs/0-first order -- that order is only
+ harmless for the TP1X board because it never reports /power/0 at
+ all. This family's dumps carry both hrefs, and common.POWER_GENERIC
+ (the power_switch entity) is unconditionally bound to /power/0
+ whenever it's present, so writing here to /power/vs/0 first would
+ leave power_switch and this fan reading/writing two different
+ resources -- disagreeing until the next poll refreshes the other
+ one (the same optimistic-apply lag coordinator.py's own comments
+ warn about)."""
+ resources = self.coordinator.last_resources
+ target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
+ return 'power', enabled, target
+
+ @property
+ def is_on(self) -> bool:
+ power = self._rep(POWER_HREF)
+ if 'value' in power:
+ return bool(power.get('value'))
+ return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
+
+ @property
+ def speed_count(self) -> int:
+ return len(_AIRFLOW_SPEED_CODES)
+
+ @property
+ def percentage(self) -> int | None:
+ if not self.is_on:
+ return 0
+ current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
+ if current not in _AIRFLOW_SPEED_CODES:
+ return None
+ return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
+
+ async def async_turn_on(
+ self, percentage: int | None = None, preset_mode: str | None = None,
+ **kwargs,
+ ) -> None:
+ await self.coordinator.async_send_command(self._bound, self._power_payload(True))
+ if percentage is not None:
+ await self.async_set_percentage(percentage)
+
+ async def async_turn_off(self, **kwargs) -> None:
+ await self.coordinator.async_send_command(self._bound, self._power_payload(False))
+
+ async def async_set_percentage(self, percentage: int) -> None:
+ if percentage <= 0:
+ await self.async_turn_off()
+ return
+ if not self.is_on:
+ await self.coordinator.async_send_command(self._bound, self._power_payload(True))
+ code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
+ await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
diff --git a/custom_components/localthings/manifest.json b/custom_components/localthings/manifest.json
index 470bd6c..fde1d36 100644
--- a/custom_components/localthings/manifest.json
+++ b/custom_components/localthings/manifest.json
@@ -10,7 +10,7 @@
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
- "smartthings-local>=0.1.0"
+ "smartthings-local>=0.1.1"
],
- "version": "0.13.0"
+ "version": "0.14.0"
}
diff --git a/custom_components/localthings/registry/batch.py b/custom_components/localthings/registry/batch.py
index 6a90c2e..c7fb3ad 100644
--- a/custom_components/localthings/registry/batch.py
+++ b/custom_components/localthings/registry/batch.py
@@ -2,8 +2,25 @@
from __future__ import annotations
+def is_stub_rep(rep: dict) -> bool:
+ """True for the device's "resource exists, no data fetched yet" marker --
+ an echoed {"href": "..."} with no other fields.
+
+ Distinct from a genuinely empty {} rep, which is the device's confirmed
+ (if empty) answer -- e.g. an unsupported resource on this model that will
+ never populate. Conflating the two used to make every field-gated entity
+ on a permanently-empty resource look like a not-yet-fetched stub forever,
+ creating phantom always-"unknown" entities (issue #127)."""
+ return isinstance(rep, dict) and set(rep.keys()) == {'href'}
+
+
def parse_device0_batch(device0: list) -> dict[str, dict]:
- """Extract {href: rep} from a /device/0 CBOR list response."""
+ """Extract {href: rep} from a /device/0 CBOR list response.
+
+ A stub rep is passed through unchanged rather than collapsed to {} --
+ downstream code (entity._is_included, capability exists_fns) uses
+ is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
+ """
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
@@ -12,8 +29,6 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
rep = entry.get('rep')
if not href:
continue
- # rep == {"href": "..."} is a stub (resource present, no current data).
- # Include it as {} so capabilities still bind and the entity exists.
if isinstance(rep, dict):
- out[href] = {} if set(rep.keys()) == {'href'} else rep
+ out[href] = rep
return out
diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py
index 13107d8..1a9b9aa 100644
--- a/custom_components/localthings/registry/by_type/__init__.py
+++ b/custom_components/localthings/registry/by_type/__init__.py
@@ -4,8 +4,8 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer,
- induction_cooktop, oven, range as _range, range_hood, refrigerator,
- vacuum_station, washer, water_purifier,
+ induction_cooktop, microwave, oven, range as _range, range_hood,
+ refrigerator, vacuum_station, washer, water_purifier,
)
__all__ = [
@@ -24,6 +24,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
+ 'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
@@ -211,6 +212,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
+ # Room air conditioners on the ARTIK051 board (e.g. ARTIK051_KRAC_18K,
+ # issue #136) report no oneUiVersion and carry a '_KRAC_' token. The '_RAC_'
+ # check above cannot see it -- the 'K' sits between the underscore and 'RAC' --
+ # and the consumer-prefix fallback only covers washers/dryers/dishwashers, so
+ # these units fell back to 'unknown' and exposed nothing but power. Same
+ # ARTIK051 board family as the '_TVTL_' air purifier below.
+ if key is None and '_KRAC_' in (model_num or ''):
+ key = 'airconditioner'
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
@@ -232,15 +241,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
- # Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same
- # board family as the wall oven above (an '/oven/vs/0' cavity resource
- # with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a
- # different cavity. Reports no oneUiVersion and doesn't match the
- # washer/dryer/dishwasher prefix map either. Binds cleanly against the
- # existing oven registry once routed here (confirmed against the issue
- # #121 dump) -- no microwave-specific device type needed.
+ # Microwaves, both combi (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121)
+ # and plain (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same board
+ # family as the wall oven above (an '/oven/vs/0' cavity resource, same
+ # /operational/state/vs/0 + /doors/vs/0 shape), but a distinct mode
+ # vocabulary (Convection/AirFryer/Grill/MicroWave*) and setpoint bounds
+ # from the oven registry, plus a powerLevel field ovens don't report --
+ # its own device type rather than folded into 'oven' (issue #121 shipped
+ # it onto the oven registry initially; split out per user feedback).
+ # Reports no oneUiVersion and doesn't match the washer/dryer/dishwasher
+ # prefix map either.
if key is None and '-MICROWAVE-' in (model_num or '').upper():
- key = 'oven'
+ key = 'microwave'
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
# as the range combo above, but no oven attached at all. Distinct from
diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py
new file mode 100644
index 0000000..7e76a9a
--- /dev/null
+++ b/custom_components/localthings/registry/by_type/microwave.py
@@ -0,0 +1,37 @@
+"""Microwave device registry (combi and plain microwaves, issues #66/#121).
+
+Shares the oven board family's cavity/cook-cycle resource shape, so the
+operational-state, door, cloud-connected, and quick-recipe-display
+Capability objects are reused directly from oven.py rather than duplicated.
+Cooking mode, setpoint, cavity power level, and lamp are genuinely
+different for this family (different mode vocabulary, different setpoint
+bounds, an extra powerLevel field, a differently-named lamp option) and are
+defined fresh in capabilities/microwave.py -- see that module's docstring.
+
+Some combi units (built-in over-the-range microwaves, issues #137/#142)
+also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
+shape a standalone range hood reports it in -- reused directly from
+range_hood.py rather than duplicated. Unlike a standalone hood, this dump
+has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
+LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
+case (see its `_speed_zero_is_off` check).
+"""
+from ..capabilities import common, ignored, microwave, oven, range_hood
+from ._base import DeviceRegistry, _build
+
+REGISTRY = DeviceRegistry(
+ name='microwave',
+ capabilities=_build([
+ *ignored.IGNORED,
+ *common.UNIVERSAL,
+ *common.POWER,
+ microwave.MICROWAVE_CAVITY,
+ microwave.MICROWAVE_SETPOINT,
+ microwave.MICROWAVE_MODE,
+ oven.OVEN_OPERATIONAL_STATE,
+ oven.OVEN_DOOR,
+ oven.OVEN_CONNECTED,
+ oven.OVEN_RECIPE_COOK,
+ range_hood.HOOD_FAN,
+ ]),
+)
diff --git a/custom_components/localthings/registry/by_type/range_hood.py b/custom_components/localthings/registry/by_type/range_hood.py
index 9eab99c..b647844 100644
--- a/custom_components/localthings/registry/by_type/range_hood.py
+++ b/custom_components/localthings/registry/by_type/range_hood.py
@@ -11,6 +11,7 @@ REGISTRY = DeviceRegistry(
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
+ range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py
index 43e776b..4c0abf5 100644
--- a/custom_components/localthings/registry/by_type/refrigerator.py
+++ b/custom_components/localthings/registry/by_type/refrigerator.py
@@ -36,5 +36,7 @@ REGISTRY = DeviceRegistry(
fridge.TEMP_SETPOINT,
fridge.ICEMAKER_GENERIC,
fridge.DOOR_GENERIC,
+ fridge.KIMCHI_ZONE,
+ fridge.KIMCHI_DOOR_GENERIC,
],
)
diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py
index 5e867d6..f9ba661 100644
--- a/custom_components/localthings/registry/capabilities/air_purifier.py
+++ b/custom_components/localthings/registry/capabilities/air_purifier.py
@@ -39,14 +39,23 @@ set to Auto/Sleep/Low/Medium/High):
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
-/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
-fan-speed control: across the same five dumps it read 0 for both Auto *and*
-High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
-selectable level, and the dumps were all captured within about three minutes
-of each other (only one poll cycle apart at this integration's 30s summary
-interval), so the values may not have settled after each change before the
-diagnostics snapshot was taken. Exposed read-only pending a confirmed,
-stable capture -- see the issue #56 discussion for what's needed.
+/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
+The first round of five dumps above wasn't conclusive -- it read 0 for both
+Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
+were captured within about a minute of each other, faster than this
+integration's own ~30s poll cycle could settle each change. A second round,
+captured 60-90s apart per setting on two independent units, confirmed a
+clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4.
+AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4
+range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's
+percentage steps straight onto the raw code, no named-preset table needed
+(unlike the TP1X family's FAN, which exposes real named modes because its
+board actually reports a supportedModes list to hang names off of).
+
+/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it
+was unreliable on both units in that second round (Low/Medium collided on
+one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
+stays a plain read-only diagnostic even after this change.
"""
from ..capability import Capability
from ..entities import (
@@ -64,6 +73,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
# are mutually exclusive via this presence check rather than colliding.
HREF_MODE = '/mode/vs/0'
+HREF_AIRFLOW = '/airflow/0'
def _has_top_level_modes(rep, resources):
@@ -126,21 +136,52 @@ DEVICE_ACTIVE = Capability(
),
)
-# OCF-native / vendor pair for fan speed+direction -- see module docstring for
-# why these are read-only for now.
+def _airflow_fan_write(payload, rep, href=None):
+ kind, value, *args = payload
+ if kind == 'power':
+ power_href = args[0] if args else '/power/vs/0'
+ if power_href == '/power/0':
+ return ['power', '0'], {'value': bool(value)}
+ return (['power', 'vs', '0'],
+ {'x.com.samsung.da.power': 'On' if value else 'Off'})
+ if kind == 'speed':
+ return ['airflow', '0'], {'speed': int(value)}
+ return None
+
+
+# Confirmed via issue #56's second, properly-spaced round of diagnostics
+# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
+# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
+# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same
+# SET_SPEED shape as the range hood's) instead of a read-only sensor --
+# no named-preset table needed, since HA's percentage steps map onto the
+# raw 0-4 code directly, the same way the range hood's numeric levels do.
+# `direction` stays a plain diagnostic: every dump seen (both rounds, both
+# units) reads 'Off' for it regardless of fan setting, so there's nothing
+# confirmed to control there yet.
+#
+# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
+# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
+# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
+# only *empirically* mutually exclusive (every dump seen has one board
+# generation's shape or the other, never both), not architecturally
+# enforced the way same-href caps are by _build()'s match_fn check -- a
+# same key would collide if a future board ever reported both.
AIRFLOW_GENERIC = Capability(
- href='/airflow/0',
+ href=HREF_AIRFLOW,
poll_tier='warm',
entities=(
- SensorDesc(key='fan_speed_level', field='speed',
- icon='mdi:fan',
- state_class='measurement', entity_category='diagnostic'),
+ FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
SensorDesc(key='fan_direction', field='direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
+# Left exactly as a read-only fallback -- speedLevel is NOT the same
+# confirmed-reliable field as /airflow/0's speed above (see module
+# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
+# on the other in the same properly-spaced round.
AIRFLOW_VS_FALLBACK = Capability(
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py
index 4355155..5ccfae7 100644
--- a/custom_components/localthings/registry/capabilities/airconditioner.py
+++ b/custom_components/localthings/registry/capabilities/airconditioner.py
@@ -29,21 +29,19 @@ def _int(v):
return None
-def _option_token(rep, prefix):
- """Return the `_` token from /mode/vs/0 options, else None."""
- for o in _mode_options(rep):
- if isinstance(o, str) and o.startswith(prefix + '_'):
- return o
- return None
-
-
def _beep_on(rep):
"""Beep on/off from the `Volume_*` option token: Volume_Mute = off,
- Volume_100 (and any non-Mute) = on. None when no Volume_ slot."""
+ Volume_100 (and any non-Mute) = on. None when no Volume_ slot.
+
+ _option_token (defined further below, alongside the legacy-board token
+ helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the
+ full 'Volume_100' token -- shared with _option_token_num/_option_token_on,
+ which this module's other options[] readers already rely on.
+ """
tok = _option_token(rep, 'Volume')
if tok is None:
return None
- return tok != 'Volume_Mute'
+ return tok != 'Mute'
def _beep_write(payload, rep, href=None):
@@ -58,7 +56,7 @@ def _beep_write(payload, rep, href=None):
token = 'Mute'
else:
prev = _option_token(rep, 'Volume')
- token = prev[len('Volume_'):] if (prev and prev != 'Volume_Mute') else '100'
+ token = prev if (prev and prev != 'Mute') else '100'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Volume', token),
}
@@ -155,11 +153,18 @@ HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
+# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources
+# at all: fan speed and vane direction sit together in this one resource, as
+# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and
+# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their
+# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a
+# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient.
+HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode
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_WIND_OSCILLATION, HREF_CONVENIENT,
+ HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW,
]
@@ -239,6 +244,91 @@ def _display_light_write(payload, rep, href=None):
{'x.com.samsung.da.options': option_write('Light', token)})
+# ---------------------------------------------------------------------------
+# Legacy ARTIK051 boards keep several settings that newer boards expose as their
+# own resources (/option/*, /electriccurrent/vs/0, ...) as `_`
+# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart;
+# writes reuse option_write's single-token merge, exactly like the display light.
+# ---------------------------------------------------------------------------
+
+
+def _option_token(rep, prefix):
+ """Value part of a `_` token in /mode/vs/0's options."""
+ for option in _mode_options(rep):
+ if isinstance(option, str) and option.startswith(prefix + '_'):
+ return option.split('_', 1)[1]
+ return None
+
+
+def _is_legacy_board(resources):
+ """True for the board generation whose airflow lives in /airflow/vs/0.
+
+ Newer families carry several of the same option tokens (Volume, Sleep,
+ OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings,
+ so an ungated token entity would either duplicate an existing one or apply a
+ scale calibrated elsewhere. Every AC dump on record has one shape or the
+ other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no
+ /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities
+ below and the climate entity can never disagree about the generation.
+ """
+ return ('/airflow/vs/0' in resources
+ and '/wind/strength/vs/0' not in resources)
+
+
+def _has_option_token(prefix):
+ return lambda rep, resources: (
+ _is_legacy_board(resources) and _option_token(rep, prefix) is not None)
+
+
+def _option_token_on(prefix):
+ return lambda rep: _option_token(rep, prefix) == 'On'
+
+
+def _option_token_num(prefix, offset=0, divisor=1):
+ def read(rep):
+ raw = _option_token(rep, prefix)
+ try:
+ return (float(raw) - offset) / divisor
+ except (TypeError, ValueError):
+ return None
+ return read
+
+
+def _option_switch_write(prefix):
+ def write(payload, rep, href=None):
+ return (['mode', 'vs', '0'],
+ {'x.com.samsung.da.options': option_write(prefix, payload)})
+ return write
+
+
+def _option_number_write(prefix):
+ def write(payload, rep, href=None):
+ return (['mode', 'vs', '0'],
+ {'x.com.samsung.da.options':
+ option_write(prefix, str(int(round(float(payload)))))})
+ return write
+
+
+def _humidity(rep):
+ """Relative humidity, preferring the 5%-rounded field where it exists.
+
+ ARTIK051 boards have no fivepercentHumidity field at all and report the
+ plain x.com.samsung.da.humidity instead -- and only populate it while the
+ Air monitoring option is on: the unit measures for roughly half a minute
+ (51% observed, matching what the same unit's cloud integration reported at
+ that moment), then zeroes the field and switches Air monitoring back off by
+ itself. So 0 reads as "not measuring" and is reported as unknown rather than
+ as 0% humidity, which would poison long-term history -- which is also why
+ the boards that do have fivepercentHumidity read a permanent 0 here.
+ """
+ for field in ('x.com.samsung.da.fivepercentHumidity',
+ 'x.com.samsung.da.humidity'):
+ if field in rep:
+ value = _num(rep[field])
+ return value if value else None
+ return None
+
+
def _climate_write(payload, rep, href=None):
"""Map a (kind, value) command from the climate platform to the
(path_segs, body) for that one sub-write. `value` is already the raw device
@@ -292,6 +382,16 @@ def _climate_write(payload, rep, href=None):
'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix',
'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix',
})
+ if kind == 'fan_legacy':
+ return (['airflow', 'vs', '0'],
+ {'x.com.samsung.da.speedLevel': str(value)})
+ if kind == 'swing_legacy':
+ return (['airflow', 'vs', '0'],
+ {'x.com.samsung.da.direction': value})
+ if kind == 'preset_legacy':
+ # Single-token options merge, same mechanism as _display_light_write.
+ return (['mode', 'vs', '0'],
+ {'x.com.samsung.da.options': option_write('Comode', value)})
if kind == 'preset':
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
return None
@@ -318,17 +418,79 @@ CLIMATE = Capability(
icon='mdi:led-on', entity_category='config'),
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
+ # Gated off the legacy ARTIK051 board generation (see _is_legacy_board):
+ # that generation's own Volume_ token is already modeled as the
+ # buzzer_volume Number below, and both reading the same options[] slot
+ # into two entities would be redundant.
SwitchDesc(key='beep', rep_fn=_beep_on,
- exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
+ exists_fn=lambda rep, resources: (
+ not _is_legacy_board(resources)
+ and _option_token(rep, 'Volume') is not None),
write_fn=_beep_write,
icon='mdi:volume-high', entity_category='config'),
# Tropical night mode level (0-16) from the `Sleep_` option token.
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
+ # Gated off the legacy board for the same reason as beep above -- its
+ # Sleep_ token is already the good_sleep Number below.
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
- exists_fn=lambda rep, resources: _option_token(rep, 'Sleep') is not None,
+ exists_fn=lambda rep, resources: (
+ not _is_legacy_board(resources)
+ and _option_token(rep, 'Sleep') is not None),
write_fn=_tropical_night_write,
native_min=0, native_max=16, step=1,
icon='mdi:weather-night', entity_category='config'),
+ # Settings that this board generation keeps as options[] tokens.
+ SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'),
+ exists_fn=_has_option_token('Spi'),
+ write_fn=_option_switch_write('Spi'),
+ icon='mdi:air-purifier', entity_category='config'),
+ # Shares AUTO_CLEAN's catalog entry rather than duplicating it: same
+ # feature, different board generation (that one is a /option/autoclean/
+ # vs/0 field, absent here). Distinct key, so nothing collides if some
+ # future board ever reported both.
+ SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean',
+ rep_fn=_option_token_on('Autoclean'),
+ exists_fn=_has_option_token('Autoclean'),
+ write_fn=_option_switch_write('Autoclean'),
+ icon='mdi:fan-auto', entity_category='config'),
+ SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'),
+ exists_fn=_has_option_token('AirMonitoring'),
+ write_fn=_option_switch_write('AirMonitoring'),
+ icon='mdi:air-filter', entity_category='config'),
+ NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'),
+ exists_fn=_has_option_token('Volume'),
+ write_fn=_option_number_write('Volume'),
+ native_min=0, native_max=100, step=10,
+ icon='mdi:volume-high', entity_category='config'),
+ # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
+ # carries no range hint and only 0 has been observed on hardware), so a
+ # write above 0 is unverified.
+ NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'),
+ exists_fn=_has_option_token('Sleep'),
+ write_fn=_option_number_write('Sleep'),
+ native_min=0, native_max=12, step=1, unit='h',
+ icon='mdi:sleep', entity_category='config'),
+ # Outdoor temperature, offset by 55. Two calibration points on one unit:
+ # token 75 while an independent outdoor thermometer in the same install
+ # read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far
+ # worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer
+ # climate, which is also plausible.
+ SensorDesc(key='outdoor_temperature',
+ rep_fn=_option_token_num('OutdoorTemp', offset=55),
+ exists_fn=_has_option_token('OutdoorTemp'),
+ device_class='temperature', state_class='measurement',
+ unit='°C', icon='mdi:home-thermometer-outline'),
+ # Filter time in tenths of an hour: the token read 1710 while the official
+ # Samsung app displayed "171 hours 0 minutes" for the filter on the same
+ # unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
+ # it counts up or down is NOT established -- it was seen rising (171.0 ->
+ # 171.5) while the unit ran, which contradicts the app's "remaining"
+ # wording, so the entity is deliberately named neutrally.
+ SensorDesc(key='filter_time',
+ rep_fn=_option_token_num('FilterTime', divisor=10),
+ exists_fn=_has_option_token('FilterTime'),
+ device_class='duration', unit='h',
+ state_class='measurement', icon='mdi:air-filter'),
),
)
@@ -504,15 +666,23 @@ CURRENT_TEMPERATURE_VS = Capability(
# Only the vendor resource's `fivepercentHumidity` (current reading, rounded
# to the nearest 5%) has live data on the issue #75 dump -- its `humidity`
# field, and the OCF-standard /humidity/0 resource entirely, both read a
-# stuck "0" there and stay ignored per the 'don't guess' rule (see
-# _AC_IGNORED below).
+# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule
+# (see _AC_IGNORED below).
+#
+# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and
+# there the plain `humidity` field is not stuck: it carries a real reading
+# (51%, matching the same unit's cloud integration at that moment) for as long
+# as the Air monitoring option is on, which the unit itself switches back off
+# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's
+# fallback, and hence 0 reading as "not measuring" rather than 0%: on both
+# board generations a zero here means no measurement, never dry air.
+# /humidity/0 stayed 0 throughout that same observation too.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
- SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity',
- device_class='humidity', state_class='measurement', unit='%',
- value_fn=_num),
+ SensorDesc(key='humidity', rep_fn=_humidity,
+ device_class='humidity', state_class='measurement', unit='%'),
),
)
@@ -573,6 +743,12 @@ _AC_IGNORED = [
'/humidity/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
+ # OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs
+ # {speedLevel, direction}, identical values). The climate entity reads and
+ # writes the vendor form, which is the one confirmed on hardware here --
+ # note that air_purifier.py found the opposite ordering on its family, so
+ # neither form is reliable sight-unseen and this one stays unmodeled.
+ '/airflow/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.
diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py
index cd03a91..ac60523 100644
--- a/custom_components/localthings/registry/capabilities/common.py
+++ b/custom_components/localthings/registry/capabilities/common.py
@@ -12,6 +12,7 @@ against live device dumps:
"""
from datetime import datetime, timezone
+from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
@@ -380,32 +381,35 @@ _DEAD_INSTANTANEOUS_POWER = '-500'
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
- # `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
+ # `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
- # entity when /device/0 returns a not-yet-fetched stub. On a populated
- # rep, hide power only for the dead sentinel or an absent field.
+ # entity when /device/0 returns a not-yet-fetched stub. A genuinely
+ # empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
+ # answer, so it falls through to the normal field/sentinel checks like
+ # any populated rep. On a populated rep, hide power only for the dead
+ # sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
- exists_fn=lambda rep, resources: not rep or (
+ exists_fn=lambda rep, resources: is_stub_rep(rep) or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
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.cumulativePower' in rep)),
+ is_stub_rep(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/
+ # both. Self-gates off where only cumulativePower is present. The
+ # `is_stub_rep(rep) or` keeps the same 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',
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)),
+ is_stub_rep(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.
@@ -413,7 +417,7 @@ ENERGY_METER = Capability(
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)),
+ is_stub_rep(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.
@@ -421,12 +425,12 @@ ENERGY_METER = Capability(
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)),
+ is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
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)),
+ is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
),
)
@@ -499,7 +503,7 @@ 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
+ # No is_stub_rep 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
@@ -562,7 +566,7 @@ SELF_CHECK = Capability(
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
- not rep or 'x.com.samsung.da.error' in rep),
+ is_stub_rep(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='', payload='Start',
icon='mdi:play-circle-outline',
diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py
index b76138f..96f1cfd 100644
--- a/custom_components/localthings/registry/capabilities/cooktop.py
+++ b/custom_components/localthings/registry/capabilities/cooktop.py
@@ -9,6 +9,7 @@ cooktop must not be remotely ignited by an automation.
import re
+from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
@@ -97,7 +98,7 @@ COOKTOP_MODE = Capability(
options, f'OperationState{slot}'
),
exists_fn=lambda rep, resources, slot=slot: (
- not rep or _option_value(
+ is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py
index 3e9677a..14e7436 100644
--- a/custom_components/localthings/registry/capabilities/fridge.py
+++ b/custom_components/localthings/registry/capabilities/fridge.py
@@ -590,8 +590,17 @@ FLEX_ZONE = Capability(
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')),
+ # A nonempty supportedOptions alone isn't sufficient: the
+ # kimchi-refrigerator family (issue #26) also populates
+ # /mode/vs/0's modes/supportedOptions with real data, but
+ # its tokens carry a "_[n]:[n]" parameter suffix on
+ # supportedOptions that modes never repeats, so no item
+ # ever overlaps -- the RF9000/Bespoke-class overlap this
+ # capability was built for never happens there. Require an
+ # actual resolvable value instead of just a populated
+ # list, so this stays absent on that family rather than
+ # showing a select permanently stuck on "unknown".
+ exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
rep_fn=_flex_zone_current,
write_fn=_flex_zone_write),
),
@@ -625,6 +634,105 @@ DOOR_GENERIC = Capability(
),
)
+# ---------------------------------------------------------------------------
+# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
+# units, issue #26) -- top/middle/bottom each report their own storage mode
+# plus a ripening status/timer on /status/kimchi//vs/0, all three in
+# an identical shape; modeled as a pattern capability the same way
+# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
+# key and {instance_name} from the href's top/middle/bottom segment. Only
+# the top compartment's door has been seen reported separately (kimchidoors);
+# middle/bottom apparently have no contact switch of their own, so that's
+# its own narrower pattern cap rather than assumed universal.
+#
+# The same state is also mirrored -- packed into single tokens like
+# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
+# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
+# for RF9000-class fridges. /status/kimchi//vs/0's plain currentMode/
+# supportMode fields are unpacked and self-describing, so that's what this
+# binds to instead.
+#
+# Write path is unconfirmed (no live write against a real unit) -- same
+# "write the same field back to the entity's own href" convention as
+# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
+#
+# translations/en.json's kimchi_zone_mode state labels were translated
+# directly from the reporter's own (Korean-language) SmartThings app
+# screenshots, not guessed from the codes or from their English paraphrase.
+# Cross-checking the screenshots against supportMode confirms the on-screen
+# option order matches the array order everywhere it's verifiable: the top
+# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
+# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
+# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
+# ripening list, and 4-entry custom-storage list each line up 1:1 with their
+# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
+# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
+# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
+# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
+# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
+# and BUY (구입, "purchased") are also confirmed exact matches, not
+# abbreviation guesses.
+# ---------------------------------------------------------------------------
+
+def _kimchi_mode_write(p, rep, href=None):
+ if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
+ return None
+ return [s for s in href.strip('/').split('/') if s], {
+ 'x.com.samsung.da.currentMode': p,
+ }
+
+
+KIMCHI_ZONE = Capability(
+ href=None,
+ href_prefix='/status/kimchi/',
+ strip_prefix_in_key=True,
+ poll_tier='warm',
+ entities=(
+ SelectDesc(key='mode', field='x.com.samsung.da.currentMode',
+ use_instance_name=True, icon='mdi:fridge-outline',
+ translation_key='kimchi_zone_mode',
+ entity_category='config',
+ options_field='x.com.samsung.da.supportMode',
+ write_fn=_kimchi_mode_write),
+ SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
+ use_instance_name=True, icon='mdi:progress-clock',
+ translation_key='kimchi_ripening_status',
+ entity_category='diagnostic'),
+ SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
+ use_instance_name=True, icon='mdi:timer-sand',
+ translation_key='kimchi_ripening_remaining',
+ entity_category='diagnostic',
+ # No dump has this nonzero (ripeStatus is always "Off" so
+ # far) -- device-reported unit unconfirmed, so this stays
+ # a bare number rather than asserting minutes or hours.
+ value_fn=_int),
+ SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount',
+ use_instance_name=True, icon='mdi:tray-full',
+ translation_key='kimchi_rack_count',
+ entity_category='diagnostic', enabled_default=False,
+ value_fn=_int),
+ ),
+)
+
+KIMCHI_DOOR_GENERIC = Capability(
+ href=None,
+ href_prefix='/kimchidoors/',
+ strip_prefix_in_key=True,
+ poll_tier='hot',
+ entities=(
+ # Not deduped against DOORS_FALLBACK below: on the one reporter
+ # (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
+ # /doors/vs/0 aggregate carries a single generic item (id "4", no
+ # /door/ siblings for DOORS_FALLBACK's match_fn to see)
+ # that doesn't share this compartment's "top" instance numbering --
+ # a distinct main-cabinet door, not this kimchi drawer's own contact
+ # switch reported twice.
+ BinarySensorDesc(key='open', rep_fn=_door_open_state,
+ translation_key='instance_open',
+ use_instance_name=True, device_class='door'),
+ ),
+)
+
# ---------------------------------------------------------------------------
# Aggregate-resource fallbacks
#
diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py
new file mode 100644
index 0000000..7dcb7f5
--- /dev/null
+++ b/custom_components/localthings/registry/capabilities/microwave.py
@@ -0,0 +1,195 @@
+"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-*
+class boards, both combi units and plain microwaves).
+
+Shares the oven board family's cavity/cook-cycle resource shape
+(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`,
+`/recipe/cook/vs/0`) -- those Capability objects are reused directly from
+oven.py in by_type/microwave.py rather than duplicated. What's genuinely
+different from an oven, and defined fresh here:
+
+ * Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
+ KeepWarm never appear on an oven's /mode/vs/0, and this family spells
+ some shared-sounding modes differently than oven.py's own constants
+ (e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
+ mode list, not oven.OVEN_MODE.
+ * Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
+ (issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
+ 30-270 C range (verified against a different, bake-oven-class board).
+ * Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
+ on the MicroWave mode's powerListData) that plain ovens don't report --
+ exposed as its own sensor.
+ * Lamp: this family's option-array token is bare 'Lamp' (issue #137's
+ 'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
+ the combi dump (issue #121), so it's gated with exists_fn rather than
+ assumed universal like oven.py's lamp switch.
+
+Note: cooking-mode writes are unproven here, same caveat as oven.py's
+OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
+is also the test.
+"""
+from ..capability import Capability
+from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
+from .common import int_or_none, normalize_temp_unit
+from .laundry import option_value, option_write
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+# Union of every mode seen across the two known dumps: issue #121's combi
+# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
+# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
+# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
+# every mode below on one device -- the select surfaces whatever a given
+# board's own /mode/vs/0 supportedModes reports; an entry here that a device
+# never sends just never gets picked.
+_MICROWAVE_MODES = (
+ 'NoOperation',
+ 'MicroWave',
+ 'MicroWaveGrill',
+ 'MicroWaveConvection',
+ 'Convection',
+ 'AirFryer',
+ 'Grill',
+ 'Autocook',
+ 'AutocookCustom',
+ 'Deodorization',
+ 'KeepWarm',
+)
+
+# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
+# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
+# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
+# against issue #44's range dump), there's nothing to verify a microwave's
+# Fahrenheit bounds against, so this module only exposes the setpoint
+# control when the live unit is Celsius (see _microwave_temp_unit below).
+SETPOINT_MIN_C = 40
+SETPOINT_MAX_C = 200
+SETPOINT_STEP_C = 5
+
+
+def _microwave_temp_unit(rep):
+ """Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
+ carries a per-item x.com.samsung.da.unit field. Both known dumps for
+ this family report 'Celsius'; kept live rather than hardcoded per the
+ fridge/oven convention (issue #7)."""
+ items = rep.get('x.com.samsung.da.items') or []
+ unit = items[0].get('x.com.samsung.da.unit') if items else None
+ return normalize_temp_unit(unit, default='°C')
+
+
+def _setpoint_write(p, rep, href=None):
+ """RMW write to /temperatures/vs/0 items array -- unproven for this
+ family (no live write confirmed against a real unit), same "exposed for
+ fidelity" caveat as the mode select."""
+ try:
+ temp = float(p)
+ except (TypeError, ValueError):
+ return None
+ temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
+ if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
+ return None
+ items = rep.get('x.com.samsung.da.items')
+ if not items:
+ return None
+ items = [dict(it) for it in items]
+ items[0]['x.com.samsung.da.desired'] = str(temp_i)
+ return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
+
+
+def _power_level_watts(v):
+ """'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts."""
+ if v is None:
+ return None
+ s = str(v).strip()
+ if s.upper().endswith('W'):
+ s = s[:-1]
+ return int_or_none(s)
+
+
+def _mode_write(p, rep, href=None):
+ if p not in _MICROWAVE_MODES:
+ return None
+ return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
+
+
+def _sound_write(p, rep, href=None):
+ if p not in ('On', 'Off'):
+ return None
+ if not rep.get('x.com.samsung.da.options'):
+ return None
+ return ['mode', 'vs', '0'], {
+ 'x.com.samsung.da.options': option_write('Sound', p),
+ }
+
+
+def _lamp_exists(rep, resources):
+ return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
+
+
+def _lamp_write(p, rep, href=None):
+ if p not in ('On', 'Off'):
+ return None
+ if not rep.get('x.com.samsung.da.options'):
+ return None
+ return ['mode', 'vs', '0'], {
+ 'x.com.samsung.da.options': option_write('Lamp', p),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Capabilities
+# ---------------------------------------------------------------------------
+
+MICROWAVE_CAVITY = Capability(
+ href='/oven/vs/0',
+ poll_tier='hot',
+ entities=(
+ SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
+ SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
+ unit='W', value_fn=_power_level_watts),
+ ),
+)
+
+MICROWAVE_SETPOINT = Capability(
+ href='/temperatures/vs/0',
+ poll_tier='hot',
+ entities=(
+ NumberDesc(key='setpoint', field='x.com.samsung.da.items',
+ device_class='temperature', unit_fn=_microwave_temp_unit,
+ native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
+ step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
+ exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
+ value_fn=lambda items: int_or_none(
+ (items[0].get('x.com.samsung.da.desired') if items else None)),
+ write_fn=_setpoint_write),
+ SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
+ device_class='temperature',
+ state_class='measurement', unit_fn=_microwave_temp_unit,
+ value_fn=lambda items: int_or_none(
+ (items[0].get('x.com.samsung.da.current') if items else None))),
+ ),
+)
+
+MICROWAVE_MODE = Capability(
+ href='/mode/vs/0',
+ poll_tier='warm',
+ entities=(
+ # SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
+ SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
+ icon='mdi:tune',
+ options=_MICROWAVE_MODES,
+ value_fn=lambda v: v[0] if v else None,
+ write_fn=_mode_write),
+ SwitchDesc(key='sound', field='x.com.samsung.da.options',
+ icon='mdi:volume-high',
+ entity_category='config',
+ value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
+ write_fn=_sound_write),
+ SwitchDesc(key='lamp', field='x.com.samsung.da.options',
+ icon='mdi:track-light',
+ exists_fn=_lamp_exists,
+ value_fn=lambda opts: option_value(opts, 'Lamp') == 'On',
+ write_fn=_lamp_write),
+ ),
+)
diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py
index 59cebc9..59aa9b7 100644
--- a/custom_components/localthings/registry/capabilities/range_hood.py
+++ b/custom_components/localthings/registry/capabilities/range_hood.py
@@ -9,9 +9,11 @@ independent fields.
from datetime import datetime, timezone
+from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
+ ButtonDesc,
FanDesc,
SelectDesc,
SensorDesc,
@@ -99,6 +101,11 @@ HOOD_FAN = Capability(
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
+ # Absent on the microwave family's built-in vent fan (issue
+ # #137) -- this board has no auto-ventilation mode, unlike the
+ # standalone range hood this capability was written for.
+ exists_fn=lambda rep, resources: (
+ is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
),
),
@@ -179,6 +186,50 @@ HOOD_FILTER = Capability(
)
+# After Run (issue #147): the hood keeps the fan running at low speed for a
+# while after it's switched off, to clear residual cooking smoke -- a
+# feature a user actively watches and cancels, not passive diagnostics, so
+# none of the three entities below carry entity_category. No
+# supported-values list is advertised for activationState, so it's modeled
+# read-only (monitoring, not an invented "enable" write) per the 'don't
+# guess' rule; runningCancel's only observed value is the command name
+# itself ('Cancel'), the same self-describing command-field shape as
+# operational.STOP_BUTTON. runningProgress's own name states its domain
+# (a percentage of the cycle completed), so it's modeled as one rather than
+# left an opaque passthrough -- unlike activationState/runningCancel, there's
+# no ambiguous field name or missing-write-contract question here to hedge on.
+AFTER_RUN = Capability(
+ href='/afterrun/vs/0',
+ poll_tier='warm',
+ entities=(
+ BinarySensorDesc(
+ key='after_run_active',
+ field='x.com.samsung.da.activationState',
+ icon='mdi:fan-clock',
+ value_fn=lambda value: str(value).lower() == 'on',
+ ),
+ SensorDesc(
+ key='after_run_progress',
+ field='x.com.samsung.da.runningProgress',
+ unit='%',
+ state_class='measurement',
+ icon='mdi:fan-clock',
+ value_fn=int_or_none,
+ ),
+ ButtonDesc(
+ key='after_run_cancel',
+ field='',
+ payload='Cancel',
+ icon='mdi:fan-off',
+ write_fn=lambda p, rep, href=None: (
+ ['afterrun', 'vs', '0'],
+ {'x.com.samsung.da.runningCancel': p},
+ ),
+ ),
+ ),
+)
+
+
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py
index 953cd3d..019517c 100644
--- a/custom_components/localthings/registry/capabilities/water_purifier.py
+++ b/custom_components/localthings/registry/capabilities/water_purifier.py
@@ -102,20 +102,64 @@ FAVORITE_CAPACITY = Capability(
),
)
-# Coffee-capable variant (issue #107) -- same "favorite" enable-toggle +
-# supported-list select shape as FAVORITE_CAPACITY above, but for the hot
-# water dispensed alongside brewing rather than the pour capacity.
+# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
+# for the hot water dispensed alongside brewing, same shape as
+# FAVORITE_CAPACITY above.
+
+
+def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
+ """Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
+ LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
+ still ambiguous:
+
+ - href entirely absent from this device -> definitely no clash, the
+ switchHotwater fallback below may claim the entity.
+ - href present but an unfetched stub ({}) -> outcome pending, *not* a
+ confirmed absence. LOCK's own exists_fn optimistically includes itself
+ through a stub (matching entity.py's default), so returning True here
+ too would register both descriptors -- as SwitchDescs sharing one key,
+ with identical unique_ids -- until the next poll resolves it.
+ - href present and fetched -> the real answer."""
+ rep = resources.get('/status/lock/vs/0')
+ if rep is None:
+ return True
+ if not rep:
+ return False
+ return 'x.com.samsung.da.hotwaterLock' not in rep
+
+
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
entities=(
- SwitchDesc(key='favorite_hotwater_enabled', field='x.com.samsung.da.switchHotwater',
- icon='mdi:star-outline',
+ # Despite the resource/field naming, switchHotwater's value domain is
+ # Locked/Unlocked, not an enable flag (issue #144) -- it's the same
+ # hot-water lock as LOCK.hotwater_lock below, just surfaced through
+ # this href on boards that don't populate /status/lock/vs/0's
+ # hotwaterLock field. Shares that descriptor's key so only one "Hot
+ # water lock" entity ever appears.
+ #
+ # Both halves of this fallback pair need an exists_fn, not just this
+ # one: adapter.flatten() (the coordinator.data source every entity's
+ # is_on reads) only ever honours exists_fn, never entity.py's
+ # implicit "require own field present" default that gates plain
+ # registration. Two same-keyed descriptors with only one of them
+ # gated still both land in flatten()'s output dict -- whichever is
+ # processed last silently wins, decided by device-reported href
+ # order, not by which one is actually correct. So this exists_fn
+ # also re-asserts its own field's presence (switchHotwater), the
+ # gate a bare `field=` used to get for free before it had to share a
+ # key with LOCK's descriptor.
+ SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
+ device_class='lock',
entity_category='config',
- value_fn=lambda v: v != 'Locked',
+ value_fn=lambda v: v != 'Unlocked',
+ exists_fn=lambda rep, resources: (
+ 'x.com.samsung.da.switchHotwater' in rep
+ and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
- {'x.com.samsung.da.switchHotwater': 'Unlocked' if p == 'On' else 'Locked'})),
+ {'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
@@ -150,10 +194,19 @@ LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
entities=(
+ # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
+ # above (issue #144); see the comment there for why this half also
+ # needs an explicit exists_fn now that the two share a key in
+ # adapter.flatten()'s output. A stub rep ({}) still counts as
+ # "present" here (matches entity.py's own default for a field-less
+ # gate) since the alternative -- treating an unfetched resource as
+ # confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
+ exists_fn=lambda rep, resources: (
+ not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json
index 4c8cf80..b311177 100644
--- a/custom_components/localthings/translations/en.json
+++ b/custom_components/localthings/translations/en.json
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
+ "after_run_active": {
+ "name": "After run active"
+ },
"any_burner_active": {
"name": "Any burner active"
},
@@ -93,6 +96,9 @@
}
},
"button": {
+ "after_run_cancel": {
+ "name": "Cancel after run"
+ },
"diagnosis_start": {
"name": "Start diagnosis"
},
@@ -128,7 +134,8 @@
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
- "drycomfort": "Dry comfort"
+ "drycomfort": "Dry comfort",
+ "2step": "2-Step"
}
}
}
@@ -150,6 +157,9 @@
}
},
"number": {
+ "buzzer_volume": {
+ "name": "Beep volume"
+ },
"cook_time": {
"name": "Cook time"
},
@@ -159,12 +169,18 @@
"dispense_capacity": {
"name": "Dispense capacity"
},
+ "good_sleep": {
+ "name": "Good Sleep"
+ },
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
"oven_setpoint": {
"name": "Setpoint"
},
+ "setpoint": {
+ "name": "Setpoint"
+ },
"sound_volume": {
"name": "Sound volume"
},
@@ -350,6 +366,22 @@
"high": "High"
}
},
+ "cooking_mode": {
+ "name": "Cooking mode",
+ "state": {
+ "no_operation": "No operation",
+ "micro_wave": "Microwave",
+ "micro_wave_grill": "Microwave + grill",
+ "micro_wave_convection": "Microwave + convection",
+ "convection": "Convection",
+ "air_fryer": "Air fry",
+ "grill": "Grill",
+ "autocook": "Auto cook",
+ "autocook_custom": "Auto cook (custom)",
+ "deodorization": "Deodorize",
+ "keep_warm": "Keep warm"
+ }
+ },
"oven_mode": {
"name": "Cooking mode",
"state": {
@@ -368,6 +400,37 @@
"operating_mode": {
"name": "Operating mode"
},
+ "kimchi_zone_mode": {
+ "name": "{instance_name} storage mode",
+ "state": {
+ "off": "Off",
+ "kimchi_storage_normal": "Kimchi",
+ "kimchi_storage_cold": "Kimchi, strong",
+ "kimchi_storage_warm": "Kimchi, weak",
+ "kimchi_storage_low_salt_normal": "Low-salt kimchi",
+ "kimchi_storage_low_salt_cold": "Low-salt kimchi, strong",
+ "kimchi_storage_low_salt_warm": "Low-salt kimchi, weak",
+ "kimchi_storage_crunfch": "Crisp kimchi",
+ "kimchi_storage_buy": "Purchased kimchi",
+ "storage_fridge_normal": "Fridge",
+ "storage_fridge_cold": "Fridge, strong",
+ "storage_fridge_warm": "Fridge, weak",
+ "storage_freezer_normal": "Freezer",
+ "storage_freezer_cold": "Freezer, strong",
+ "storage_freezer_warm": "Freezer, weak",
+ "kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
+ "kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
+ "kimchi_ripe_kkakdugi": "Kkakdugi ripening",
+ "kimchi_ripe_dongchimi": "Dongchimi ripening",
+ "meat_ripe_normal": "Meat ripening",
+ "storage_meat": "Meat & fish",
+ "storage_fridge_vegetables_fruit": "Fruit & vegetables",
+ "storage_fresh_cereal": "Grains",
+ "storage_fridge_drink": "Beverages",
+ "storage_fresh_wine": "Wine",
+ "storage_fresh_potato_banana": "Potato & banana"
+ }
+ },
"pantry_zone_mode": {
"name": "Pantry zone mode",
"state": {
@@ -516,6 +579,9 @@
}
},
"sensor": {
+ "after_run_progress": {
+ "name": "After run progress"
+ },
"air_filter_usage": {
"name": "Filter usage"
},
@@ -559,9 +625,15 @@
"current_limit_level": {
"name": "Current limit level"
},
+ "filter_time": {
+ "name": "Filter time"
+ },
"hepa_filter_usage": {
"name": "HEPA filter usage"
},
+ "outdoor_temperature": {
+ "name": "Outdoor temperature"
+ },
"panel_status": {
"name": "Panel status"
},
@@ -668,6 +740,15 @@
"fridge_temperature": {
"name": "Fridge temperature"
},
+ "kimchi_ripening_status": {
+ "name": "{instance_name} ripening status"
+ },
+ "kimchi_ripening_remaining": {
+ "name": "{instance_name} ripening time remaining"
+ },
+ "kimchi_rack_count": {
+ "name": "{instance_name} rack count"
+ },
"hood_filter_capacity": {
"name": "Filter capacity"
},
@@ -710,6 +791,12 @@
"oven_state": {
"name": "Cavity state"
},
+ "cavity_state": {
+ "name": "Cavity state"
+ },
+ "power_level": {
+ "name": "Power level"
+ },
"paired_hood_fan_speed": {
"name": "Paired hood fan speed"
},
@@ -801,6 +888,9 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
+ "air_monitoring": {
+ "name": "Air monitoring"
+ },
"air_purify": {
"name": "Air purification"
},
@@ -822,6 +912,9 @@
"dustbin_auto_close": {
"name": "Dustbin auto-close"
},
+ "spi": {
+ "name": "Purifying ion"
+ },
"uvc_intensive_mode": {
"name": "UV-C intensive mode"
},
@@ -870,9 +963,6 @@
"favorite_coffee_enabled": {
"name": "Favorite coffee"
},
- "favorite_hotwater_enabled": {
- "name": "Favorite hot water"
- },
"fridge_sound": {
"name": "Sound"
},
diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json
index b74eb7d..fdccfc1 100644
--- a/custom_components/localthings/translations/nl.json
+++ b/custom_components/localthings/translations/nl.json
@@ -1,6 +1,9 @@
{
"entity": {
"binary_sensor": {
+ "after_run_active": {
+ "name": "Nadraaien actief"
+ },
"any_burner_active": {
"name": "Een brander actief"
},
@@ -93,6 +96,9 @@
}
},
"button": {
+ "after_run_cancel": {
+ "name": "Nadraaien annuleren"
+ },
"diagnosis_start": {
"name": "Diagnose starten"
},
@@ -128,7 +134,8 @@
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
- "drycomfort": "Droog comfort"
+ "drycomfort": "Droog comfort",
+ "2step": "2-Step"
}
}
}
@@ -150,6 +157,9 @@
}
},
"number": {
+ "buzzer_volume": {
+ "name": "Zoemervolume"
+ },
"cook_time": {
"name": "Bereidingstijd"
},
@@ -159,12 +169,18 @@
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
+ "good_sleep": {
+ "name": "Good Sleep"
+ },
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
"oven_setpoint": {
"name": "Instelpunt"
},
+ "setpoint": {
+ "name": "Instelpunt"
+ },
"sound_volume": {
"name": "Geluidsvolume"
},
@@ -350,6 +366,22 @@
"high": "Hoog"
}
},
+ "cooking_mode": {
+ "name": "Bereidingsmodus",
+ "state": {
+ "no_operation": "Niet actief",
+ "micro_wave": "Magnetron",
+ "micro_wave_grill": "Magnetron + grill",
+ "micro_wave_convection": "Magnetron + hetelucht",
+ "convection": "Hetelucht",
+ "air_fryer": "Airfryen",
+ "grill": "Grillen",
+ "autocook": "Automatisch koken",
+ "autocook_custom": "Automatisch koken (aangepast)",
+ "deodorization": "Geurverwijdering",
+ "keep_warm": "Warmhouden"
+ }
+ },
"oven_mode": {
"name": "Bereidingsmodus",
"state": {
@@ -368,6 +400,37 @@
"operating_mode": {
"name": "Bedrijfsmodus"
},
+ "kimchi_zone_mode": {
+ "name": "{instance_name} opslagmodus",
+ "state": {
+ "off": "Uit",
+ "kimchi_storage_normal": "Kimchi",
+ "kimchi_storage_cold": "Kimchi, sterk",
+ "kimchi_storage_warm": "Kimchi, zwak",
+ "kimchi_storage_low_salt_normal": "Kimchi met weinig zout",
+ "kimchi_storage_low_salt_cold": "Kimchi met weinig zout, sterk",
+ "kimchi_storage_low_salt_warm": "Kimchi met weinig zout, zwak",
+ "kimchi_storage_crunfch": "Knapperige kimchi",
+ "kimchi_storage_buy": "Gekochte kimchi",
+ "storage_fridge_normal": "Koelkast",
+ "storage_fridge_cold": "Koelkast, sterk",
+ "storage_fridge_warm": "Koelkast, zwak",
+ "storage_freezer_normal": "Vriezer",
+ "storage_freezer_cold": "Vriezer, sterk",
+ "storage_freezer_warm": "Vriezer, zwak",
+ "kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
+ "kimchi_ripe_normal_temp": "Kimchi rijpen, kamertemperatuur",
+ "kimchi_ripe_kkakdugi": "Kkakdugi rijpen",
+ "kimchi_ripe_dongchimi": "Dongchimi rijpen",
+ "meat_ripe_normal": "Vlees rijpen",
+ "storage_meat": "Vlees en vis",
+ "storage_fridge_vegetables_fruit": "Groenten en fruit",
+ "storage_fresh_cereal": "Granen",
+ "storage_fridge_drink": "Dranken",
+ "storage_fresh_wine": "Wijn",
+ "storage_fresh_potato_banana": "Aardappelen en bananen"
+ }
+ },
"pantry_zone_mode": {
"name": "Modus voorraadzone",
"state": {
@@ -516,6 +579,9 @@
}
},
"sensor": {
+ "after_run_progress": {
+ "name": "Voortgang nadraaien"
+ },
"air_filter_usage": {
"name": "Filterverbruik"
},
@@ -559,9 +625,15 @@
"current_limit_level": {
"name": "Niveau stroombegrenzing"
},
+ "filter_time": {
+ "name": "Filtertijd"
+ },
"hepa_filter_usage": {
"name": "HEPA-filtergebruik"
},
+ "outdoor_temperature": {
+ "name": "Buitentemperatuur"
+ },
"panel_status": {
"name": "Paneelstatus"
},
@@ -668,6 +740,15 @@
"fridge_temperature": {
"name": "Koelkasttemperatuur"
},
+ "kimchi_ripening_status": {
+ "name": "{instance_name} rijpingsstatus"
+ },
+ "kimchi_ripening_remaining": {
+ "name": "{instance_name} resterende rijptijd"
+ },
+ "kimchi_rack_count": {
+ "name": "{instance_name} aantal rekken"
+ },
"hood_filter_capacity": {
"name": "Filtercapaciteit"
},
@@ -710,6 +791,12 @@
"oven_state": {
"name": "Status ovenruimte"
},
+ "cavity_state": {
+ "name": "Status ovenruimte"
+ },
+ "power_level": {
+ "name": "Vermogensniveau"
+ },
"paired_hood_fan_speed": {
"name": "Ventilatorsnelheid gekoppelde afzuigkap"
},
@@ -801,6 +888,9 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
+ "air_monitoring": {
+ "name": "Luchtmonitoring"
+ },
"air_purify": {
"name": "Luchtzuivering"
},
@@ -822,6 +912,9 @@
"dustbin_auto_close": {
"name": "Stofreservoir automatisch sluiten"
},
+ "spi": {
+ "name": "Ionisatie"
+ },
"uvc_intensive_mode": {
"name": "Intensieve UV-C-modus"
},
@@ -870,9 +963,6 @@
"favorite_coffee_enabled": {
"name": "Favoriete koffie"
},
- "favorite_hotwater_enabled": {
- "name": "Favoriet warm water"
- },
"fridge_sound": {
"name": "Geluid"
},
diff --git a/tests/fixtures/airconditioner_artik051_krac_18k_device.json b/tests/fixtures/airconditioner_artik051_krac_18k_device.json
new file mode 100644
index 0000000..a607a2c
--- /dev/null
+++ b/tests/fixtures/airconditioner_artik051_krac_18k_device.json
@@ -0,0 +1,232 @@
+{
+ "device0": [
+ {
+ "rt": [
+ "x.com.samsung.devcol",
+ "oic.wk.col"
+ ],
+ "if": [
+ "oic.if.baseline",
+ "oic.if.ll",
+ "oic.if.b"
+ ]
+ },
+ {
+ "href": "/airflow/0",
+ "rep": {
+ "speed": 3,
+ "direction": "Fix"
+ }
+ },
+ {
+ "href": "/airflow/vs/0",
+ "rep": {
+ "x.com.samsung.da.speedLevel": "3",
+ "x.com.samsung.da.direction": "Fix"
+ }
+ },
+ {
+ "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-28T00:18:15",
+ "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-28T00:18:15",
+ "x.com.samsung.da.state": "Deleted"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/configuration/vs/0",
+ "rep": {
+ "x.com.samsung.da.region": "0000000000"
+ }
+ },
+ {
+ "href": "/diagnosis/vs/0",
+ "rep": {
+ "x.com.samsung.da.diagnosisStart": "Ready"
+ }
+ },
+ {
+ "href": "/energy/consumption/0",
+ "rep": {}
+ },
+ {
+ "href": "/energy/consumption/vs/0",
+ "rep": {
+ "x.com.samsung.da.cumulativePower": "0"
+ }
+ },
+ {
+ "href": "/file/information/vs/0",
+ "rep": {
+ "x.com.samsung.timeoffset": "+01:00",
+ "x.com.samsung.supprtedtype": 1
+ }
+ },
+ {
+ "href": "/humidity/0",
+ "rep": {
+ "humidity": 0
+ }
+ },
+ {
+ "href": "/humidity/vs/0",
+ "rep": {
+ "x.com.samsung.da.humidity": "0"
+ }
+ },
+ {
+ "href": "/information/vs/0",
+ "rep": {
+ "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
+ "x.com.samsung.da.description": "ARTIK051_KRAC_18K",
+ "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": "Version",
+ "x.com.samsung.da.type": "Software",
+ "x.com.samsung.da.number": "02016A200825",
+ "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": "18020800,17120500",
+ "x.com.samsung.da.newVersionAvailable": "0"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/mode/0",
+ "rep": {
+ "supportedModes": [
+ "Cool",
+ "Dry",
+ "Wind",
+ "Auto",
+ "Heat",
+ "HOMECARE_WIZARD_V2"
+ ],
+ "modes": [
+ "Cool"
+ ]
+ }
+ },
+ {
+ "href": "/mode/vs/0",
+ "rep": {
+ "x.com.samsung.da.supportedModes": [
+ "Cool",
+ "Dry",
+ "Wind",
+ "Auto",
+ "Heat",
+ "HOMECARE_WIZARD_V2"
+ ],
+ "x.com.samsung.da.modes": [
+ "Cool"
+ ],
+ "x.com.samsung.da.options": [
+ "Comode_Off",
+ "Sleep_0",
+ "OutdoorTemp_74",
+ "CoolCapa_25",
+ "WarmCapa_32",
+ "Spi_Off",
+ "Autoclean_Off",
+ "Light_Off",
+ "Volume_100",
+ "AirMonitoring_Off",
+ "AutocleanProgress_1",
+ "StopAutoClean_Idle",
+ "FilterTime_1715",
+ "FilterAlarmTime_500",
+ "OptionCode_35882",
+ "ExtendOptionCode_7",
+ "RacInfo_None",
+ "UpdateAllow_NotAllowed"
+ ]
+ }
+ },
+ {
+ "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": "/temperature/current/0",
+ "rep": {
+ "range": [
+ 16.0,
+ 30.0
+ ],
+ "units": "C",
+ "temperature": 21.0
+ }
+ },
+ {
+ "href": "/temperature/desired/0",
+ "rep": {
+ "range": [
+ 16.0,
+ 30.0
+ ],
+ "units": "C",
+ "temperature": 20.0
+ }
+ },
+ {
+ "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": "20",
+ "x.com.samsung.da.current": "21",
+ "x.com.samsung.da.maximum": "30",
+ "x.com.samsung.da.minimum": "16",
+ "x.com.samsung.da.unit": "Celsius"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json
index 91ce610..05e3410 100644
--- a/tests/fixtures/golden/air_purifier.json
+++ b/tests/fixtures/golden/air_purifier.json
@@ -1,24 +1,18 @@
{
"state_keys": [
+ "airflow_fan",
"alarm_code",
"clean_level",
"device_active",
"diagnosis_status",
"display_light",
"dust",
- "energy_kwh",
- "energy_last_month_kwh",
- "energy_saved_kwh",
- "energy_this_month_kwh",
"fan_direction",
- "fan_speed_level",
"filter_progress",
"fine_dust",
"odor",
"operating_mode",
- "power_energy_kwh",
"power_switch",
- "power_watts",
"super_fine_dust"
]
}
diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json
new file mode 100644
index 0000000..c7fabfb
--- /dev/null
+++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json
@@ -0,0 +1,18 @@
+{
+ "state_keys": [
+ "air_monitoring",
+ "alarm_code",
+ "auto_clean_legacy",
+ "buzzer_volume",
+ "climate",
+ "current_temperature_c",
+ "diagnosis_status",
+ "display_light",
+ "energy_kwh",
+ "filter_time",
+ "good_sleep",
+ "humidity",
+ "outdoor_temperature",
+ "spi"
+ ]
+}
diff --git a/tests/fixtures/golden/microwave_me7500d.json b/tests/fixtures/golden/microwave_me7500d.json
new file mode 100644
index 0000000..40df21d
--- /dev/null
+++ b/tests/fixtures/golden/microwave_me7500d.json
@@ -0,0 +1,22 @@
+{
+ "state_keys": [
+ "alarm_code",
+ "cavity_state",
+ "child_lock",
+ "cloud_connected",
+ "cook_time",
+ "cooking_mode",
+ "cycle_active",
+ "door_open",
+ "energy_kwh",
+ "fan",
+ "finish_time",
+ "firmware_update",
+ "lamp",
+ "machine_state",
+ "operation_time_minutes",
+ "power_level",
+ "progress_percentage",
+ "sound"
+ ]
+}
diff --git a/tests/fixtures/golden/oven_mw7300b.json b/tests/fixtures/golden/microwave_mw7300b.json
similarity index 75%
rename from tests/fixtures/golden/oven_mw7300b.json
rename to tests/fixtures/golden/microwave_mw7300b.json
index 435dec4..f648bde 100644
--- a/tests/fixtures/golden/oven_mw7300b.json
+++ b/tests/fixtures/golden/microwave_mw7300b.json
@@ -1,25 +1,23 @@
{
"state_keys": [
"alarm_code",
+ "cavity_state",
"child_lock",
"cloud_connected",
"cook_time",
+ "cooking_mode",
"current_temp_c",
"cycle_active",
"door_open",
"energy_kwh",
- "fast_preheat",
"finish_time",
"firmware_update",
- "lamp",
"machine_state",
- "natural_steam",
"operation_time_minutes",
- "oven_mode",
- "oven_setpoint",
- "oven_state",
+ "power_level",
"progress_percentage",
"remote_control",
+ "setpoint",
"sound"
]
-}
\ No newline at end of file
+}
diff --git a/tests/fixtures/golden/range_hood.json b/tests/fixtures/golden/range_hood.json
index 2753cb3..f7f0d8b 100644
--- a/tests/fixtures/golden/range_hood.json
+++ b/tests/fixtures/golden/range_hood.json
@@ -1,5 +1,7 @@
{
"state_keys": [
+ "after_run_active",
+ "after_run_progress",
"air_sensing_state",
"alarm_code",
"auto_ventilation_action",
diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json
index 6174968..6c50fe9 100644
--- a/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json
+++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref.json
@@ -5,16 +5,10 @@
"defrost_delay",
"diagnosis_status",
"door_onedoorfreezer_open",
- "energy_kwh",
- "energy_last_month_kwh",
- "energy_saved_kwh",
- "energy_this_month_kwh",
"firmware_update",
"freezer_setpoint",
"freezer_temperature",
"ice_maker_enabled",
- "power_energy_kwh",
- "power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
diff --git a/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json
index 984569c..44d2d96 100644
--- a/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json
+++ b/tests/fixtures/golden/refrigerator_artik051_dongle_ref_cooler.json
@@ -8,14 +8,8 @@
"diagnosis_status",
"door_cooler_open",
"door_onedoorfreezer_open",
- "energy_kwh",
- "energy_last_month_kwh",
- "energy_saved_kwh",
- "energy_this_month_kwh",
"firmware_update",
"ice_maker_enabled",
- "power_energy_kwh",
- "power_watts",
"rapid_freezing",
"rapid_fridge",
"sabbath_mode"
diff --git a/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json
new file mode 100644
index 0000000..e456a64
--- /dev/null
+++ b/tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json
@@ -0,0 +1,24 @@
+{
+ "state_keys": [
+ "alarm_code",
+ "bottom_mode",
+ "bottom_rack_count",
+ "bottom_ripening_remaining",
+ "bottom_ripening_status",
+ "diagnosis_status",
+ "door_open",
+ "energy_kwh",
+ "firmware_update",
+ "middle_mode",
+ "middle_rack_count",
+ "middle_ripening_remaining",
+ "middle_ripening_status",
+ "power_energy_kwh",
+ "power_watts",
+ "top_mode",
+ "top_open",
+ "top_rack_count",
+ "top_ripening_remaining",
+ "top_ripening_status"
+ ]
+}
diff --git a/tests/fixtures/golden/water_purifier_coffee.json b/tests/fixtures/golden/water_purifier_coffee.json
index 3b8003b..42a5035 100644
--- a/tests/fixtures/golden/water_purifier_coffee.json
+++ b/tests/fixtures/golden/water_purifier_coffee.json
@@ -9,7 +9,6 @@
"favorite_capacity",
"favorite_capacity_enabled",
"favorite_coffee_enabled",
- "favorite_hotwater_enabled",
"favorite_hotwater_temperature",
"filter_clean_remain_time",
"filter_door_status",
diff --git a/tests/fixtures/microwave_me7500d_device.json b/tests/fixtures/microwave_me7500d_device.json
new file mode 100644
index 0000000..d37c591
--- /dev/null
+++ b/tests/fixtures/microwave_me7500d_device.json
@@ -0,0 +1,201 @@
+{
+ "device0": [
+ {
+ "rt": ["x.com.samsung.devcol", "oic.wk.col"],
+ "if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
+ },
+ {
+ "href": "/otninformation/vs/0",
+ "rep": {
+ "x.com.samsung.da.target": "",
+ "x.com.samsung.da.newVersionAvailable": "false",
+ "flashingProgress": "",
+ "otnStatus": "None",
+ "otnList": [
+ {
+ "type": "WIFI",
+ "modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
+ "versions": ["40241114"],
+ "visVersion": "241114"
+ },
+ {
+ "type": "Micom",
+ "modelId": "07424047334140473241",
+ "versions": ["24071000", "23120700"],
+ "visVersion": "240710"
+ },
+ {
+ "type": "Micom",
+ "modelId": "074240475443FFFFFFFF",
+ "versions": ["23120800", "FFFFFFFF"],
+ "visVersion": "231208"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/connected/vs/0",
+ "rep": {
+ "x.com.samsung.da.connected": "On",
+ "rt": ["x.com.samsung.da.connected"],
+ "if": ["oic.if.baseline", "oic.if.s"]
+ }
+ },
+ {
+ "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"
+ }
+ ],
+ "rt": ["x.com.samsung.da.doors"],
+ "if": ["oic.if.baseline", "oic.if.s"]
+ }
+ },
+ {
+ "href": "/information/vs/0",
+ "rep": {
+ "x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000",
+ "x.com.samsung.da.description": "ME7500D-/AA1",
+ "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": "Version",
+ "x.com.samsung.da.type": "Software",
+ "x.com.samsung.da.number": "24111400",
+ "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": "04733A24071000, 04732A23120700",
+ "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.number": "04754C23120800",
+ "x.com.samsung.da.newVersionAvailable": "0"
+ }
+ ],
+ "x.com.samsung.da.diagProtocolType": "BLE_OCF",
+ "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": "KM5",
+ "x.com.samsung.da.diagMinVersion": "3.0",
+ "x.com.samsung.da.diagTsId": "DA01"
+ }
+ },
+ {
+ "href": "/mode/vs/0",
+ "rep": {
+ "x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"],
+ "x.com.samsung.da.modes": ["NoOperation"],
+ "x.com.samsung.da.options": [
+ "DeviceType_ME7500D-/AA1",
+ "TimeAutoSync_On",
+ "weight_LBS",
+ "TimeSystem_12",
+ "Sound_On",
+ "RemindBeep_On",
+ "FilterRemind_Off",
+ "Lamp_Off"
+ ],
+ "x.com.samsung.da.defaultMode": "MicroWave",
+ "rt": ["x.com.samsung.da.mode"],
+ "if": ["oic.if.baseline", "oic.if.a"]
+ }
+ },
+ {
+ "href": "/operational/state/vs/0",
+ "rep": {
+ "x.com.samsung.da.state": "Ready",
+ "x.com.samsung.da.operationTime": "00:00:00",
+ "x.com.samsung.da.remainingTime": "00:00:00",
+ "x.com.samsung.da.progressPercentage": "0",
+ "rt": ["x.com.samsung.da.operation"],
+ "if": ["oic.if.baseline", "oic.if.a"]
+ }
+ },
+ {
+ "href": "/alarms/vs/0",
+ "rep": {
+ "rt": ["x.com.samsung.da.alarms"],
+ "if": ["oic.if.baseline", "oic.if.s"],
+ "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": "OV_E_OFF",
+ "x.com.samsung.da.triggeredTime": "2026-07-27T20:03:10"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/oven/vs/0",
+ "rep": {
+ "x.com.samsung.da.state": "Ready",
+ "x.com.samsung.da.recipe": "00000000000000",
+ "x.com.samsung.da.powerLevel": "0",
+ "rt": ["x.com.samsung.da.oven"],
+ "if": ["oic.if.baseline", "oic.if.s"]
+ }
+ },
+ {
+ "href": "/timezone/vs/0",
+ "rep": {
+ "timezoneid": "America/New_York",
+ "offset": "-04:00",
+ "DST": "ON"
+ }
+ },
+ {
+ "href": "/energy/consumption/vs/0",
+ "rep": {
+ "x.com.samsung.da.instantaneousPower": "-500",
+ "x.com.samsung.da.instantaneousPowerUnit": "W",
+ "x.com.samsung.da.cumulativePower": "3400",
+ "x.com.samsung.da.cumulativeUnit": "Wh"
+ }
+ },
+ {
+ "href": "/hood/fanspeed/vs/0",
+ "rep": {
+ "x.com.samsung.da.hood.fanSpeed": "0",
+ "x.com.samsung.da.hood.supportedFanSpeed": ["0", "1", "2", "3", "4"],
+ "x.com.samsung.da.hood.settableMaxFanSpeed": "4",
+ "x.com.samsung.da.hood.settableMinFanSpeed": "0"
+ }
+ },
+ {
+ "href": "/kidslock/vs/0",
+ "rep": {
+ "x.com.samsung.da.kidsLock": "Ready"
+ }
+ },
+ {
+ "href": "/wirelessinfo/vs/0",
+ "rep": {
+ "macaddressWiFi": "**REDACTED**",
+ "macaddressBLE": "**REDACTED**"
+ }
+ },
+ {
+ "href": "/quickcontrol/info/vs/0",
+ "rep": {
+ "supportedVersion": "1.0"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/oven_mw7300b_device.json b/tests/fixtures/microwave_mw7300b_device.json
similarity index 100%
rename from tests/fixtures/oven_mw7300b_device.json
rename to tests/fixtures/microwave_mw7300b_device.json
diff --git a/tests/fixtures/range_hood_device.json b/tests/fixtures/range_hood_device.json
index 88607e6..cea8e2b 100644
--- a/tests/fixtures/range_hood_device.json
+++ b/tests/fixtures/range_hood_device.json
@@ -160,6 +160,14 @@
"href": "/da/softreset/vs/0",
"rep": {"x.com.samsung.da.softwarereset": "false"}
},
+ {
+ "href": "/afterrun/vs/0",
+ "rep": {
+ "x.com.samsung.da.runningProgress": "0",
+ "x.com.samsung.da.runningCancel": "Cancel",
+ "x.com.samsung.da.activationState": "Off"
+ }
+ },
{
"href": "/filter/hoodfilter/vs/0",
"rep": {
diff --git a/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json
new file mode 100644
index 0000000..a1d4a28
--- /dev/null
+++ b/tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json
@@ -0,0 +1,274 @@
+{
+ "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": "/diagnosis/vs/0",
+ "rep": {
+ "x.com.samsung.da.diagnosisStart": "Ready"
+ }
+ },
+ {
+ "href": "/energy/consumption/vs/0",
+ "rep": {
+ "x.com.samsung.da.cumulativeConsumption": "5",
+ "x.com.samsung.da.cumulativePower": "1733313",
+ "x.com.samsung.da.cumulativeUnit": "Wh",
+ "x.com.samsung.da.instantaneousPower": "61",
+ "x.com.samsung.da.instantaneousPowerUnit": "W"
+ }
+ },
+ {
+ "href": "/mode/vs/0",
+ "rep": {
+ "x.com.samsung.da.supportedModes": [
+ "HOMECARE_WIZARD_V2",
+ "ENERGY_REPORT_MODEL",
+ "18K_KIMCHI_OUTDOOR_CONTROL"
+ ],
+ "x.com.samsung.da.modes": [
+ "KIMCHIT_STORAGE_FREEZER_NORMAL",
+ "KIMCHIT_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_NORMAL",
+ "KIMCHIM_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_NORMAL",
+ "KIMCHIB_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIT_BOX_COUNT_[6]",
+ "KIMCHIM_BOX_COUNT_[4]",
+ "KIMCHIB_BOX_COUNT_[2]",
+ "KIMCHI_BOX_COUNT_[0]"
+ ],
+ "x.com.samsung.da.supportedOptions": [
+ "KIMCHIT_KIMCHI_STORAGE_NORMAL_[0]:[0]",
+ "KIMCHIT_KIMCHI_STORAGE_COLD_[0]:[0]",
+ "KIMCHIT_KIMCHI_STORAGE_WARM_[0]:[0]",
+ "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
+ "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
+ "KIMCHIT_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
+ "KIMCHIT_STORAGE_FRIDGE_NORMAL_[0]:[0]",
+ "KIMCHIT_STORAGE_FRIDGE_COLD_[0]:[0]",
+ "KIMCHIT_STORAGE_FRIDGE_WARM_[0]:[0]",
+ "KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]",
+ "KIMCHIT_STORAGE_FREEZER_COLD_[0]:[0]",
+ "KIMCHIT_STORAGE_FREEZER_WARM_[0]:[0]",
+ "KIMCHIT_OFF_[0]:[0]",
+ "KIMCHIT_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
+ "KIMCHIT_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
+ "KIMCHIM_KIMCHI_STORAGE_NORMAL_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_COLD_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_WARM_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_BUY_[0]:[0]",
+ "KIMCHIM_MEAT_RIPE_NORMAL_[3]:[0]",
+ "KIMCHIM_STORAGE_MEAT_[0]:[0]",
+ "KIMCHIM_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
+ "KIMCHIM_STORAGE_FRESH_CEREAL_[0]:[0]",
+ "KIMCHIM_OFF_[0]:[0]",
+ "KIMCHIM_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
+ "KIMCHIM_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
+ "KIMCHIB_KIMCHI_STORAGE_NORMAL_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_COLD_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_WARM_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_BUY_[0]:[0]",
+ "KIMCHIB_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
+ "KIMCHIB_STORAGE_FRIDGE_DRINK_[0]:[0]",
+ "KIMCHIB_STORAGE_FRESH_WINE_[0]:[0]",
+ "KIMCHIB_STORAGE_FRESH_POTATO_BANANA_[0]:[0]",
+ "KIMCHIB_OFF_[0]:[0]",
+ "KIMCHIB_KIMCHI_RIPE_KKAKDUGI_[4]:[5]",
+ "KIMCHIB_KIMCHI_RIPE_DONGCHIMI_[7]:[6]"
+ ]
+ }
+ },
+ {
+ "href": "/mode/0",
+ "rep": {
+ "supportedModes": [
+ "HOMECARE_WIZARD_V2",
+ "ENERGY_REPORT_MODEL",
+ "18K_KIMCHI_OUTDOOR_CONTROL"
+ ],
+ "modes": [
+ "KIMCHIT_STORAGE_FREEZER_NORMAL",
+ "KIMCHIT_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIM_KIMCHI_STORAGE_NORMAL",
+ "KIMCHIM_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIB_KIMCHI_STORAGE_NORMAL",
+ "KIMCHIB_RIPE_REMAIN_[0]:[0]",
+ "KIMCHIT_BOX_COUNT_[6]",
+ "KIMCHIM_BOX_COUNT_[4]",
+ "KIMCHIB_BOX_COUNT_[2]",
+ "KIMCHI_BOX_COUNT_[0]"
+ ]
+ }
+ },
+ {
+ "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|00135941|10010221011411010103642021000000",
+ "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": "20082109,FFFFFFFF",
+ "x.com.samsung.da.newVersionAvailable": "0"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/file/information/vs/0",
+ "rep": {
+ "x.com.samsung.timeoffset": "+09:00",
+ "x.com.samsung.supprtedtype": 1
+ }
+ },
+ {
+ "href": "/doors/vs/0",
+ "rep": {
+ "x.com.samsung.da.items": [
+ {
+ "x.com.samsung.da.id": "4",
+ "x.com.samsung.da.description": "Door",
+ "x.com.samsung.da.openState": "Close"
+ }
+ ]
+ }
+ },
+ {
+ "href": "/kimchidoors/top/vs/0",
+ "rep": {
+ "x.com.samsung.da.openState": "Close"
+ }
+ },
+ {
+ "href": "/configuration/vs/0",
+ "rep": {
+ "x.com.samsung.da.countryCode": "",
+ "x.com.samsung.da.region": ""
+ }
+ },
+ {
+ "href": "/bespoke/vs/0",
+ "rep": {
+ "x.com.samsung.da.BespokeProduct": "On"
+ }
+ },
+ {
+ "href": "/otninformation/vs/0",
+ "rep": {
+ "x.com.samsung.da.target": "Micom",
+ "x.com.samsung.da.newVersionAvailable": "false"
+ }
+ },
+ {
+ "href": "/status/kimchi/top/vs/0",
+ "rep": {
+ "x.com.samsung.da.currentMode": "STORAGE_FREEZER_NORMAL",
+ "x.com.samsung.da.ripeStatus": "Off",
+ "x.com.samsung.da.ripeRemaintime": "0",
+ "x.com.samsung.da.rackCount": "6",
+ "x.com.samsung.da.supportMode": [
+ "KIMCHI_STORAGE_NORMAL",
+ "KIMCHI_STORAGE_COLD",
+ "KIMCHI_STORAGE_WARM",
+ "KIMCHI_STORAGE_LOW_SALT_NORMAL",
+ "KIMCHI_STORAGE_LOW_SALT_COLD",
+ "KIMCHI_STORAGE_LOW_SALT_WARM",
+ "STORAGE_FRIDGE_NORMAL",
+ "STORAGE_FRIDGE_COLD",
+ "STORAGE_FRIDGE_WARM",
+ "STORAGE_FREEZER_NORMAL",
+ "STORAGE_FREEZER_COLD",
+ "STORAGE_FREEZER_WARM",
+ "OFF",
+ "KIMCHI_RIPE_LOW_TEMP",
+ "KIMCHI_RIPE_NORMAL_TEMP"
+ ]
+ }
+ },
+ {
+ "href": "/status/kimchi/middle/vs/0",
+ "rep": {
+ "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
+ "x.com.samsung.da.ripeStatus": "Off",
+ "x.com.samsung.da.ripeRemaintime": "0",
+ "x.com.samsung.da.rackCount": "4",
+ "x.com.samsung.da.supportMode": [
+ "KIMCHI_STORAGE_NORMAL",
+ "KIMCHI_STORAGE_COLD",
+ "KIMCHI_STORAGE_WARM",
+ "KIMCHI_STORAGE_LOW_SALT_NORMAL",
+ "KIMCHI_STORAGE_LOW_SALT_COLD",
+ "KIMCHI_STORAGE_LOW_SALT_WARM",
+ "KIMCHI_STORAGE_CRUNFCH",
+ "KIMCHI_STORAGE_BUY",
+ "MEAT_RIPE_NORMAL",
+ "STORAGE_MEAT",
+ "STORAGE_FRIDGE_VEGETABLES_FRUIT",
+ "STORAGE_FRESH_CEREAL",
+ "OFF",
+ "KIMCHI_RIPE_LOW_TEMP",
+ "KIMCHI_RIPE_NORMAL_TEMP"
+ ]
+ }
+ },
+ {
+ "href": "/status/kimchi/bottom/vs/0",
+ "rep": {
+ "x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
+ "x.com.samsung.da.ripeStatus": "Off",
+ "x.com.samsung.da.ripeRemaintime": "0",
+ "x.com.samsung.da.rackCount": "2",
+ "x.com.samsung.da.supportMode": [
+ "KIMCHI_STORAGE_NORMAL",
+ "KIMCHI_STORAGE_COLD",
+ "KIMCHI_STORAGE_WARM",
+ "KIMCHI_STORAGE_LOW_SALT_NORMAL",
+ "KIMCHI_STORAGE_LOW_SALT_COLD",
+ "KIMCHI_STORAGE_LOW_SALT_WARM",
+ "KIMCHI_STORAGE_CRUNFCH",
+ "KIMCHI_STORAGE_BUY",
+ "STORAGE_FRIDGE_VEGETABLES_FRUIT",
+ "STORAGE_FRIDGE_DRINK",
+ "STORAGE_FRESH_WINE",
+ "STORAGE_FRESH_POTATO_BANANA",
+ "OFF",
+ "KIMCHI_RIPE_KKAKDUGI",
+ "KIMCHI_RIPE_DONGCHIMI"
+ ]
+ }
+ }
+ ]
+}
diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py
index 924933d..e9e6f45 100644
--- a/tests/localthings/test_coordinator.py
+++ b/tests/localthings/test_coordinator.py
@@ -11,9 +11,12 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
- CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
+ CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE,
+ SUMMARY_INTERVAL_S,
+)
+from custom_components.localthings.coordinator import (
+ LocalThingsCoordinator, _local_source_port,
)
-from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
remote_control_required_for_write,
@@ -1174,3 +1177,32 @@ async def test_send_command_operational_still_blocked_when_without_sc(
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == 'remote_control_disabled'
assert posted is False
+
+
+def test_local_source_port_stable_and_in_range() -> None:
+ """Same host always maps to the same port (stability is the whole point:
+ a reconnect must reuse the 5-tuple), and it stays in the documented window."""
+ for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'):
+ port = _local_source_port(host)
+ assert port == _local_source_port(host)
+ assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
+
+
+def test_local_source_port_unique_per_host_on_a_24() -> None:
+ """Distinct last octets -> distinct ports, so two devices on the same HA
+ host never share a source port (the lib's socket is unconnected, so a
+ shared port would cross-deliver datagrams)."""
+ ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)}
+ assert len(ports) == 254
+
+
+def test_local_source_port_ipv4_uses_last_octet() -> None:
+ """The IPv4 fast path is the last octet, not a hash."""
+ assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217
+
+
+def test_local_source_port_non_ipv4_falls_back_to_hash() -> None:
+ """A non-IPv4 host still yields a deterministic in-range port."""
+ port = _local_source_port('some-hostname')
+ assert port == _local_source_port('some-hostname')
+ assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF
diff --git a/tests/test_air_purifier_airflow_fan.py b/tests/test_air_purifier_airflow_fan.py
new file mode 100644
index 0000000..f92b0d3
--- /dev/null
+++ b/tests/test_air_purifier_airflow_fan.py
@@ -0,0 +1,105 @@
+"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56)."""
+
+from custom_components.localthings.fan import LocalThingsAirflowFan
+from custom_components.localthings.registry.by_type import air_purifier
+from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW
+from custom_components.localthings.registry.discovery import discover
+from custom_components.localthings.registry.entities import FanDesc
+from tests.conftest import _load_device
+
+
+class _FakeCoordinator:
+ device_serial = 'TEST-AIRFLOW-SERIAL'
+ device_info = {}
+ data = {}
+
+ def __init__(self, resources):
+ self.last_resources = resources
+ self.commands = []
+
+ def resource(self, href):
+ return self.last_resources.get(href, {})
+
+ async def async_send_command(self, bound, payload):
+ self.commands.append((bound, payload))
+
+
+def _entity(resources, coordinator=None):
+ bound = discover(
+ resources,
+ air_purifier.REGISTRY.capabilities,
+ air_purifier.REGISTRY.pattern_capabilities,
+ )
+ fan_bound = next(
+ item for item in bound
+ if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
+ )
+ return LocalThingsAirflowFan(
+ coordinator or _FakeCoordinator(resources), fan_bound,
+ )
+
+
+def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
+ entity = _entity(_load_device('air_purifier'))
+ assert entity.is_on is False
+ assert entity.speed_count == 5
+ assert entity.percentage == 0
+
+
+def test_active_codes_map_to_ordered_percentages():
+ """Confirmed via issue #56's second, properly-spaced diagnostics round:
+ /airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High."""
+ resources = _load_device('air_purifier')
+ resources['/power/0']['value'] = True
+
+ for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)):
+ resources['/airflow/0']['speed'] = code
+ assert _entity(resources).percentage == expected_percentage
+
+
+async def test_power_write_prefers_standard_resource_when_both_exist():
+ """Both /power/0 and /power/vs/0 are present on this family's dumps --
+ /power/0 must win, matching common.POWER_GENERIC's own preference (the
+ power_switch entity is unconditionally bound to /power/0 whenever it's
+ present, so writing here to /power/vs/0 first would leave the two
+ entities disagreeing until the next poll)."""
+ resources = _load_device('air_purifier')
+ coordinator = _FakeCoordinator(resources)
+ entity = _entity(resources, coordinator)
+
+ await entity.async_turn_on()
+
+ assert coordinator.commands[-1][1] == ('power', True, '/power/0')
+
+
+async def test_power_write_falls_back_to_vendor_resource():
+ resources = _load_device('air_purifier')
+ resources.pop('/power/0')
+ coordinator = _FakeCoordinator(resources)
+ entity = _entity(resources, coordinator)
+
+ await entity.async_turn_off()
+
+ assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
+
+
+async def test_set_percentage_writes_raw_speed_code():
+ resources = _load_device('air_purifier')
+ resources['/power/0']['value'] = True
+ coordinator = _FakeCoordinator(resources)
+ entity = _entity(resources, coordinator)
+
+ await entity.async_set_percentage(60)
+
+ assert coordinator.commands[-1][1] == ('speed', 2)
+
+
+async def test_set_percentage_zero_turns_off():
+ resources = _load_device('air_purifier')
+ resources['/power/0']['value'] = True
+ coordinator = _FakeCoordinator(resources)
+ entity = _entity(resources, coordinator)
+
+ await entity.async_set_percentage(0)
+
+ assert coordinator.commands[-1][1] == ('power', False, '/power/0')
diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py
index 7f483c8..8e85228 100644
--- a/tests/test_air_purifier_capabilities.py
+++ b/tests/test_air_purifier_capabilities.py
@@ -41,7 +41,7 @@ def test_expected_entities_present():
for key in (
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active',
- 'diagnosis_status', 'fan_speed_level', 'fan_direction',
+ 'diagnosis_status', 'airflow_fan', 'fan_direction',
'display_light', 'operating_mode',
):
assert key in state, key
@@ -114,3 +114,27 @@ def test_airflow_vs_fallback_only_binds_without_generic():
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
) is False
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
+
+
+def test_airflow_fan_write_contract():
+ """Confirmed via issue #56's second, properly-spaced diagnostics round
+ (two independent units, 60-90s apart per setting): /airflow/0's `speed`
+ is a clean, monotonic 0-4 code, so the write is a plain int passthrough
+ -- no named-preset table needed (see fan.py's LocalThingsAirflowFan)."""
+ fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'airflow_fan')
+ assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3})
+ assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == (
+ ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
+ )
+ assert fan_desc.write_fn(('power', False, '/power/0'), {}) == (
+ ['power', '0'], {'value': False},
+ )
+
+
+def test_airflow_speed_reads_from_dump():
+ """The fixture's /airflow/0 (power-off snapshot) reads speed=0, and
+ fan_direction stays a plain diagnostic (every dump seen reads 'Off'
+ regardless of fan setting)."""
+ state = _state()
+ assert state['airflow_fan'] == 0
+ assert state['fan_direction'] == 'Off'
diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py
new file mode 100644
index 0000000..2e71e89
--- /dev/null
+++ b/tests/test_airconditioner_artik051_krac.py
@@ -0,0 +1,234 @@
+"""ARTIK051_KRAC_18K room air conditioner (issue #136).
+
+This board generation predates every AC dump the registry was built from and
+differs from all of them in three ways, each covered below:
+
+* No ``/wind/*`` resources at all -- fan speed and vane direction share one
+ ``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as
+ ``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``).
+* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
+ token in ``/mode/vs/0``'s ``options`` array.
+* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
+ outdoor temperature, filter time) are ``options`` tokens too, where newer
+ boards have dedicated resources.
+
+Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
+by read-back on the unit this fixture is dumped from. The issue #136 unit is
+the same model with a slightly different token set (no ``Spi``,
+``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence
+gating handles the same way it handles newer boards.
+"""
+from custom_components.localthings.climate import LocalThingsClimate
+from custom_components.localthings.registry.adapter import flatten
+from custom_components.localthings.registry.by_type import (
+ airconditioner, for_device_by_model,
+)
+from custom_components.localthings.registry.capabilities.airconditioner import (
+ HREF_AIRFLOW, _option_number_write, _option_switch_write,
+)
+from custom_components.localthings.registry.discovery import discover
+from custom_components.localthings.registry.entities import ClimateDesc
+from tests.conftest import _load_device
+
+FIXTURE = 'airconditioner_artik051_krac_18k'
+MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000'
+
+
+class _FakeCoordinator:
+ device_serial = 'TEST-KRAC-SERIAL'
+ device_info = {}
+ data = {}
+
+ def __init__(self, resources):
+ self.last_resources = resources
+ self.commands = []
+
+ def resource(self, href):
+ return self.last_resources.get(href, {})
+
+ async def async_send_command(self, bound, payload):
+ self.commands.append((bound, payload))
+
+
+def _discover(resources, registry=airconditioner.REGISTRY):
+ unbound = []
+ bound = discover(resources, registry.capabilities,
+ registry.pattern_capabilities, log=unbound.append)
+ return bound, unbound
+
+
+def _state(fixture=FIXTURE):
+ resources = _load_device(fixture)
+ bound, _ = _discover(resources)
+ return flatten(bound, resources)
+
+
+def _climate(resources, coordinator=None):
+ bound, _ = _discover(resources)
+ climate_bound = next(
+ item for item in bound if isinstance(item.desc, ClimateDesc)
+ )
+ return LocalThingsClimate(
+ coordinator or _FakeCoordinator(resources), climate_bound,
+ )
+
+
+# -- device type --------------------------------------------------------------
+
+def test_krac_model_resolves_to_the_airconditioner_registry():
+ """The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the
+ underscore and 'RAC' -- and the consumer-prefix fallback only covers
+ washers/dryers/dishwashers, so this model resolved to 'unknown' and
+ exposed nothing but power."""
+ registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K')
+ assert registry is not None
+ assert registry.name == 'airconditioner'
+
+
+def test_no_unbound_hrefs():
+ _, unbound = _discover(_load_device(FIXTURE))
+ assert unbound == []
+
+
+# -- option-token entities ----------------------------------------------------
+
+def test_token_entities_present_with_calibrated_values():
+ state = _state()
+ # token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
+ # official app on this unit, which pins the scale (the .5 here is a later
+ # reading). Whether it counts up or down is deliberately not asserted --
+ # see the descriptor comment.
+ assert state['filter_time'] == 171.5
+ # token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
+ assert state['outdoor_temperature'] == 19.0
+ assert state['buzzer_volume'] == 100.0
+ assert state['good_sleep'] == 0.0
+ assert state['spi'] is False
+ assert state['auto_clean_legacy'] is False
+ assert state['air_monitoring'] is False
+
+
+def test_token_entities_stay_off_newer_boards():
+ """Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
+ while also exposing those settings as dedicated resources -- ungated, the
+ token entities would duplicate them (auto clean) or apply a scale
+ calibrated on another board generation (outdoor temperature)."""
+ state = _state('airconditioner_tp1x_rac')
+ for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume',
+ 'good_sleep', 'outdoor_temperature', 'filter_time'):
+ assert key not in state, key
+
+
+def test_absent_token_yields_no_entity():
+ """The issue #136 unit of this same model reports no Spi token."""
+ resources = _load_device(FIXTURE)
+ options = resources['/mode/vs/0']['x.com.samsung.da.options']
+ resources['/mode/vs/0']['x.com.samsung.da.options'] = [
+ option for option in options if not option.startswith('Spi_')
+ ]
+ bound, _ = _discover(resources)
+ assert 'spi' not in flatten(bound, resources)
+
+
+def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
+ """This board has no fivepercentHumidity field; the plain humidity field
+ only carries a reading while Air monitoring is on, and the unit switches
+ that back off by itself after about a minute."""
+ resources = _load_device(FIXTURE)
+ assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0'
+ bound, _ = _discover(resources)
+ assert flatten(bound, resources)['humidity'] is None
+
+ resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51'
+ bound, _ = _discover(resources)
+ assert flatten(bound, resources)['humidity'] == 51.0
+
+
+def test_option_writes_carry_one_token():
+ """Both go through option_write's single-token merge, the same mechanism
+ the display light already uses on this href. Confirmed on hardware by
+ read-back: Spi_On/Spi_Off, and the volume token surviving a write."""
+ assert _option_switch_write('Spi')('On', {}) == (
+ ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']},
+ )
+ # The number platform hands over a float; the device wants an integer token.
+ assert _option_number_write('Volume')(70.0, {}) == (
+ ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']},
+ )
+
+
+# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
+
+def test_fan_mode_reads_the_airflow_speed_level():
+ entity = _climate(_load_device(FIXTURE))
+ assert entity.fan_mode == 'high' # speedLevel 3 in the fixture
+ # No supportedModes on this resource, so the full 0-4 scale is offered.
+ assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo']
+
+
+def test_swing_mode_reads_the_airflow_direction():
+ resources = _load_device(FIXTURE)
+ entity = _climate(resources)
+ assert entity.swing_mode == 'off' # 'Fix' in the fixture
+
+ resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All'
+ assert _climate(resources).swing_mode == 'both'
+ assert 'both' in _climate(resources).swing_modes
+
+
+async def test_fan_and_swing_writes_target_the_airflow_resource():
+ resources = _load_device(FIXTURE)
+ coordinator = _FakeCoordinator(resources)
+ entity = _climate(resources, coordinator)
+
+ await entity.async_set_fan_mode('turbo')
+ await entity.async_set_swing_mode('both')
+
+ assert [payload for _, payload in coordinator.commands] == [
+ ('fan_legacy', '4'), ('swing_legacy', 'All'),
+ ]
+
+
+def test_preset_comes_from_the_comode_token():
+ resources = _load_device(FIXTURE)
+ entity = _climate(resources)
+ assert entity.preset_mode == 'none' # Comode_Off in the fixture
+ # Codes learned by driving this unit through its cloud integration and
+ # reading the token back. They go through the same dynamic resolver as a
+ # real convenient resource's supportedModes, so 'Nano' resolves to the
+ # existing 'nano' preset -- already labelled WindFree in the catalog.
+ assert entity.preset_modes == [
+ 'none', 'nano', 'quiet', 'comfort', '2step', 'speed',
+ ]
+
+ options = resources['/mode/vs/0']['x.com.samsung.da.options']
+ resources['/mode/vs/0']['x.com.samsung.da.options'] = [
+ 'Comode_Nano' if option.startswith('Comode_') else option
+ for option in options
+ ]
+ assert _climate(resources).preset_mode == 'nano'
+
+
+async def test_preset_write_uses_the_token_path():
+ resources = _load_device(FIXTURE)
+ coordinator = _FakeCoordinator(resources)
+ entity = _climate(resources, coordinator)
+
+ await entity.async_set_preset_mode('nano')
+
+ assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano')
+
+
+async def test_newer_boards_keep_the_resource_paths():
+ """The legacy fallbacks are gated on this board's resource shape, so a
+ board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
+ resources = _load_device('airconditioner_tp1x_rac')
+ coordinator = _FakeCoordinator(resources)
+ entity = _climate(resources, coordinator)
+
+ await entity.async_set_fan_mode('high')
+ await entity.async_set_swing_mode('off')
+ await entity.async_set_preset_mode('quiet') # from its own supportedModes
+
+ kinds = [payload[0] for _, payload in coordinator.commands]
+ assert kinds == ['fan', 'swing', 'preset']
diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py
index 630ccb2..15f1a47 100644
--- a/tests/test_airconditioner_capabilities.py
+++ b/tests/test_airconditioner_capabilities.py
@@ -405,7 +405,17 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent():
def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
desc = airconditioner.HUMIDITY.entities[0]
rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'}
- assert desc.value_fn(rep.get(desc.field)) == 42.0
+ assert desc.rep_fn(rep) == 42.0
+
+
+def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent():
+ """ARTIK051 boards (issue #136) have no fivepercentHumidity field at all.
+ Their plain field is not stuck -- it carries a reading while Air monitoring
+ is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
+ desc = airconditioner.HUMIDITY.entities[0]
+ assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0
+ assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None
+ assert desc.rep_fn({}) is None
# ---------------------------------------------------------------------------
@@ -634,3 +644,17 @@ def test_sensor_item_value_picks_first_value():
assert airconditioner._sensor_item_value(items, 'Odor') is None
assert airconditioner._sensor_item_value(items, 'Missing') is None
assert airconditioner._sensor_item_value(None, 'Dust') is None
+
+
+def test_beep_and_tropical_night_stay_off_legacy_krac_board():
+ """ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
+ option token, but they're already modeled as buzzer_volume/good_sleep
+ (see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
+ bind there, or the same options[] slot would surface as two entities."""
+ reg, resources = _resolve('airconditioner_artik051_krac_18k')
+ state = flatten(
+ discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
+ assert 'beep' not in state
+ assert 'tropical_night_mode' not in state
+ assert state['buzzer_volume'] == 100.0
+ assert state['good_sleep'] == 0.0
diff --git a/tests/test_batch.py b/tests/test_batch.py
new file mode 100644
index 0000000..d707662
--- /dev/null
+++ b/tests/test_batch.py
@@ -0,0 +1,56 @@
+"""Tests for registry.batch — the /device/0 sweep parser and its stub marker.
+
+issue #127: a device whose /energy/consumption/vs/0 is permanently
+unsupported reports a genuinely empty {} rep for it. The previous parser
+collapsed /device/0's own {"href": "..."} "not fetched yet" marker to that
+same {} shape, so downstream exists_fn checks couldn't tell "confirmed
+empty" apart from "haven't polled it yet" and created phantom always-
+"unknown" entities either way. is_stub_rep/parse_device0_batch now keep the
+two shapes distinct.
+"""
+from custom_components.localthings.registry.batch import is_stub_rep, parse_device0_batch
+
+
+class TestIsStubRep:
+ def test_true_for_bare_href_marker(self):
+ assert is_stub_rep({'href': '/energy/consumption/vs/0'}) is True
+
+ def test_false_for_genuinely_empty_rep(self):
+ assert is_stub_rep({}) is False
+
+ def test_false_for_populated_rep(self):
+ assert is_stub_rep({'x.com.samsung.da.cumulativePower': '58900'}) is False
+
+ def test_false_for_href_plus_data(self):
+ """A real, populated rep may legitimately echo 'href' alongside
+ actual fields -- only a rep with *no other keys* is the stub."""
+ assert is_stub_rep({'href': '/x/0', 'value': True}) is False
+
+
+class TestParseDevice0Batch:
+ def test_stub_rep_kept_distinct_from_genuine_empty(self):
+ device0 = [
+ {},
+ {'href': '/energy/consumption/vs/0', 'rep': {'href': '/energy/consumption/vs/0'}},
+ {'href': '/sabbath/vs/0', 'rep': {}},
+ ]
+ resources = parse_device0_batch(device0)
+ assert is_stub_rep(resources['/energy/consumption/vs/0']) is True
+ assert is_stub_rep(resources['/sabbath/vs/0']) is False
+ assert resources['/sabbath/vs/0'] == {}
+
+ def test_populated_rep_passes_through_unchanged(self):
+ device0 = [
+ {},
+ {'href': '/door/cooler/0', 'rep': {'openState': 'Close'}},
+ ]
+ resources = parse_device0_batch(device0)
+ assert resources['/door/cooler/0'] == {'openState': 'Close'}
+
+ def test_skips_entries_without_href(self):
+ device0 = [{}, {'rep': {'a': 1}}]
+ assert parse_device0_batch(device0) == {}
+
+ def test_skips_non_dict_rep(self):
+ device0 = [{}, {'href': '/x/0', 'rep': 'not-a-dict'}]
+ assert parse_device0_batch(device0) == {}
diff --git a/tests/test_by_type.py b/tests/test_by_type.py
index 15ac1e2..b7a476e 100644
--- a/tests/test_by_type.py
+++ b/tests/test_by_type.py
@@ -329,19 +329,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
- def test_oven_via_microwave_token(self):
- """Issue #121: a combi microwave (MW7300B-/EU1) reports no
+ def test_microwave_via_microwave_token(self):
+ """Issue #121/#66: a combi microwave (MW7300B-/EU1) reports no
oneUiVersion and an unrecognized consumer token; falls back to the
- '-MICROWAVE-' token in modelNum onto the *existing* oven registry
- (same '/oven/vs/0' cavity + cook-mode shape), not a new device
- type."""
+ '-MICROWAVE-' token in modelNum onto its own microwave registry
+ (initially folded into 'oven' for issue #121, split out into a
+ distinct device type per user feedback)."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000',
'MW7300B-/EU1',
)
assert reg is not None
- assert reg.name == 'oven'
+ assert reg.name == 'microwave'
def test_vacuum_station_via_vskr_token(self):
"""Issue #131: a stick-vacuum clean/auto-empty station
diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py
index 8f7bcad..2050a70 100644
--- a/tests/test_common_capabilities.py
+++ b/tests/test_common_capabilities.py
@@ -253,13 +253,24 @@ class TestEnergyMeter:
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
- def test_both_entities_included_on_empty_stub(self):
- """An empty {} rep means the resource exists but data isn't fetched yet
- (see entity._is_included) -- include both so sub-polls populate them."""
+ def test_both_entities_included_on_true_stub(self):
+ """A true stub -- /device/0's {"href": "..."} "not fetched yet"
+ marker (see registry.batch.is_stub_rep) -- means the resource exists
+ but data isn't fetched yet; include both so sub-polls populate them."""
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
- assert pw.exists_fn({}, {}) is True
- assert kwh.exists_fn({}, {}) is True
+ stub = {'href': '/energy/consumption/vs/0'}
+ assert pw.exists_fn(stub, {}) is True
+ assert kwh.exists_fn(stub, {}) is True
+
+ def test_both_entities_hidden_on_genuinely_empty_rep(self):
+ """A real {} rep (no 'href' key) is the device's confirmed -- if
+ empty -- answer, not a stub, so a model that never populates this
+ resource doesn't get a phantom always-"unknown" entity (issue #127)."""
+ pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
+ kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
+ assert pw.exists_fn({}, {}) is False
+ assert kwh.exists_fn({}, {}) is False
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
"""A populated rep that lacks instantaneousPower must not spawn a
@@ -388,13 +399,20 @@ class TestAiEnergyLevelStubDoesNotDecideThePlatform:
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 --
+ expecting a string option). Neither side gets an is_stub_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):
+ def test_neither_widget_exists_on_true_stub_rep(self):
+ switch = _ai_energy_level_desc('SwitchDesc')
+ select = _ai_energy_level_desc('SelectDesc')
+ stub = {'href': '/energy/ailevel/vs/0'}
+ assert switch.exists_fn(stub, {}) is False
+ assert select.exists_fn(stub, {}) is False
+
+ def test_neither_widget_exists_on_genuinely_empty_rep(self):
switch = _ai_energy_level_desc('SwitchDesc')
select = _ai_energy_level_desc('SelectDesc')
assert switch.exists_fn({}, {}) is False
@@ -416,11 +434,17 @@ class TestSelfCheckError:
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."""
+ def test_exists_for_true_stub_rep(self):
+ """A true stub ({"href": "..."}) is /device/0's not-yet-fetched
+ marker -- must be included-for-now, same as ENERGY_METER's fields."""
desc = self._desc()
- assert desc.exists_fn({}, {}) is True
+ assert desc.exists_fn({'href': '/selfcheck/vs/0'}, {}) is True
+
+ def test_hidden_for_genuinely_empty_rep(self):
+ """A real {} rep is the device's confirmed empty answer, not a stub --
+ must NOT be force-included (issue #127's phantom-entity pattern)."""
+ desc = self._desc()
+ assert desc.exists_fn({}, {}) is False
def test_value_joins_list(self):
desc = self._desc()
diff --git a/tests/test_entity.py b/tests/test_entity.py
new file mode 100644
index 0000000..1e0538b
--- /dev/null
+++ b/tests/test_entity.py
@@ -0,0 +1,79 @@
+"""Tests for entity._is_included -- the one-time entity-creation gate run
+per platform at async_setup_entry (see sensor.py etc.), separate from
+adapter.flatten()'s per-poll state values. issue #127 review: this default
+gate has to stay permissive on a genuinely empty {} rep, not just a true
+is_stub_rep stub -- a resource like /alarms/vs/0 is validly empty in its
+normal, no-alarm state (fridge._active_alarm_codes), so excluding the
+entity there would silently drop working sensors on first-poll timing,
+not just fix phantom ones.
+"""
+from custom_components.localthings.entity import _is_included
+from custom_components.localthings.registry.capability import Capability
+from custom_components.localthings.registry.discovery import BoundEntity
+from custom_components.localthings.registry.entities import SensorDesc
+
+
+class _FakeCoordinator:
+ def __init__(self, last_resources):
+ self.last_resources = last_resources
+
+
+def _bound(desc, href):
+ capability = Capability(href=href, entities=(desc,))
+ return BoundEntity(href=href, capability=capability, desc=desc)
+
+
+class TestDefaultFieldGate:
+ """No explicit exists_fn -- the field-presence default in entity.py."""
+
+ def _bound(self):
+ return _bound(SensorDesc(key='x', field='x.com.samsung.da.value'), '/x/vs/0')
+
+ def test_included_when_field_present(self):
+ bound = self._bound()
+ coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.value': '1'}})
+ assert _is_included(bound, coord) is True
+
+ def test_excluded_when_field_absent_from_populated_rep(self):
+ bound = self._bound()
+ coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.other': '1'}})
+ assert _is_included(bound, coord) is False
+
+ def test_included_on_true_stub(self):
+ bound = self._bound()
+ coord = _FakeCoordinator({'/x/vs/0': {'href': '/x/vs/0'}})
+ assert _is_included(bound, coord) is True
+
+ def test_included_on_genuinely_empty_rep(self):
+ """The default gate stays permissive on a real {} -- see
+ /alarms/vs/0, whose empty rep is the normal no-alarm state, not a
+ signal the hardware is unsupported. Only a capability-specific
+ exists_fn (e.g. common.ENERGY_METER) opts into excluding on
+ confirmed-empty, after verifying that's actually safe for its field."""
+ bound = self._bound()
+ coord = _FakeCoordinator({'/x/vs/0': {}})
+ assert _is_included(bound, coord) is True
+
+ def test_excluded_when_href_missing_from_resources(self):
+ bound = self._bound()
+ coord = _FakeCoordinator({})
+ assert _is_included(bound, coord) is False
+
+
+class TestExplicitExistsFnGate:
+ def test_explicit_exists_fn_overrides_default_field_gate(self):
+ desc = SensorDesc(key='x', field='x.com.samsung.da.value',
+ exists_fn=lambda rep, resources: rep.get('flag') is True)
+ bound = _bound(desc, '/x/vs/0')
+ coord = _FakeCoordinator({'/x/vs/0': {'flag': True}})
+ assert _is_included(bound, coord) is True
+ coord = _FakeCoordinator({'/x/vs/0': {'flag': False, 'x.com.samsung.da.value': '1'}})
+ assert _is_included(bound, coord) is False
+
+
+class TestNoFieldEntities:
+ def test_rep_fn_entity_always_included(self):
+ desc = SensorDesc(key='x', rep_fn=lambda rep: rep.get('x.com.samsung.da.value'))
+ bound = _bound(desc, '/x/vs/0')
+ coord = _FakeCoordinator({'/x/vs/0': {}})
+ assert _is_included(bound, coord) is True
diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py
index 94f7d96..3b6399e 100644
--- a/tests/test_fridge_capabilities.py
+++ b/tests/test_fridge_capabilities.py
@@ -163,6 +163,26 @@ class TestFlexZone:
assert 'CV_FDR_MEAT' not in modes
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
+ def test_exists_only_when_a_current_value_resolves(self):
+ """Issue #26's kimchi-refrigerator family also populates /mode/vs/0's
+ modes/supportedOptions, but its supportedOptions tokens carry a
+ "_[n]:[n]" suffix modes never repeats, so no item ever overlaps --
+ the entity used to bind anyway (supportedOptions is nonempty) and
+ get stuck on "unknown" forever."""
+ no_overlap_rep = {
+ 'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'],
+ 'x.com.samsung.da.supportedOptions': [
+ 'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'],
+ }
+ desc = fridge.FLEX_ZONE.entities[0]
+ assert desc.exists_fn(no_overlap_rep, {}) is False
+
+ overlap_rep = {
+ 'x.com.samsung.da.modes': ['CV_FDR_MEAT'],
+ 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
+ }
+ assert desc.exists_fn(overlap_rep, {}) is True
+
class TestTp1xNativeDuplicateResources:
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
@@ -210,6 +230,97 @@ class TestPantryZone:
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
+class TestKimchiZone:
+ """Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
+ top/middle/bottom each report an identically-shaped currentMode/
+ supportMode resource under /status/kimchi//vs/0."""
+
+ def test_href_prefix(self):
+ assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/'
+ assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/'
+
+ def test_write_derives_path_from_href(self):
+ desc = fridge.KIMCHI_ZONE.entities[0]
+ rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
+ path, body = desc.write_fn(
+ 'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0')
+ assert path == ['status', 'kimchi', 'middle', 'vs', '0']
+ assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'}
+
+ def test_write_without_href_is_rejected(self):
+ desc = fridge.KIMCHI_ZONE.entities[0]
+ rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
+ assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None
+
+ def test_write_rejects_value_outside_supportmode(self):
+ """A value the compartment never advertised is rejected rather than
+ written blind -- this write path is unconfirmed against real
+ hardware (module docstring above KIMCHI_ZONE), so a bad value here
+ is a food-safety-adjacent outcome, not just a cosmetic one."""
+ desc = fridge.KIMCHI_ZONE.entities[0]
+ rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
+ assert desc.write_fn(
+ 'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0',
+ ) is None
+
+ def test_ripening_status_passes_through_device_value(self):
+ """No device_class='enum' catalog entry exists for this sensor, so
+ lowercasing it would only make the raw device token un-translatable
+ by HA -- pass the device's own casing straight through instead."""
+ desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status')
+ assert desc.value_fn('Off') == 'Off'
+
+ def test_door_reuses_open_state_helper(self):
+ desc = fridge.KIMCHI_DOOR_GENERIC.entities[0]
+ assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
+ assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
+
+ async def test_zone_mode_select_round_trips_through_display_casing(self):
+ """kimchi_zone_mode's displayed value (lowercase, catalog-translated)
+ and the raw device code it writes back can silently drift apart --
+ this is the one place that casing conversion could break. Runs
+ through the real discovery/select pipeline against the tp2x_ref_20k
+ kimchi fixture rather than a hand-built descriptor, so it also
+ catches use_instance_name key derivation going wrong."""
+ 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 custom_components.localthings.registry.entities import SelectDesc
+ from custom_components.localthings.select import LocalThingsSelect
+ from tests.conftest import _load_device
+
+ resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
+ bound = discover(
+ resources, refrigerator.REGISTRY.capabilities,
+ refrigerator.REGISTRY.pattern_capabilities,
+ )
+ mode_bound = next(
+ b for b in bound
+ if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0'
+ )
+
+ class _FakeCoordinator:
+ device_serial = 'TEST-SERIAL'
+
+ def __init__(self, resources, data):
+ self.last_resources = resources
+ self.data = data
+ self.commands = []
+
+ async def async_send_command(self, bound, value):
+ self.commands.append(value)
+
+ coordinator = _FakeCoordinator(resources, flatten(bound, resources))
+ entity = LocalThingsSelect(coordinator, mode_bound)
+
+ assert entity.current_option == 'kimchi_storage_normal'
+ assert 'kimchi_storage_cold' in entity.options
+
+ await entity.async_select_option('kimchi_storage_cold')
+
+ assert coordinator.commands == ['KIMCHI_STORAGE_COLD']
+
+
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
the incomplete-capability-coverage repair; both must resolve to zero
@@ -253,6 +364,33 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
state = flatten(bound, resources)
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
+ def test_tp2x_ref_20k_kimchi(self):
+ """A different physical unit sharing the same modelNum string (issue
+ #26, second reporter) -- a 3-compartment kimchi refrigerator instead
+ of the flex-zone fridge above. /kimchidoors/top/vs/0 and
+ /status/kimchi/{top,middle,bottom}/vs/0 were previously unbound."""
+ 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_kimchi')
+ unbound = []
+ bound = discover(
+ resources,
+ refrigerator.REGISTRY.capabilities,
+ refrigerator.REGISTRY.pattern_capabilities,
+ log=unbound.append,
+ )
+ assert unbound == []
+ state = flatten(bound, resources)
+ assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL'
+ assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL'
+ assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL'
+ assert state['top_open'] is False
+ assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported
+ assert 'flex_zone_mode' not in state # no resolvable overlap on this family
+
class TestArtik051DongleRefFixtureCoverage:
"""Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves
diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py
index 96f7c32..917e12c 100644
--- a/tests/test_golden_regression.py
+++ b/tests/test_golden_regression.py
@@ -300,6 +300,23 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
)
+def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi():
+ """A different physical unit reporting the same "TP2X_REF_20K" modelNum
+ string as the fixture above (issue #26's second reporter) -- a
+ 3-compartment kimchi refrigerator with no flex zone, doors/icemaker, or
+ freezer/cooler split, but its own /status/kimchi//vs/0 and
+ /kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC)."""
+ from tests.conftest import _load_device
+ resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
+ golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text())
+ state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', 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
@@ -568,19 +585,44 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil
)
-def test_registry_reproduces_golden_state_keys_for_oven_mw7300b():
+def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
"""TP1X_DA-KS-MICROWAVE-01041 combi microwave (model MW7300B, issue
#121) -- reports no oneUiVersion; resolved via the '-MICROWAVE-'
- modelNum token fallback onto the *existing* oven registry rather than
- a new device type, since it shares the same '/oven/vs/0' cavity and
- '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*).
- The only href the oven registry didn't already cover was
- /recipe/cook/vs/0 (oven.OVEN_RECIPE_COOK, an empty quick-recipe-display
- blob with no entity)."""
+ modelNum token fallback onto its own microwave registry. Shares the
+ oven board family's operational-state/door/connected/recipe-cook
+ Capability objects (reused directly from oven.py), but has its own
+ cooking-mode vocabulary, setpoint bounds, and cavity power-level sensor
+ (capabilities/microwave.py) -- this device's initial routing folded it
+ into the oven registry (issue #121); split into its own device type per
+ user feedback that microwaves shouldn't show up as ovens."""
from tests.conftest import _load_device
- resources = _load_device('oven_mw7300b')
- golden = json.loads((GOLDEN / 'oven_mw7300b.json').read_text())
- state_keys = _new_state_keys('oven_mw7300b', resources)
+ resources = _load_device('microwave_mw7300b')
+ golden = json.loads((GOLDEN / 'microwave_mw7300b.json').read_text())
+ state_keys = _new_state_keys('microwave_mw7300b', 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_microwave_me7500d():
+ """TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
+ #137/#142) -- the same microwave registry as MW7300B above, but this
+ board also reports the built-in vent fan's `/hood/fanspeed/vs/0`
+ resource, previously unbound. Bound via range_hood.HOOD_FAN (reused
+ directly, same resource shape a standalone range hood reports); unlike
+ a standalone hood this board has no sibling `/power/0` or
+ `/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats
+ fan speed 0 as the off state instead of writing a separate power
+ resource -- see its `_speed_zero_is_off` check. This board also has
+ no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation`
+ field, unlike MW7300B, so `setpoint`/`current_temp_c` and
+ `automatic_operation` are correctly absent here."""
+ from tests.conftest import _load_device
+ resources = _load_device('microwave_me7500d')
+ golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text())
+ state_keys = _new_state_keys('microwave_me7500d', 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"
@@ -629,6 +671,27 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station():
)
+def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k():
+ """ARTIK051_KRAC_18K (issue #136) -- reports no oneUiVersion, and its
+ '_KRAC_' token was invisible to for_device_by_model's '_RAC_' check (the
+ 'K' sits between the underscore and 'RAC'), so it fell back to 'unknown'
+ and exposed nothing but power. Same ARTIK051 board generation as the
+ '_TVTL_' air purifier: no /wind/* resources at all (fan and vane live in
+ /airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token),
+ and several settings carried as /mode/vs/0 options[] tokens."""
+ from tests.conftest import _load_device
+ resources = _load_device('airconditioner_artik051_krac_18k')
+ golden = json.loads(
+ (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text()
+ )
+ state_keys = _new_state_keys('airconditioner_artik051_krac_18k', 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 = {
diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py
new file mode 100644
index 0000000..05f0b91
--- /dev/null
+++ b/tests/test_microwave_capabilities.py
@@ -0,0 +1,177 @@
+"""Unit tests for the microwave-family capabilities (issue #121/#66 split
+into their own device type instead of being folded into oven.py)."""
+from custom_components.localthings.registry.by_type import for_device_by_model
+from custom_components.localthings.registry.capabilities import microwave
+from custom_components.localthings.registry.discovery import discover
+
+
+# ---------------------------------------------------------------------------
+# Device-type detection + full-dump coverage
+# ---------------------------------------------------------------------------
+
+def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
+ from tests.conftest import _load_device
+ resources = _load_device('microwave_mw7300b')
+ info = resources['/information/vs/0']
+ reg = for_device_by_model(
+ info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
+ assert reg is not None
+ assert reg.name == 'microwave'
+
+ unbound = []
+ discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
+ assert unbound == []
+
+
+def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
+ """Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in
+ vent fan) was previously unbound on this family."""
+ from tests.conftest import _load_device
+ resources = _load_device('microwave_me7500d')
+ info = resources['/information/vs/0']
+ reg = for_device_by_model(
+ info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
+ assert reg is not None
+ assert reg.name == 'microwave'
+
+ unbound = []
+ discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
+ assert unbound == []
+
+
+# ---------------------------------------------------------------------------
+# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
+# ---------------------------------------------------------------------------
+
+def test_microwave_setpoint_write_is_read_modify_write():
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
+ path, body = desc.write_fn(180, rep)
+ assert path == ['temperatures', 'vs', '0']
+ assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
+
+
+def test_microwave_setpoint_rmw_preserves_other_item_fields():
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ rep = {'x.com.samsung.da.items': [{
+ 'x.com.samsung.da.current': '150',
+ 'x.com.samsung.da.desired': '150',
+ }]}
+ path, body = desc.write_fn(180, rep)
+ item = body['x.com.samsung.da.items'][0]
+ assert item['x.com.samsung.da.desired'] == '180'
+ assert item['x.com.samsung.da.current'] == '150'
+
+
+def test_microwave_setpoint_clamps_to_step():
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]}
+ _, body = desc.write_fn(182, rep) # nearest 5 = 180
+ assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180'
+
+
+def test_microwave_setpoint_rejects_out_of_range():
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]}
+ assert desc.write_fn(20, rep) is None # below min (40)
+ assert desc.write_fn(210, rep) is None # above max (200)
+
+
+def test_microwave_setpoint_rejects_missing_items():
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ assert desc.write_fn(180, {}) is None
+
+
+def test_microwave_setpoint_exists_only_for_celsius():
+ """No Fahrenheit dump exists for this family (unlike oven.py's, verified
+ against issue #44) -- the writable setpoint stays hidden rather than
+ showing unverified bounds under the wrong unit."""
+ desc = microwave.MICROWAVE_SETPOINT.entities[0]
+ celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
+ fahrenheit_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Fahrenheit'}]}
+ assert desc.exists_fn(celsius_rep, {}) is True
+ assert desc.exists_fn(fahrenheit_rep, {}) is False
+
+
+# ---------------------------------------------------------------------------
+# MICROWAVE_CAVITY — power_level sensor
+# ---------------------------------------------------------------------------
+
+def test_power_level_parses_watt_suffix():
+ """Issue #121's combi dump reports e.g. '0W'."""
+ desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
+ assert desc.value_fn('900W') == 900
+
+
+def test_power_level_parses_bare_number():
+ """Issue #137's plain microwave reports the bare number, no 'W' suffix."""
+ desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
+ assert desc.value_fn('0') == 0
+
+
+def test_power_level_handles_missing_value():
+ desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
+ assert desc.value_fn(None) is None
+
+
+# ---------------------------------------------------------------------------
+# MICROWAVE_MODE — SelectDesc with non-empty, family-specific options
+# ---------------------------------------------------------------------------
+
+def test_microwave_mode_options_nonempty():
+ desc = microwave.MICROWAVE_MODE.entities[0]
+ assert len(desc.options) > 0
+ assert 'MicroWave' in desc.options
+ assert 'AirFryer' in desc.options # distinct spelling from oven.py's 'AirFry'
+
+
+def test_microwave_mode_write_round_trips():
+ desc = microwave.MICROWAVE_MODE.entities[0]
+ path, body = desc.write_fn('MicroWave', {})
+ assert path == ['mode', 'vs', '0']
+ assert body['x.com.samsung.da.modes'] == ['MicroWave']
+
+
+def test_microwave_mode_rejects_unknown():
+ desc = microwave.MICROWAVE_MODE.entities[0]
+ assert desc.write_fn('SpaghettiMode', {}) is None
+
+
+# ---------------------------------------------------------------------------
+# MICROWAVE_MODE — lamp/sound options-array writes
+# ---------------------------------------------------------------------------
+
+def test_sound_write_is_single_token():
+ desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
+ rep = {'x.com.samsung.da.options': ['Sound_On']}
+ path, body = desc.write_fn('Off', rep)
+ assert path == ['mode', 'vs', '0']
+ assert body == {'x.com.samsung.da.options': ['Sound_Off']}
+
+
+def test_lamp_gated_absent_when_no_lamp_option():
+ """Issue #121's combi dump has no 'Lamp_*' token at all -- unlike
+ oven.py's lamp switch (assumed universal), this one self-gates off."""
+ desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
+ rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']}
+ assert desc.exists_fn(rep, {}) is False
+
+
+def test_lamp_gated_present_when_lamp_option_reported():
+ """Issue #137's plain microwave reports 'Lamp_Off'."""
+ desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
+ rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']}
+ assert desc.exists_fn(rep, {}) is True
+
+
+def test_lamp_write_is_single_token():
+ desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
+ rep = {'x.com.samsung.da.options': ['Lamp_Off']}
+ path, body = desc.write_fn('On', rep)
+ assert path == ['mode', 'vs', '0']
+ assert body == {'x.com.samsung.da.options': ['Lamp_On']}
+
+
+def test_lamp_write_requires_existing_options():
+ desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp')
+ assert desc.write_fn('On', {}) is None
diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py
index 3892036..7727d49 100644
--- a/tests/test_range_hood_capabilities.py
+++ b/tests/test_range_hood_capabilities.py
@@ -61,6 +61,8 @@ def test_range_hood_fixture_values():
assert state['periodic_air_sensing'] is False
assert state['air_sensing_state'] == 'NonProcessing'
assert state['last_air_sensing_level'] == 'Kr2'
+ assert state['after_run_active'] is False
+ assert state['after_run_progress'] == 0
def test_one_composite_fan_is_bound():
@@ -114,3 +116,19 @@ def test_lamp_write_contract():
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'},
)
assert brightness.write_fn('Unsupported', rep) is None
+
+
+def test_after_run_cancel_write_contract():
+ """issue #147: /afterrun/vs/0 was previously unbound. runningCancel's
+ only observed value is the command name itself, so cancel is modeled as
+ a ButtonDesc that writes that value back."""
+ active, progress, cancel = range_hood.AFTER_RUN.entities
+ assert cancel.write_fn('Cancel', {}) == (
+ ['afterrun', 'vs', '0'], {'x.com.samsung.da.runningCancel': 'Cancel'},
+ )
+
+
+def test_after_run_active_reads_on_off():
+ active, progress, cancel = range_hood.AFTER_RUN.entities
+ assert active.value_fn('On') is True
+ assert active.value_fn('Off') is False
diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py
index 3f52d78..a1fbf4d 100644
--- a/tests/test_range_hood_fan.py
+++ b/tests/test_range_hood_fan.py
@@ -1,7 +1,7 @@
"""HA fan-entity mapping tests for the range hood."""
from custom_components.localthings.fan import LocalThingsRangeHoodFan
-from custom_components.localthings.registry.by_type import range_hood
+from custom_components.localthings.registry.by_type import microwave, range_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
@@ -23,11 +23,11 @@ class _FakeCoordinator:
self.commands.append((bound, payload))
-def _entity(resources, coordinator=None):
+def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
bound = discover(
resources,
- range_hood.REGISTRY.capabilities,
- range_hood.REGISTRY.pattern_capabilities,
+ registry.capabilities,
+ registry.pattern_capabilities,
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(
@@ -35,6 +35,10 @@ def _entity(resources, coordinator=None):
)
+def _microwave_entity(resources, coordinator=None):
+ return _entity(resources, coordinator, registry=microwave.REGISTRY)
+
+
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
entity = _entity(_load_device('range_hood'))
assert entity.is_on is False
@@ -86,3 +90,131 @@ async def test_power_write_falls_back_to_vendor_resource():
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
+
+
+# ---------------------------------------------------------------------------
+# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
+# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
+# itself the off state.
+# ---------------------------------------------------------------------------
+
+def test_standalone_hood_has_separate_power():
+ """Explicit converse of the microwave case below: guards the
+ discriminator itself, not just its downstream effects, so a future
+ change to it fails loudly here instead of only via behavioral drift."""
+ entity = _entity(_load_device('range_hood'))
+ assert entity._has_separate_power() is True
+ assert entity._speed_zero_is_off() is False
+
+
+def test_microwave_vent_fan_has_no_separate_power_resource():
+ resources = _load_device('microwave_me7500d')
+ assert '/power/0' not in resources
+ assert '/power/vs/0' not in resources
+ entity = _microwave_entity(resources)
+ assert entity._has_separate_power() is False
+ assert entity._speed_zero_is_off() is True
+
+
+async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off():
+ """A combi over-the-range microwave can report a /power/0 resource for
+ the cavity while the vent fan still has no power resource of its own
+ (settableMinFanSpeed '0' -- same board shape as microwave_me7500d).
+ _speed_zero_is_off must key off the hood resource itself, not merely
+ "some power resource exists on this device", so turning the fan off
+ writes fan speed rather than the shared cavity power resource."""
+ resources = _load_device('microwave_me7500d')
+ resources['/power/0'] = {'value': True}
+ resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ assert entity._has_separate_power() is True
+ assert entity._speed_zero_is_off() is True
+
+ await entity.async_turn_off()
+
+ assert coordinator.commands[-1][1] == ('speed', '0')
+
+
+def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
+ """supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
+ standalone hood's codes, which never include an off entry."""
+ resources = _load_device('microwave_me7500d')
+ entity = _microwave_entity(resources)
+ assert entity.is_on is False
+ assert entity.percentage == 0
+ assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
+
+
+def test_microwave_vent_fan_nonzero_speed_reads_as_on():
+ resources = _load_device('microwave_me7500d')
+ resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
+ entity = _microwave_entity(resources)
+ assert entity.is_on is True
+ assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
+
+
+async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
+ resources = _load_device('microwave_me7500d')
+ resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_turn_off()
+
+ assert coordinator.commands[-1][1] == ('speed', '0')
+
+
+async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
+ resources = _load_device('microwave_me7500d')
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_turn_on()
+
+ assert coordinator.commands[-1][1] == ('speed', '1')
+
+
+async def test_microwave_vent_fan_set_percentage_writes_speed_only():
+ resources = _load_device('microwave_me7500d')
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_set_percentage(100)
+
+ assert coordinator.commands == [(entity._bound, ('speed', '4'))]
+
+
+async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly():
+ resources = _load_device('microwave_me7500d')
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_turn_on(percentage=75)
+
+ assert coordinator.commands == [(entity._bound, ('speed', '3'))]
+
+
+async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop():
+ """A scene or automation calling fan.turn_on on an already-running vent
+ fan must not reset it to the lowest speed."""
+ resources = _load_device('microwave_me7500d')
+ resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3'
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_turn_on()
+
+ assert coordinator.commands == []
+
+
+async def test_microwave_vent_fan_set_percentage_zero_turns_off():
+ resources = _load_device('microwave_me7500d')
+ resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
+ coordinator = _FakeCoordinator(resources)
+ entity = _microwave_entity(resources, coordinator)
+
+ await entity.async_set_percentage(0)
+
+ assert coordinator.commands[-1][1] == ('speed', '0')
diff --git a/tests/test_translations.py b/tests/test_translations.py
index d70a6d9..94c879a 100644
--- a/tests/test_translations.py
+++ b/tests/test_translations.py
@@ -126,8 +126,10 @@ def test_no_catalog_carries_unresolved_core_references():
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
-# so it presents as the device itself, and never reads a catalog name.
-UNNAMED_DESCRIPTORS = {("fan", "fan")}
+# so it presents as the device itself, and never reads a catalog name. Same
+# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
+# levels, no presets, same _attr_name = None treatment.
+UNNAMED_DESCRIPTORS = {("fan", "fan"), ("fan", "airflow_fan")}
def test_every_descriptor_has_an_entity_catalog_entry():
@@ -208,3 +210,27 @@ def test_every_ac_convenient_mode_code_has_a_preset_label():
missing.append((path.name, code))
assert missing == []
+
+def test_every_kimchi_zone_supportmode_code_has_a_state_label():
+ """Same guard as the AC preset one above, for KIMCHI_ZONE's
+ kimchi_zone_mode select (fridge.py, issue #26): the write path resolved
+ from options_field is fully dynamic too, so an unlabelled supportMode
+ code across any /status/kimchi//vs/0 resource would silently
+ render as its raw device token instead of the translated state.
+ """
+ state_labels = set(
+ _load("en")["entity"]["select"]["kimchi_zone_mode"]["state"]
+ )
+ fixtures_dir = Path(__file__).parent / "fixtures"
+ missing = []
+ for path in sorted(fixtures_dir.glob("*_device.json")):
+ dump = json.loads(path.read_text())
+ for item in dump.get("device0", []):
+ href = item.get("href", "")
+ if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")):
+ continue
+ for code in item["rep"].get("x.com.samsung.da.supportMode", []):
+ if code.lower() not in state_labels:
+ missing.append((path.name, href, code))
+ assert missing == []
+
diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py
index cd1f2cd..0e2ef53 100644
--- a/tests/test_water_purifier_capabilities.py
+++ b/tests/test_water_purifier_capabilities.py
@@ -169,6 +169,14 @@ def _desc_coffee(key):
return next(b.desc for b in bound if b.desc.key == key)
+def _desc_coffee_by_href(key, href):
+ """Like _desc_coffee, but disambiguates descriptors that share a key
+ across hrefs (hotwater_lock: LOCK's hotwaterLock field and
+ FAVORITE_HOTWATER's switchHotwater fallback, issue #144)."""
+ bound, _ = _bound_coffee()
+ return next(b.desc for b in bound if b.desc.key == key and b.href == href)
+
+
def test_coffee_variant_no_unbound_hrefs():
"""Every resource in the issue #107 dump binds or is covered, including
the four coffee-recipe hrefs not present in issue #90's dump."""
@@ -181,7 +189,7 @@ def test_coffee_variant_no_unbound_hrefs():
def test_coffee_variant_expected_state_keys_present():
state = _state_coffee()
for key in ('favorite_coffee_enabled', 'coffee_brew_status',
- 'favorite_hotwater_enabled', 'favorite_hotwater_temperature'):
+ 'hotwater_lock', 'favorite_hotwater_temperature'):
assert key in state, key
@@ -193,12 +201,84 @@ def test_favorite_coffee_write_contract():
['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'})
-def test_favorite_hotwater_write_contract():
- enabled = _desc_coffee('favorite_hotwater_enabled')
- assert enabled.write_fn('On', {}) == (
- ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
- assert enabled.write_fn('Off', {}) == (
+def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag():
+ """issue #144: switchHotwater's value domain is Locked/Unlocked, not an
+ enable flag, and it's misspelled as "Favorite hot water" -- it's the same
+ hot-water lock as LOCK.hotwater_lock, just surfaced through this href on
+ boards (like this fixture's) that don't populate /status/lock/vs/0's
+ hotwaterLock field."""
+ lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
+ assert lock.write_fn('On', {}) == (
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'})
+ assert lock.write_fn('Off', {}) == (
+ ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
+ assert lock.value_fn('Unlocked') is False
+ assert lock.value_fn('Locked') is True
+
+
+def test_favorite_hotwater_lock_wins_in_flattened_state():
+ """adapter.flatten() -- the actual source of coordinator.data every
+ switch's is_on reads -- only honours exists_fn, never entity.py's
+ implicit own-field-presence default. So it's not enough for the
+ *registered* entity to resolve correctly (test_expected_state_keys_present
+ territory); the shared 'hotwater_lock' key in the flattened dict itself
+ must reflect the live switchHotwater value, not a stale phantom from
+ LOCK's ungated hotwaterLock read (issue #144). This fixture's
+ switchHotwater reads 'Unlocked'; without exists_fn on *both* sides of the
+ pair, LOCK's descriptor computes None != 'Unlocked' == True regardless,
+ and flatten() would pick whichever of the two entities happens to be
+ processed last."""
+ state = _state_coffee()
+ assert state['hotwater_lock'] is False
+
+
+def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state():
+ """Both LOCK.hotwater_lock and FAVORITE_HOTWATER's switchHotwater
+ fallback are always bound on this fixture (their hrefs are both always
+ present) -- discrimination happens entirely in exists_fn. Exactly one of
+ the two must ever pass, regardless of iteration order, or two switch
+ entities would be registered with the same unique_id."""
+ bound, resources = _bound_coffee()
+ candidates = [b for b in bound if b.desc.key == 'hotwater_lock']
+ assert len(candidates) == 2
+ included = [b for b in candidates
+ if b.desc.exists_fn(resources.get(b.href) or {}, resources)]
+ assert len(included) == 1
+ assert included[0].href == '/favorite/hotwater/vs/0'
+
+
+def test_hotwater_lock_fallback_gating_across_status_lock_states():
+ """The fallback (FAVORITE_HOTWATER's switchHotwater descriptor) must
+ activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock --
+ never while that resource is an unfetched stub ({}), and never when it
+ does carry the field. LOCK's own descriptor is the mirror image."""
+ fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
+ primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0')
+ own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'}
+
+ # /status/lock/vs/0 absent entirely -- device genuinely lacks it.
+ assert fallback.exists_fn(own_rep, {}) is True
+
+ # /status/lock/vs/0 present but not yet fetched (a stub): outcome
+ # pending, so the fallback must defer to LOCK rather than assume absence.
+ stub_resources = {'/status/lock/vs/0': {}}
+ assert fallback.exists_fn(own_rep, stub_resources) is False
+ assert primary.exists_fn({}, stub_resources) is True
+
+ # /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this
+ # fixture's actual shape) -- the fallback wins.
+ absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}}
+ assert fallback.exists_fn(own_rep, absent_resources) is True
+ assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False
+
+ # /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins.
+ present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}
+ assert fallback.exists_fn(own_rep, present_resources) is False
+ assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True
+
+ # The fallback also re-asserts its own field, since it no longer gets
+ # that check for free once it shares LOCK's key (issue #144 review).
+ assert fallback.exists_fn({}, {}) is False
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():