diff --git a/README.md b/README.md index c433033..731cfcd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index fcdcc6e..f6566de 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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) -- 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 9cfaaf7..c866af2 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index ba41847..ee801a1 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/fixtures/airconditioner_window_ac_device.json b/tests/fixtures/airconditioner_window_ac_device.json new file mode 100644 index 0000000..c02ae37 --- /dev/null +++ b/tests/fixtures/airconditioner_window_ac_device.json @@ -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**" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json new file mode 100644 index 0000000..5b4b5e4 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -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" + ] +} 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 d3eff2b..8c7264a 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') @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 2cc19d3..30d2ff4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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.""" 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