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:
10 files changed
+238
-20
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user