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.
This commit is contained in:
Marc Billow
2026-07-29 03:34:18 +00:00
parent a6eb1c62d4
commit a7aea3764b
4 changed files with 71 additions and 36 deletions
@@ -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'),
@@ -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"
},
@@ -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"
},
+28 -6
View File
@@ -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'})
# ---------------------------------------------------------------------------