diff --git a/README.md b/README.md index 64f7413..0c9c89e 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,13 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | -| Cooktop (read-only burner status) | `by_type/cooktop.py` | +| Gas cooktop (read-only burner status) | `by_type/cooktop.py` | | Range hood | `by_type/range_hood.py` | | Range | `by_type/range.py` | | Dishwasher | `by_type/dishwasher.py` | | Refrigerator | `by_type/refrigerator.py` | | Washer | `by_type/washer.py` | +| Water purifier | `by_type/water_purifier.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 29d9daf..4568555 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -4,7 +4,7 @@ from typing import Optional from ._base import DeviceRegistry from . import ( air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - oven, range as _range, range_hood, refrigerator, washer, + oven, range as _range, range_hood, refrigerator, washer, water_purifier, ) __all__ = [ @@ -28,6 +28,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'range_hood': range_hood.REGISTRY, 'refrigerator': refrigerator.REGISTRY, 'washer': washer.REGISTRY, + 'water_purifier': water_purifier.REGISTRY, } @@ -165,6 +166,10 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi model_identity = f'{model_num} {description}'.upper() if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity): key = 'cooktop' + # Water purifiers (e.g. TP2X_WATERPURIFIER_20K, issue #90) report no + # oneUiVersion and carry the 'WATERPURIFIER' board-family token. + if key is None and 'WATERPURIFIER' in model_identity: + key = 'water_purifier' if key is None and model_identity.startswith('AHD-'): key = 'range_hood' # Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) -- diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index 08084d9..91fc189 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -1,11 +1,21 @@ -"""Cooktop device registry.""" +"""Gas cooktop device registry (NA9300K-class, PR #23). + +Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label +doesn't collide with the unrelated induction_cooktop family (issue #86, +by_type/induction_cooktop.py) -- two different OCF surfaces that happen to +share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup +key is unchanged: it's relied on by real devices reporting +oneUiVersion "Cooktop" (for_device), the legacy ARTIK051 modelNum rule +(for_device_by_model), and the resource-signature fallback +(for_device_by_resources) alike. +""" from ..capabilities import common, cooktop, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( - name='cooktop', + name='gas_cooktop', capabilities=_build([ *ignored.IGNORED, cooktop.COOKTOP_POWER, diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py new file mode 100644 index 0000000..e1ac333 --- /dev/null +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -0,0 +1,18 @@ +"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90). +""" +from ..capabilities import common, ignored, water_purifier +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='water_purifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + common.WATER_FILTER, + water_purifier.DISPENSE, + water_purifier.STATUS, + water_purifier.FAVORITE_CAPACITY, + 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 new file mode 100644 index 0000000..f27e484 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -0,0 +1,165 @@ +"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class, +issue #90, model TP2X_WATERPURIFIER_20K). + +Resources verified against the issue #90 diagnostics dump. +""" +from datetime import datetime, timezone + +from ..capability import Capability +from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc +from .common import int_or_none + + +def _parse_iso_utc(raw): + """Bare ISO datetime with no timezone field alongside it -- treated as + UTC, matching this integration's convention for other bare ISO datetime + fields (see washer.py's drum-clean-log comment).""" + if not raw: + return None + try: + return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc) + except ValueError: + return None + + +DISPENSE = Capability( + href='/setting/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType', + icon='mdi:cup-water', + options_field='x.com.samsung.da.supportedTypes', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredType': p})), + # 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. + SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.supportedHotTemperatures', + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.tempDesiredHotWater': p})), + # Bounds and step come live from the device's own + # desiredCapacityRange/capacityResolution fields, not a hardcoded + # constant -- see the adding-device-support skill's "never hard-code + # the one dump's values" section. No unit is set: capacityUnit reads + # "C" on this dump, which can't be right for a volume field, so per + # the 'don't guess' rule the unit is left unset rather than assumed + # to be mL. + NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity', + icon='mdi:cup-water', + value_fn=int_or_none, + range_field='x.com.samsung.da.desiredCapacityRange', + step_fn=lambda rep: int_or_none( + rep.get('x.com.samsung.da.capacityResolution')) or 1, + write_fn=lambda p, rep, href=None: ( + ['setting', 'waterpurifier', 'vs', '0'], + {'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})), + BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus', + icon='mdi:cup-water', + value_fn=lambda v: v == 'On'), + ), +) + +STATUS = Capability( + href='/status/waterpurifier/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status', + icon='mdi:water-pump', + entity_category='diagnostic'), + BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus', + device_class='door', + entity_category='diagnostic', + value_fn=lambda v: v == 'Open'), + SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod', + icon='mdi:calendar-sync', + entity_category='diagnostic'), + SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime', + icon='mdi:timer-outline', + entity_category='diagnostic'), + SensorDesc(key='sterilize_last_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizeLastTime'))), + SensorDesc(key='sterilize_plan_time', device_class='timestamp', + entity_category='diagnostic', + rep_fn=lambda rep: _parse_iso_utc( + rep.get('x.com.samsung.da.sterilizePlanTime'))), + SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime', + icon='mdi:timer-sand', + entity_category='diagnostic'), + ), +) + +FAVORITE_CAPACITY = Capability( + href='/favorite/capacity/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity', + icon='mdi:star-outline', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})), + SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity', + icon='mdi:cup-water', + entity_category='config', + options_field='x.com.samsung.da.capacityList', + write_fn=lambda p, rep, href=None: ( + ['favorite', 'capacity', 'vs', '0'], + {'x.com.samsung.da.defaultCapacity': p})), + ), +) + +LOCK = Capability( + href='/status/lock/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})), + SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock', + device_class='lock', + entity_category='config', + value_fn=lambda v: v != 'Unlocked', + write_fn=lambda p, rep, href=None: ( + ['status', 'lock', 'vs', '0'], + {'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})), + ), +) + +# --------------------------------------------------------------------------- +# Water-purifier-scoped coverage: hrefs with no user-actionable state or no +# confirmed contract, following the 'don't guess' rule. +# --------------------------------------------------------------------------- +_WP_IGNORED = [ + # supportedModes carries a single opaque wizard-workflow token + # ('HOMECARE_WIZARD_V2') and modes reports a completely different, + # unrelated value ('WATERFILTER_DISABLE') not even present in + # supportedModes -- internal plumbing, not a real user-facing mode + # select. OCF-standard /mode/0 mirrors the same vendor resource but is + # already covered by the global ignored.IGNORED (fridge's OCF-native + # vacation-mode flag shares that href). + '/mode/vs/0', + # Static support-flags blob (automation.supported.modes/options) -- no + # live "current automation setting" field to expose. + '/automation/waterpurifier/vs/0', +] + +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 494e244..ada50d7 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -34,6 +34,9 @@ "door_open": { "name": "Door" }, + "filter_door_status": { + "name": "Filter door" + }, "firmware_update": { "name": "Firmware update available" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodic air sensing" }, + "pouring": { + "name": "Pouring" + }, "power_state": { "name": "Power state" }, @@ -114,6 +120,9 @@ "delay_start_hours": { "name": "Delay start" }, + "dispense_capacity": { + "name": "Dispense capacity" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -196,6 +205,9 @@ "8f": "Baby Care" } }, + "dispense_type": { + "name": "Dispense type" + }, "door_alert": { "name": "Door alarm", "state": { @@ -226,6 +238,9 @@ "1f": "Mixed load" } }, + "favorite_capacity": { + "name": "Favorite capacity" + }, "finish_sound": { "name": "Finish sound", "state": { @@ -257,6 +272,9 @@ "extra_high": "Extra high" } }, + "hot_water_temperature": { + "name": "Hot water temperature" + }, "ice_type": { "name": "{instance_name} type", "state": { @@ -524,6 +542,9 @@ "fan_speed_level": { "name": "Fan speed level" }, + "filter_clean_remain_time": { + "name": "Filter clean remaining time" + }, "filter_progress": { "name": "Filter progress" }, @@ -620,6 +641,18 @@ "selfcheck_status": { "name": "Self-check status" }, + "sterilize_last_time": { + "name": "Sterilize last time" + }, + "sterilize_period": { + "name": "Sterilize period" + }, + "sterilize_plan_time": { + "name": "Sterilize plan time" + }, + "sterilize_run_time": { + "name": "Sterilize run time" + }, "super_fine_dust": { "name": "Super fine dust" }, @@ -629,6 +662,9 @@ "water_liters": { "name": "Water consumption" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machine state", "state": { @@ -675,6 +711,9 @@ "bubble_soak": { "name": "Bubble soak" }, + "buzz_lock": { + "name": "Buzzer lock" + }, "cabinet_light_dim": { "name": "Brighten gradually" }, @@ -684,6 +723,9 @@ "child_lock": { "name": "Child lock" }, + "coldwater_lock": { + "name": "Cold water lock" + }, "defrost_delay": { "name": "Defrost delay" }, @@ -693,9 +735,15 @@ "fast_preheat": { "name": "Fast preheat" }, + "favorite_capacity_enabled": { + "name": "Favorite capacity" + }, "fridge_sound": { "name": "Sound" }, + "hotwater_lock": { + "name": "Hot water lock" + }, "ice_maker_enabled": { "name": "Ice maker" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 80d792d..e6e3813 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -34,6 +34,9 @@ "door_open": { "name": "Deur" }, + "filter_door_status": { + "name": "Filterdeur" + }, "firmware_update": { "name": "Firmware-update beschikbaar" }, @@ -52,6 +55,9 @@ "periodic_air_sensing": { "name": "Periodieke luchtmeting" }, + "pouring": { + "name": "Schenken" + }, "power_state": { "name": "Voedingsstatus" }, @@ -114,6 +120,9 @@ "delay_start_hours": { "name": "Uitgestelde start" }, + "dispense_capacity": { + "name": "Schenkhoeveelheid" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -196,6 +205,9 @@ "8f": "Babyverzorging" } }, + "dispense_type": { + "name": "Type dispenser" + }, "door_alert": { "name": "Deuralarm", "state": { @@ -226,6 +238,9 @@ "1f": "Gemengde was" } }, + "favorite_capacity": { + "name": "Favoriete capaciteit" + }, "finish_sound": { "name": "Eindgeluid", "state": { @@ -257,6 +272,9 @@ "extra_high": "Extra hoog" } }, + "hot_water_temperature": { + "name": "Temperatuur warm water" + }, "ice_type": { "name": "Type {instance_name}", "state": { @@ -524,6 +542,9 @@ "fan_speed_level": { "name": "Ventilatorniveau" }, + "filter_clean_remain_time": { + "name": "Resterende tijd filterreiniging" + }, "filter_progress": { "name": "Filterverbruik" }, @@ -620,6 +641,18 @@ "selfcheck_status": { "name": "Status zelfcontrole" }, + "sterilize_last_time": { + "name": "Laatste sterilisatie" + }, + "sterilize_period": { + "name": "Sterilisatie-interval" + }, + "sterilize_plan_time": { + "name": "Geplande sterilisatie" + }, + "sterilize_run_time": { + "name": "Sterilisatieduur" + }, "super_fine_dust": { "name": "Ultrafijnstof" }, @@ -629,6 +662,9 @@ "water_liters": { "name": "Waterverbruik" }, + "waterpurifier_status": { + "name": "Status" + }, "machine_state": { "name": "Machinestatus", "state": { @@ -675,6 +711,9 @@ "bubble_soak": { "name": "Bubble Soak" }, + "buzz_lock": { + "name": "Zoemerslot" + }, "cabinet_light_dim": { "name": "Geleidelijk feller" }, @@ -684,6 +723,9 @@ "child_lock": { "name": "Kinderslot" }, + "coldwater_lock": { + "name": "Koudwaterslot" + }, "defrost_delay": { "name": "Ontdooien uitstellen" }, @@ -693,9 +735,15 @@ "fast_preheat": { "name": "Snel voorverwarmen" }, + "favorite_capacity_enabled": { + "name": "Favoriete capaciteit" + }, "fridge_sound": { "name": "Geluid" }, + "hotwater_lock": { + "name": "Heetwaterslot" + }, "ice_maker_enabled": { "name": "IJsmaker" }, diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index ea27504..5b4b5e4 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -5,10 +5,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "power_watts", "selfcheck_error", diff --git a/tests/fixtures/golden/water_purifier.json b/tests/fixtures/golden/water_purifier.json new file mode 100644 index 0000000..3854bc9 --- /dev/null +++ b/tests/fixtures/golden/water_purifier.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "buzz_lock", + "coldwater_lock", + "dispense_capacity", + "dispense_type", + "favorite_capacity", + "favorite_capacity_enabled", + "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_device.json b/tests/fixtures/water_purifier_device.json new file mode 100644 index 0000000..ecfaed2 --- /dev/null +++ b/tests/fixtures/water_purifier_device.json @@ -0,0 +1,234 @@ +{ + "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": "/mode/0", + "rep": { + "supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "WATERFILTER_DISABLE" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000", + "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": "540", + "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": "02144A240615", + "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": "24061111, 24091218, 21072807, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09: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-20-COMMON", + "versions": [ + "20240615" + ], + "visVersion": "240615" + }, + { + "type": "Micom", + "modelId": "03800013234100132441", + "versions": [ + "24061111", + "24091218" + ], + "visVersion": "240912" + }, + { + "type": "Micom", + "modelId": "038000132541FFFFFFFF", + "versions": [ + "21072807", + "FFFFFFFF" + ], + "visVersion": "210728" + } + ] + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "28", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.lastResetDate": "2026-04-14T14:33:15", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.hotwaterLock": "Unlocked", + "x.com.samsung.da.coldwaterLock": "Unlocked", + "x.com.samsung.da.buzzLock": "Unlocked" + } + }, + { + "href": "/setting/waterpurifier/vs/0", + "rep": { + "x.com.samsung.da.desiredType": "coldwater", + "x.com.samsung.da.tempDesiredHotWater": "85", + "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.desiredCapacityRange": [ + "50", + "2000" + ], + "x.com.samsung.da.supportedTypes": [ + "ambientwater", + "coldwater", + "hotwater" + ], + "x.com.samsung.da.supportedHotTemperatures": [ + "40", + "75", + "85" + ], + "x.com.samsung.da.triggerTime": "1785063153", + "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-27T18:00:00", + "x.com.samsung.da.sterilizePeriod": "3", + "x.com.samsung.da.sterilizePlanTime": "2026-07-27T18:00:00", + "x.com.samsung.da.sterilizeLastTime": "2026-07-24T18:09:25", + "x.com.samsung.da.sterilizeMaxTime": "10", + "x.com.samsung.da.filterCleanRemainTime": "0", + "x.com.samsung.da.filterCleanMaxTime": "15" + } + }, + { + "href": "/favorite/capacity/vs/0", + "rep": { + "x.com.samsung.da.switchCapacity": "On", + "x.com.samsung.da.defaultCapacity": "100", + "x.com.samsung.da.maxCapacity": "6", + "x.com.samsung.da.capacityList": [ + "100", + "150", + "200", + "250", + "550", + "9999" + ] + } + }, + { + "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" + } + } + ] +} diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index a6abccd..cac35f6 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -169,7 +169,7 @@ def test_run_discovery_detects_cooktop_via_resource_signature( coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(_load_device('cooktop')) - assert coordinator.device_type_name == 'cooktop' + assert coordinator.device_type_name == 'gas_cooktop' assert coordinator._unbound_hrefs == [] diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 5638b15..22ef7d1 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -42,9 +42,12 @@ class TestForDevice: assert registry.name == 'refrigerator' def test_for_device_returns_cooktop_registry(self): + """The registry's own .name is 'gas_cooktop' (disambiguated from + induction_cooktop), but the lookup key devices route through stays + 'cooktop' -- oneUiVersion "Cooktop" still resolves here.""" registry = for_device('7.0 Cooktop') assert registry is not None - assert registry.name == 'cooktop' + assert registry.name == 'gas_cooktop' def test_for_device_returns_range_hood_registry(self): registry = for_device('7.0 Range Hood') @@ -238,7 +241,19 @@ class TestForDeviceByModel: ) assert reg is not None assert reg.name == 'dehumidifier' - + + def test_water_purifier_via_waterpurifier_token(self): + """Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no + oneUiVersion and no consumer-prefix match; falls back to the + 'WATERPURIFIER' token shared by modelNum and description.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000', + 'TP2X_WATERPURIFIER_20K', + ) + assert reg is not None + assert reg.name == 'water_purifier' + 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 @@ -260,7 +275,7 @@ class TestForDeviceByModel: 'ARTIK051_GLOBAL_COOKTOP', ) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_range_hood_via_ahd_model(self): from custom_components.localthings.registry.by_type import for_device_by_model @@ -321,7 +336,7 @@ class TestForDeviceByResources: reg = for_device_by_resources(_load_device('cooktop')) assert reg is not None - assert reg.name == 'cooktop' + assert reg.name == 'gas_cooktop' def test_unrelated_mode_options_are_not_cooktop(self): from custom_components.localthings.registry.by_type import for_device_by_resources diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 6093d71..0b6ed14 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -119,6 +119,22 @@ def test_registry_reproduces_golden_state_keys_for_dehumidifier(): ) +def test_registry_reproduces_golden_state_keys_for_water_purifier(): + """TP2X_WATERPURIFIER_20K (issue #90) reports no oneUiVersion; resolved + via the 'WATERPURIFIER' modelNum/description fallback into a dedicated + water_purifier registry (dispense settings, sterilize/filter status, + favorite capacity, and the three lock switches).""" + from tests.conftest import _load_device + resources = _load_device('water_purifier') + golden = json.loads((GOLDEN / 'water_purifier.json').read_text()) + state_keys = _new_state_keys('water_purifier', 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 new file mode 100644 index 0000000..c0eaa98 --- /dev/null +++ b/tests/test_water_purifier_capabilities.py @@ -0,0 +1,140 @@ +"""Tests for Samsung water-purifier support (issue #90, TP2X_WATERPURIFIER_20K). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the write contracts. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import NumberDesc + +from tests.conftest import _load_device + + +def _water_purifier(): + resources = _load_device('water_purifier') + 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(): + reg, resources = _water_purifier() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_water_purifier_registry(): + reg, _ = _water_purifier() + assert reg is not None and reg.name == 'water_purifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #90 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _water_purifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity', + 'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status', + 'hotwater_lock', 'coldwater_lock', 'buzz_lock'): + assert key in state, key + + +def test_dispense_type_options_come_from_live_supported_types(): + desc = _desc('dispense_type') + assert desc.options_field == 'x.com.samsung.da.supportedTypes' + + +def test_dispense_type_write_contract(): + desc = _desc('dispense_type') + path, body = desc.write_fn('hotwater', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredType': 'hotwater'} + + +def test_hot_water_temperature_is_a_select_not_a_number(): + """Only a handful of discrete temperatures are selectable (not a + continuous range) -- confirmed by supportedHotTemperatures being a short + enumerated list, not a [min, max] range field.""" + desc = _desc('hot_water_temperature') + assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures' + + +def test_dispense_capacity_bounds_come_live_not_hardcoded(): + """Bounds and step come from the device's own desiredCapacityRange/ + capacityResolution fields, not a hardcoded constant -- see the + adding-device-support skill's 'never hard-code the one dump's values' + section.""" + desc = _desc('dispense_capacity') + assert isinstance(desc, NumberDesc) + assert desc.native_min is None + assert desc.native_max is None + assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange' + rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'], + 'x.com.samsung.da.capacityResolution': '10'} + assert desc.step_fn(rep) == 10 + + +def test_dispense_capacity_write_contract(): + desc = _desc('dispense_capacity') + path, body = desc.write_fn('550', {}) + assert path == ['setting', 'waterpurifier', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredCapacity': '550'} + + +def test_lock_switches_read_unlocked_as_off(): + state = _state() + assert state['hotwater_lock'] is False + assert state['coldwater_lock'] is False + assert state['buzz_lock'] is False + + +def test_lock_switch_write_contracts(): + hot = _desc('hotwater_lock') + cold = _desc('coldwater_lock') + buzz = _desc('buzz_lock') + assert hot.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'}) + assert cold.write_fn('Off', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'}) + assert buzz.write_fn('On', {}) == ( + ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'}) + + +def test_favorite_capacity_options_come_from_live_capacity_list(): + desc = _desc('favorite_capacity') + assert desc.options_field == 'x.com.samsung.da.capacityList' + + +def test_sterilize_timestamps_parsed_as_utc(): + state = _state() + assert state['sterilize_last_time'].tzinfo is not None + assert state['sterilize_plan_time'].tzinfo is not None + + +def test_mode_hrefs_are_ignored_not_guessed(): + """/mode/vs/0's supportedModes carries a single opaque wizard token and + modes reports an unrelated value not even in supportedModes -- internal + plumbing, left unmodeled per the 'don't guess' rule rather than exposed + as a nonsensical select.""" + from custom_components.localthings.registry.capabilities import water_purifier + 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