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'}) # ---------------------------------------------------------------------------