Merge pull request #148 from mbillow/claude/triage-issues-144-147-yv80ab

Fix water-purifier hot-water lock mislabel, add range-hood after-run support
This commit is contained in:
Marc Billow
2026-07-27 21:31:31 -05:00
committed by GitHub
10 changed files with 238 additions and 20 deletions
@@ -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,
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
ButtonDesc,
FanDesc,
SelectDesc,
SensorDesc,
@@ -184,6 +185,50 @@ HOOD_FILTER = Capability(
)
# After Run (issue #147): the hood keeps the fan running at low speed for a
# while after it's switched off, to clear residual cooking smoke -- a
# feature a user actively watches and cancels, not passive diagnostics, so
# none of the three entities below carry entity_category. No
# supported-values list is advertised for activationState, so it's modeled
# read-only (monitoring, not an invented "enable" write) per the 'don't
# guess' rule; runningCancel's only observed value is the command name
# itself ('Cancel'), the same self-describing command-field shape as
# operational.STOP_BUTTON. runningProgress's own name states its domain
# (a percentage of the cycle completed), so it's modeled as one rather than
# left an opaque passthrough -- unlike activationState/runningCancel, there's
# no ambiguous field name or missing-write-contract question here to hedge on.
AFTER_RUN = Capability(
href='/afterrun/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='after_run_active',
field='x.com.samsung.da.activationState',
icon='mdi:fan-clock',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='after_run_progress',
field='x.com.samsung.da.runningProgress',
unit='%',
state_class='measurement',
icon='mdi:fan-clock',
value_fn=int_or_none,
),
ButtonDesc(
key='after_run_cancel',
field='',
payload='Cancel',
icon='mdi:fan-off',
write_fn=lambda p, rep, href=None: (
['afterrun', 'vs', '0'],
{'x.com.samsung.da.runningCancel': p},
),
),
),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
@@ -102,20 +102,64 @@ FAVORITE_CAPACITY = Capability(
),
)
# Coffee-capable variant (issue #107) -- same "favorite" enable-toggle +
# supported-list select shape as FAVORITE_CAPACITY above, but for the hot
# water dispensed alongside brewing rather than the pour capacity.
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
# for the hot water dispensed alongside brewing, same shape as
# FAVORITE_CAPACITY above.
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
still ambiguous:
- href entirely absent from this device -> definitely no clash, the
switchHotwater fallback below may claim the entity.
- href present but an unfetched stub ({}) -> outcome pending, *not* a
confirmed absence. LOCK's own exists_fn optimistically includes itself
through a stub (matching entity.py's default), so returning True here
too would register both descriptors -- as SwitchDescs sharing one key,
with identical unique_ids -- until the next poll resolves it.
- href present and fetched -> the real answer."""
rep = resources.get('/status/lock/vs/0')
if rep is None:
return True
if not rep:
return False
return 'x.com.samsung.da.hotwaterLock' not in rep
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='favorite_hotwater_enabled', field='x.com.samsung.da.switchHotwater',
icon='mdi:star-outline',
# Despite the resource/field naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
# this href on boards that don't populate /status/lock/vs/0's
# hotwaterLock field. Shares that descriptor's key so only one "Hot
# water lock" entity ever appears.
#
# Both halves of this fallback pair need an exists_fn, not just this
# one: adapter.flatten() (the coordinator.data source every entity's
# is_on reads) only ever honours exists_fn, never entity.py's
# implicit "require own field present" default that gates plain
# registration. Two same-keyed descriptors with only one of them
# gated still both land in flatten()'s output dict -- whichever is
# processed last silently wins, decided by device-reported href
# order, not by which one is actually correct. So this exists_fn
# also re-asserts its own field's presence (switchHotwater), the
# gate a bare `field=` used to get for free before it had to share a
# key with LOCK's descriptor.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Locked',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
'x.com.samsung.da.switchHotwater' in rep
and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Unlocked' if p == 'On' else 'Locked'})),
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
@@ -150,10 +194,19 @@ LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also
# needs an explicit exists_fn now that the two share a key in
# adapter.flatten()'s output. A stub rep ({}) still counts as
# "present" here (matches entity.py's own default for a field-less
# gate) since the alternative -- treating an unfetched resource as
# confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
@@ -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"
},
@@ -560,6 +566,9 @@
}
},
"sensor": {
"after_run_progress": {
"name": "After run progress"
},
"air_filter_usage": {
"name": "Filter usage"
},
@@ -926,9 +935,6 @@
"favorite_coffee_enabled": {
"name": "Favorite coffee"
},
"favorite_hotwater_enabled": {
"name": "Favorite hot water"
},
"fridge_sound": {
"name": "Sound"
},
@@ -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"
},
@@ -560,6 +566,9 @@
}
},
"sensor": {
"after_run_progress": {
"name": "Voortgang nadraaien"
},
"air_filter_usage": {
"name": "Filterverbruik"
},
@@ -926,9 +935,6 @@
"favorite_coffee_enabled": {
"name": "Favoriete koffie"
},
"favorite_hotwater_enabled": {
"name": "Favoriet warm water"
},
"fridge_sound": {
"name": "Geluid"
},
+2
View File
@@ -1,5 +1,7 @@
{
"state_keys": [
"after_run_active",
"after_run_progress",
"air_sensing_state",
"alarm_code",
"auto_ventilation_action",
-1
View File
@@ -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",
+8
View File
@@ -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": {
+18
View File
@@ -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
+86 -6
View File
@@ -169,6 +169,14 @@ def _desc_coffee(key):
return next(b.desc for b in bound if b.desc.key == key)
def _desc_coffee_by_href(key, href):
"""Like _desc_coffee, but disambiguates descriptors that share a key
across hrefs (hotwater_lock: LOCK's hotwaterLock field and
FAVORITE_HOTWATER's switchHotwater fallback, issue #144)."""
bound, _ = _bound_coffee()
return next(b.desc for b in bound if b.desc.key == key and b.href == href)
def test_coffee_variant_no_unbound_hrefs():
"""Every resource in the issue #107 dump binds or is covered, including
the four coffee-recipe hrefs not present in issue #90's dump."""
@@ -181,7 +189,7 @@ def test_coffee_variant_no_unbound_hrefs():
def test_coffee_variant_expected_state_keys_present():
state = _state_coffee()
for key in ('favorite_coffee_enabled', 'coffee_brew_status',
'favorite_hotwater_enabled', 'favorite_hotwater_temperature'):
'hotwater_lock', 'favorite_hotwater_temperature'):
assert key in state, key
@@ -193,12 +201,84 @@ def test_favorite_coffee_write_contract():
['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'})
def test_favorite_hotwater_write_contract():
enabled = _desc_coffee('favorite_hotwater_enabled')
assert enabled.write_fn('On', {}) == (
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
assert enabled.write_fn('Off', {}) == (
def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag():
"""issue #144: switchHotwater's value domain is Locked/Unlocked, not an
enable flag, and it's misspelled as "Favorite hot water" -- it's the same
hot-water lock as LOCK.hotwater_lock, just surfaced through this href on
boards (like this fixture's) that don't populate /status/lock/vs/0's
hotwaterLock field."""
lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
assert lock.write_fn('On', {}) == (
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'})
assert lock.write_fn('Off', {}) == (
['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'})
assert lock.value_fn('Unlocked') is False
assert lock.value_fn('Locked') is True
def test_favorite_hotwater_lock_wins_in_flattened_state():
"""adapter.flatten() -- the actual source of coordinator.data every
switch's is_on reads -- only honours exists_fn, never entity.py's
implicit own-field-presence default. So it's not enough for the
*registered* entity to resolve correctly (test_expected_state_keys_present
territory); the shared 'hotwater_lock' key in the flattened dict itself
must reflect the live switchHotwater value, not a stale phantom from
LOCK's ungated hotwaterLock read (issue #144). This fixture's
switchHotwater reads 'Unlocked'; without exists_fn on *both* sides of the
pair, LOCK's descriptor computes None != 'Unlocked' == True regardless,
and flatten() would pick whichever of the two entities happens to be
processed last."""
state = _state_coffee()
assert state['hotwater_lock'] is False
def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state():
"""Both LOCK.hotwater_lock and FAVORITE_HOTWATER's switchHotwater
fallback are always bound on this fixture (their hrefs are both always
present) -- discrimination happens entirely in exists_fn. Exactly one of
the two must ever pass, regardless of iteration order, or two switch
entities would be registered with the same unique_id."""
bound, resources = _bound_coffee()
candidates = [b for b in bound if b.desc.key == 'hotwater_lock']
assert len(candidates) == 2
included = [b for b in candidates
if b.desc.exists_fn(resources.get(b.href) or {}, resources)]
assert len(included) == 1
assert included[0].href == '/favorite/hotwater/vs/0'
def test_hotwater_lock_fallback_gating_across_status_lock_states():
"""The fallback (FAVORITE_HOTWATER's switchHotwater descriptor) must
activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock --
never while that resource is an unfetched stub ({}), and never when it
does carry the field. LOCK's own descriptor is the mirror image."""
fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0')
primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0')
own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'}
# /status/lock/vs/0 absent entirely -- device genuinely lacks it.
assert fallback.exists_fn(own_rep, {}) is True
# /status/lock/vs/0 present but not yet fetched (a stub): outcome
# pending, so the fallback must defer to LOCK rather than assume absence.
stub_resources = {'/status/lock/vs/0': {}}
assert fallback.exists_fn(own_rep, stub_resources) is False
assert primary.exists_fn({}, stub_resources) is True
# /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this
# fixture's actual shape) -- the fallback wins.
absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}}
assert fallback.exists_fn(own_rep, absent_resources) is True
assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False
# /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins.
present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}}
assert fallback.exists_fn(own_rep, present_resources) is False
assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True
# The fallback also re-asserts its own field, since it no longer gets
# that check for free once it shares LOCK's key (issue #144 review).
assert fallback.exists_fn({}, {}) is False
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():