Merge remote-tracking branch 'origin/main' into claude/device-support-issue-88-dehumidifier

# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
#	tests/test_golden_regression.py
This commit is contained in:
Marc Billow committed 2026-07-27 03:58:53 +00:00
commit 38263eaa72
14 files changed
+738 -9

No files matched your search

@@ -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) --
@@ -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,
@@ -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"
},
@@ -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"
},
@@ -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"
},