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 =