From 0ebcfb00ff7d623b61a31fcdf1bbf968a458df2a Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Mon, 27 Jul 2026 18:55:11 +0100 Subject: [PATCH 01/18] fix: bind a fixed DTLS source port per device to evict stale sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When HA restarts without a clean DTLS close_notify (crash, host reboot), the appliance keeps an orphaned DTLS association keyed to the client's (IP, source port). Reconnecting from a fresh ephemeral port looks like a new peer, so the device holds the orphan until its own timer reaps it, which is 5 to 15 min on always-on appliances (fridges), during which the new session's first reads hang. This is the root cause behind the repeated "DTLS handshake timeout" reconnect storms on always-on devices (#119). Bind a deterministic source port per device so every reconnect re-handshakes over the same 5-tuple, which the device must treat as a rebooted peer and evict the old association for (RFC 6347 §4.2.8). Recovery drops from a device-timer wait to a single handshake. The port must be stable across restarts and unique per device on the HA host (the library socket is unconnected, so a shared source port would cross- deliver datagrams). _local_source_port() uses the host's last IPv4 octet as the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback for non-IPv4 hosts. Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=). The fix is backwards compatible upstream: local_port defaults to None (previous ephemeral-port behaviour). Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14 (bench-verified on oven + dryer, field-verified on an always-on fridge across repeated restarts). --- custom_components/localthings/const.py | 7 ++++ custom_components/localthings/coordinator.py | 29 +++++++++++++++- custom_components/localthings/manifest.json | 4 +-- tests/localthings/test_coordinator.py | 36 ++++++++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) 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/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/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 From d4583b324055e84eac86f60273456100c65ab12c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 21:32:44 +0000 Subject: [PATCH 02/18] Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56) /airflow/0's speed was left read-only because the first round of diagnostics wasn't conclusive (0 for both Auto and High, 3 for Low/Medium and Sleep) -- likely because all five dumps were captured within about a minute of each other, faster than this integration's own poll cycle could settle each change. A second round, captured 60-90s apart per setting on two independent units, confirmed a clean monotonic 0-4 mapping across Auto/Sleep/Low/Medium/ High instead. Builds an ordered-speed fan off that confirmed range, the same SET_SPEED shape as the range hood's fan -- this board never self-reports a supportedModes-style label list, so there's no named-preset table to preserve, just percentage steps over the raw code. /airflow/vs/0's vendor speedLevel stays a read-only fallback since it was unreliable in that same second round. --- custom_components/localthings/fan.py | 93 ++++++++++++++++++- .../registry/capabilities/air_purifier.py | 59 +++++++++--- tests/fixtures/golden/air_purifier.json | 2 +- tests/test_air_purifier_capabilities.py | 26 +++++- 4 files changed, 160 insertions(+), 20 deletions(-) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 15ac111..693d45d 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 @@ -48,6 +54,8 @@ 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) @@ -214,3 +222,78 @@ 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]: + """Same power-href fallback as LocalThingsAirPurifierFan above.""" + resources = self.coordinator.last_resources + target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF + return 'power', enabled, target + + @property + def is_on(self) -> bool: + power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power') + if power is not None: + return str(power).lower() == 'on' + return bool(self._rep(POWER_HREF).get('value')) + + @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/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index 5e867d6..be67cd8 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,44 @@ DEVICE_ACTIVE = Capability( ), ) -# OCF-native / vendor pair for fan speed+direction -- see module docstring for -# why these are read-only for now. +# 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. +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 + + AIRFLOW_GENERIC = Capability( href='/airflow/0', poll_tier='warm', entities=( - SensorDesc(key='fan_speed_level', field='speed', - icon='mdi:fan', - state_class='measurement', entity_category='diagnostic'), + FanDesc(key='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/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json index 91ce610..cea09bb 100644 --- a/tests/fixtures/golden/air_purifier.json +++ b/tests/fixtures/golden/air_purifier.json @@ -10,8 +10,8 @@ "energy_last_month_kwh", "energy_saved_kwh", "energy_this_month_kwh", + "fan", "fan_direction", - "fan_speed_level", "filter_progress", "fine_dust", "odor", diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 7f483c8..5040673 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', '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 == '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['fan'] == 0 + assert state['fan_direction'] == 'Off' From 2be742489bb91dd4085f7c648ef1a555f0bfba13 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 21:48:23 +0000 Subject: [PATCH 03/18] Fix airflow fan power-href preference and unique_id collision risk Opus review of the previous commit caught two real bugs. The airflow fan's power writes preferred /power/vs/0, copied from the TP1X fan class -- but that order is only harmless there because TP1X never reports /power/0 at all. This family's dumps carry both hrefs, and the power_switch entity is unconditionally bound to /power/0 when present, so the fan was writing to a different resource than power_switch reads/writes, leaving the two entities disagreeing until the next poll. Flipped to prefer /power/0, matching the range hood's fan and common.POWER_GENERIC. Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity unique_ids are derived from key alone, not href, so it collided with air_purifier.FAN's own 'fan' key on the (currently unobserved, but unenforced) possibility of a board reporting both. Added a platform-level test file covering the power-href preference and percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness. --- custom_components/localthings/fan.py | 21 +++- .../registry/capabilities/air_purifier.py | 32 ++++-- tests/fixtures/golden/air_purifier.json | 2 +- tests/test_air_purifier_airflow_fan.py | 105 ++++++++++++++++++ tests/test_air_purifier_capabilities.py | 6 +- tests/test_translations.py | 6 +- 6 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 tests/test_air_purifier_airflow_fan.py diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 693d45d..9b7ebed 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -253,17 +253,26 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity): return self.coordinator.resource(href) or {} def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: - """Same power-href fallback as LocalThingsAirPurifierFan above.""" + """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_VS_HREF if POWER_VS_HREF in resources else POWER_HREF + 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_VS_HREF).get('x.com.samsung.da.power') - if power is not None: - return str(power).lower() == 'on' - return bool(self._rep(POWER_HREF).get('value')) + 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: diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index be67cd8..f9ba661 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -136,16 +136,6 @@ DEVICE_ACTIVE = Capability( ), ) -# 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. def _airflow_fan_write(payload, rep, href=None): kind, value, *args = payload if kind == 'power': @@ -159,11 +149,29 @@ def _airflow_fan_write(payload, rep, href=None): 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=( - FanDesc(key='fan', field='speed', write_fn=_airflow_fan_write), + 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'), diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json index cea09bb..7e049c5 100644 --- a/tests/fixtures/golden/air_purifier.json +++ b/tests/fixtures/golden/air_purifier.json @@ -1,5 +1,6 @@ { "state_keys": [ + "airflow_fan", "alarm_code", "clean_level", "device_active", @@ -10,7 +11,6 @@ "energy_last_month_kwh", "energy_saved_kwh", "energy_this_month_kwh", - "fan", "fan_direction", "filter_progress", "fine_dust", 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 5040673..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', 'fan_direction', + 'diagnosis_status', 'airflow_fan', 'fan_direction', 'display_light', 'operating_mode', ): assert key in state, key @@ -121,7 +121,7 @@ def test_airflow_fan_write_contract(): (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 == 'fan') + 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'}, @@ -136,5 +136,5 @@ def test_airflow_speed_reads_from_dump(): fan_direction stays a plain diagnostic (every dump seen reads 'Off' regardless of fan setting).""" state = _state() - assert state['fan'] == 0 + assert state['airflow_fan'] == 0 assert state['fan_direction'] == 'Off' diff --git a/tests/test_translations.py b/tests/test_translations.py index d70a6d9..15b58a8 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(): From a1f14cd6332fba7d6e8f671399b099b090b3943f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 21:51:39 +0000 Subject: [PATCH 04/18] Split microwaves into their own device type instead of the oven registry Microwaves (combi and plain) were routed onto the oven registry (issue #121), which meant entities carried oven-flavored keys (oven_state, oven_mode, oven_setpoint) and inherited oven-specific behavior that's wrong for this family: a 30-270C setpoint range instead of this family's actual 40-200C, a cooking-mode list missing MicroWave/MicroWaveGrill/ MicroWaveConvection/KeepWarm entirely, and a lamp switch that read/wrote the oven's 'UpperLamp' option token instead of this family's 'Lamp' token. Adds a microwave device type (by_type/microwave.py, capabilities/microwave.py) that reuses the oven board family's shared operational-state/door/connected/recipe-cook capabilities but defines its own cooking-mode, setpoint, and cavity capabilities with the corrected bounds/vocabulary, plus a new power_level sensor for the cavity's Watt setting that was previously unexposed. --- README.md | 3 +- .../localthings/registry/by_type/__init__.py | 24 ++- .../localthings/registry/by_type/microwave.py | 28 +++ .../registry/capabilities/microwave.py | 195 ++++++++++++++++++ .../localthings/translations/en.json | 25 +++ .../localthings/translations/nl.json | 25 +++ ...en_mw7300b.json => microwave_mw7300b.json} | 12 +- ...ice.json => microwave_mw7300b_device.json} | 0 tests/test_by_type.py | 12 +- tests/test_golden_regression.py | 21 +- tests/test_microwave_capabilities.py | 161 +++++++++++++++ 11 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 custom_components/localthings/registry/by_type/microwave.py create mode 100644 custom_components/localthings/registry/capabilities/microwave.py rename tests/fixtures/golden/{oven_mw7300b.json => microwave_mw7300b.json} (75%) rename tests/fixtures/{oven_mw7300b_device.json => microwave_mw7300b_device.json} (100%) create mode 100644 tests/test_microwave_capabilities.py diff --git a/README.md b/README.md index e9881b3..adb44db 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,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 (combi and plain) | `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/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 13107d8..4803a26 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, @@ -232,15 +233,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..c038e49 --- /dev/null +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -0,0 +1,28 @@ +"""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. +""" +from ..capabilities import common, ignored, microwave, oven +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, + ]), +) 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/translations/en.json b/custom_components/localthings/translations/en.json index 2b5c321..dc92b91 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -165,6 +165,9 @@ "oven_setpoint": { "name": "Setpoint" }, + "setpoint": { + "name": "Setpoint" + }, "sound_volume": { "name": "Sound volume" }, @@ -344,6 +347,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": { @@ -704,6 +723,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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 9699b1f..b23b93f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -165,6 +165,9 @@ "oven_setpoint": { "name": "Instelpunt" }, + "setpoint": { + "name": "Instelpunt" + }, "sound_volume": { "name": "Geluidsvolume" }, @@ -344,6 +347,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": { @@ -704,6 +723,12 @@ "oven_state": { "name": "Status ovenruimte" }, + "cavity_state": { + "name": "Status ovenruimte" + }, + "power_level": { + "name": "Vermogensniveau" + }, "paired_hood_fan_speed": { "name": "Ventilatorsnelheid gekoppelde afzuigkap" }, 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/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/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_golden_regression.py b/tests/test_golden_regression.py index 96f7c32..c13b45c 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -568,19 +568,20 @@ 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" diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py new file mode 100644 index 0000000..45a7487 --- /dev/null +++ b/tests/test_microwave_capabilities.py @@ -0,0 +1,161 @@ +"""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 == [] + + +# --------------------------------------------------------------------------- +# 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 From 5af9d951c6b559141fc0d3ddc7d1e0a70346fd86 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 21:55:06 +0000 Subject: [PATCH 05/18] Simplify README microwave row label --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index adb44db..d133be7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | -| Microwave (combi and plain) | `by_type/microwave.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` | From e9281aaeade261703acffc0e49c8af7041f5a270 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 00:15:48 +0000 Subject: [PATCH 06/18] Bind microwave built-in vent fan's /hood/fanspeed/vs/0 (issues #137, #142) Combi microwave units report their vent fan on the same resource shape a standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the microwave registry. Unlike a standalone hood, this board has no sibling /power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls back to treating fan speed 0 as the off state when no separate power resource is present. Also gate HOOD_FAN's automatic_operation sensor on field presence, since this board doesn't report it. --- custom_components/localthings/fan.py | 44 +++- .../localthings/registry/by_type/microwave.py | 11 +- .../registry/capabilities/range_hood.py | 5 + tests/fixtures/golden/microwave_me7500d.json | 22 ++ tests/fixtures/microwave_me7500d_device.json | 201 ++++++++++++++++++ tests/test_golden_regression.py | 24 +++ tests/test_microwave_capabilities.py | 16 ++ tests/test_range_hood_fan.py | 75 ++++++- 8 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/golden/microwave_me7500d.json create mode 100644 tests/fixtures/microwave_me7500d_device.json diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9b7ebed..dbc73c1 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -62,7 +62,16 @@ async def async_setup_entry( 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. `_has_separate_power` detects that shape 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. + """ _enable_turn_on_off_backwards_compatibility = False _attr_supported_features = ( @@ -78,15 +87,24 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _rep(self, href: str) -> dict: return self.coordinator.resource(href) or {} + def _has_separate_power(self) -> bool: + resources = self.coordinator.last_resources + return POWER_HREF in resources or POWER_VS_HREF in resources + 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._has_separate_power(): + # 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 + # No separate power resource: '0' is the off step, not a speed. + return [code for code in codes if code != '0'] def _power_payload(self, enabled: bool) -> tuple[str, bool, str]: """Target whichever power resource this hood actually exposes.""" @@ -96,6 +114,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: + if not self._has_separate_power(): + current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) + return current not in ('', '0') rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -121,6 +142,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: + if not self._has_separate_power(): + if percentage is not None: + await self.async_set_percentage(percentage) + 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), ) @@ -128,6 +157,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: + if not self._has_separate_power(): + await self.coordinator.async_send_command(self._bound, ('speed', '0')) + return await self.coordinator.async_send_command( self._bound, self._power_payload(False), ) @@ -139,7 +171,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if not self.is_on: + if self._has_separate_power() and not self.is_on: await self.coordinator.async_send_command( self._bound, self._power_payload(True), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index c038e49..cbe85fa 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -7,8 +7,16 @@ 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 `_has_separate_power` check). """ -from ..capabilities import common, ignored, microwave, oven +from ..capabilities import common, ignored, microwave, oven, range_hood from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -24,5 +32,6 @@ REGISTRY = DeviceRegistry( oven.OVEN_DOOR, oven.OVEN_CONNECTED, oven.OVEN_RECIPE_COOK, + range_hood.HOOD_FAN, ]), ) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 59cebc9..f3b4eca 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -99,6 +99,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: ( + not rep or 'x.com.samsung.da.hood.autoOperation' in rep), value_fn=lambda value: str(value).lower() == 'on', ), ), 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/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/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..1c27880 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -589,6 +589,30 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b(): ) +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 `_has_separate_power` 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" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air(): """TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion '7.0 Air purifier' and resolves via for_device() onto the existing diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 45a7487..05f0b91 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -23,6 +23,22 @@ def test_microwave_fixture_resolves_and_has_no_unbound_hrefs(): 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 # --------------------------------------------------------------------------- diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 3f52d78..4da948b 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,66 @@ 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_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 + + +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'))] From 75f2be7aaf63c3f964dc93ea7fbabd49b4a0dbf5 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:13 +0200 Subject: [PATCH 07/18] fix(registry): resolve ARTIK051_KRAC_18K to the airconditioner registry Room air conditioners on the ARTIK051 board (ARTIK051_KRAC_18K, issue #136) report no oneUiVersion and carry a '_KRAC_' token in modelNum. The existing '_RAC_' check can't 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': 8 of their 19 resources ended up unbound and the device exposed nothing but a power switch. Same ARTIK051 board family as the '_TVTL_' air purifier handled just below. --- .../localthings/registry/by_type/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 4803a26..1a9b9aa 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -212,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() From 5b30099c42ee5ef131c396efebd1841cc4a951ce Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:33 +0200 Subject: [PATCH 08/18] feat(airconditioner): fan, swing, presets and option-token settings on ARTIK051 boards This board generation predates every AC dump the registry was built from and differs in three ways, all handled here behind presence checks so no other family's behaviour changes: * No /wind/* resources at all. Fan speed and vane direction share a single /airflow/vs/0 resource, whose speedLevel uses the same 0-4 scale as _DEVICE_TO_FAN and whose direction uses the same codes as _DEVICE_TO_SWING, so the existing maps are reused rather than duplicated. The resource reports no supportedModes, so the full scale is offered. * No /mode/convenient/vs/0. The convenient-mode preset is a Comode_* token in /mode/vs/0's options, synthesised into a convenient-shaped rep so the existing dynamic preset resolver keeps working unchanged. Codes were learned by driving one unit through its cloud integration and reading the token back each time: Nano is what the app calls WindFree, plus Quiet/Comfort/2Step/ Speed (Fast Turbo) and Off. * Several settings that newer boards expose as dedicated resources are options tokens here: SPI, auto clean, air monitoring, beep volume, Good Sleep, outdoor temperature and filter time. Writes reuse option_write's single-token merge, the same mechanism the display light already uses on this href. Newer families carry some of the same tokens *alongside* dedicated resources for those settings, so the token entities are gated on this generation's resource shape (/airflow/vs/0 present, /wind/strength/vs/0 absent -- the same test the climate entity's fan/swing fallback uses, so the two can never disagree). Without that gate they duplicated auto clean on TP1X/TP2X boards and applied a calibration from this board to theirs. Two calibrations, both from hardware rather than from the token names: OutdoorTemp is offset by 55 (token 75 against a 20.3 C outdoor thermometer in the same install, token 74 against a 19.4 C forecast; Fahrenheit fits far worse), and FilterTime is tenths of an hour (token 1710 while the official Samsung app displayed "171 hours 0 minutes" for the same unit's filter). Whether filter time counts up or down is deliberately not claimed: it was seen rising while the unit ran, which contradicts the app's "remaining" wording. Humidity now falls back to the plain x.com.samsung.da.humidity field where fivepercentHumidity is absent, still as one entity rather than two, and 0 reads as "not measuring" rather than 0% -- on this board the field only carries a reading (51%, matching the same unit's cloud integration) while Air monitoring is on, which the unit switches back off by itself after about a minute. Fan, swing, preset, SPI and beep-volume writes were confirmed by read-back on hardware. Good Sleep's upper bound is a guess (only 0 has been observed), and /airflow/0 -- the OCF-standard mirror of the vendor resource -- is ignored rather than modelled, since air_purifier.py found the opposite reliability ordering between these two hrefs on its own family. Refs #136 --- custom_components/localthings/climate.py | 66 ++++++- .../registry/capabilities/airconditioner.py | 184 +++++++++++++++++- .../localthings/translations/en.json | 18 ++ .../localthings/translations/nl.json | 18 ++ 4 files changed, 277 insertions(+), 9 deletions(-) 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/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 91f0466..5958018 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,7 +15,9 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc, +) from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -43,11 +45,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, ] @@ -127,6 +136,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 @@ -180,6 +274,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 @@ -204,6 +308,58 @@ CLIMATE = Capability( exists_fn=_has_display_light_option, write_fn=_display_light_write, icon='mdi:led-on', 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'), ), ) @@ -357,15 +513,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='%'), ), ) @@ -397,6 +561,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/translations/en.json b/custom_components/localthings/translations/en.json index dc92b91..ca6ab24 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Beep volume" + }, "cook_time": { "name": "Cook time" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Dispense capacity" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -569,9 +575,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" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Air monitoring" + }, "air_purify": { "name": "Air purification" }, @@ -838,6 +853,9 @@ "dustbin_auto_close": { "name": "Dustbin auto-close" }, + "spi": { + "name": "Purifying ion" + }, "uvc_intensive_mode": { "name": "UV-C intensive mode" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b23b93f..80becb6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Zoemervolume" + }, "cook_time": { "name": "Bereidingstijd" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Schenkhoeveelheid" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -569,9 +575,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" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Luchtmonitoring" + }, "air_purify": { "name": "Luchtzuivering" }, @@ -838,6 +853,9 @@ "dustbin_auto_close": { "name": "Stofreservoir automatisch sluiten" }, + "spi": { + "name": "Ionisatie" + }, "uvc_intensive_mode": { "name": "Intensieve UV-C-modus" }, From 3032b0c0321cf7638dac43c6419839852564c66a Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:59 +0200 Subject: [PATCH 09/18] test(airconditioner): lock in the ARTIK051_KRAC_18K surface Fixture is a scrubbed diagnostics dump from the unit the writes and calibrations were verified on; the issue #136 unit is the same model with a slightly different token set (no Spi, FilterTime_5460, OutdoorTemp_81), which the presence gating handles the same way. Covers the pieces the golden's state_keys can't: that the token entities stay off newer boards, that an absent token yields no entity, that humidity's zero reads as unknown, that fan/swing/preset read and write through /airflow/vs/0 and the Comode token, and that a board with /wind/* and /mode/convenient/vs/0 still takes the resource paths. Refs #136 --- ...rconditioner_artik051_krac_18k_device.json | 232 ++++++++++++++++++ .../airconditioner_artik051_krac_18k.json | 18 ++ tests/test_airconditioner_artik051_krac.py | 230 +++++++++++++++++ tests/test_golden_regression.py | 21 ++ 4 files changed, 501 insertions(+) create mode 100644 tests/fixtures/airconditioner_artik051_krac_18k_device.json create mode 100644 tests/fixtures/golden/airconditioner_artik051_krac_18k.json create mode 100644 tests/test_airconditioner_artik051_krac.py 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/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/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py new file mode 100644 index 0000000..7c8f942 --- /dev/null +++ b/tests/test_airconditioner_artik051_krac.py @@ -0,0 +1,230 @@ +"""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: Nano is what the app calls WindFree. + assert 'windfree' in entity.preset_modes + + 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 == 'windfree' + + +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('windfree') + + 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('windfree') + + kinds = [payload[0] for _, payload in coordinator.commands] + assert kinds == ['fan', 'swing', 'preset'] diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..eb9e24f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -630,6 +630,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 = { From 265d94eded5886665551be64e30d377358e58d61 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 00:35:13 +0000 Subject: [PATCH 10/18] Add kimchi-refrigerator compartment coverage (issue #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TP2X_REF_20K-class 3-compartment kimchi refrigerators report each compartment's storage mode and ripening status/timer on /status/kimchi//vs/0, plus a top-compartment door sensor on /kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving the per-compartment entity key and display name from the href's top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC already do. Storage-mode option labels were translated directly from the reporter's own SmartThings app screenshots rather than guessed from the raw device codes or their English paraphrase, confirming the on-screen option order matches supportMode's array order (including the freezer triplet's -19/-21/-17°C -> Standard/Strong/Weak mapping). Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also populates modes/supportedOptions, but with a token shape that never overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never repeats), so the existing "supportedOptions is nonempty" check let the entity bind anyway and get stuck permanently on "unknown". Requiring an actual resolvable value keeps it working for the RF9000/Bespoke-class fridges it was built for while leaving it absent here. --- .../registry/by_type/refrigerator.py | 2 + .../registry/capabilities/fridge.py | 106 ++++++- .../localthings/translations/en.json | 40 +++ .../localthings/translations/nl.json | 40 +++ .../refrigerator_tp2x_ref_20k_kimchi.json | 24 ++ ...frigerator_tp2x_ref_20k_kimchi_device.json | 274 ++++++++++++++++++ tests/test_fridge_capabilities.py | 78 +++++ tests/test_golden_regression.py | 17 ++ 8 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/golden/refrigerator_tp2x_ref_20k_kimchi.json create mode 100644 tests/fixtures/refrigerator_tp2x_ref_20k_kimchi_device.json 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/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 3e9677a..e90666b 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,99 @@ 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: + 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', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + 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=( + 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/translations/en.json b/custom_components/localthings/translations/en.json index dc92b91..2199e3f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -381,6 +381,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": { @@ -678,6 +709,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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b23b93f..7015d9f 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -381,6 +381,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": { @@ -678,6 +709,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" }, 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/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/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 94f7d96..5ce2c1d 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,37 @@ 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] + path, body = desc.write_fn( + 'KIMCHI_STORAGE_COLD', {}, 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] + assert desc.write_fn('KIMCHI_STORAGE_COLD', {}) is None + + def test_ripening_status_is_lowercased(self): + desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status') + assert desc.value_fn('Off') == 'off' + assert desc.value_fn(None) is None + + 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 + + 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 +304,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 1c27880..a13fb98 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 From 55c7b88a8f4283a181f9e64863476a1cbf39df25 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:38:28 +0200 Subject: [PATCH 11/18] fix(airconditioner): label the 2Step preset and test presets by their HA value The legacy Comode codes resolve through the same dynamic resolver as a real convenient resource, so Nano lands on the existing 'nano' preset (already labelled WindFree) rather than on a 'windfree' value of its own -- the new tests asserted the label instead of the value. 2Step had no catalog entry in either language and would have surfaced as the raw code. Also updates the existing five-percent-humidity test, which reached into the descriptor's field/value_fn directly, to the rep_fn the fallback needs, and covers the fallback itself. --- custom_components/localthings/translations/en.json | 3 ++- custom_components/localthings/translations/nl.json | 3 ++- tests/test_airconditioner_artik051_krac.py | 14 +++++++++----- tests/test_airconditioner_capabilities.py | 12 +++++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index ca6ab24..bd41375 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -128,7 +128,8 @@ "longwind": "Long wind", "motionindirect": "Motion indirect", "motiondirect": "Motion direct", - "drycomfort": "Dry comfort" + "drycomfort": "Dry comfort", + "2step": "2-Step" } } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 80becb6..b3779f5 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -128,7 +128,8 @@ "longwind": "Lange wind", "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", - "drycomfort": "Droog comfort" + "drycomfort": "Droog comfort", + "2step": "2-Step" } } } diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 7c8f942..2e71e89 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -194,15 +194,19 @@ def test_preset_comes_from_the_comode_token(): 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: Nano is what the app calls WindFree. - assert 'windfree' in entity.preset_modes + # 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 == 'windfree' + assert _climate(resources).preset_mode == 'nano' async def test_preset_write_uses_the_token_path(): @@ -210,7 +214,7 @@ async def test_preset_write_uses_the_token_path(): coordinator = _FakeCoordinator(resources) entity = _climate(resources, coordinator) - await entity.async_set_preset_mode('windfree') + await entity.async_set_preset_mode('nano') assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano') @@ -224,7 +228,7 @@ async def test_newer_boards_keep_the_resource_paths(): await entity.async_set_fan_mode('high') await entity.async_set_swing_mode('off') - await entity.async_set_preset_mode('windfree') + 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 3a63492..f513be7 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -404,4 +404,14 @@ 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 From 5055d6bb720085039deb01eefadd941cf35ede7f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:00:57 +0000 Subject: [PATCH 12/18] Fix water-purifier hot-water lock mislabel, add range-hood after-run support Water purifier (#144, #145): /favorite/hotwater/vs/0's switchHotwater field is a Locked/Unlocked hot-water lock, not a "favorite enabled" flag. It's the same lock as LOCK.hotwater_lock, just surfaced through this href on boards that don't populate /status/lock/vs/0's hotwaterLock -- the two now share the hotwater_lock key/translation, gated so only one is ever active. Range hood (#147): binds /afterrun/vs/0 (after-run activation state, progress, and a cancel button), clearing the last unbound href for AHD-WW-TP1-22-COMMON. --- .../registry/by_type/range_hood.py | 1 + .../registry/capabilities/range_hood.py | 42 +++++++++++++++++++ .../registry/capabilities/water_purifier.py | 24 +++++++---- .../localthings/translations/en.json | 12 ++++-- .../localthings/translations/nl.json | 12 ++++-- tests/fixtures/golden/range_hood.json | 2 + .../golden/water_purifier_coffee.json | 1 - tests/fixtures/range_hood_device.json | 8 ++++ tests/test_range_hood_capabilities.py | 18 ++++++++ tests/test_water_purifier_capabilities.py | 37 +++++++++++++--- 10 files changed, 137 insertions(+), 20 deletions(-) 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/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 59cebc9..f579c0f 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from ..capability import Capability from ..entities import ( BinarySensorDesc, + ButtonDesc, FanDesc, SelectDesc, SensorDesc, @@ -179,6 +180,47 @@ 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. 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. +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', + entity_category='diagnostic', + 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', + entity_category='diagnostic', + 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..08b7346 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -102,20 +102,30 @@ 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. 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; exists_fn activates this fallback + # only when the primary field is absent, so a board reporting both + # can't collide. + 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.hotwaterLock' not in ( + resources.get('/status/lock/vs/0') or {}), 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', diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index dc92b91..ddd269c 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" }, @@ -529,6 +535,9 @@ } }, "sensor": { + "after_run_progress": { + "name": "After run progress" + }, "air_filter_usage": { "name": "Filter usage" }, @@ -886,9 +895,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 b23b93f..5cd0181 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" }, @@ -529,6 +535,9 @@ } }, "sensor": { + "after_run_progress": { + "name": "Nadraaien voortgang" + }, "air_filter_usage": { "name": "Filterverbruik" }, @@ -886,9 +895,6 @@ "favorite_coffee_enabled": { "name": "Favoriete koffie" }, - "favorite_hotwater_enabled": { - "name": "Favoriet warm water" - }, "fridge_sound": { "name": "Geluid" }, 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/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/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/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_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index cd1f2cd..bf5c9dc 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,29 @@ 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_fallback_only_activates_when_primary_absent(): + """The switchHotwater-based lock and LOCK's hotwaterLock-based lock share + the 'hotwater_lock' key so only one entity is ever registered (issue + #144). This fixture has no hotwaterLock field, so the fallback must be + active; a board reporting both must not.""" + lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') + assert lock.exists_fn({}, {'/status/lock/vs/0': {}}) is True + assert lock.exists_fn({}, {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}) is False def test_favorite_hotwater_temperature_options_come_from_live_supported_list(): From cc3ce12aa453ca773ecc2fcb1f440b34247a142b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:10:01 +0000 Subject: [PATCH 13/18] Fix range hood fan power targeting and kimchi mode write validation - _speed_zero_is_off now keys off the hood resource's own settableMinFanSpeed/supportedFanSpeed fields instead of asking whether the device has any power resource at all, so a combi appliance's cavity /power/0 can no longer be toggled off by turning off just the vent fan. - async_turn_on() no longer resets an already-running fan to its lowest speed when called without a percentage. - _has_separate_power() reads through the O(1) resource cache instead of copying the full resource snapshot on every property access. - _kimchi_mode_write rejects values the compartment didn't advertise in supportMode instead of writing them blind. - kimchi_ripening_status no longer lowercases its value, since it has no enum catalog entry to translate the lowercased token back through. - Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0 aggregate fallback on the one fixture that reports both. Adds regression tests for the combi-appliance power targeting, the already-on turn_on no-op, kimchi mode write validation, and a kimchi select display/write casing round-trip; a translation-coverage guard for kimchi_zone_mode codes mirroring the existing AC preset one. --- custom_components/localthings/fan.py | 69 ++++++++++++------ .../localthings/registry/by_type/microwave.py | 2 +- .../registry/capabilities/fridge.py | 12 +++- tests/test_fridge_capabilities.py | 70 +++++++++++++++++-- tests/test_golden_regression.py | 2 +- tests/test_range_hood_fan.py | 65 +++++++++++++++++ tests/test_translations.py | 24 +++++++ 7 files changed, 212 insertions(+), 32 deletions(-) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index dbc73c1..9c9d623 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -37,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' @@ -65,12 +67,20 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): """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. `_has_separate_power` detects that shape 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. + 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 @@ -88,8 +98,19 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): return self.coordinator.resource(href) or {} def _has_separate_power(self) -> bool: - resources = self.coordinator.last_resources - return POWER_HREF in resources or POWER_VS_HREF in resources + 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) @@ -97,14 +118,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): def _active_speed_codes(self) -> list[str]: codes = self._all_speed_codes() - if self._has_separate_power(): - # 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 - # No separate power resource: '0' is the off step, not a speed. - return [code for code in codes if code != '0'] + 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.""" @@ -114,9 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): @property def is_on(self) -> bool: - if not self._has_separate_power(): + if self._speed_zero_is_off(): current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0')) - return current not in ('', '0') + return current not in ('', _OFF_SPEED_CODE) rep = self._rep(POWER_HREF) if 'value' in rep: return bool(rep.get('value')) @@ -142,10 +163,14 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: - if not self._has_separate_power(): + 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])) @@ -157,8 +182,8 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: - if not self._has_separate_power(): - await self.coordinator.async_send_command(self._bound, ('speed', '0')) + 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), @@ -171,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity): codes = self._active_speed_codes() if not codes: return - if self._has_separate_power() and 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), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index cbe85fa..7e76a9a 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -14,7 +14,7 @@ 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 `_has_separate_power` check). +case (see its `_speed_zero_is_off` check). """ from ..capabilities import common, ignored, microwave, oven, range_hood from ._base import DeviceRegistry, _build diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index e90666b..14e7436 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -675,7 +675,7 @@ DOOR_GENERIC = Capability( # --------------------------------------------------------------------------- def _kimchi_mode_write(p, rep, href=None): - if not href: + 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, @@ -697,8 +697,7 @@ KIMCHI_ZONE = Capability( 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', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), + 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', @@ -721,6 +720,13 @@ KIMCHI_DOOR_GENERIC = Capability( 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'), diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 5ce2c1d..3b6399e 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -241,25 +241,85 @@ class TestKimchiZone: 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', {}, href='/status/kimchi/middle/vs/0') + '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] - assert desc.write_fn('KIMCHI_STORAGE_COLD', {}) is None + rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} + assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None - def test_ripening_status_is_lowercased(self): + 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' - assert desc.value_fn(None) is None + 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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a13fb98..09650ff 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -615,7 +615,7 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): 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 `_has_separate_power` check. This board also has + 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.""" diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 4da948b..a1fbf4d 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -98,12 +98,43 @@ async def test_power_write_falls_back_to_vendor_resource(): # 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(): @@ -153,3 +184,37 @@ async def test_microwave_vent_fan_set_percentage_writes_speed_only(): 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 15b58a8..94c879a 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -210,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 == [] + From 0640ac3477cd80bbf65b518fd83e6d4cbdb3eb31 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:25:42 +0000 Subject: [PATCH 14/18] Fix hotwater_lock key-collision hazard, address Opus review of triage fixes An Opus review of the previous commit found that giving the switchHotwater fallback and LOCK.hotwater_lock the same key introduced a real bug: adapter.flatten() (the source of coordinator.data, which every switch's is_on reads) only ever honours exists_fn, never entity.py's implicit own-field-presence default that gates plain registration. With only one side of the pair gated, both descriptors still wrote the same key into the flattened state dict, and whichever was processed last -- decided by device-reported href order, not correctness -- silently won. Reproduced with the existing coffee fixture: reversing resource order flipped hotwater_lock from correct (False) to a stuck True. Fixed by gating both sides symmetrically via a shared tri-state helper that also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather than "confirmed absent" -- otherwise the stub window let both descriptors pass exists_fn at once, which would have registered two switch entities with the same unique_id. The fallback also re-asserts its own field's presence, a check it used to get for free before it shared LOCK's key. Also addresses two smaller findings from the same review, both in the range-hood after-run capability (#147): runningProgress's unit='%' was a guess from a single "0" sample with no supported-values/range field to confirm the domain -- inconsistent with treating activationState as read-only for the same "don't guess" reason -- so it's now a bare passthrough sensor; and entity_category='diagnostic' was dropped from the two read entities since after-run is a feature the user actively watches and cancels via the (correctly uncategorized) button, not passive diagnostics. --- .../registry/capabilities/range_hood.py | 15 ++-- .../registry/capabilities/water_purifier.py | 53 ++++++++++++-- tests/test_range_hood_capabilities.py | 2 +- tests/test_water_purifier_capabilities.py | 71 ++++++++++++++++--- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index f579c0f..8854dc4 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -181,12 +181,18 @@ 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. No +# 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. +# operational.STOP_BUTTON. runningProgress is left a bare passthrough for the +# same 'don't guess' reason -- the only observed sample is "0", with no +# range or supported-values field to confirm it's a percentage rather than a +# minutes/seconds count, and unit + state_class='measurement' would commit +# it to long-term statistics under a possibly-wrong unit. AFTER_RUN = Capability( href='/afterrun/vs/0', poll_tier='warm', @@ -195,17 +201,12 @@ AFTER_RUN = Capability( key='after_run_active', field='x.com.samsung.da.activationState', icon='mdi:fan-clock', - entity_category='diagnostic', 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', - entity_category='diagnostic', - value_fn=int_or_none, ), ButtonDesc( key='after_run_cancel', diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 08b7346..019517c 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -105,6 +105,29 @@ FAVORITE_CAPACITY = Capability( # 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', @@ -114,15 +137,26 @@ FAVORITE_HOTWATER = Capability( # 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; exists_fn activates this fallback - # only when the primary field is absent, so a board reporting both - # can't collide. + # 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 != 'Unlocked', - exists_fn=lambda rep, resources: 'x.com.samsung.da.hotwaterLock' not in ( - resources.get('/status/lock/vs/0') or {}), + 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': 'Locked' if p == 'On' else 'Unlocked'})), @@ -160,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/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py index 7727d49..bca96fa 100644 --- a/tests/test_range_hood_capabilities.py +++ b/tests/test_range_hood_capabilities.py @@ -62,7 +62,7 @@ def test_range_hood_fixture_values(): 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 + assert state['after_run_progress'] == '0' def test_one_composite_fan_is_bound(): diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index bf5c9dc..0e2ef53 100644 --- a/tests/test_water_purifier_capabilities.py +++ b/tests/test_water_purifier_capabilities.py @@ -216,14 +216,69 @@ def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag(): assert lock.value_fn('Locked') is True -def test_favorite_hotwater_lock_fallback_only_activates_when_primary_absent(): - """The switchHotwater-based lock and LOCK's hotwaterLock-based lock share - the 'hotwater_lock' key so only one entity is ever registered (issue - #144). This fixture has no hotwaterLock field, so the fallback must be - active; a board reporting both must not.""" - lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') - assert lock.exists_fn({}, {'/status/lock/vs/0': {}}) is True - assert lock.exists_fn({}, {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}) is False +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(): From 9011f84787166db1bdff9551f4f3f66096e9e43a Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:29:01 +0000 Subject: [PATCH 15/18] Model after_run_progress as a percentage, fix Dutch after-run-progress name runningProgress's own field name states its domain, so restore unit='%' and state_class='measurement' rather than leaving it an opaque passthrough -- that hedge made sense for activationState (no supported-values list, no write contract to invent) but not here, where the name itself is the evidence. "Nadraaien voortgang" also wasn't idiomatic Dutch (nouns don't stack that way); "Voortgang nadraaien" matches how the rest of the catalog compounds these names. --- .../localthings/registry/capabilities/range_hood.py | 12 +++++++----- custom_components/localthings/translations/nl.json | 2 +- tests/test_range_hood_capabilities.py | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index 8854dc4..70a7732 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -188,11 +188,10 @@ HOOD_FILTER = Capability( # 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 is left a bare passthrough for the -# same 'don't guess' reason -- the only observed sample is "0", with no -# range or supported-values field to confirm it's a percentage rather than a -# minutes/seconds count, and unit + state_class='measurement' would commit -# it to long-term statistics under a possibly-wrong unit. +# 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', @@ -206,7 +205,10 @@ AFTER_RUN = Capability( 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', diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 5cd0181..cdfca45 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -536,7 +536,7 @@ }, "sensor": { "after_run_progress": { - "name": "Nadraaien voortgang" + "name": "Voortgang nadraaien" }, "air_filter_usage": { "name": "Filterverbruik" diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py index bca96fa..7727d49 100644 --- a/tests/test_range_hood_capabilities.py +++ b/tests/test_range_hood_capabilities.py @@ -62,7 +62,7 @@ def test_range_hood_fixture_values(): 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' + assert state['after_run_progress'] == 0 def test_one_composite_fan_is_bound(): From 33786ad584cbf358bade24c78f848d9ffb74af7c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:57:30 +0000 Subject: [PATCH 16/18] Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127) parse_device0_batch used to collapse /device/0's {"href": "..."} "no data yet" marker into a plain {}, indistinguishable from a resource the device had actually polled and confirmed empty. Every exists_fn using the "not rep or ..." stub carve-out (and entity._is_included's default field-gate) then treated both the same way, creating phantom always-"unknown" entities for any resource a model simply doesn't support (e.g. GSzabados's fridge's /energy/consumption/vs/0). is_stub_rep() now recognizes only the literal {"href": ...} marker as a stub; a genuine {} is treated as the device's real (if empty) answer and gates the entity off like any other missing field. Updated the energy meter, self-check error, cooktop burner, and range-hood auto-operation exists_fn call sites, plus three golden fixtures that had baked the phantom-entity behavior in as "expected". --- custom_components/localthings/entity.py | 12 ++-- .../localthings/registry/batch.py | 23 ++++++-- .../registry/capabilities/common.py | 28 ++++++---- .../registry/capabilities/cooktop.py | 3 +- .../registry/capabilities/range_hood.py | 3 +- tests/fixtures/golden/air_purifier.json | 6 -- .../refrigerator_artik051_dongle_ref.json | 6 -- ...frigerator_artik051_dongle_ref_cooler.json | 6 -- tests/test_batch.py | 56 +++++++++++++++++++ tests/test_common_capabilities.py | 35 +++++++++--- 10 files changed, 129 insertions(+), 49 deletions(-) create mode 100644 tests/test_batch.py diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index e8c0b9b..b15d166 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,12 @@ 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 the device's confirmed (if empty) answer, not + a stub, and gates the entity off like any other missing field — else a + resource this model never populates would spawn a phantom always- + "unknown" entity every session (issue #127). """ rep = coordinator.last_resources.get(bound.href) if rep is None: @@ -31,7 +35,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 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/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/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index cd03a91..775a318 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)), ), ) @@ -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/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index f3b4eca..58e262b 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -9,6 +9,7 @@ independent fields. from datetime import datetime, timezone +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, @@ -103,7 +104,7 @@ HOOD_FAN = Capability( # #137) -- this board has no auto-ventilation mode, unlike the # standalone range hood this capability was written for. exists_fn=lambda rep, resources: ( - not rep or 'x.com.samsung.da.hood.autoOperation' in rep), + is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep), value_fn=lambda value: str(value).lower() == 'on', ), ), diff --git a/tests/fixtures/golden/air_purifier.json b/tests/fixtures/golden/air_purifier.json index 7e049c5..05e3410 100644 --- a/tests/fixtures/golden/air_purifier.json +++ b/tests/fixtures/golden/air_purifier.json @@ -7,18 +7,12 @@ "diagnosis_status", "display_light", "dust", - "energy_kwh", - "energy_last_month_kwh", - "energy_saved_kwh", - "energy_this_month_kwh", "fan_direction", "filter_progress", "fine_dust", "odor", "operating_mode", - "power_energy_kwh", "power_switch", - "power_watts", "super_fine_dust" ] } 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/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_common_capabilities.py b/tests/test_common_capabilities.py index 8f7bcad..5a834a1 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 @@ -416,11 +427,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() From 623970606567d5ed1d5e10dae5b62cabe15eedf6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 03:18:23 +0000 Subject: [PATCH 17/18] Keep entity._is_included's default gate permissive on confirmed-empty reps Opus review of #149 caught that swapping is_stub_rep(rep) in for the default field-presence gate (not just the 9 hand-audited exists_fn call sites) was too broad: it silently excludes entities on ANY resource whose normal, valid state includes reporting {} -- /alarms/vs/0's {} is fridge.py's documented no-alarm state, not an absence signal, and it's not the only one (job_beginning_status, diagnosis_status, sabbath_mode, defrost_delay, ice_maker_enabled all lost entities on real fixtures under the broader change). That's the opposite of #127's fix: a real fridge would have dropped its alarm sensor on first-poll timing, not just its phantom energy sensors. Restored the default gate to include on either a stub or a genuinely-empty rep -- verified byte-identical to the pre-#149 baseline across all 40 fixtures, apart from the 9 deliberately-audited exists_fn sites (energy meter, self-check error, cooktop burner, range-hood auto-op), which are unaffected and still fix #127. Added tests/test_entity.py exercising _is_included directly (previously untested) and fixed two now-stale "not rep" doc references the review also flagged. --- custom_components/localthings/entity.py | 15 ++-- .../registry/capabilities/common.py | 2 +- tests/test_common_capabilities.py | 13 ++- tests/test_entity.py | 79 +++++++++++++++++++ 4 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 tests/test_entity.py diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index b15d166..cbdbca1 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -24,10 +24,15 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b 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 the device's confirmed (if empty) answer, not - a stub, and gates the entity off like any other missing field — else a - resource this model never populates would spawn a phantom always- - "unknown" entity every session (issue #127). + 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: @@ -35,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 is_stub_rep(rep): + 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/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 775a318..ac60523 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -503,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 diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 5a834a1..2050a70 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -399,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 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 From 6b28272647081fb86012666784713e40c224208c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 23:26:01 -0500 Subject: [PATCH 18/18] Enhance README with GitHub badges Added badges for GitHub stars, watchers, releases, and validations. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index d133be7..3ac35d0 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,19 @@ LocalThings Logo +

+ GitHub Repo stars + GitHub watchers +

+ +

+ GitHub Release + hacs validation + hassfest + tests + +

+ # 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.