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.
This commit is contained in:
@@ -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,
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"after_run_active",
|
||||
"after_run_progress",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_ventilation_action",
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user