From 3ef4f790c5c4d838ccbd8a270f065dd4c7797c54 Mon Sep 17 00:00:00 2001 From: Haxk20 <16738302+Haxk20@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:10:11 +0200 Subject: [PATCH] feat: Add support for Direct Valve Control for TRVs Some TRVs (as example Sonoff) have the ability to be controlled via direct valve adjustments. We can make use of this in roommind by sending the power fraction directly to the TRV. The support is implemented in a way so it doesnt matter whether the TRV has support for opening and closing or only one of them. --- .../roommind/control/mpc_controller.py | 137 ++++++++++++++++-- .../roommind/managers/valve_manager.py | 125 +++++++++------- .../roommind/utils/device_utils.py | 19 +++ custom_components/roommind/websocket_api.py | 2 + frontend/src/components/rs-device-section.ts | 78 ++++++++++ frontend/src/locales/de.json | 5 + frontend/src/locales/en.json | 5 + frontend/src/locales/fr.json | 5 + frontend/src/types/index.ts | 2 + 9 files changed, 317 insertions(+), 61 deletions(-) diff --git a/custom_components/roommind/control/mpc_controller.py b/custom_components/roommind/control/mpc_controller.py index 954ab506..a31c25ff 100644 --- a/custom_components/roommind/control/mpc_controller.py +++ b/custom_components/roommind/control/mpc_controller.py @@ -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 @@ -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 ":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.""" @@ -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( @@ -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 [] @@ -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( @@ -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"}) @@ -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( @@ -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. diff --git a/custom_components/roommind/managers/valve_manager.py b/custom_components/roommind/managers/valve_manager.py index 95ed6cc2..36eafaa0 100644 --- a/custom_components/roommind/managers/valve_manager.py +++ b/custom_components/roommind/managers/valve_manager.py @@ -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__) @@ -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.""" @@ -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) diff --git a/custom_components/roommind/utils/device_utils.py b/custom_components/roommind/utils/device_utils.py index 2e8e69d4..41c44964 100644 --- a/custom_components/roommind/utils/device_utils.py +++ b/custom_components/roommind/utils/device_utils.py @@ -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. diff --git a/custom_components/roommind/websocket_api.py b/custom_components/roommind/websocket_api.py index f56fa63d..59d79ce9 100644 --- a/custom_components/roommind/websocket_api.py +++ b/custom_components/roommind/websocket_api.py @@ -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, ) diff --git a/frontend/src/components/rs-device-section.ts b/frontend/src/components/rs-device-section.ts index 32515169..a5031188 100644 --- a/frontend/src/components/rs-device-section.ts +++ b/frontend/src/components/rs-device-section.ts @@ -288,6 +288,17 @@ export class RsDeviceSection extends LitElement { --mdc-icon-size: 12px; } + .detail-section-label { + font-size: 11px; + font-weight: 500; + color: var(--secondary-text-color); + text-transform: uppercase; + letter-spacing: 0.4px; + padding: 10px 0 4px 0; + border-top: 1px solid var(--divider-color, rgba(0,0,0,0.08)); + margin-top: 4px; + } + /* View mode styles */ .view-row { display: flex; @@ -395,6 +406,9 @@ export class RsDeviceSection extends LitElement { device?.idle_action === "setback" || device?.idle_action === "low"; const showDirectBadge = device?.setpoint_mode === "direct" && !!this.selectedTempSensor; + const showValveBadge = + device?.type === "trv" && + (!!device?.valve_opening_entity || !!device?.valve_closing_entity); return html`
@@ -418,6 +432,12 @@ export class RsDeviceSection extends LitElement { ${localize("devices.setpoint_mode_direct", this.hass.language)} ` : nothing} + ${showValveBadge + ? html` + + ${localize("devices.valve_direct_badge", this.hass.language)} + ` + : nothing} ${showExcludeBadge ? html` @@ -804,6 +824,46 @@ export class RsDeviceSection extends LitElement {
` : nothing} + ${isThermostat + ? html` +
+ ${localize("devices.valve_direct_control", lang)} +
+
+ + this._onValveEntityChange( + entityId, + "valve_opening_entity", + e.detail.value as string, + )} + allowCustomEntity + > + +
+
+ + this._onValveEntityChange( + entityId, + "valve_closing_entity", + e.detail.value as string, + )} + allowCustomEntity + > +
+ ` + : nothing} `; } @@ -889,6 +949,24 @@ export class RsDeviceSection extends LitElement { this._fireDeviceChanged(newDevices); } + private _onValveEntityChange( + entityId: string, + field: "valve_opening_entity" | "valve_closing_entity", + value: string, + ): void { + const newDevices = this.devices.map((d) => { + if (d.entity_id !== entityId) return d; + const updated = { ...d }; + if (value) { + updated[field] = value; + } else { + delete updated[field]; + } + return updated; + }); + this._fireDeviceChanged(newDevices); + } + private _onHeatingSystemTypeChange(e: Event) { const raw = getSelectValue(e) ?? ""; const value = raw === "standard" ? "" : raw; diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 7af01093..ae7dd855 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -195,6 +195,11 @@ "devices.setpoint_mode_hint": "Direkt sendet die tatsächliche Raum-Zieltemperatur ans Gerät — geeignet für Thermostate und Heizlüfter mit eigener Regelung. Proportional sendet einen erhöhten Sollwert Richtung Geräte-max_temp — geeignet für Heizkörperventile (TRVs).", "devices.valve_protection_excluded": "Vom Ventilschutz ausgenommen", "devices.valve_protection_exclude_hint": "Dieses Gerät wird nicht vom Ventilschutz bewegt (z.B. virtuelle Kessel-Entitäten)", + "devices.valve_direct_control": "Direkte Ventilsteuerung", + "devices.valve_opening_entity": "Öffnungsgrad-Entität (0–100%)", + "devices.valve_closing_entity": "Schließgrad-Entität (0–100%)", + "devices.valve_direct_control_hint": "Sendet die Ventilposition direkt an Number-Entitäten. Öffnung 100% / Schließung 0% = vollständig offen. Ersetzt bei Konfiguration die proportionale Sollwertsteuerung.", + "devices.valve_direct_badge": "Direkte Steuerung", "devices.info.types_title": "Gerätetypen", "devices.info.types_body": "Thermostat bedeutet Heizkörperthermostat / TRV. Klimagerät bedeutet AC, Wärmepumpe oder eine andere Klima-Entität für Kühlung oder Warmluftheizung. Beides sind Home-Assistant-Klima-Entitäten; der Unterschied liegt darin, wie RoomMind sie ansteuert.", "devices.info.control_title": "Wie RoomMind sie steuert", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 532cbb9a..80f1bbcd 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -195,6 +195,11 @@ "devices.setpoint_mode_hint": "Direct sends the actual room target to the device — best for thermostats and space heaters that regulate themselves. Proportional sends a boosted setpoint toward the device max_temp — best for radiator valves (TRVs).", "devices.valve_protection_excluded": "Excluded from valve protection", "devices.valve_protection_exclude_hint": "This entity will not be cycled by valve protection (e.g. virtual boiler entities)", + "devices.valve_direct_control": "Direct Valve Control", + "devices.valve_opening_entity": "Opening degree entity (0–100%)", + "devices.valve_closing_entity": "Closing degree entity (0–100%)", + "devices.valve_direct_control_hint": "Send valve position directly to number entities. Opening 100% / closing 0% = fully open. If set, this replaces proportional setpoint control.", + "devices.valve_direct_badge": "Direct valve", "devices.info.types_title": "Device types", "devices.info.types_body": "Thermostat means a radiator thermostat / TRV. Climate Device means an AC, heat pump, or other climate entity used for cooling or forced-air heating. Both are Home Assistant climate entities; the distinction is how RoomMind controls them.", "devices.info.control_title": "How RoomMind controls them", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 346485f7..83e3f887 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -195,6 +195,11 @@ "devices.setpoint_mode_hint": "Direct envoie la cible réelle de la pièce à l'appareil — idéal pour les thermostats et les chauffages d'appoint qui se régulent eux-mêmes. Proportionnel envoie une consigne renforcée vers la température maximale de l'appareil — idéal pour les vannes de radiateur (TRV).", "devices.valve_protection_excluded": "Exclu de la protection des vannes", "devices.valve_protection_exclude_hint": "Cette entité ne sera pas cyclée par la protection des vannes (ex. entités chaudière virtuelles)", + "devices.valve_direct_control": "Contrôle direct de vanne", + "devices.valve_opening_entity": "Entité degré d'ouverture (0–100%)", + "devices.valve_closing_entity": "Entité degré de fermeture (0–100%)", + "devices.valve_direct_control_hint": "Envoie la position de vanne directement aux entités nombre. Ouverture 100% / fermeture 0% = complètement ouvert. Remplace le contrôle par consigne proportionnel si configuré.", + "devices.valve_direct_badge": "Vanne directe", "devices.info.types_title": "Types d'appareil", "devices.info.types_body": "Thermostat désigne un thermostat de radiateur / TRV. Appareil climatique désigne un climatiseur, une pompe à chaleur ou autre entité climatique utilisée pour le refroidissement ou le chauffage à air pulsé. Les deux sont des entités climatiques dans Home Assistant; la différence réside dans la façon dont RoomMind les contrôle.", "devices.info.control_title": "Comment RoomMind les contrôle", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 61e52530..7e21bc5a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -69,6 +69,8 @@ export interface DeviceConfig { idle_action?: "off" | "fan_only" | "setback" | "low"; // default "off" idle_fan_mode?: string; // default "low" setpoint_mode?: "proportional" | "direct"; // default "proportional" + valve_opening_entity?: string; // number entity for direct valve opening degree (0-100%) + valve_closing_entity?: string; // number entity for direct valve closing degree (0-100%) } export type ConflictResolution =