diff --git a/custom_components/sat/coordinator/mqtt/ems.py b/custom_components/sat/coordinator/mqtt/ems.py index 921104b9..642369e8 100644 --- a/custom_components/sat/coordinator/mqtt/ems.py +++ b/custom_components/sat/coordinator/mqtt/ems.py @@ -1,14 +1,20 @@ from __future__ import annotations import logging +import time from typing import Any, Optional +from homeassistant.core import HomeAssistant + from . import SatMqttCoordinator +from ...const import MINIMUM_SETPOINT +from ...entry_data import SatConfig from ...helpers import float_value from ...types import HeaterState DATA_ON = "on" DATA_OFF = "off" +TRUE_VALUES = (DATA_ON, "ON", True, 1, "1") DATA_BOILER_DATA = "boiler_data" DATA_FLAME_ACTIVE = "burngas" @@ -25,12 +31,24 @@ DATA_REL_MIN_MOD_LEVEL = "burnminpower" DATA_MAX_REL_MOD_LEVEL_SETTING = "burnmaxpower" +SETPOINT_HEARTBEAT_INTERVAL = 60 # 60s keepalive to prevent EMS-ESP fallback timeout (~240s) to 0 + _LOGGER: logging.Logger = logging.getLogger(__name__) class SatEmsMqttCoordinator(SatMqttCoordinator): """Class to manage to fetch data from the OTGW Gateway using MQTT.""" + def __init__(self, hass: HomeAssistant, config: SatConfig) -> None: + super().__init__(hass, config) + + self._last_published_control_setpoint: Optional[int] = None + self._last_published_control_setpoint_time: float = 0.0 + self._last_control_setpoint_value: Optional[float] = None + self._last_published_max_relative_modulation: Optional[int] = None + self._last_published_hot_water_setpoint: Optional[float] = None + self._last_published_max_setpoint: Optional[float] = None + @property def type(self) -> str: return "Energy Management System (via mqtt)" @@ -53,22 +71,48 @@ def supports_relative_modulation(self) -> bool: @property def active(self) -> bool: - return self.data.get(DATA_CENTRAL_HEATING) == DATA_ON + return self.data.get(DATA_CENTRAL_HEATING) in TRUE_VALUES @property def flame_active(self) -> bool: - return self.data.get(DATA_FLAME_ACTIVE) == DATA_ON + return self.data.get(DATA_FLAME_ACTIVE) in TRUE_VALUES @property def hot_water_active(self) -> bool: - return self.data.get(DATA_DHW_ENABLE) == DATA_ON + if any(k in self.data for k in ("tapwateractive", "dhwcharging", "dhw_charging")): + return ( + self.data.get("tapwateractive") in TRUE_VALUES + or self.data.get("dhwcharging") in TRUE_VALUES + or self.data.get("dhw_charging") in TRUE_VALUES + ) + return self.data.get(DATA_DHW_ENABLE) in TRUE_VALUES @property def setpoint(self) -> Optional[float]: - return float_value(self.data.get(DATA_CONTROL_SETPOINT)) + val = float_value(self.data.get(DATA_CONTROL_SETPOINT)) + if val is None: + return None + if val == 0: + return MINIMUM_SETPOINT + + # EMS bus telegrams have a 1°C integer resolution for boiler flow setpoint. + # If the reported boiler setpoint matches the integer-rounded value of our + # last requested setpoint, report the requested value back to SAT to prevent + # float quantisation mismatches (e.g. 51.9°C requested vs 52°C boiler) from + # tripping the Control Setpoint Synchronization sensor. + if ( + self._last_control_setpoint_value is not None + and round(self._last_control_setpoint_value) == round(val) + ): + return self._last_control_setpoint_value + + return val @property def hot_water_setpoint(self) -> Optional[float]: + dhw_data = self.data.get("dhw") + if isinstance(dhw_data, dict): + return float_value(dhw_data.get("seltemp") or dhw_data.get("settemp")) return float_value(self.data.get(DATA_DHW_SETPOINT)) @property @@ -108,12 +152,43 @@ def get_tracked_entities(self) -> list[str]: return [DATA_BOILER_DATA] async def async_set_control_setpoint(self, value: float) -> None: - await self._publish_command(f'{{"cmd": "selflowtemp", "value": {0 if value == 10 else value}}}') + self._last_control_setpoint_value = value + target = 0 if value <= 10 else int(round(value)) + now = time.monotonic() + + # If the boiler is actively producing Domestic Hot Water (ACS), + # do not interfere with the flow temperature setpoint! + if self.hot_water_active: + await super().async_set_control_setpoint(value) + return + + raw_val = float_value(self.data.get(DATA_CONTROL_SETPOINT)) + raw_boiler_setpoint = int(round(raw_val)) if raw_val is not None else None + should_publish = ( + self._last_published_control_setpoint is None + or self._last_published_control_setpoint != target + or (raw_boiler_setpoint is not None and raw_boiler_setpoint != target) + or (target > 0 and now - self._last_published_control_setpoint_time >= SETPOINT_HEARTBEAT_INTERVAL) + ) + + if should_publish: + await self._publish_command(f'{{"cmd": "selflowtemp", "value": {target}}}') + self._last_published_control_setpoint = target + self._last_published_control_setpoint_time = now await super().async_set_control_setpoint(value) async def async_set_control_hot_water_setpoint(self, value: float) -> None: - await self._publish_command(f'{{"cmd": "dhw/seltemp", "value": {value}}}') + current_dhw = self.hot_water_setpoint + should_publish = ( + self._last_published_hot_water_setpoint is None + or self._last_published_hot_water_setpoint != value + or (current_dhw is not None and current_dhw != value) + ) + + if should_publish: + await self._publish_command(f'{{"cmd": "dhw/seltemp", "value": {value}}}') + self._last_published_hot_water_setpoint = value await super().async_set_control_hot_water_setpoint(value) @@ -122,17 +197,26 @@ async def async_set_control_thermostat_setpoint(self, value: float) -> None: await super().async_set_control_thermostat_setpoint(value) async def async_set_heater_state(self, state: HeaterState) -> None: - await self._publish_command(f'{{"cmd": "heatingactivated", "value": "{DATA_ON if state == HeaterState.ON else DATA_OFF}"}}') - + # On EMS-ESP / Bosch / Buderus boilers, central heating demand is controlled + # purely via the 'selflowtemp' setpoint (>0 for heat demand, 0 for off). + # We must NOT toggle 'heatingactivated' here, as that represents the boiler's + # seasonal mode (Summer/Winter). Toggling it on every cycle trips the boiler's + # 15-minute Siphon Filling Program (Siphonfüllprogramm), locking power to 14%. await super().async_set_heater_state(state) async def async_set_control_max_relative_modulation(self, value: int) -> None: - await self._publish_command(f'{{"cmd": "burnmaxpower", "value": {max(value, 20)}}}') + target = max(value, 20) + if self._last_published_max_relative_modulation != target: + await self._publish_command(f'{{"cmd": "burnmaxpower", "value": {target}}}') + self._last_published_max_relative_modulation = target + self.data[DATA_MAX_REL_MOD_LEVEL_SETTING] = target await super().async_set_control_max_relative_modulation(value) async def async_set_control_max_setpoint(self, value: float) -> None: - await self._publish_command(f'{{"cmd": "heatingtemp", "value": {value}}}') + if self._last_published_max_setpoint != value: + await self._publish_command(f'{{"cmd": "heatingtemp", "value": {value}}}') + self._last_published_max_setpoint = value await super().async_set_control_max_setpoint(value)