Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 54 additions & 16 deletions custom_components/roommind/control/mpc_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions custom_components/roommind/utils/device_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion custom_components/roommind/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
},
Expand Down