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
137 changes: 125 additions & 12 deletions custom_components/roommind/control/mpc_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
get_direct_setpoint_eids,
get_idle_action,
get_trv_eids,
get_valve_eids_map,
has_reliable_hvac_modes,
)
from ..utils.temp_utils import celsius_delta_to_ha, celsius_to_ha_temp
Expand All @@ -64,6 +65,13 @@
_last_commands: dict[str, dict[str, Any]] = {}
_setpoint_override_warned: set[str] = set()

# Cache of last sent valve opening percentage per climate entity.
# Keyed as "<climate_entity_id>:opening" → last opening % (0-100).
_last_valve_pcts: dict[str, int] = {}

# Minimum valve position change (%) before a new number.set_value is sent.
VALVE_POSITION_DEADBAND = 5


def _cache_entry(service: str, data: dict) -> dict[str, Any]:
"""Build a cache entry from a service call."""
Expand Down Expand Up @@ -100,6 +108,70 @@ def clear_command_cache() -> None:
"""Clear the sent-command cache (for tests)."""
_last_commands.clear()
_setpoint_override_warned.clear()
_last_valve_pcts.clear()


async def async_send_valve_position(
hass: HomeAssistant,
opening_entity: str | None,
closing_entity: str | None,
opening_pct: int,
*,
area_id: str = "unknown",
climate_eid: str = "unknown",
) -> None:
"""Send direct valve position commands to TRV number entities.

``opening_pct`` is 0-100 (100 = fully open). The closing percentage is
computed as ``100 - opening_pct``. Commands are suppressed when the
position has not changed by more than ``VALVE_POSITION_DEADBAND`` percent
since the last successful send.
"""
opening_pct = max(0, min(100, opening_pct))
closing_pct = 100 - opening_pct

cache_key = f"{climate_eid}:opening"
last_opening = _last_valve_pcts.get(cache_key)
if last_opening is not None and abs(opening_pct - last_opening) < VALVE_POSITION_DEADBAND:
return

if opening_entity:
try:
await hass.services.async_call(
"number",
"set_value",
{"entity_id": opening_entity, "value": float(opening_pct)},
blocking=True,
context=make_roommind_context(),
)
except Exception: # noqa: BLE001
_LOGGER.warning(
"Area '%s': number.set_value(%d) failed on valve opening entity '%s'",
area_id,
opening_pct,
opening_entity,
exc_info=True,
)

if closing_entity:
try:
await hass.services.async_call(
"number",
"set_value",
{"entity_id": closing_entity, "value": float(closing_pct)},
blocking=True,
context=make_roommind_context(),
)
except Exception: # noqa: BLE001
_LOGGER.warning(
"Area '%s': number.set_value(%d) failed on valve closing entity '%s'",
area_id,
closing_pct,
closing_entity,
exc_info=True,
)

_last_valve_pcts[cache_key] = opening_pct


def _resolve_idle_setpoint(
Expand Down Expand Up @@ -743,6 +815,7 @@ def __init__(
self.acs: list[str] = get_ac_eids(room_config.get("devices", []))
self._devices: list[dict] = room_config.get("devices", [])
self._direct_eids: set[str] = get_direct_setpoint_eids(self._devices)
self._valve_eids: dict[str, tuple[str | None, str | None]] = get_valve_eids_map(self._devices)
self.climate_mode: str = room_config.get("climate_mode", "auto")
self.outdoor_temp = outdoor_temp
self.outdoor_forecast = outdoor_forecast or []
Expand Down Expand Up @@ -1440,12 +1513,23 @@ async def async_apply(
t_final = effective_target if cmd.entity_id in self._direct_eids else t
ha_t = celsius_to_ha_temp(self.hass, t_final)
await self._call("set_hvac_mode", {"entity_id": cmd.entity_id, "hvac_mode": "heat"})
await self._call(
"set_temperature",
{"entity_id": cmd.entity_id, "temperature": ha_t, "hvac_mode": "heat"},
temp_intent="heat",
deadband=self._proportional_deadband(cmd.entity_id, current_temp, effective_target),
)
_valve_ents = self._valve_eids.get(cmd.entity_id)
if _valve_ents:
await async_send_valve_position(
self.hass,
_valve_ents[0],
_valve_ents[1],
round(cmd.power_fraction * 100),
area_id=self._area_id,
climate_eid=cmd.entity_id,
)
else:
await self._call(
"set_temperature",
{"entity_id": cmd.entity_id, "temperature": ha_t, "hvac_mode": "heat"},
temp_intent="heat",
deadband=self._proportional_deadband(cmd.entity_id, current_temp, effective_target),
)
else: # ac
if self.has_external_sensor and current_temp is not None:
t = round(
Expand Down Expand Up @@ -1494,6 +1578,16 @@ async def async_apply(
area_id=self._area_id,
targets=targets,
)
_valve_ents = self._valve_eids.get(cmd.entity_id)
if _valve_ents:
await async_send_valve_position(
self.hass,
_valve_ents[0],
_valve_ents[1],
0,
area_id=self._area_id,
climate_eid=cmd.entity_id,
)
else:
# ACs can be turned off without boiler cycling concerns
await self._call("set_hvac_mode", {"entity_id": cmd.entity_id, "hvac_mode": "off"})
Expand All @@ -1517,15 +1611,28 @@ async def async_apply(
for eid in thermostats:
if eid in _forced_off:
await async_idle_device(self.hass, eid, self._devices, area_id=self._area_id, targets=targets)
_valve_ents = self._valve_eids.get(eid)
if _valve_ents:
await async_send_valve_position(
self.hass, _valve_ents[0], _valve_ents[1], 0,
area_id=self._area_id, climate_eid=eid,
)
continue
ha_t = ha_trv_direct if eid in self._direct_eids else ha_trv
await self._call("set_hvac_mode", {"entity_id": eid, "hvac_mode": "heat"})
await self._call(
"set_temperature",
{"entity_id": eid, "temperature": ha_t, "hvac_mode": "heat"},
temp_intent="heat",
deadband=self._proportional_deadband(eid, current_temp, effective_target),
)
_valve_ents = self._valve_eids.get(eid)
if _valve_ents:
await async_send_valve_position(
self.hass, _valve_ents[0], _valve_ents[1], round(power_fraction * 100),
area_id=self._area_id, climate_eid=eid,
)
else:
await self._call(
"set_temperature",
{"entity_id": eid, "temperature": ha_t, "hvac_mode": "heat"},
temp_intent="heat",
deadband=self._proportional_deadband(eid, current_temp, effective_target),
)
# ACs: proportional setpoint in Full Control, actual target otherwise
if self.has_external_sensor and current_temp is not None:
ac_heat_target = round(
Expand Down Expand Up @@ -1642,6 +1749,12 @@ async def async_apply(
targets=targets,
force_off=force_off,
)
_valve_ents = self._valve_eids.get(eid)
if _valve_ents:
await async_send_valve_position(
self.hass, _valve_ents[0], _valve_ents[1], 0,
area_id=self._area_id, climate_eid=eid,
)

def _proportional_deadband(self, eid: str, current_temp: float | None, effective_target: float) -> float | None:
"""Deadband threshold for a proportional setpoint send, or None to disable.
Expand Down
125 changes: 76 additions & 49 deletions custom_components/roommind/managers/valve_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
TargetTemps,
make_roommind_context,
)
from ..control.mpc_controller import async_idle_device, async_turn_off_climate, resolve_hvac_mode
from ..utils.device_utils import build_rooms_devices_map, get_trv_eids
from ..control.mpc_controller import async_idle_device, async_send_valve_position, async_turn_off_climate, resolve_hvac_mode
from ..utils.device_utils import build_rooms_devices_map, get_trv_eids, get_valve_eids_map
from ..utils.temp_utils import celsius_to_ha_temp

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -122,11 +122,23 @@ async def async_finish_cycles(
now = time.time()
finished = [eid for eid, start in self._cycling.items() if now - start >= VALVE_PROTECTION_CYCLE_DURATION]
for eid in finished:
await self._async_close_valve(eid, rooms_devices, log_context="after cycle")
self._cycling.pop(eid, None)
self._last_actuation[eid] = now
self._actuation_dirty = True
_LOGGER.info("Valve protection: cycle complete for '%s'", eid)
dev_devices = rooms_devices.get(eid) if rooms_devices else None
_valve_ents = get_valve_eids_map(dev_devices).get(eid) if dev_devices else None
if _valve_ents:
await async_send_valve_position(
self.hass, _valve_ents[0], _valve_ents[1], 0,
area_id="valve_protection", climate_eid=eid,
)
self._cycling.pop(eid, None)
self._last_actuation[eid] = now
self._actuation_dirty = True
_LOGGER.info("Valve protection: cycle complete for '%s'", eid)
else:
await self._async_close_valve(eid, rooms_devices, log_context="after cycle")
self._cycling.pop(eid, None)
self._last_actuation[eid] = now
self._actuation_dirty = True
_LOGGER.info("Valve protection: cycle complete for '%s'", eid)

async def async_check_and_cycle(self, rooms: dict, settings: dict) -> None:
"""Scan for TRV valves that have been idle too long and start cycling them."""
Expand Down Expand Up @@ -157,62 +169,77 @@ async def async_check_and_cycle(self, rooms: dict, settings: dict) -> None:
all_trvs -= all_excluded

# Start cycling stale valves
rooms_devices_map = build_rooms_devices_map(rooms)
for eid in all_trvs:
if eid in self._cycling:
continue
last = self._last_actuation.get(eid, 0)
if now - last >= threshold:
try:
eid_state = self.hass.states.get(eid)
vp_modes = (eid_state.attributes.get("hvac_modes") or []) if eid_state else []
vp_resolved = resolve_hvac_mode("heat", vp_modes)
if vp_resolved is None:
_LOGGER.debug(
"Valve protection: '%s' supports neither 'heat' nor 'auto', skipping",
eid,
idle_days = int((now - last) / 86400) if last else 0
_valve_ents = get_valve_eids_map(rooms_devices_map.get(eid, [])).get(eid)
if _valve_ents:
# Direct valve control: open fully for the cycle duration
await async_send_valve_position(
self.hass, _valve_ents[0], _valve_ents[1], 100,
area_id="valve_protection", climate_eid=eid,
)
continue
await self.hass.services.async_call(
"climate",
"set_hvac_mode",
{"entity_id": eid, "hvac_mode": vp_resolved},
blocking=True,
context=make_roommind_context(),
)
boost_temp = celsius_to_ha_temp(self.hass, HEATING_BOOST_TARGET)
if eid_state:
dev_max = eid_state.attributes.get("max_temp")
if dev_max is not None and boost_temp > dev_max:
boost_temp = dev_max
is_range = eid_state and eid_state.attributes.get("target_temp_low") is not None
if is_range:
cur_high = eid_state.attributes.get("target_temp_high", boost_temp)
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": eid,
"target_temp_low": boost_temp,
"target_temp_high": max(boost_temp, cur_high),
},
blocking=True,
context=make_roommind_context(),
self._cycling[eid] = now
_LOGGER.info(
"Valve protection: cycling '%s' via direct valve (idle for %d days)",
eid,
idle_days,
)
else:
eid_state = self.hass.states.get(eid)
vp_modes = (eid_state.attributes.get("hvac_modes") or []) if eid_state else []
vp_resolved = resolve_hvac_mode("heat", vp_modes)
if vp_resolved is None:
_LOGGER.debug(
"Valve protection: '%s' supports neither 'heat' nor 'auto', skipping",
eid,
)
continue
await self.hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": eid, "temperature": boost_temp},
"set_hvac_mode",
{"entity_id": eid, "hvac_mode": vp_resolved},
blocking=True,
context=make_roommind_context(),
)
self._cycling[eid] = now
idle_days = int((now - last) / 86400) if last else 0
_LOGGER.info(
"Valve protection: cycling '%s' (idle for %d days)",
eid,
idle_days,
)
boost_temp = celsius_to_ha_temp(self.hass, HEATING_BOOST_TARGET)
if eid_state:
dev_max = eid_state.attributes.get("max_temp")
if dev_max is not None and boost_temp > dev_max:
boost_temp = dev_max
is_range = eid_state and eid_state.attributes.get("target_temp_low") is not None
if is_range:
cur_high = eid_state.attributes.get("target_temp_high", boost_temp)
await self.hass.services.async_call(
"climate",
"set_temperature",
{
"entity_id": eid,
"target_temp_low": boost_temp,
"target_temp_high": max(boost_temp, cur_high),
},
blocking=True,
context=make_roommind_context(),
)
else:
await self.hass.services.async_call(
"climate",
"set_temperature",
{"entity_id": eid, "temperature": boost_temp},
blocking=True,
context=make_roommind_context(),
)
self._cycling[eid] = now
_LOGGER.info(
"Valve protection: cycling '%s' (idle for %d days)",
eid,
idle_days,
)
except Exception: # noqa: BLE001
_LOGGER.warning("Valve protection: failed to start cycle for '%s'", eid)

Expand Down
19 changes: 19 additions & 0 deletions custom_components/roommind/utils/device_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,25 @@ def get_direct_setpoint_eids(devices: list[dict]) -> set[str]:
return {d["entity_id"] for d in devices if d.get("entity_id") and d.get("setpoint_mode") == SETPOINT_MODE_DIRECT}


def get_valve_eids_map(devices: list[dict]) -> dict[str, tuple[str | None, str | None]]:
"""Return {climate_entity_id: (opening_eid, closing_eid)} for TRVs with direct valve control.

Only TRV devices with at least one of ``valve_opening_entity`` or
``valve_closing_entity`` configured are included. Either field may be
``None`` if only one number entity is available for that TRV.
"""
result: dict[str, tuple[str | None, str | None]] = {}
for d in devices:
eid = d.get("entity_id")
if not eid or d.get("type") != DEVICE_TYPE_TRV:
continue
opening = d.get("valve_opening_entity") or None
closing = d.get("valve_closing_entity") or None
if opening or closing:
result[eid] = (opening, closing)
return result


def build_rooms_devices_map(rooms: dict) -> dict[str, list[dict]]:
"""Return {entity_id: devices[]} map across all rooms.

Expand Down
2 changes: 2 additions & 0 deletions custom_components/roommind/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ async def websocket_list_rooms(
vol.Optional("idle_action", default="off"): vol.In(["off", "fan_only", "setback", "low"]),
vol.Optional("idle_fan_mode", default="low"): str,
vol.Optional("setpoint_mode", default="proportional"): vol.In(["proportional", "direct"]),
vol.Optional("valve_opening_entity"): vol.Any(str, None),
vol.Optional("valve_closing_entity"): vol.Any(str, None),
},
_validate_device_idle_action,
)
Expand Down
Loading