From 9c0cf9b053d8246842d34a3e0c79317e2caa940a Mon Sep 17 00:00:00 2001 From: Andrea Giannangelo Date: Wed, 5 Aug 2026 13:55:54 +0200 Subject: [PATCH] feat(idle): add opt-in idle_action "engaged" (never-off float on setpoint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth idle_action, "engaged", for climate devices that should never fully switch off at idle — an inverter AC that stays on its setpoint and lets the compressor modulate rather than cycling off. It is a never-off variant of "setback": - floats exactly on the setpoint (offset 0, vs setback's DEFAULT_IDLE_SETBACK_OFFSET); - when the unit is found idle in a non-cooling mode (auto/off/dry/fan_only), re-engages a cooling mode (set_hvac_mode before set_temperature) instead of falling back to off — so a device left in "auto" (common on Airstage) keeps holding the setpoint; - is exempt from force_off (#368), like idle_action=low: when the room is shut down explicitly it still stays engaged. Users who want the room to turn off on schedule-off/away keep the default idle_action. Opt-in only: existing idle_action values (off/fan_only/setback/low) are unchanged. Added to the websocket rooms/save schema enum. Rationale: for a floor-mounted inverter AC whose onboard sensor reads the cold air pooling at the floor, turning off at idle lets the occupied zone drift warm; keeping the unit engaged on an external room sensor holds true comfort. --- .../roommind/control/mpc_controller.py | 70 ++++++++++++++----- .../roommind/utils/device_utils.py | 1 + custom_components/roommind/websocket_api.py | 2 +- 3 files changed, 56 insertions(+), 17 deletions(-) diff --git a/custom_components/roommind/control/mpc_controller.py b/custom_components/roommind/control/mpc_controller.py index 954ab506..41806def 100644 --- a/custom_components/roommind/control/mpc_controller.py +++ b/custom_components/roommind/control/mpc_controller.py @@ -35,6 +35,7 @@ ) from ..utils.device_utils import ( DEFAULT_IDLE_SETBACK_OFFSET, + IDLE_ACTION_ENGAGED, IDLE_ACTION_FAN_ONLY, IDLE_ACTION_LOW, IDLE_ACTION_OFF, @@ -394,7 +395,7 @@ async def async_idle_device( commands (#183), and lowering to min_temp already stops all heat output. """ idle_action, idle_fan_mode = get_idle_action(devices, entity_id) - if force_off and idle_action != IDLE_ACTION_LOW: + if force_off and idle_action not in (IDLE_ACTION_LOW, IDLE_ACTION_ENGAGED): idle_action = IDLE_ACTION_OFF # Fallback low setpoint (in HA display units) for devices where min_temp @@ -422,27 +423,51 @@ async def async_idle_device( return # --- SETBACK branch --- - if idle_action == IDLE_ACTION_SETBACK: + if idle_action in (IDLE_ACTION_SETBACK, IDLE_ACTION_ENGAGED): + # "engaged" is a never-off variant of "setback": it floats exactly on + # the setpoint (offset 0) and, when the unit is found idle in a + # non-cooling mode (auto/off/dry/fan_only), re-engages a cooling mode + # instead of turning off, so an inverter AC keeps modulating on the + # setpoint. It is also exempt from force_off (handled above, like LOW). + engaged = idle_action == IDLE_ACTION_ENGAGED + offset = 0.0 if engaged else DEFAULT_IDLE_SETBACK_OFFSET state = hass.states.get(entity_id) current_hvac = state.state if state else None + reengage_mode: str | None = None - if current_hvac not in ("heat", "cool") or targets is None: - _LOGGER.debug( - "Area '%s': setback not applicable for '%s' (hvac=%s, targets=%s), falling back to off", - area_id, - entity_id, - current_hvac, - targets, - ) + if state is None or targets is None: await async_turn_off_climate(hass, entity_id, area_id=area_id, fallback_setpoint=fallback_temp) return - # Compute setback temperature + hvac_modes = state.attributes.get("hvac_modes") or [] + + # Compute the float/setback setpoint. For "engaged", a cool-capable unit + # found outside heat/cool re-engages cooling rather than falling to off. if current_hvac == "heat" and targets.heat is not None: - setback_temp = targets.heat - DEFAULT_IDLE_SETBACK_OFFSET + setback_temp = targets.heat - offset elif current_hvac == "cool" and targets.cool is not None: - setback_temp = targets.cool + DEFAULT_IDLE_SETBACK_OFFSET + setback_temp = targets.cool + offset + elif ( + engaged + and targets.cool is not None + and any(m in hvac_modes for m in ("cool", "heat_cool", "auto")) + ): + setback_temp = targets.cool + offset + reengage_mode = ( + "cool" + if "cool" in hvac_modes + else "heat_cool" + if "heat_cool" in hvac_modes + else "auto" + ) else: + if not engaged: + _LOGGER.debug( + "Area '%s': setback not applicable for '%s' (hvac=%s), falling back to off", + area_id, + entity_id, + current_hvac, + ) await async_turn_off_climate(hass, entity_id, area_id=area_id, fallback_setpoint=fallback_temp) return @@ -463,13 +488,17 @@ async def async_idle_device( if max_t is not None: ha_t = min(ha_t, float(max_t)) - # Redundancy check: already at setback temp + # Redundancy check: already at setback temp (skip while re-engaging a mode) current_temp_attr = state.attributes.get("temperature") - if current_temp_attr is not None and abs(float(current_temp_attr) - ha_t) < 0.1: + if ( + reengage_mode is None + and current_temp_attr is not None + and abs(float(current_temp_attr) - ha_t) < 0.1 + ): return # Cache check (only for devices without reliable state feedback) - if _should_use_cache(state): + if reengage_mode is None and _should_use_cache(state): cached = _last_commands.get(entity_id) if cached and cached.get("service") == "set_temperature" and cached.get("temperature") == ha_t: return @@ -482,6 +511,15 @@ async def async_idle_device( ha_t, ) try: + if reengage_mode is not None: + # Airstage gotcha: set_hvac_mode BEFORE set_temperature + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": reengage_mode}, + blocking=True, + context=make_roommind_context(), + ) await hass.services.async_call( "climate", "set_temperature", diff --git a/custom_components/roommind/utils/device_utils.py b/custom_components/roommind/utils/device_utils.py index 2e8e69d4..7268f1bd 100644 --- a/custom_components/roommind/utils/device_utils.py +++ b/custom_components/roommind/utils/device_utils.py @@ -26,6 +26,7 @@ DEFAULT_IDLE_FAN_MODE = "low" IDLE_ACTION_SETBACK = "setback" IDLE_ACTION_LOW = "low" +IDLE_ACTION_ENGAGED = "engaged" DEFAULT_IDLE_SETBACK_OFFSET = 2.0 SETPOINT_MODE_PROPORTIONAL = "proportional" diff --git a/custom_components/roommind/websocket_api.py b/custom_components/roommind/websocket_api.py index f56fa63d..1ed7709a 100644 --- a/custom_components/roommind/websocket_api.py +++ b/custom_components/roommind/websocket_api.py @@ -313,7 +313,7 @@ async def websocket_list_rooms( vol.Required("type"): vol.In(["trv", "ac"]), vol.Optional("role", default="auto"): vol.In(["primary", "secondary", "auto"]), vol.Optional("heating_system_type", default=""): vol.In(["", "radiator", "underfloor"]), - vol.Optional("idle_action", default="off"): vol.In(["off", "fan_only", "setback", "low"]), + vol.Optional("idle_action", default="off"): vol.In(["off", "fan_only", "setback", "low", "engaged"]), vol.Optional("idle_fan_mode", default="low"): str, vol.Optional("setpoint_mode", default="proportional"): vol.In(["proportional", "direct"]), },