Add device support for Samsung water purifiers (issue #90)

The TP2X_WATERPURIFIER_20K water purifier reports no oneUiVersion and
its modelNum/description don't match any consumer-prefix or existing
board-family token, so it fell into the unknown-device-type fallback
with only common capabilities. Add a new water_purifier registry,
routed via a 'WATERPURIFIER' modelNum/description fallback rule.

Models dispense settings (type/temperature/capacity/pouring status),
sterilize and filter status, favorite-capacity presets, and the three
water/buzzer locks. Per the adding-device-support skill's "never
hard-code the one dump's values" rule: dispense-capacity bounds and
step come live from the device's own desiredCapacityRange/
capacityResolution fields (range_field/step_fn), not a hardcoded
constant, and the hot-water-temperature control is a select over the
live supportedHotTemperatures list rather than a number with invented
bounds, since only a few discrete temperatures are selectable.

/mode/vs/0 and /automation/waterpurifier/vs/0 are left unmodeled: the
former carries an opaque wizard-workflow token with no coherent
current-value contract, the latter is a static support-flags blob with
no live setting to expose.

Confirmed against the issue #90 diagnostics dump with zero unbound
hrefs. Updates the README's supported-appliance-types table for the
new device type.
This commit is contained in:
Marc Billow committed 2026-07-27 01:40:05 +00:00
1 parent aff7647460
commit d53459d047
11 files changed
+715 -1

No files matched your search

@@ -4,7 +4,7 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
range as _range, range_hood, refrigerator, washer,
range as _range, range_hood, refrigerator, washer, water_purifier,
)
__all__ = [
@@ -27,6 +27,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
}
@@ -129,6 +130,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) --
@@ -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,
]),
)
@@ -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]
@@ -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"
},
@@ -110,6 +116,9 @@
"delay_start_hours": {
"name": "Delay start"
},
"dispense_capacity": {
"name": "Dispense capacity"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
@@ -189,6 +198,9 @@
"8f": "Baby Care"
}
},
"dispense_type": {
"name": "Dispense type"
},
"door_alert": {
"name": "Door alarm",
"state": {
@@ -217,6 +229,9 @@
"1f": "Mixed load"
}
},
"favorite_capacity": {
"name": "Favorite capacity"
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -248,6 +263,9 @@
"extra_high": "Extra high"
}
},
"hot_water_temperature": {
"name": "Hot water temperature"
},
"ice_type": {
"name": "{instance_name} type",
"state": {
@@ -499,6 +517,9 @@
"fan_speed_level": {
"name": "Fan speed level"
},
"filter_clean_remain_time": {
"name": "Filter clean remaining time"
},
"filter_progress": {
"name": "Filter progress"
},
@@ -589,12 +610,27 @@
"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"
},
"water_liters": {
"name": "Water consumption"
},
"waterpurifier_status": {
"name": "Status"
},
"machine_state": {
"name": "Machine state",
"state": {
@@ -641,6 +677,9 @@
"bubble_soak": {
"name": "Bubble soak"
},
"buzz_lock": {
"name": "Buzzer lock"
},
"cabinet_light_dim": {
"name": "Brighten gradually"
},
@@ -650,6 +689,9 @@
"child_lock": {
"name": "Child lock"
},
"coldwater_lock": {
"name": "Cold water lock"
},
"defrost_delay": {
"name": "Defrost delay"
},
@@ -659,9 +701,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"
},
@@ -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"
},
@@ -110,6 +116,9 @@
"delay_start_hours": {
"name": "Uitgestelde start"
},
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
@@ -189,6 +198,9 @@
"8f": "Babyverzorging"
}
},
"dispense_type": {
"name": "Type dispenser"
},
"door_alert": {
"name": "Deuralarm",
"state": {
@@ -217,6 +229,9 @@
"1f": "Gemengde was"
}
},
"favorite_capacity": {
"name": "Favoriete capaciteit"
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -248,6 +263,9 @@
"extra_high": "Extra hoog"
}
},
"hot_water_temperature": {
"name": "Temperatuur warm water"
},
"ice_type": {
"name": "Type {instance_name}",
"state": {
@@ -499,6 +517,9 @@
"fan_speed_level": {
"name": "Ventilatorniveau"
},
"filter_clean_remain_time": {
"name": "Resterende tijd filterreiniging"
},
"filter_progress": {
"name": "Filterverbruik"
},
@@ -589,12 +610,27 @@
"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"
},
"water_liters": {
"name": "Waterverbruik"
},
"waterpurifier_status": {
"name": "Status"
},
"machine_state": {
"name": "Machinestatus",
"state": {
@@ -641,6 +677,9 @@
"bubble_soak": {
"name": "Bubble Soak"
},
"buzz_lock": {
"name": "Zoemerslot"
},
"cabinet_light_dim": {
"name": "Geleidelijk feller"
},
@@ -650,6 +689,9 @@
"child_lock": {
"name": "Kinderslot"
},
"coldwater_lock": {
"name": "Koudwaterslot"
},
"defrost_delay": {
"name": "Ontdooien uitstellen"
},
@@ -659,9 +701,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"
},