diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py index e1ac333..1fb38e6 100644 --- a/custom_components/localthings/registry/by_type/water_purifier.py +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -12,6 +12,8 @@ REGISTRY = DeviceRegistry( water_purifier.DISPENSE, water_purifier.STATUS, water_purifier.FAVORITE_CAPACITY, + water_purifier.FAVORITE_HOTWATER, + water_purifier.COFFEE, water_purifier.LOCK, *water_purifier.COVERAGE, ]), diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index f27e484..ef287cb 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -116,6 +116,50 @@ 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. +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', + entity_category='config', + value_fn=lambda v: v != 'Locked', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'hotwater', 'vs', '0'], + {'x.com.samsung.da.switchHotwater': 'Unlocked' if p == 'On' else 'Locked'})), + SelectDesc(key='favorite_hotwater_temperature', + field='x.com.samsung.da.favorite.defaultTemperature', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.favorite.supportedList', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'hotwater', 'vs', '0'], + {'x.com.samsung.da.favorite.defaultTemperature': p})), + ), +) + +# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix +# on this resource, unlike the rest of the water-purifier surface. +COFFEE = Capability( + href='/favorite/coffee/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate', + icon='mdi:coffee-outline', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'coffee', 'vs', '0'], + {'favorite.activate': 'On' if p == 'On' else 'Off'})), + SensorDesc(key='coffee_brew_status', field='brew.status', + icon='mdi:coffee-outline', + entity_category='diagnostic'), + ), +) + LOCK = Capability( href='/status/lock/vs/0', poll_tier='warm', @@ -160,6 +204,14 @@ _WP_IGNORED = [ # Static support-flags blob (automation.supported.modes/options) -- no # live "current automation setting" field to expose. '/automation/waterpurifier/vs/0', + # Coffee-capable variant (issue #107). All four are static + # capability-advertisement blobs or empty -- no live "current recipe" / + # "current custom slot" field to expose, unlike /favorite/coffee/vs/0 + # (COFFEE above), which does carry live brew status. + '/brand/recipe/info/vs/0', # revision + max-brand-count metadata + '/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs + '/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content + '/recipe/coffee/deletion/vs/0', # empty {} on this dump ] COVERAGE = [Capability(href=h) for h in _WP_IGNORED] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7ce43c0..4bbced4 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -250,6 +250,9 @@ "favorite_capacity": { "name": "Favorite capacity" }, + "favorite_hotwater_temperature": { + "name": "Favorite hot water temperature" + }, "finish_sound": { "name": "Finish sound", "state": { @@ -487,6 +490,9 @@ "clean_level": { "name": "Clean level" }, + "coffee_brew_status": { + "name": "Coffee brew status" + }, "connection_mode": { "name": "Connection mode", "state": { @@ -759,6 +765,12 @@ "favorite_capacity_enabled": { "name": "Favorite capacity" }, + "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 f47b701..89f382b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -250,6 +250,9 @@ "favorite_capacity": { "name": "Favoriete capaciteit" }, + "favorite_hotwater_temperature": { + "name": "Favoriete temperatuur warm water" + }, "finish_sound": { "name": "Eindgeluid", "state": { @@ -487,6 +490,9 @@ "clean_level": { "name": "Reinigingsniveau" }, + "coffee_brew_status": { + "name": "Koffiezetstatus" + }, "connection_mode": { "name": "Verbindingsmodus", "state": { @@ -759,6 +765,12 @@ "favorite_capacity_enabled": { "name": "Favoriete capaciteit" }, + "favorite_coffee_enabled": { + "name": "Favoriete koffie" + }, + "favorite_hotwater_enabled": { + "name": "Favoriet warm water" + }, "fridge_sound": { "name": "Geluid" }, diff --git a/tests/fixtures/golden/water_purifier_coffee.json b/tests/fixtures/golden/water_purifier_coffee.json new file mode 100644 index 0000000..3b8003b --- /dev/null +++ b/tests/fixtures/golden/water_purifier_coffee.json @@ -0,0 +1,31 @@ +{ + "state_keys": [ + "alarm_code", + "buzz_lock", + "coffee_brew_status", + "coldwater_lock", + "dispense_capacity", + "dispense_type", + "favorite_capacity", + "favorite_capacity_enabled", + "favorite_coffee_enabled", + "favorite_hotwater_enabled", + "favorite_hotwater_temperature", + "filter_clean_remain_time", + "filter_door_status", + "filter_status", + "filter_usage", + "firmware_update", + "hot_water_temperature", + "hotwater_lock", + "pouring", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "sterilize_last_time", + "sterilize_period", + "sterilize_plan_time", + "sterilize_run_time", + "waterpurifier_status" + ] +} diff --git a/tests/fixtures/water_purifier_coffee_device.json b/tests/fixtures/water_purifier_coffee_device.json new file mode 100644 index 0000000..ab19dc9 --- /dev/null +++ b/tests/fixtures/water_purifier_coffee_device.json @@ -0,0 +1,295 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ], + "x.com.samsung.da.supportedActions": [ + "Start" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_WATERPURIFIER_20K|00156641|900000000216130001088700000E0000", + "x.com.samsung.da.description": "TP2X_WATERPURIFIER_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RP0", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02144A240904", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24091109, 2409110D, 22092302, FFFFFFFF, 23030200, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "", + "otnList": [ + { + "type": "WIFI", + "modelId": "A-WPWW-TP2-22-COMMON", + "versions": [ + "20240904" + ], + "visVersion": "240904" + }, + { + "type": "Micom", + "modelId": "03800015664100156041", + "versions": [ + "24091109", + "2409110D" + ], + "visVersion": "240911" + }, + { + "type": "Micom", + "modelId": "038000156141FFFFFFFF", + "versions": [ + "22092302", + "FFFFFFFF" + ], + "visVersion": "220923" + }, + { + "type": "Micom", + "modelId": "038000159141FFFFFFFF", + "versions": [ + "23030200", + "FFFFFFFF" + ], + "visVersion": "230302" + } + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "90", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.lastResetDate": "2025-08-29T03:22:31", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.coldwaterLock": "Unlocked", + "x.com.samsung.da.buzzLock": "Unlocked" + } + }, + { + "href": "/setting/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.hotwaterLevel": "5", + "x.com.samsung.da.desiredType": "ambientwater", + "x.com.samsung.da.tempDesiredHotWater": "40", + "x.com.samsung.da.tempUnit": "C", + "x.com.samsung.da.desiredCapacity": "100", + "x.com.samsung.da.capacityUnit": "C", + "x.com.samsung.da.capacityResolution": "10", + "x.com.samsung.da.hotwaterRange": [ + "40", + "90" + ], + "x.com.samsung.da.desiredCapacityRange": [ + "50", + "2000" + ], + "x.com.samsung.da.supportedTypes": [ + "ambientwater", + "coldwater", + "hotwater" + ], + "x.com.samsung.da.pourStatus": "Off" + } + }, + { + "href": "/status/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.filterDoorStatus": "Close", + "x.com.samsung.da.sterilizeRunTime": "0", + "x.com.samsung.da.sterilizeStartTime": "2026-07-27T17:00:00", + "x.com.samsung.da.sterilizePeriod": "3", + "x.com.samsung.da.sterilizePlanTime": "2026-07-27T17:00:00", + "x.com.samsung.da.sterilizeLastTime": "2026-07-24T17:08:25", + "x.com.samsung.da.sterilizeMaxTime": "10", + "x.com.samsung.da.filterCleanRemainTime": "0", + "x.com.samsung.da.filterCleanMaxTime": "14" + } + }, + { + "href": "/favorite/capacity/vs/0", + "rep": { + "x.com.samsung.da.switchCapacity": "On", + "x.com.samsung.da.defaultCapacity": "100", + "x.com.samsung.da.capacityList": [ + "100", + "200", + "260", + "500", + "9999" + ] + } + }, + { + "href": "/favorite/hotwater/vs/0", + "rep": { + "x.com.samsung.da.switchHotwater": "Unlocked", + "x.com.samsung.da.favorite.defaultTemperature": "40", + "x.com.samsung.da.favorite.revision": "0", + "x.com.samsung.da.favorite.showList": [ + "40", + "75", + "85", + "90" + ], + "x.com.samsung.da.favorite.supportedList": [ + "40", + "75", + "85", + "90" + ] + } + }, + { + "href": "/automation/waterpurifier/vs/0", + "rep": { + "automation.supported.modes": [ + "0" + ], + "automation.supported.options": [ + "capacity" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/recipe/coffee/vs/0", + "rep": { + "publisher.support": [ + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/coffee/custom/recipe/vs/0", + "rep": { + "publisher.support": [ + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/favorite/coffee/vs/0", + "rep": { + "favorite.activate": "On", + "brew.status": "Suspend", + "favorite.enable.list": [ + "0", + "1", + "2" + ] + } + }, + { + "href": "/recipe/coffee/deletion/vs/0", + "rep": {} + }, + { + "href": "/brand/recipe/info/vs/0", + "rep": { + "revision": "0", + "brand.num.max": "1" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 8abb316..bdcfaab 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -150,6 +150,21 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier(): ) +def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee(): + """TP2X_WATERPURIFIER_20K coffee-capable variant (issue #107) adds + /favorite/coffee/vs/0, /favorite/hotwater/vs/0, and three static + coffee-recipe resources not present in issue #90's original dump.""" + from tests.conftest import _load_device + resources = _load_device('water_purifier_coffee') + golden = json.loads((GOLDEN / 'water_purifier_coffee.json').read_text()) + state_keys = _new_state_keys('water_purifier_coffee', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_cooktop(): from tests.conftest import _load_device resources = _load_device('cooktop') diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index c0eaa98..cd1f2cd 100644 --- a/tests/test_water_purifier_capabilities.py +++ b/tests/test_water_purifier_capabilities.py @@ -138,3 +138,80 @@ def test_mode_hrefs_are_ignored_not_guessed(): ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} assert '/mode/vs/0' in ignored_hrefs assert '/automation/waterpurifier/vs/0' in ignored_hrefs + + +# --------------------------------------------------------------------------- +# Coffee-capable variant (issue #107) -- /favorite/coffee/vs/0 and +# /favorite/hotwater/vs/0, not present in issue #90's original dump. +# --------------------------------------------------------------------------- + +def _water_purifier_coffee(): + resources = _load_device('water_purifier_coffee') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def _bound_coffee(): + reg, resources = _water_purifier_coffee() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state_coffee(): + bound, resources = _bound_coffee() + return flatten(bound, resources) + + +def _desc_coffee(key): + bound, _ = _bound_coffee() + return next(b.desc for b in bound if b.desc.key == key) + + +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.""" + reg, resources = _water_purifier_coffee() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +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'): + assert key in state, key + + +def test_favorite_coffee_write_contract(): + desc = _desc_coffee('favorite_coffee_enabled') + assert desc.write_fn('On', {}) == ( + ['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'On'}) + assert desc.write_fn('Off', {}) == ( + ['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', {}) == ( + ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'}) + + +def test_favorite_hotwater_temperature_options_come_from_live_supported_list(): + desc = _desc_coffee('favorite_hotwater_temperature') + assert desc.options_field == 'x.com.samsung.da.favorite.supportedList' + + +def test_coffee_recipe_hrefs_are_ignored_not_guessed(): + """Static capability-advertisement blobs or empty resources -- no live + 'current recipe'/'current custom slot' field to expose, per the 'don't + guess' rule.""" + from custom_components.localthings.registry.capabilities import water_purifier + ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} + for href in ('/brand/recipe/info/vs/0', '/coffee/custom/recipe/vs/0', + '/recipe/coffee/vs/0', '/recipe/coffee/deletion/vs/0'): + assert href in ignored_hrefs, href