From 5055d6bb720085039deb01eefadd941cf35ede7f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:00:57 +0000 Subject: [PATCH 1/3] 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 0640ac3477cd80bbf65b518fd83e6d4cbdb3eb31 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 02:25:42 +0000 Subject: [PATCH 2/3] 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 3/3] 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():