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 f3b4eca..0b14f34 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, @@ -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', diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 953cd3d..019517c 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -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'})), diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 2199e3f..07ab5b7 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" }, @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 7015d9f..3b8a1ea 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" }, @@ -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" }, 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..0e2ef53 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,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():