Merge remote-tracking branch 'origin/main' into claude/device-support-issue-86-cooktop

# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
#	custom_components/localthings/translations/en.json
#	custom_components/localthings/translations/nl.json
This commit is contained in:
Marc Billow
2026-07-27 04:00:15 +00:00
15 changed files with 1319 additions and 9 deletions
+2 -1
View File
@@ -25,12 +25,13 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Air purifier | `by_type/air_purifier.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.
@@ -3,8 +3,9 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, induction_cooktop, oven,
range as _range, range_hood, refrigerator, washer,
air_purifier, airconditioner, cooktop, dishwasher, dryer,
induction_cooktop, oven, range as _range, range_hood, refrigerator,
washer, water_purifier,
)
__all__ = [
@@ -28,6 +29,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
}
@@ -142,6 +144,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# resource (see airconditioner.py's _AC_IGNORED).
if key is None and '-CAWW-' in (model_num or '').upper():
key = 'airconditioner'
# Window air conditioners (e.g. TP1X_DA_AC_WAC_01001_0000, issue #87)
# report no oneUiVersion and carry the '_WAC_' (Window Air Conditioner)
# token instead of '_RAC_'/'_PRAC_'. Same TP1X-class resource surface
# as the room-AC models above (mode/convenient/wind/temperature/power/
# filter/humidity all confirmed against the issue #87 dump binding
# cleanly against the existing airconditioner registry once routed
# here) -- no WAC-specific resources needed.
if key is None and '_WAC_' in (model_num or ''):
key = 'airconditioner'
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
# oneUiVersion either, and carry the '_TVTL_' board-family token.
if key is None and '_TVTL_' in (model_num or ''):
@@ -149,6 +160,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]
@@ -40,6 +40,9 @@
"door_open": {
"name": "Door"
},
"filter_door_status": {
"name": "Filter door"
},
"firmware_update": {
"name": "Firmware update available"
},
@@ -58,6 +61,9 @@
"periodic_air_sensing": {
"name": "Periodic air sensing"
},
"pouring": {
"name": "Pouring"
},
"power_state": {
"name": "Power state"
},
@@ -123,6 +129,9 @@
"delay_start_hours": {
"name": "Delay start"
},
"dispense_capacity": {
"name": "Dispense capacity"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
@@ -202,6 +211,9 @@
"8f": "Baby Care"
}
},
"dispense_type": {
"name": "Dispense type"
},
"door_alert": {
"name": "Door alarm",
"state": {
@@ -232,6 +244,9 @@
"1f": "Mixed load"
}
},
"favorite_capacity": {
"name": "Favorite capacity"
},
"finish_sound": {
"name": "Finish sound",
"state": {
@@ -263,6 +278,9 @@
"extra_high": "Extra high"
}
},
"hot_water_temperature": {
"name": "Hot water temperature"
},
"ice_type": {
"name": "{instance_name} type",
"state": {
@@ -527,6 +545,9 @@
"fan_speed_level": {
"name": "Fan speed level"
},
"filter_clean_remain_time": {
"name": "Filter clean remaining time"
},
"filter_progress": {
"name": "Filter progress"
},
@@ -629,6 +650,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"
},
@@ -638,6 +671,9 @@
"water_liters": {
"name": "Water consumption"
},
"waterpurifier_status": {
"name": "Status"
},
"machine_state": {
"name": "Machine state",
"state": {
@@ -684,6 +720,9 @@
"bubble_soak": {
"name": "Bubble soak"
},
"buzz_lock": {
"name": "Buzzer lock"
},
"cabinet_light_dim": {
"name": "Brighten gradually"
},
@@ -693,6 +732,9 @@
"child_lock": {
"name": "Child lock"
},
"coldwater_lock": {
"name": "Cold water lock"
},
"cooktop_child_lock": {
"name": "Child lock"
},
@@ -705,9 +747,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"
},
@@ -40,6 +40,9 @@
"door_open": {
"name": "Deur"
},
"filter_door_status": {
"name": "Filterdeur"
},
"firmware_update": {
"name": "Firmware-update beschikbaar"
},
@@ -58,6 +61,9 @@
"periodic_air_sensing": {
"name": "Periodieke luchtmeting"
},
"pouring": {
"name": "Schenken"
},
"power_state": {
"name": "Voedingsstatus"
},
@@ -123,6 +129,9 @@
"delay_start_hours": {
"name": "Uitgestelde start"
},
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
@@ -202,6 +211,9 @@
"8f": "Babyverzorging"
}
},
"dispense_type": {
"name": "Type dispenser"
},
"door_alert": {
"name": "Deuralarm",
"state": {
@@ -232,6 +244,9 @@
"1f": "Gemengde was"
}
},
"favorite_capacity": {
"name": "Favoriete capaciteit"
},
"finish_sound": {
"name": "Eindgeluid",
"state": {
@@ -263,6 +278,9 @@
"extra_high": "Extra hoog"
}
},
"hot_water_temperature": {
"name": "Temperatuur warm water"
},
"ice_type": {
"name": "Type {instance_name}",
"state": {
@@ -527,6 +545,9 @@
"fan_speed_level": {
"name": "Ventilatorniveau"
},
"filter_clean_remain_time": {
"name": "Resterende tijd filterreiniging"
},
"filter_progress": {
"name": "Filterverbruik"
},
@@ -629,6 +650,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"
},
@@ -638,6 +671,9 @@
"water_liters": {
"name": "Waterverbruik"
},
"waterpurifier_status": {
"name": "Status"
},
"machine_state": {
"name": "Machinestatus",
"state": {
@@ -684,6 +720,9 @@
"bubble_soak": {
"name": "Bubble Soak"
},
"buzz_lock": {
"name": "Zoemerslot"
},
"cabinet_light_dim": {
"name": "Geleidelijk feller"
},
@@ -693,6 +732,9 @@
"child_lock": {
"name": "Kinderslot"
},
"coldwater_lock": {
"name": "Koudwaterslot"
},
"cooktop_child_lock": {
"name": "Kinderslot"
},
@@ -705,9 +747,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"
},
+523
View File
@@ -0,0 +1,523 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-26T06:26:40",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "8D0D00B4012C3F21064804000000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2647000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"AI_RAC_KOREA_COOLONLY_3.0",
"AI_3.0",
"Auto_To_AI"
]
}
},
{
"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"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 244.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "244.000000",
"x.com.samsung.da.cumulativePower": "11001",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "20",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "60"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA_AC_WAC_01001_0000|10254341|60050000001711014E00402000002000",
"x.com.samsung.da.description": "TP1X_DA_AC_WAC_01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"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": "AW0",
"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": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02545A260601",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02543A24061800,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "02580A10000100,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Touch IC",
"x.com.samsung.da.number": "02553A23031600,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Quiet",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Fan"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"OnTimer_0",
"OffTimer_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_230",
"Volume_Mute",
"OutdoorTemp_97",
"CoolCapa_0",
"StopAutoClean_Idle",
"Autoclean_Off",
"OptionCode_51320",
"ExtendOptionCode_230093",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_93",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AWA-KR-TP1-23-AW7000",
"versions": [
"15260601"
],
"visVersion": "260601"
},
{
"type": "Micom",
"modelId": "042010254341FFFFFFFF",
"versions": [
"24061800",
"FFFFFFFF"
],
"visVersion": "240618"
},
{
"type": "Micom",
"modelId": "042010258041FFFFFFFF",
"versions": [
"10000100",
"FFFFFFFF"
],
"visVersion": "100001"
},
{
"type": "Micom",
"modelId": "042010255341FFFFFFFF",
"versions": [
"23031600",
"FFFFFFFF"
],
"visVersion": "230316"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"causeSource": "SMTS"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "8DFFFFFFFF121E121E0000FFFFFFFFFFFF01000000011F0000000000000000001F8C00",
"x.com.samsung.da.id": "WAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 30.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 30.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "30.0",
"x.com.samsung.da.current": "30.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Left_And_Right"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4",
"5"
],
"x.com.samsung.da.modesName": [
"Auto",
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_usage",
"alarm_code",
"auto_clean",
"climate",
"current_temperature_c",
"display_light",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"humidity",
"mute_once",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+27
View File
@@ -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"
]
}
+234
View File
@@ -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"
}
}
]
}
+1 -1
View File
@@ -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 == []
+31 -3
View File
@@ -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')
@@ -225,6 +228,31 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
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
Conditioner) token instead of '_RAC_'/'_PRAC_'; falls back to the
'_WAC_' token in modelNum."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000',
'AW06C7155EWAZ',
)
assert reg is not None
assert reg.name == 'airconditioner'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
@@ -233,7 +261,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
@@ -316,7 +344,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
+33
View File
@@ -102,6 +102,22 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
)
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')
@@ -248,6 +264,23 @@ def test_registry_reproduces_golden_state_keys_for_caww_tp2():
)
def test_registry_reproduces_golden_state_keys_for_window_ac():
"""TP1X_DA_AC_WAC_01001_0000 (issue #87, Bespoke Window AC AW06C7155EWAZ)
reports no oneUiVersion and carries the '_WAC_' (Window Air Conditioner)
token instead of '_RAC_'/'_PRAC_'; resolved via the '_WAC_' modelNum
fallback in for_device_by_model. Otherwise binds cleanly against the
existing airconditioner registry with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_window_ac')
golden = json.loads((GOLDEN / 'airconditioner_window_ac.json').read_text())
state_keys = _new_state_keys('airconditioner_window_ac', 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_tp1x_rac():
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display
light, self-check, mute-once, and a current-limit setting."""
+140
View File
@@ -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