From fb2d26763287cdf1bfc32cc130ebaf552b4b1f58 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 23:23:56 +0000 Subject: [PATCH] fix(water_purifier): route AILITE_DA-REF-WATERPURIFIER boards correctly (#196) The AILITE water-purifier board (RWP70F15ANW) spells its modelNum '...-REF-WATERPURIFIER-...', so the board-token scan hit the bare 'REF' token before ever reaching 'WATERPURIFIER' and misrouted the device to the refrigerator registry, whose resource surface shares almost nothing with a water purifier -- hence the incomplete-coverage warning. Add a documented carve-out for this one token co-occurrence and a matching TestBoardTokenAmbiguity exception. Also bind the water-purifier hrefs this board additionally exposes: cup-detection status, the settings/sound/{mode,output,volume} trio (read live, since this board's own supportedModes vocabulary differs from both laundry's and air_purifier's hardcoded/live sets), and last-pour statistics. Separately, gate hot_water_temperature off when the device doesn't report a supportedHotTemperatures list (only a hotwaterRange/hotwaterLevel pair with no confirmed write contract) -- previously an empty options list plus a live current value rendered as 'unknown' in HA, the second bug reported in #196. The existing water_purifier_coffee fixture (#107) turns out to hit the same shape, so its golden drops the entity too. Issue #195 (TP1X_REF_21K) needed no change: its diagnostics show zero unbound hrefs and the model already routes to the refrigerator registry, matching the maintainer's own comment on the issue. --- .../localthings/registry/by_type/__init__.py | 14 +- .../registry/by_type/water_purifier.py | 5 + .../registry/capabilities/water_purifier.py | 99 ++- .../localthings/translations/en.json | 20 + .../localthings/translations/nl.json | 20 + .../golden/water_purifier_ailite_25k.json | 36 ++ .../golden/water_purifier_coffee.json | 1 - .../water_purifier_ailite_25k_device.json | 590 ++++++++++++++++++ tests/test_by_type.py | 37 +- tests/test_golden_regression.py | 29 +- tests/test_water_purifier_capabilities.py | 95 +++ 11 files changed, 940 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/golden/water_purifier_ailite_25k.json create mode 100644 tests/fixtures/water_purifier_ailite_25k_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 9b42c58..69ea149 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -136,8 +136,20 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]: a flat lookup, not a priority list. Adding an entry that could co-occur with another (a family token, or one short enough to collide by accident) would break that property; see this table's comment. + + One documented exception (issue #196): AILITE water-purifier boards + spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the + shared cooling-subsystem board, not the refrigerator device type -- + 'WATERPURIFIER' is the actual, more specific type here. Rather than drop + or rename either entry (both are correct on their own for the model + strings that exist today), this one known co-occurrence resolves to + 'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a + matching carve-out for this exact pair. """ - for token in _board_tokens(value, cut_at): + tokens = _board_tokens(value, cut_at) + if 'REF' in tokens and 'WATERPURIFIER' in tokens: + return 'water_purifier' + for token in tokens: key = _BOARD_TOKEN_TO_KEY.get(token) if key is not None: return key diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py index 1fb38e6..7e82042 100644 --- a/custom_components/localthings/registry/by_type/water_purifier.py +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -15,6 +15,11 @@ REGISTRY = DeviceRegistry( water_purifier.FAVORITE_HOTWATER, water_purifier.COFFEE, water_purifier.LOCK, + water_purifier.CUP_STATE, + water_purifier.SOUND_MODE, + water_purifier.SOUND_OUTPUT, + water_purifier.SOUND_VOLUME, + water_purifier.STATISTIC_POUR, *water_purifier.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 019517c..159f11a 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -1,8 +1,10 @@ """Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class, -issue #90, model TP2X_WATERPURIFIER_20K). +issue #90, model TP2X_WATERPURIFIER_20K; also AILITE_DA-REF-WATERPURIFIER-class, +issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K). -Resources verified against the issue #90 diagnostics dump. +Resources verified against the issue #90 and #196 diagnostics dumps. """ +from ..batch import is_stub_rep from ..capability import Capability from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc from .common import int_or_none, parse_iso_utc as _parse_iso_utc @@ -21,10 +23,23 @@ DISPENSE = Capability( # Only a handful of discrete temperatures are selectable (not a # continuous range) -- a select over the live-reported set, not a # number with invented bounds. + # + # Newer boards (issue #196, RWP70F15ANW) don't populate + # supportedHotTemperatures at all -- they report a hotwaterRange + # (min/max) and a hotwaterLevel (step count?) instead, with no + # confirmed write contract for values off the old preset list. With + # an empty options_field result, HA's current_option still returns + # the live tempDesiredHotWater, which isn't in the (empty) options + # list and renders as "unknown" -- the exact symptom reported. Gate + # the entity off entirely when the board doesn't report a supported + # list, rather than guess at hotwaterRange/hotwaterLevel's meaning. SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater', icon='mdi:thermometer', entity_category='config', options_field='x.com.samsung.da.supportedHotTemperatures', + exists_fn=lambda rep, resources: ( + is_stub_rep(rep) + or 'x.com.samsung.da.supportedHotTemperatures' in rep), write_fn=lambda p, rep, href=None: ( ['setting', 'waterpurifier', 'vs', '0'], {'x.com.samsung.da.tempDesiredHotWater': p})), @@ -190,6 +205,86 @@ COFFEE = Capability( ), ) +# Cup-detection status (issue #196, RWP70F15ANW). Only "UnReady" observed; +# the full state domain isn't confirmed, so this stays a plain diagnostic +# sensor rather than an enum with an invented state table. +CUP_STATE = Capability( + href='/cup/state/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='cup_state', field='water.cup.state', + icon='mdi:cup-outline', entity_category='diagnostic'), + ), +) + +# Sound mode/output/volume (issue #196). Shapes echo laundry.py/ +# air_purifier.py's same-named hrefs, but this board's own values differ +# from both (supportedModes here is voice/fixedTone/mute, not laundry's +# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would +# reject a live-supported value, so these read the device's own supported +# list/range like air_purifier's versions do. +SOUND_MODE = Capability( + href='/settings/sound/mode/vs/0', + poll_tier='cold', + entities=( + SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode', + field='mode', + icon='mdi:volume-high', + entity_category='config', + options_field='supportedModes', + write_fn=lambda p, rep, href=None: ( + ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})), + ), +) + +SOUND_OUTPUT = Capability( + href='/settings/sound/output/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='sound_output', field='deviceType', + icon='mdi:volume-high', entity_category='diagnostic'), + # No confirmed write contract (no sibling field advertising this as + # user-settable) -- surfaced read-only per the 'don't guess' rule. + BinarySensorDesc(key='alarm_in_mute', field='alarmInMute', + icon='mdi:volume-mute', + entity_category='diagnostic', + value_fn=lambda v: str(v).lower() == 'true'), + ), +) + +SOUND_VOLUME = Capability( + href='/settings/sound/volume/vs/0', + poll_tier='cold', + entities=( + NumberDesc(key='sound_volume', field='level', + icon='mdi:volume-medium', + entity_category='config', + native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0, + native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0, + step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1, + value_fn=int_or_none, + write_fn=lambda p, rep, href=None: ( + ['settings', 'sound', 'volume', 'vs', '0'], + {'level': str(int(p))})), + ), +) + +# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed +# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity +# which at least has an -- albeit suspect -- capacityUnit) so it's left +# unitless rather than assumed to be mL. +STATISTIC_POUR = Capability( + href='/statistic/pour/vs/0', + poll_tier='cold', + entities=( + SensorDesc(key='last_pour_type', field='last.type', + icon='mdi:cup-water', entity_category='diagnostic'), + SensorDesc(key='last_pour_capacity', field='last.capacity', + icon='mdi:cup-water', entity_category='diagnostic', + value_fn=int_or_none), + ), +) + LOCK = Capability( href='/status/lock/vs/0', poll_tier='warm', diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 45382cb..5d718da 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -73,6 +73,9 @@ "pouring": { "name": "Pouring" }, + "alarm_in_mute": { + "name": "Alarm in mute" + }, "power_state": { "name": "Power state" }, @@ -490,6 +493,14 @@ "buzzer": "Buzzer" } }, + "water_purifier_sound_mode": { + "name": "Sound mode", + "state": { + "voice": "Voice", + "fixedtone": "Fixed tone", + "mute": "Mute" + } + }, "spin_speed": { "name": "Spin speed", "state": { @@ -876,6 +887,15 @@ "waterpurifier_status": { "name": "Status" }, + "cup_state": { + "name": "Cup state" + }, + "last_pour_type": { + "name": "Last pour type" + }, + "last_pour_capacity": { + "name": "Last pour capacity" + }, "machine_state": { "name": "Machine state", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 777c496..890ea12 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -73,6 +73,9 @@ "pouring": { "name": "Schenken" }, + "alarm_in_mute": { + "name": "Alarm bij dempen" + }, "power_state": { "name": "Voedingsstatus" }, @@ -490,6 +493,14 @@ "buzzer": "Zoemer" } }, + "water_purifier_sound_mode": { + "name": "Geluidsmodus", + "state": { + "voice": "Spraak", + "fixedtone": "Vaste toon", + "mute": "Dempen" + } + }, "spin_speed": { "name": "Centrifugesnelheid", "state": { @@ -876,6 +887,15 @@ "waterpurifier_status": { "name": "Status" }, + "cup_state": { + "name": "Kopstatus" + }, + "last_pour_type": { + "name": "Laatste schenktype" + }, + "last_pour_capacity": { + "name": "Laatste schenkhoeveelheid" + }, "machine_state": { "name": "Machinestatus", "state": { diff --git a/tests/fixtures/golden/water_purifier_ailite_25k.json b/tests/fixtures/golden/water_purifier_ailite_25k.json new file mode 100644 index 0000000..3eac563 --- /dev/null +++ b/tests/fixtures/golden/water_purifier_ailite_25k.json @@ -0,0 +1,36 @@ +{ + "state_keys": [ + "alarm_code", + "alarm_in_mute", + "buzz_lock", + "coffee_brew_status", + "coldwater_lock", + "cup_state", + "dispense_capacity", + "dispense_type", + "favorite_capacity", + "favorite_capacity_enabled", + "favorite_coffee_enabled", + "favorite_hotwater_temperature", + "filter_clean_remain_time", + "filter_door_status", + "filter_status", + "filter_usage", + "firmware_update", + "hotwater_lock", + "last_pour_capacity", + "last_pour_type", + "pouring", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "sound_mode", + "sound_output", + "sound_volume", + "sterilize_last_time", + "sterilize_period", + "sterilize_plan_time", + "sterilize_run_time", + "waterpurifier_status" + ] +} diff --git a/tests/fixtures/golden/water_purifier_coffee.json b/tests/fixtures/golden/water_purifier_coffee.json index 42a5035..952841e 100644 --- a/tests/fixtures/golden/water_purifier_coffee.json +++ b/tests/fixtures/golden/water_purifier_coffee.json @@ -15,7 +15,6 @@ "filter_status", "filter_usage", "firmware_update", - "hot_water_temperature", "hotwater_lock", "pouring", "selfcheck_error", diff --git a/tests/fixtures/water_purifier_ailite_25k_device.json b/tests/fixtures/water_purifier_ailite_25k_device.json new file mode 100644 index 0000000..9ae21aa --- /dev/null +++ b/tests/fixtures/water_purifier_ailite_25k_device.json @@ -0,0 +1,590 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": [ + "Start" + ], + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "ErrorCode_None" + ], + "x.com.samsung.da.status": "Ready" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "69", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ], + "x.com.samsung.da.lastResetDate": "2025-11-19T05:39:17", + "x.com.samsung.da.filterStatus": "normal" + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AILITE_DA-REF-WATERPURIFIER-01011|70674641|900100000219130081088700001E0000", + "x.com.samsung.da.description": "AILITE_WATERPURIFIER_25K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RX0", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "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": "251121", + "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": "25071603, 2510140E, 22092302, FFFFFFFF, 25061700, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Bixby", + "x.com.samsung.da.type": "Bixby", + "x.com.samsung.da.number": "1.2.2-rc.3", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/setting/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.supportedTypes": [ + "ambientwater", + "coldwater", + "hotwater" + ], + "x.com.samsung.da.desiredType": "coldwater", + "x.com.samsung.da.tempDesiredHotWater": "85", + "x.com.samsung.da.desiredCapacity": "120", + "x.com.samsung.da.hotwaterLevel": "5", + "x.com.samsung.da.hotwaterRange": [ + "40", + "90" + ], + "x.com.samsung.da.tempUnit": "C", + "x.com.samsung.da.pourStatus": "Off", + "x.com.samsung.da.capacityUnit": "C", + "x.com.samsung.da.capacityResolution": "10", + "x.com.samsung.da.desiredCapacityRange": [ + "50", + "2000" + ], + "x.com.samsung.da.triggerTime": "1785341896789" + } + }, + { + "href": "/favorite/capacity/vs/0", + "rep": { + "x.com.samsung.da.switchCapacity": "On", + "x.com.samsung.da.defaultCapacity": "120", + "x.com.samsung.da.capacityList": [ + "80", + "120", + "260", + "500", + "9999" + ], + "x.com.samsung.da.triggerTime": "1785341867176" + } + }, + { + "href": "/status/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.filterCleanMaxTime": "14", + "x.com.samsung.da.sterilizeMaxTime": "10", + "x.com.samsung.da.sterilizeRunTime": "0", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.sterilizePlanTime": "2026-07-31T17:00:00", + "x.com.samsung.da.sterilizeLastTime": "2026-07-28T17:08:36", + "x.com.samsung.da.sterilizeStartTime": "2026-07-31T17:00:00", + "x.com.samsung.da.sterilizePeriod": "3" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.coldwaterLock": "Unlocked", + "x.com.samsung.da.device.microphone": "On" + } + }, + { + "href": "/favorite/hotwater/vs/0", + "rep": { + "x.com.samsung.da.favorite.revision": "0", + "x.com.samsung.da.favorite.defaultTemperature": "85", + "x.com.samsung.da.favorite.showList": [ + "40", + "50", + "75", + "85", + "90" + ], + "x.com.samsung.da.favorite.supportedList": [ + "40", + "75", + "85", + "90" + ], + "x.com.samsung.da.switchHotwater": "Unlocked", + "x.com.samsung.da.triggerTime": "1785341867177" + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "level": "5", + "minLevel": "0", + "maxLevel": "15", + "resolution": "5" + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "mode": "voice", + "supportedModes": [ + "voice", + "fixedTone", + "mute" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "speaker", + "alarmInMute": "true" + } + }, + { + "href": "/setting/vs/0", + "rep": { + "x.com.samsung.da.setLanguage": "ko_KR", + "x.com.samsung.da.supportedSetLanguage": [ + "ko_KR" + ] + } + }, + { + "href": "/automation/waterpurifier/vs/0", + "rep": { + "automation.supported.modes": [ + "0" + ], + "automation.supported.options": [ + "capacity" + ] + } + }, + { + "href": "/favorite/coffee/vs/0", + "rep": { + "favorite.activate": "On", + "brew.status": "Suspend", + "favorite.enable.list": [ + "0", + "1", + "2", + "3" + ] + } + }, + { + "href": "/brand/recipe/info/vs/0", + "rep": { + "revision": "0", + "brand.num.max": "1" + } + }, + { + "href": "/recipe/coffee/vs/0", + "rep": {} + }, + { + "href": "/coffee/custom/recipe/vs/0", + "rep": { + "publisher": "0", + "cups": 1, + "favor": "223", + "numberOfStages": "2", + "stages": [ + { + "temperature": 0, + "waterAmount": 30, + "timeSecond": 20, + "turnOver": 0 + }, + { + "temperature": 0, + "waterAmount": 30, + "timeSecond": 20, + "turnOver": 0 + } + ], + "publisher.support": [ + "1", + "2", + "3", + "4", + "5" + ] + } + }, + { + "href": "/recipe/coffee/deletion/vs/0", + "rep": {} + }, + { + "href": "/cup/state/vs/0", + "rep": { + "water.cup.state": "UnReady", + "water.cup.basis.revision": "0" + } + }, + { + "href": "/statistic/pour/vs/0", + "rep": { + "last.type": "coldwater", + "last.capacity": "40" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Water purifier", + "osVersion": "5.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-WPWW-AIL-25-COMMON", + "versions": [ + "20251121" + ], + "visVersion": "251121" + }, + { + "type": "Micom", + "modelId": "838070674741FFFFFFFF", + "versions": [ + "2510140E", + "FFFFFFFF" + ], + "visVersion": "251014" + }, + { + "type": "Micom", + "modelId": "838070674641FFFFFFFF", + "versions": [ + "25071603", + "FFFFFFFF" + ], + "visVersion": "250716" + }, + { + "type": "Micom", + "modelId": "03807067464170674741", + "versions": [ + "25071603", + "2510140E" + ], + "visVersion": "251014" + }, + { + "type": "Micom", + "modelId": "038000156141FFFFFFFF", + "versions": [ + "22092302", + "FFFFFFFF" + ], + "visVersion": "220923" + }, + { + "type": "Micom", + "modelId": "038070673941FFFFFFFF", + "versions": [ + "25061700", + "FFFFFFFF" + ], + "visVersion": "250617" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G", + "5G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/bixbyuservalidate/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.loginId": "**REDACTED**", + "x.com.samsung.da.isExpired": "false" + } + ] + } + }, + { + "href": "/bixby/status/vs/0", + "rep": { + "x.com.samsung.da.enable": "true" + } + }, + { + "href": "/bixbyterms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "AudioReview", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1773704680415/kor/kor_kor_AudioRecordingReview.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "Datasharing", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1690269284216/kor/kor_kor_DataSharingAgreement_v3.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "Personalization", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1773644167282/kor/kor_kor_PersonalizationAgreement.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "PrivacyNotice", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1726806774110/kor/kor_kor_pn.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "PrivacyPolicy", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.7.8/1783668586319/kor/kor_kor_pp.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "SamsungLocation", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://static.bada.com/contents/legal/kor/kor/locationinfortnc.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "FMMPrivacyPolicy", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://account.samsung.com/membership/policy/privacy/service/o12ht525e6" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "BixbyPrivacy", + "x.com.samsung.da.termsStatus": "NotOnboarding", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "" + } + ], + "x.com.samsung.da.lastCheckedTime": "1785341867" + } + }, + { + "href": "/bixby/vs/0", + "rep": { + "x.com.samsung.da.voiceWakeup": "Disable", + "x.com.samsung.da.multidevWakeup": "true" + } + }, + { + "href": "/sec/networkaudio/audio/vs/0", + "rep": { + "volume": "4", + "mute": "false", + "maxVolume": "15", + "timestamp": "0" + } + }, + { + "href": "/voice/feature/vs/0", + "rep": { + "voice.feature": { + "ContinuousConversation": { + "support": "true", + "value": "false", + "supportedValues": [ + "true", + "false" + ] + }, + "DiscoverBixby": { + "support": "false" + }, + "LLMBixby": { + "support": "true" + }, + "SimpleCommand": { + "support": "false", + "value": "false", + "supportedValues": [ + "true", + "false" + ], + "description": [ + "\ub0c9\uc7a5\uace0 \ubb38 \uc5f4\uc5b4\uc918" + ] + } + } + } + }, + { + "href": "/voice/provisioning/vs/0", + "rep": { + "voice.provisioning.headers": { + "login_id": "**REDACTED**", + "voice.provisioning.headers.bixbyLanguage": "ko_KR", + "voice.provisioning.headers.bixbyType": "BixbyAgent", + "voice.provisioning.headers.bixbyVersion": "1.2.2-rc.3", + "voice.provisioning.headers.companioncountry": "KR", + "voice.provisioning.headers.csc": "920", + "voice.provisioning.headers.csciso": "KR", + "voice.provisioning.headers.deviceLanguage": "ko-KR", + "voice.provisioning.headers.deviceModelNameAsh": "RWP70F15ANW", + "voice.provisioning.headers.deviceModelNamePro": "BIXBY-LITE", + "voice.provisioning.headers.deviceSubType": "screenless_waterpurifier_lite", + "voice.provisioning.headers.deviceTypeAsh": "appliance", + "voice.provisioning.headers.deviceTypePro": "appliance", + "voice.provisioning.headers.mcc": "450", + "voice.provisioning.headers.osType": "Tizen Lite", + "voice.provisioning.headers.osVersion": "Tizen Lite", + "voice.provisioning.headers.requestTrackId": "1785341867293", + "voice.provisioning.headers.sspServerUrl": "us-auth2.samsungosp.com", + "voice.provisioning.headers.storecountry": "KR", + "voice.provisioning.headers.userType": "COMMERCIAL" + }, + "voice.provisioning.termsstatechange": { + "voice.provisioning.termsstatechange.refresh": "", + "voice.provisioning.termsstatechange.state": "Agreed", + "voice.provisioning.termsstatechange.terms": "" + }, + "voice.provisioning.deviceinfo": { + "voice.provisioning.deviceinfo.accesstoken": "**REDACTED**", + "voice.provisioning.deviceinfo.bixbyserver": "bxb3PrdUS", + "voice.provisioning.deviceinfo.bixbyserviceid": "US1DAAPPFD31232A4E4B40D6B83630AF58385719", + "voice.provisioning.deviceinfo.deviceid": "**REDACTED**", + "voice.provisioning.deviceinfo.gdprcountry": "false", + "voice.provisioning.deviceinfo.isSupportedCountry": "true", + "voice.provisioning.deviceinfo.userid": "**REDACTED**" + } + } + } + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 3828557..96106e8 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -277,6 +277,33 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'water_purifier' + def test_water_purifier_wins_over_ref_when_both_tokens_present(self): + """Issue #196: an AILITE water purifier (RWP70F15ANW) spells its + modelNum '...-REF-WATERPURIFIER-...', which would otherwise match the + bare 'REF' board token (refrigerator) before ever reaching + 'WATERPURIFIER' -- misrouting it to the refrigerator registry, whose + resource surface shares almost nothing with a water purifier.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'AILITE_DA-REF-WATERPURIFIER-01011|70674641|' + '900100000219130081088700001E0000', + 'AILITE_WATERPURIFIER_25K', + ) + assert reg is not None + assert reg.name == 'water_purifier' + + def test_bare_ref_token_still_resolves_refrigerator(self): + """Regression guard for the #196 carve-out above: a genuine + refrigerator modelNum with no 'WATERPURIFIER' token must still + resolve to 'refrigerator'.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_REF_21K|00175941|00050126001811344100000020090000', + 'TP1X_REF_21K', + ) + assert reg is not None + assert reg.name == 'refrigerator' + def test_airconditioner_via_wac_token(self): """Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no oneUiVersion and a modelNum carrying the '_WAC_' (Window Air @@ -487,6 +514,14 @@ class TestBoardTokenAmbiguity: safe while no real model string contains two tokens naming different device types. Guard that against every dump we have.""" + # Issue #196: AILITE water-purifier boards spell their modelNum + # '...-REF-WATERPURIFIER-...' -- 'REF' names the shared cooling-subsystem + # board, not a refrigerator. `_board_family_key` carries an explicit + # carve-out resolving this one pair to 'water_purifier'; this is the one + # documented exception to the "no two board tokens ever co-occur" + # invariant the rest of this test enforces. + _ALLOWED_CONFLICTS = {frozenset({'refrigerator', 'water_purifier'})} + def test_no_fixture_model_string_yields_two_conflicting_keys(self, all_device_fixtures): from custom_components.localthings.registry.by_type import ( _BOARD_TOKEN_TO_KEY, _board_tokens, @@ -502,7 +537,7 @@ class TestBoardTokenAmbiguity: for t in _board_tokens(field, cut) if t in _BOARD_TOKEN_TO_KEY } - assert len(keys) <= 1, ( + assert len(keys) <= 1 or frozenset(keys) in self._ALLOWED_CONFLICTS, ( f"{name}: {field!r} matches conflicting board tokens {keys}" ) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 1733eae..8752069 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -138,10 +138,37 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier(): ) +def test_registry_reproduces_golden_state_keys_for_water_purifier_ailite_25k(): + """AILITE_WATERPURIFIER_25K (issue #196, RWP70F15ANW) -- a coffee-capable + water purifier on an AILITE_DA-REF-WATERPURIFIER board, whose modelNum's + 'REF' token would otherwise misroute it to the refrigerator registry (see + TestBoardTokenAmbiguity's carve-out). Also the first dump to expose + /cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on + this device type, and a hot_water_temperature select that must gate off + (no supportedHotTemperatures reported) rather than surface as 'unknown'.""" + from tests.conftest import _load_device + resources = _load_device('water_purifier_ailite_25k') + golden = json.loads((GOLDEN / 'water_purifier_ailite_25k.json').read_text()) + state_keys = _new_state_keys('water_purifier_ailite_25k', 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_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.""" + coffee-recipe resources not present in issue #90's original dump. + + This fixture's /setting/waterpurifier/vs/0 turns out to report no + supportedHotTemperatures either (only hotwaterLevel/hotwaterRange, the + same shape issue #196 surfaced) -- so hot_water_temperature dropped out + of this golden when the #196 fix (see water_purifier.DISPENSE's + exists_fn) landed. This fixture was quietly hitting the same 'unknown' + bug all along; the registry-level golden just had no way to show it + since flatten()'s state dict doesn't model select option membership.""" from tests.conftest import _load_device resources = _load_device('water_purifier_coffee') golden = json.loads((GOLDEN / 'water_purifier_coffee.json').read_text()) diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index 0e2ef53..432eb8a 100644 --- a/tests/test_water_purifier_capabilities.py +++ b/tests/test_water_purifier_capabilities.py @@ -295,3 +295,98 @@ def test_coffee_recipe_hrefs_are_ignored_not_guessed(): 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 + + +# --------------------------------------------------------------------------- +# AILITE_DA-REF-WATERPURIFIER board (issue #196, RWP70F15ANW) -- a +# coffee-capable variant on a different board family than issue #90/#107's +# TP2X_WATERPURIFIER_20K, whose modelNum's 'REF' token would otherwise +# misroute it to the refrigerator registry (see test_by_type.py's +# TestBoardTokenAmbiguity carve-out). Also the first dump to expose +# /cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on +# this device type. +# --------------------------------------------------------------------------- + +def _water_purifier_ailite(): + resources = _load_device('water_purifier_ailite_25k') + 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_ailite(): + reg, resources = _water_purifier_ailite() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state_ailite(): + bound, resources = _bound_ailite() + return flatten(bound, resources) + + +def _desc_ailite(key): + bound, _ = _bound_ailite() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_ailite_model_resolves_to_water_purifier_not_refrigerator(): + reg, _ = _water_purifier_ailite() + assert reg is not None and reg.name == 'water_purifier' + + +def test_ailite_no_unbound_hrefs(): + reg, resources = _water_purifier_ailite() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_ailite_expected_state_keys_present(): + state = _state_ailite() + for key in ('cup_state', 'sound_mode', 'sound_output', 'sound_volume', + 'alarm_in_mute', 'last_pour_type', 'last_pour_capacity'): + assert key in state, key + + +def test_ailite_hot_water_temperature_gated_off_without_supported_list(): + """This board reports tempDesiredHotWater but no + supportedHotTemperatures (only a hotwaterRange/hotwaterLevel pair whose + write contract isn't confirmed) -- the exact shape that used to make + HA's select show 'unknown' (issue #196), since current_option isn't in + an empty options list. The descriptor must gate off entirely rather than + register with empty options.""" + state = _state_ailite() + assert 'hot_water_temperature' not in state + + +def test_ailite_sound_mode_options_come_from_live_supported_modes(): + """This board's supportedModes (voice/fixedTone/mute) differs from both + laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's + mute/buzzer -- reusing either would reject a value this device actually + supports, per the oven._OVEN_MODES lesson from issue #138.""" + desc = _desc_ailite('sound_mode') + assert desc.options_field == 'supportedModes' + assert desc.translation_key == 'water_purifier_sound_mode' + + +def test_ailite_sound_volume_bounds_come_live_not_hardcoded(): + desc = _desc_ailite('sound_volume') + assert isinstance(desc, NumberDesc) + assert desc.native_min is None and desc.native_max is None + rep = {'minLevel': '0', 'maxLevel': '15', 'resolution': '5'} + assert desc.native_min_fn(rep) == 0 + assert desc.native_max_fn(rep) == 15 + assert desc.step_fn(rep) == 5 + + +def test_ailite_alarm_in_mute_is_read_only(): + """No sibling field advertises alarmInMute as user-settable -- surfaced + as a read-only diagnostic per the 'don't guess' rule rather than an + invented switch.""" + from custom_components.localthings.registry.entities import BinarySensorDesc + desc = _desc_ailite('alarm_in_mute') + assert isinstance(desc, BinarySensorDesc) + assert desc.value_fn('true') is True + assert desc.value_fn('false') is False