diff --git a/README.md b/README.md index c433033..64f7413 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi |---|---| | Air conditioner | `by_type/airconditioner.py` | | Air purifier | `by_type/air_purifier.py` | +| Dehumidifier | `by_type/dehumidifier.py` | | Dryer | `by_type/dryer.py` | | Oven | `by_type/oven.py` | | Cooktop (read-only burner status) | `by_type/cooktop.py` | diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 7f6b864..9de445f 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,8 +3,8 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dishwasher, dryer, oven, - range as _range, range_hood, refrigerator, washer, + air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, + oven, range as _range, range_hood, refrigerator, washer, ) __all__ = [ @@ -19,6 +19,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { 'airconditioner': airconditioner.REGISTRY, 'air_conditioner': airconditioner.REGISTRY, 'cooktop': cooktop.REGISTRY, + 'dehumidifier': dehumidifier.REGISTRY, 'dishwasher': dishwasher.REGISTRY, 'dryer': dryer.REGISTRY, 'oven': oven.REGISTRY, @@ -122,6 +123,13 @@ 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' + # Dehumidifiers (e.g. TP1X_DA_AC_DHM_01001_0000, issue #88) share the + # DA_AC_ board family with the room-AC models above but carry the + # '_DHM_' (DeHuMidifier) token instead of '_RAC_'/'_PRAC_'. Distinct + # registry: target humidity, not temperature, is the primary control, + # and there's no climate composite. + if key is None and '_DHM_' in (model_num or ''): + key = 'dehumidifier' # 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 ''): diff --git a/custom_components/localthings/registry/by_type/dehumidifier.py b/custom_components/localthings/registry/by_type/dehumidifier.py new file mode 100644 index 0000000..9c3207e --- /dev/null +++ b/custom_components/localthings/registry/by_type/dehumidifier.py @@ -0,0 +1,23 @@ +"""Dehumidifier device registry (Samsung TP1X_DA_AC_DHM-class, issue #88). + +Shares the DA_AC_ board family with airconditioner.py (power, air filter, +auto-clean, mute-once all use the identical resource shapes), so those three +Capability objects are reused directly rather than duplicated. +""" +from ..capabilities import airconditioner, common, dehumidifier, ignored +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='dehumidifier', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + dehumidifier.MODE, + dehumidifier.HUMIDITY, + airconditioner.AUTO_CLEAN, + airconditioner.AIR_FILTER, + airconditioner.MUTE_ONCE, + *dehumidifier.COVERAGE, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py new file mode 100644 index 0000000..4b58047 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -0,0 +1,85 @@ +"""Capabilities for the Samsung dehumidifier family (TP1X_DA_AC_DHM-class, +issue #88, model AY18CG7500GED). + +Same DA_AC_ board family as the room-AC models in airconditioner.py (shared +power/energy/filter/auto-clean/mute-once resource shapes), but target +humidity -- not temperature -- is this device's primary control, and there +is no climate composite: power, mode, and humidity are exposed as separate +entities rather than folded into one card. +""" +from ..capability import Capability +from ..entities import NumberDesc, SelectDesc, SensorDesc +from .common import int_or_none + + +def _first_mode(rep): + """Representative scalar for the operating-mode select. `modes` is a + single-element list on every dump seen so far, mirroring + airconditioner._first_mode's handling of the same field shape.""" + modes = rep.get('x.com.samsung.da.modes') + if isinstance(modes, (list, tuple)): + return modes[0] if modes else None + return modes + + +MODE = Capability( + href='/mode/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='operating_mode', rep_fn=_first_mode, + icon='mdi:tune-variant', + options_field='x.com.samsung.da.supportedModes', + write_fn=lambda p, rep, href=None: ( + ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})), + ), +) + +# Target humidity is this device's primary control (the issue-#88 dump's +# equivalent of a thermostat setpoint). No min/max range field is present in +# any dump seen so far -- native_min/native_max are deliberately left unset +# so the number entity falls back to HA's own 0-100 default, the natural +# bound for a percentage field, rather than a bound guessed from one unit's +# spec sheet (see the adding-device-support skill's "never hard-code the one +# dump's values" section). Step comes live from the device's own `increment` +# field. +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.humidity', + device_class='humidity', unit='%', state_class='measurement', + value_fn=int_or_none), + NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity', + device_class='humidity', unit='%', + icon='mdi:water-percent', + entity_category='config', + value_fn=int_or_none, + step_fn=lambda rep: int_or_none(rep.get('increment')) or 1, + write_fn=lambda p, rep, href=None: ( + ['humidity', 'vs', '0'], + {'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})), + ), +) + +# --------------------------------------------------------------------------- +# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state +# or no documented write contract, following the same 'don't guess' rule as +# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in +# the global ignored.IGNORED since some of these hrefs collide with other +# families' schemas. +# --------------------------------------------------------------------------- +_DHM_IGNORED = [ + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM) + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/personality/presence/vs/0', # presence-personalization plumbing (empty item value) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/sensors/vs/0', # empty {} on this dump + '/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert) + # Only supportedModes ([Off, Sleep]) is present -- no live "current + # value" field on this dump to confirm the read/write contract, so per + # the 'don't guess' rule this is left unmodeled rather than assumed. + '/mode/convenient/vs/0', +] + +COVERAGE = [Capability(href=h) for h in _DHM_IGNORED] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..952b319 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -118,6 +118,9 @@ }, "sound_volume": { "name": "Sound volume" + }, + "target_humidity": { + "name": "Target humidity" } }, "select": { @@ -286,6 +289,9 @@ "air_fry": "Air fry" } }, + "operating_mode": { + "name": "Operating mode" + }, "pantry_zone_mode": { "name": "Pantry zone mode", "state": { @@ -523,6 +529,9 @@ "hood_filter_capacity": { "name": "Filter capacity" }, + "humidity": { + "name": "Humidity" + }, "hood_filter_usage": { "name": "Filter usage" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..88a7720 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -118,6 +118,9 @@ }, "sound_volume": { "name": "Geluidsvolume" + }, + "target_humidity": { + "name": "Doelvochtigheid" } }, "select": { @@ -286,6 +289,9 @@ "air_fry": "Airfryen" } }, + "operating_mode": { + "name": "Bedrijfsmodus" + }, "pantry_zone_mode": { "name": "Modus voorraadzone", "state": { @@ -523,6 +529,9 @@ "hood_filter_capacity": { "name": "Filtercapaciteit" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "hood_filter_usage": { "name": "Filterverbruik" }, diff --git a/tests/fixtures/dehumidifier_device.json b/tests/fixtures/dehumidifier_device.json new file mode 100644 index 0000000..1b138e5 --- /dev/null +++ b/tests/fixtures/dehumidifier_device.json @@ -0,0 +1,386 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "224", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "224", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "224", + "300", + "500", + "700" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "100", + "cleaningTime": "30", + "minCleaningTime": "10", + "maxCleaningTime": "60", + "cleaningTimeUnit": "10", + "x.com.samsung.da.supportedStatus": [ + "Start", + "TimedClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "TimedClean", + "Off" + ] + } + }, + { + "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-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "WaterTankFull_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-26T10:35:53", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "259.000000", + "x.com.samsung.da.cumulativePower": "183099", + "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": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Smart", + "Max", + "High", + "Medium", + "Quiet", + "ClothesDrying" + ], + "x.com.samsung.da.modes": [ + "Smart" + ], + "x.com.samsung.da.options": [ + "Volume_Mute", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000", + "x.com.samsung.da.description": "TP1X_DA_AC_DHM_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": "AY0", + "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": "02544A260401", + "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": "02538A24032700,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": "02590A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.number": "02552A23020200,FFFFFFFFFFFFFF" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_Heat" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "47", + "x.com.samsung.da.desiredHumidity": "50", + "increment": "5", + "x.com.samsung.da.fivepercentHumidity": "46" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "20001E46070A5A00000000000000", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "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": "ADH-WW-TP1-23-AY7100", + "versions": [ + "18260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "042110253841FFFFFFFF", + "versions": [ + "24032700", + "FFFFFFFF" + ], + "visVersion": "240327" + }, + { + "type": "Micom", + "modelId": "042110259041FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + }, + { + "type": "Micom", + "modelId": "042110255241FFFFFFFF", + "versions": [ + "23020200", + "FFFFFFFF" + ], + "visVersion": "230202" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "3F1E461E461E46FFFFFFFF1E4600", + "x.com.samsung.da.id": "DHM", + "x.com.samsung.da.version": "1.0" + } + }, + { + "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": "/welcome/humidity/vs/0", + "rep": { + "requestId": "0000", + "operatingStatus": "None" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/dehumidifier.json b/tests/fixtures/golden/dehumidifier.json new file mode 100644 index 0000000..cb1a015 --- /dev/null +++ b/tests/fixtures/golden/dehumidifier.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "operating_mode", + "power_switch", + "power_watts", + "target_humidity" + ] +} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 656f937..592cd61 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -190,6 +190,20 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_dehumidifier_via_dhm_token(self): + """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board + family with the room-AC models but reports no oneUiVersion and + carries the '_DHM_' (DeHuMidifier) token instead of + '_RAC_'/'_PRAC_'/'_WAC_'; falls back to the '_DHM_' token in + modelNum.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000', + 'TP1X_DA_AC_DHM_01001_0000', + ) + assert reg is not None + assert reg.name == 'dehumidifier' + 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 diff --git a/tests/test_dehumidifier_capabilities.py b/tests/test_dehumidifier_capabilities.py new file mode 100644 index 0000000..3d8e67f --- /dev/null +++ b/tests/test_dehumidifier_capabilities.py @@ -0,0 +1,118 @@ +"""Tests for Samsung dehumidifier support (issue #88, AY18CG7500GED). + +HA-free like the rest of the suite: exercises the registry, discovery/ +flatten, and the target-humidity/operating-mode 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, SelectDesc + +from tests.conftest import _load_device + + +def _dehumidifier(): + resources = _load_device('dehumidifier') + 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 = _dehumidifier() + 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_dehumidifier_registry(): + reg, _ = _dehumidifier() + assert reg is not None and reg.name == 'dehumidifier' + + +def test_no_unbound_hrefs(): + """Every resource in the issue #88 dump binds or is covered -- clears + the coverage-gap repair.""" + reg, resources = _dehumidifier() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_expected_state_keys_present(): + state = _state() + for key in ('humidity', 'target_humidity', 'operating_mode', 'power_switch', + 'auto_clean', 'air_filter_status', 'mute_once'): + assert key in state, key + + +def test_humidity_sensor_reads_current_value(): + state = _state() + assert state['humidity'] == 47 + + +def test_target_humidity_number_reads_desired_value(): + state = _state() + assert state['target_humidity'] == 50 + + +def test_target_humidity_write_contract(): + desc = _desc('target_humidity') + assert isinstance(desc, NumberDesc) + path, body = desc.write_fn('55', {}) + assert path == ['humidity', 'vs', '0'] + assert body == {'x.com.samsung.da.desiredHumidity': '55'} + + +def test_target_humidity_step_reads_live_increment(): + """Step comes from the device's own `increment` field rather than a + hardcoded constant -- see the adding-device-support skill's 'never + hard-code the one dump's values' section.""" + desc = _desc('target_humidity') + assert desc.step_fn({'increment': '5'}) == 5 + assert desc.step_fn({'increment': '10'}) == 10 + # No live field: falls back to a sane default rather than raising. + assert desc.step_fn({}) == 1 + + +def test_target_humidity_has_no_hardcoded_bounds(): + """No range field is present in any dump seen so far -- native_min/max + are deliberately left unset (falls back to HA's own 0-100 default for a + percentage field) rather than a bound guessed from a spec sheet.""" + desc = _desc('target_humidity') + assert desc.native_min is None + assert desc.native_max is None + assert desc.native_min_fn is None + assert desc.native_max_fn is None + + +def test_operating_mode_select_options_come_from_live_supported_modes(): + """Options are read live from x.com.samsung.da.supportedModes, not a + hardcoded tuple -- so a future device with a different mode set is + handled automatically.""" + desc = _desc('operating_mode') + assert isinstance(desc, SelectDesc) + assert desc.options_field == 'x.com.samsung.da.supportedModes' + assert desc.options == () + + +def test_operating_mode_write_contract(): + desc = _desc('operating_mode') + path, body = desc.write_fn('Quiet', {}) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.modes': ['Quiet']} + + +def test_operating_mode_reads_first_mode(): + state = _state() + assert state['operating_mode'] == 'Smart' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..141f39a 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -85,6 +85,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_dehumidifier(): + """TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_ + board family with the room-AC models but carries the '_DHM_' token; + resolved via the '_DHM_' modelNum fallback in for_device_by_model into a + dedicated dehumidifier registry (target humidity, operating mode, reused + AC filter/auto-clean/mute-once capabilities).""" + from tests.conftest import _load_device + resources = _load_device('dehumidifier') + golden = json.loads((GOLDEN / 'dehumidifier.json').read_text()) + state_keys = _new_state_keys('dehumidifier', 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')