From 6311bceb7c152f20102cc5207c6c0312c78ec645 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Wed, 14 Oct 2026 09:46:49 +0200 Subject: [PATCH 1/3] fix(ems): improve telemetry, deduplicate commands and fix setpoint quantization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Deduplicate outgoing MQTT commands (selflowtemp, burnmaxpower) to prevent flooding EMS-ESP every 5 seconds. - Do not toggle 'heatingactivated' in async_set_heater_state, avoiding unintended boiler Summer/Winter mode transitions that trigger the 15-minute Siphon Filling Program on Buderus/Bosch boilers. - Map idle flow setpoint (0°C) to MINIMUM_SETPOINT to prevent false positive sync alarms when heating is idle. - Expand hot_water_active detection to include EMS-ESP telemetry ('tapwateractive', 'dhwcharging', 'dhw_charging') with robust boolean truthy check. - Suppress heating setpoint overrides during active DHW production to preserve DHW priority. - Send integer-rounded flow setpoints (matching EMS telegram 1°C resolution) and bridge float quantisation to prevent control_setpoint_synchro false alarms. - Maintain a 30-second safety keepalive heartbeat for active heating setpoint. --- custom_components/sat/coordinator/mqtt/ems.py | 101 ++++++++++++++++-- 1 file changed, 91 insertions(+), 10 deletions(-) diff --git a/custom_components/sat/coordinator/mqtt/ems.py b/custom_components/sat/coordinator/mqtt/ems.py index 921104b9..18f36ed5 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 = 300 # 5 minutes safety keepalive interval + _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,19 +71,42 @@ 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]: @@ -108,12 +149,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 +194,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) From 7fbca5197464e4ab83b93871eb224e7e99b46af9 Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Sat, 17 Oct 2026 10:50:07 +0200 Subject: [PATCH 2/3] fix(ems): support nested dhw payload for hot water setpoint --- custom_components/sat/coordinator/mqtt/ems.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/coordinator/mqtt/ems.py b/custom_components/sat/coordinator/mqtt/ems.py index 18f36ed5..9a55ce39 100644 --- a/custom_components/sat/coordinator/mqtt/ems.py +++ b/custom_components/sat/coordinator/mqtt/ems.py @@ -110,6 +110,9 @@ def setpoint(self) -> Optional[float]: @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 From c913c9ff871cf948646f59d402c8b240f991e54c Mon Sep 17 00:00:00 2001 From: Stefano Pierini Date: Thu, 22 Oct 2026 23:02:00 +0200 Subject: [PATCH 3/3] fix(ems): lower setpoint heartbeat interval to 60s to prevent EMS-ESP timeout EMS-ESP firmware has a ~4-minute safety timeout on thermostat flow setpoints. A 60s keepalive interval ensures regular setpoint updates before EMS-ESP falls back to 0, preventing unintended burner cutoffs and short-cycling. --- custom_components/sat/coordinator/mqtt/ems.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator/mqtt/ems.py b/custom_components/sat/coordinator/mqtt/ems.py index 9a55ce39..642369e8 100644 --- a/custom_components/sat/coordinator/mqtt/ems.py +++ b/custom_components/sat/coordinator/mqtt/ems.py @@ -31,7 +31,7 @@ DATA_REL_MIN_MOD_LEVEL = "burnminpower" DATA_MAX_REL_MOD_LEVEL_SETTING = "burnmaxpower" -SETPOINT_HEARTBEAT_INTERVAL = 300 # 5 minutes safety keepalive interval +SETPOINT_HEARTBEAT_INTERVAL = 60 # 60s keepalive to prevent EMS-ESP fallback timeout (~240s) to 0 _LOGGER: logging.Logger = logging.getLogger(__name__)