From 2b241522763f3a7c5e6bc41f11df73fe1e897ebd Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 02:52:33 +0000 Subject: [PATCH 1/4] feat(registry): cover absence-power-saving and motion-detect-wind on AC (#173) Lennox-branded heat pump on the Samsung RAC board family (modelNum TP1X_LNX-AC-RAC-01001_0000) already routes correctly via the existing '-RAC-' token, but its dump has two resources no prior AC fixture carried: /mds/absencepowersaving/vs/0 and /option/motiondetectwind/stateful/vs/0. Bind both as read-only sensors, matching the CURRENT_LIMIT/ANOMALY_LOAD precedent -- nothing in the dump confirms write safety on live HVAC hardware. --- .../registry/by_type/airconditioner.py | 2 + .../registry/capabilities/airconditioner.py | 46 ++ .../localthings/translations/en.json | 21 + .../localthings/translations/nl.json | 21 + ...irconditioner_lnx_rac_heatpump_device.json | 626 ++++++++++++++++++ .../airconditioner_lnx_rac_heatpump.json | 24 + tests/test_airconditioner_capabilities.py | 59 ++ tests/test_golden_regression.py | 21 + 8 files changed, 820 insertions(+) create mode 100644 tests/fixtures/airconditioner_lnx_rac_heatpump_device.json create mode 100644 tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 94e4797..4d61fff 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -27,6 +27,8 @@ REGISTRY = DeviceRegistry( airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, airconditioner.ANOMALY_LOAD, + airconditioner.ABSENCE_POWER_SAVING, + airconditioner.MOTION_DETECT_WIND, airconditioner.CURRENT_TEMPERATURE, airconditioner.CURRENT_TEMPERATURE_VS, airconditioner.HUMIDITY, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index c9c8761..4c145d7 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -677,6 +677,52 @@ ANOMALY_LOAD = Capability( ), ) +# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 -- +# Lennox-branded heat pump on the RAC board family): `status` toggles the +# feature, `switchPowerSaveMode` picks the save intensity out of its own +# supportedSwitchPowerSaveMode list. A third field, `motionState`, also +# carries a supportedMotionState list but its role (a live sensor readout vs. +# a sensitivity setting) isn't distinguishable from the dump, so it's left +# unmodeled. Same 'don't guess' read-only treatment as CURRENT_LIMIT/ +# ANOMALY_LOAD above -- nothing here confirms write safety on live HVAC +# hardware. +ABSENCE_POWER_SAVING = Capability( + href='/mds/absencepowersaving/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='absence_power_saving_active', field='status', + icon='mdi:human-greeting-proximity', + entity_category='diagnostic', + value_fn=lambda v: v == 'On'), + SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode', + device_class='enum', + options=('eco', 'normal', 'comfort'), + translation_key='absence_power_saving_mode', + icon='mdi:leaf', entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + ), +) + +# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING +# above on the same dump: `status` toggles it, `modes` picks Direct/Indirect +# airflow out of `supportedModes`. Same read-only treatment. +MOTION_DETECT_WIND = Capability( + href='/option/motiondetectwind/stateful/vs/0', + poll_tier='cold', + entities=( + BinarySensorDesc(key='motion_detect_wind_active', field='status', + icon='mdi:motion-sensor', + entity_category='diagnostic', + value_fn=lambda v: v == 'On'), + SensorDesc(key='motion_detect_wind_mode', field='modes', + device_class='enum', + options=('direct', 'indirect'), + translation_key='motion_detect_wind_mode', + icon='mdi:weather-windy', entity_category='diagnostic', + value_fn=lambda v: v.lower() if isinstance(v, str) else v), + ), +) + # The climate entity already surfaces current_temperature as a card # attribute, but that's not enough for history graphs/automations/ # statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6fb19fb..c8f573f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -34,6 +34,12 @@ "overload_protection_active": { "name": "Overload protection active" }, + "absence_power_saving_active": { + "name": "Absence power saving active" + }, + "motion_detect_wind_active": { + "name": "Motion-detect wind avoidance active" + }, "cycle_active": { "name": "Running" }, @@ -669,6 +675,21 @@ "powersaving": "Power saving" } }, + "absence_power_saving_mode": { + "name": "Absence power saving mode", + "state": { + "eco": "Eco", + "normal": "Normal", + "comfort": "Comfort" + } + }, + "motion_detect_wind_mode": { + "name": "Motion-detect wind mode", + "state": { + "direct": "Direct", + "indirect": "Indirect" + } + }, "current_temp_c": { "name": "Temperature" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index af8800b..93e497c 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -34,6 +34,12 @@ "overload_protection_active": { "name": "Overbelastingsbeveiliging actief" }, + "absence_power_saving_active": { + "name": "Energiebesparing bij afwezigheid actief" + }, + "motion_detect_wind_active": { + "name": "Bewegingsdetectie luchtstroomvermijding actief" + }, "cycle_active": { "name": "Actief" }, @@ -669,6 +675,21 @@ "powersaving": "Energiebesparing" } }, + "absence_power_saving_mode": { + "name": "Modus energiebesparing bij afwezigheid", + "state": { + "eco": "Eco", + "normal": "Normaal", + "comfort": "Comfort" + } + }, + "motion_detect_wind_mode": { + "name": "Modus luchtstroomvermijding", + "state": { + "direct": "Direct", + "indirect": "Indirect" + } + }, "current_temp_c": { "name": "Temperatuur" }, diff --git a/tests/fixtures/airconditioner_lnx_rac_heatpump_device.json b/tests/fixtures/airconditioner_lnx_rac_heatpump_device.json new file mode 100644 index 0000000..1bdb5a9 --- /dev/null +++ b/tests/fixtures/airconditioner_lnx_rac_heatpump_device.json @@ -0,0 +1,626 @@ +{ + "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": "500", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "MotionDirect", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "3", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "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-28T19:55:08", + "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", + "x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08", + "x.com.samsung.da.state": "Created" + } + ] + } + }, + { + "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": "70.0", + "x.com.samsung.da.current": "69.0", + "x.com.samsung.da.maximum": "86", + "x.com.samsung.da.minimum": "47", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Fahrenheit" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 47, + 86 + ], + "units": "F", + "temperature": 69.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 47, + 86 + ], + "units": "F", + "temperature": 70.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0.000000", + "x.com.samsung.da.cumulativePower": "631483", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Fan", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Heat" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_990", + "OutdoorTemp_78", + "CoolCapa_35", + "WarmCapa_35", + "Volume_Mute", + "StopAutoClean_Set", + "Autoclean_On", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "KeyInputPermit_On", + "ModePermit_NoLimit", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "OptionCode_56636", + "ExtendOptionCode_246669", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "48" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_LNX-AC-RAC-01001_0000|10269441|60010523001811014E0048220090A000", + "x.com.samsung.da.description": "TP1X_LNX-AC-RAC-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": "0AJV", + "x.com.samsung.da.diagSetupid": "AR2", + "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": "02646A260327", + "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": "102694A24062500", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "102472A24042300,102579A10000200", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-03: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_HEATPUMP_3.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "45" + } + }, + { + "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": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000001D6035C016102490C0F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "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": "ARA-WW-TP1-24-ARXX00", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "045210269441FFFFFFFF", + "versions": [ + "24062500", + "FFFFFFFF" + ], + "visVersion": "240625" + }, + { + "type": "Micom", + "modelId": "04521024724110257941", + "versions": [ + "24042300", + "10000200" + ], + "visVersion": "240423" + } + ] + } + }, + { + "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": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Halifax", + "offset": "-03:00", + "DST": "ON" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "AEFFFFFFFF3D564156FFFF2F56FFFF00001F001F00010000001F0000009C00FFFF1E00", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencepowersaving/vs/0", + "rep": { + "status": "Off", + "modes": "Cooling_Heating", + "switchPowerSaveMode": "Normal", + "motionState": "Normal", + "supportedModes": [ + "Cooling_Heating", + "Standby" + ], + "supportedSwitchPowerSaveMode": [ + "Eco", + "Normal", + "Comfort" + ], + "supportedMotionState": [ + "Normal", + "MotionNano", + "MotionSoftOff" + ] + } + }, + { + "href": "/mds/absencestate/vs/0", + "rep": { + "status": "Off", + "maxDetectCount": [ + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254", + "254" + ], + "supportedTime": [ + "0", + "30", + "60" + ] + } + }, + { + "href": "/option/motiondetectwind/stateful/vs/0", + "rep": { + "status": "Off", + "modes": "Indirect", + "supportedModes": [ + "Direct", + "Indirect" + ] + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "KG_IoT" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json new file mode 100644 index 0000000..7def68d --- /dev/null +++ b/tests/fixtures/golden/airconditioner_lnx_rac_heatpump.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "absence_power_saving_active", + "absence_power_saving_mode", + "air_filter_status", + "air_filter_usage", + "air_filter_usage_hours", + "alarm_code", + "auto_clean", + "beep", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "motion_detect_wind_active", + "motion_detect_wind_mode", + "mute_once", + "power_watts", + "tropical_night_mode" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index c2dad10..a0d6987 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -467,6 +467,65 @@ def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): assert '/runn/vs/0' in airconditioner._AC_IGNORED +# --------------------------------------------------------------------------- +# TP1X_LNX-AC-RAC-01001_0000 -- Lennox-branded heat pump on the Samsung RAC +# board family (issue #173). Routes via the existing '-RAC-' modelNum token, +# same registry as the plain RAC family. Adds two AI-feature resources not +# seen on prior AC dumps: /mds/absencepowersaving/vs/0 (absence-detection +# power saving) and /option/motiondetectwind/stateful/vs/0 (avoid-direct- +# wind-on-motion) -- both exposed read-only, same 'don't guess' precedent as +# CURRENT_LIMIT/ANOMALY_LOAD. +# --------------------------------------------------------------------------- + +def _ac_lnx_rac_heatpump(): + resources = _load_device('airconditioner_lnx_rac_heatpump') + 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 test_lnx_rac_heatpump_resolves_to_airconditioner_registry(): + reg, _ = _ac_lnx_rac_heatpump() + assert reg is not None and reg.name == 'airconditioner' + + +def test_lnx_rac_heatpump_no_unbound_hrefs(): + reg, resources = _ac_lnx_rac_heatpump() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_lnx_rac_heatpump_absence_power_saving_state(): + reg, resources = _ac_lnx_rac_heatpump() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['absence_power_saving_active'] is False + assert state['absence_power_saving_mode'] == 'normal' + + +def test_lnx_rac_heatpump_motion_detect_wind_state(): + reg, resources = _ac_lnx_rac_heatpump() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['motion_detect_wind_active'] is False + assert state['motion_detect_wind_mode'] == 'indirect' + + +def test_lnx_rac_heatpump_new_capabilities_are_read_only(): + """Nothing in the dump confirms write safety on live HVAC hardware for + either feature -- exposed as sensors, not switches/selects.""" + absence_keys = {e.key for e in airconditioner.ABSENCE_POWER_SAVING.entities} + motion_keys = {e.key for e in airconditioner.MOTION_DETECT_WIND.entities} + assert absence_keys == {'absence_power_saving_active', 'absence_power_saving_mode'} + assert motion_keys == {'motion_detect_wind_active', 'motion_detect_wind_mode'} + for entity in (*airconditioner.ABSENCE_POWER_SAVING.entities, + *airconditioner.MOTION_DETECT_WIND.entities): + assert not hasattr(entity, 'write_fn') or entity.write_fn is None + + # --------------------------------------------------------------------------- # Additive entities layered on the ARTIK051_PRAC family on top of the upstream # registry: beep (Volume_* option), tropical night mode (Sleep_ option), diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index cffbcb3..209a1a2 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -439,6 +439,27 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_lnx_rac_heatpump(): + """Lennox-branded heat pump on the Samsung RAC board family (issue #173, + modelNum TP1X_LNX-AC-RAC-01001_0000) -- routes via the existing '-RAC-' + token, same registry as the plain RAC family. Adds two AI-feature + resources not seen on prior AC dumps: /mds/absencepowersaving/vs/0 + (absence-detection power saving) and /option/motiondetectwind/stateful/vs/0 + (avoid-direct-wind-on-motion), both exposed read-only per the 'don't + guess' rule.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_lnx_rac_heatpump') + golden = json.loads( + (GOLDEN / 'airconditioner_lnx_rac_heatpump.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_lnx_rac_heatpump', 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_range(): """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- reports no oneUiVersion; resolved via the '-RANGE-' modelNum token From 90d79f117ad129d20e1c6ce594d2d539f36a7dbf Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 02:52:38 +0000 Subject: [PATCH 2/4] build: pin minimum Python to 3.13 in pyproject.toml README.md and requirements-dev.txt already document that the test harness needs Python 3.13+ (pytest-homeassistant-custom-component doesn't resolve below it), but only in prose. Add requires-python so pip fails fast with a clear message on an older interpreter instead of a wall of "Requires-Python >=3.13" version-list noise. --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6eb3df5..3d40eb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,2 +1,5 @@ +[project] +requires-python = ">=3.13" + [tool.pytest.ini_options] asyncio_mode = "auto" From a6eb1c62d444f3e52ac7c3e3ad57c7b121c4a3b3 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 02:55:47 +0000 Subject: [PATCH 3/4] feat(microwave): add filter reminder / end signal reminder switches (#181) Both FilterRemind_*/RemindBeep_* option-array tokens are already present -- and both On and Off already confirmed -- on the existing ME7500D fixtures (issue #152), so this is a straight sibling of the Sound/Lamp switches rather than new discovery work. Gated with exists_fn like Lamp since the MW7300B combi dump has neither token. Doesn't address the rest of issue #181 (power-level slider, non-reported cooking modes, 3-level light, child lock, send-to- microwave) -- those need write-contract confirmation this dump doesn't carry. --- .../registry/capabilities/microwave.py | 49 ++++++++++++++ .../localthings/translations/en.json | 6 ++ .../localthings/translations/nl.json | 6 ++ tests/fixtures/golden/microwave_me7500d.json | 2 + .../golden/microwave_me7500d_lamp_high.json | 2 + tests/test_microwave_capabilities.py | 66 +++++++++++++++++++ 6 files changed, 131 insertions(+) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 7323c60..6910d9f 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -27,6 +27,10 @@ different from an oven, and defined fresh here: (a brightness level, not literally 'On') -- the switch now treats any non-Off/non-None value as "on" for reads, and writes back 'High'/'Off' (the two confirmed tokens) rather than the never-confirmed 'On'. + * Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep' + option-array tokens (issue #181), both with On and Off observed live + (issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since + the MW7300B combi dump has neither. Note: cooking-mode writes are unproven here, same caveat as oven.py's OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write @@ -145,6 +149,14 @@ def _lamp_exists(rep, resources): return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None +def _filter_remind_exists(rep, resources): + return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None + + +def _remind_beep_exists(rep, resources): + return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None + + def _lamp_write(p, rep, href=None): if p not in ('On', 'Off'): return None @@ -159,6 +171,26 @@ def _lamp_write(p, rep, href=None): } +def _filter_remind_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('FilterRemind', p), + } + + +def _remind_beep_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('RemindBeep', p), + } + + # --------------------------------------------------------------------------- # Capabilities # --------------------------------------------------------------------------- @@ -213,5 +245,22 @@ MICROWAVE_MODE = Capability( exists_fn=_lamp_exists, value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'), write_fn=_lamp_write), + # issue #181: Filter Reminder / End Signal Reminder toggles, + # confirmed present (both On and Off observed across dumps -- see + # issue #152's ME7500D fixtures) but only on boards that carry the + # FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the + # MW7300B combi dump has neither) rather than assumed universal. + SwitchDesc(key='filter_remind', field='x.com.samsung.da.options', + icon='mdi:air-filter', + entity_category='config', + exists_fn=_filter_remind_exists, + value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On', + write_fn=_filter_remind_write), + SwitchDesc(key='remind_beep', field='x.com.samsung.da.options', + icon='mdi:bell-ring', + entity_category='config', + exists_fn=_remind_beep_exists, + value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On', + write_fn=_remind_beep_write), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c8f573f..6b273bc 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -985,6 +985,9 @@ "favorite_coffee_enabled": { "name": "Favorite coffee" }, + "filter_remind": { + "name": "Filter reminder" + }, "fridge_sound": { "name": "Sound" }, @@ -1027,6 +1030,9 @@ "rapid_fridge": { "name": "Rapid fridge" }, + "remind_beep": { + "name": "End signal reminder" + }, "sabbath_mode": { "name": "Sabbath mode" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 93e497c..f4f2a86 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -985,6 +985,9 @@ "favorite_coffee_enabled": { "name": "Favoriete koffie" }, + "filter_remind": { + "name": "Filterherinnering" + }, "fridge_sound": { "name": "Geluid" }, @@ -1027,6 +1030,9 @@ "rapid_fridge": { "name": "Snelkoelen" }, + "remind_beep": { + "name": "Eindsignaal herinnering" + }, "sabbath_mode": { "name": "Sabbatmodus" }, diff --git a/tests/fixtures/golden/microwave_me7500d.json b/tests/fixtures/golden/microwave_me7500d.json index 40df21d..0ca4592 100644 --- a/tests/fixtures/golden/microwave_me7500d.json +++ b/tests/fixtures/golden/microwave_me7500d.json @@ -10,6 +10,7 @@ "door_open", "energy_kwh", "fan", + "filter_remind", "finish_time", "firmware_update", "lamp", @@ -17,6 +18,7 @@ "operation_time_minutes", "power_level", "progress_percentage", + "remind_beep", "sound" ] } diff --git a/tests/fixtures/golden/microwave_me7500d_lamp_high.json b/tests/fixtures/golden/microwave_me7500d_lamp_high.json index 40df21d..0ca4592 100644 --- a/tests/fixtures/golden/microwave_me7500d_lamp_high.json +++ b/tests/fixtures/golden/microwave_me7500d_lamp_high.json @@ -10,6 +10,7 @@ "door_open", "energy_kwh", "fan", + "filter_remind", "finish_time", "firmware_update", "lamp", @@ -17,6 +18,7 @@ "operation_time_minutes", "power_level", "progress_percentage", + "remind_beep", "sound" ] } diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index f01d096..7253609 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -217,3 +217,69 @@ def test_lamp_reads_any_non_off_level_as_true(): just a literal 'On'.""" desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') assert desc.value_fn(['Lamp_High']) is True + + +# --------------------------------------------------------------------------- +# MICROWAVE_MODE — filter_remind/remind_beep options-array writes (issue #181) +# --------------------------------------------------------------------------- + +def test_filter_remind_gated_absent_when_no_option(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') + rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + assert desc.exists_fn(rep, {}) is False + + +def test_filter_remind_gated_present_when_option_reported(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') + rep = {'x.com.samsung.da.options': ['FilterRemind_Off']} + assert desc.exists_fn(rep, {}) is True + + +def test_filter_remind_reads_on_off(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') + assert desc.value_fn(['FilterRemind_On']) is True + assert desc.value_fn(['FilterRemind_Off']) is False + + +def test_filter_remind_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') + rep = {'x.com.samsung.da.options': ['FilterRemind_Off']} + path, body = desc.write_fn('On', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['FilterRemind_On']} + + +def test_filter_remind_write_requires_existing_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') + assert desc.write_fn('On', {}) is None + + +def test_remind_beep_gated_absent_when_no_option(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') + rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + assert desc.exists_fn(rep, {}) is False + + +def test_remind_beep_gated_present_when_option_reported(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') + rep = {'x.com.samsung.da.options': ['RemindBeep_On']} + assert desc.exists_fn(rep, {}) is True + + +def test_remind_beep_reads_on_off(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') + assert desc.value_fn(['RemindBeep_On']) is True + assert desc.value_fn(['RemindBeep_Off']) is False + + +def test_remind_beep_write_is_single_token(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') + rep = {'x.com.samsung.da.options': ['RemindBeep_On']} + path, body = desc.write_fn('Off', rep) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['RemindBeep_Off']} + + +def test_remind_beep_write_requires_existing_options(): + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') + assert desc.write_fn('On', {}) is None From a7aea3764b360355bfaf3a5edbd83ede57c873c5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 03:34:18 +0000 Subject: [PATCH 4/4] feat(airconditioner): make absence/motion-detect enable switches writable status on both /mds/absencepowersaving/vs/0 and /option/motiondetectwind/stateful/vs/0 is a bare On/Off boolean, the same shape already shipped writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY) without a live-confirmed write either -- worst case a wrong token no-ops. The paired mode selects (switchPowerSaveMode, motion-detect modes) stay read-only: their behavioral effect on live HVAC isn't inferable from the dump, same reasoning as ANOMALY_LOAD's mode field. --- .../registry/capabilities/airconditioner.py | 49 ++++++++++++------- .../localthings/translations/en.json | 12 ++--- .../localthings/translations/nl.json | 12 ++--- tests/test_airconditioner_capabilities.py | 34 ++++++++++--- 4 files changed, 71 insertions(+), 36 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 4c145d7..f5c8983 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -678,22 +678,30 @@ ANOMALY_LOAD = Capability( ) # Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 -- -# Lennox-branded heat pump on the RAC board family): `status` toggles the -# feature, `switchPowerSaveMode` picks the save intensity out of its own -# supportedSwitchPowerSaveMode list. A third field, `motionState`, also -# carries a supportedMotionState list but its role (a live sensor readout vs. -# a sensitivity setting) isn't distinguishable from the dump, so it's left -# unmodeled. Same 'don't guess' read-only treatment as CURRENT_LIMIT/ -# ANOMALY_LOAD above -- nothing here confirms write safety on live HVAC -# hardware. +# Lennox-branded heat pump on the RAC board family): `status` is a bare +# On/Off boolean with no vendor prefix, the same shape already shipped +# writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY, +# DISPLAY_LIGHT) despite none of those having a live-confirmed write either +# -- worst case a wrong token no-ops, same risk profile as that family, so +# it's a switch rather than a sensor. `switchPowerSaveMode` picks the save +# intensity out of its own supportedSwitchPowerSaveMode list, but *what* +# writing it actually does to a running compressor isn't knowable from the +# dump -- same 'don't guess' read-only treatment as CURRENT_LIMIT/ +# ANOMALY_LOAD's mode fields. A third field, `motionState`, also carries a +# supportedMotionState list but its role (a live sensor readout vs. a +# sensitivity setting) isn't distinguishable from the dump, so it's left +# unmodeled entirely. ABSENCE_POWER_SAVING = Capability( href='/mds/absencepowersaving/vs/0', poll_tier='cold', entities=( - BinarySensorDesc(key='absence_power_saving_active', field='status', - icon='mdi:human-greeting-proximity', - entity_category='diagnostic', - value_fn=lambda v: v == 'On'), + SwitchDesc(key='absence_power_saving_active', field='status', + icon='mdi:human-greeting-proximity', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['mds', 'absencepowersaving', 'vs', '0'], + {'status': 'On' if p == 'On' else 'Off'})), SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode', device_class='enum', options=('eco', 'normal', 'comfort'), @@ -704,16 +712,21 @@ ABSENCE_POWER_SAVING = Capability( ) # Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING -# above on the same dump: `status` toggles it, `modes` picks Direct/Indirect -# airflow out of `supportedModes`. Same read-only treatment. +# above on the same dump: `status` is the same bare On/Off shape, promoted to +# a switch for the same reason. `modes` (Direct/Indirect airflow out of +# `supportedModes`) stays read-only -- same reasoning as +# absence_power_saving_mode above. MOTION_DETECT_WIND = Capability( href='/option/motiondetectwind/stateful/vs/0', poll_tier='cold', entities=( - BinarySensorDesc(key='motion_detect_wind_active', field='status', - icon='mdi:motion-sensor', - entity_category='diagnostic', - value_fn=lambda v: v == 'On'), + SwitchDesc(key='motion_detect_wind_active', field='status', + icon='mdi:motion-sensor', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['option', 'motiondetectwind', 'stateful', 'vs', '0'], + {'status': 'On' if p == 'On' else 'Off'})), SensorDesc(key='motion_detect_wind_mode', field='modes', device_class='enum', options=('direct', 'indirect'), diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6b273bc..7822ab1 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -34,12 +34,6 @@ "overload_protection_active": { "name": "Overload protection active" }, - "absence_power_saving_active": { - "name": "Absence power saving active" - }, - "motion_detect_wind_active": { - "name": "Motion-detect wind avoidance active" - }, "cycle_active": { "name": "Running" }, @@ -919,6 +913,12 @@ "auto_clean": { "name": "Auto clean" }, + "absence_power_saving_active": { + "name": "Absence power saving active" + }, + "motion_detect_wind_active": { + "name": "Motion-detect wind avoidance active" + }, "beep": { "name": "Beep" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index f4f2a86..833e3cc 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -34,12 +34,6 @@ "overload_protection_active": { "name": "Overbelastingsbeveiliging actief" }, - "absence_power_saving_active": { - "name": "Energiebesparing bij afwezigheid actief" - }, - "motion_detect_wind_active": { - "name": "Bewegingsdetectie luchtstroomvermijding actief" - }, "cycle_active": { "name": "Actief" }, @@ -919,6 +913,12 @@ "auto_clean": { "name": "Automatisch reinigen" }, + "absence_power_saving_active": { + "name": "Energiebesparing bij afwezigheid actief" + }, + "motion_detect_wind_active": { + "name": "Bewegingsdetectie luchtstroomvermijding actief" + }, "beep": { "name": "Piep" }, diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index a0d6987..851c629 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -514,16 +514,38 @@ def test_lnx_rac_heatpump_motion_detect_wind_state(): assert state['motion_detect_wind_mode'] == 'indirect' -def test_lnx_rac_heatpump_new_capabilities_are_read_only(): - """Nothing in the dump confirms write safety on live HVAC hardware for - either feature -- exposed as sensors, not switches/selects.""" +def test_lnx_rac_heatpump_enable_switches_are_writable(): + """status is a bare On/Off boolean, same shape already shipped writable + elsewhere in this file (MUTE_ONCE, AUTO_CLEAN) -- worst case a wrong + token no-ops. The paired mode selects stay read-only sensors.""" absence_keys = {e.key for e in airconditioner.ABSENCE_POWER_SAVING.entities} motion_keys = {e.key for e in airconditioner.MOTION_DETECT_WIND.entities} assert absence_keys == {'absence_power_saving_active', 'absence_power_saving_mode'} assert motion_keys == {'motion_detect_wind_active', 'motion_detect_wind_mode'} - for entity in (*airconditioner.ABSENCE_POWER_SAVING.entities, - *airconditioner.MOTION_DETECT_WIND.entities): - assert not hasattr(entity, 'write_fn') or entity.write_fn is None + absence_mode = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities + if e.key == 'absence_power_saving_mode') + motion_mode = next(e for e in airconditioner.MOTION_DETECT_WIND.entities + if e.key == 'motion_detect_wind_mode') + assert not hasattr(absence_mode, 'write_fn') or absence_mode.write_fn is None + assert not hasattr(motion_mode, 'write_fn') or motion_mode.write_fn is None + + +def test_lnx_rac_heatpump_absence_power_saving_write_target(): + write = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities + if e.key == 'absence_power_saving_active').write_fn + assert write('On', {}) == ( + ['mds', 'absencepowersaving', 'vs', '0'], {'status': 'On'}) + assert write('Off', {}) == ( + ['mds', 'absencepowersaving', 'vs', '0'], {'status': 'Off'}) + + +def test_lnx_rac_heatpump_motion_detect_wind_write_target(): + write = next(e for e in airconditioner.MOTION_DETECT_WIND.entities + if e.key == 'motion_detect_wind_active').write_fn + assert write('On', {}) == ( + ['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'On'}) + assert write('Off', {}) == ( + ['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'Off'}) # ---------------------------------------------------------------------------