From 38bc33e519d98e868911f862323553d79d37595c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 15:28:10 +0100 Subject: [PATCH 001/194] Dropped some obsolete variables and improved the dynamic minimum setpoint setup --- custom_components/sat/climate.py | 75 +++++++++++------------ custom_components/sat/minimum_setpoint.py | 24 ++++---- 2 files changed, 47 insertions(+), 52 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 22c254a5..a45780dc 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -43,8 +43,7 @@ from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, \ - calculate_derivative_per_hour, create_minimum_setpoint_controller +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, create_minimum_setpoint_controller ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -123,10 +122,9 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensors = [] self._rooms = None self._setpoint = None + self._warming_up = False self._calculated_setpoint = None - - self._warming_up_data = None - self._warming_up_derivative = None + self._last_boiler_temperature = None self._hvac_mode = None self._target_temperature = None @@ -300,12 +298,6 @@ async def _restore_previous_state_or_set_defaults(self): if old_state.attributes.get(ATTR_PRESET_MODE): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) - if warming_up := old_state.attributes.get(ATTR_WARMING_UP): - self._warming_up_data = SatWarmingUp(warming_up["error"], warming_up["boiler_temperature"], warming_up["started"]) - - if old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE): - self._warming_up_derivative = old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE) - if old_state.attributes.get(ATTR_PRE_ACTIVITY_TEMPERATURE): self._pre_activity_temperature = old_state.attributes.get(ATTR_PRE_ACTIVITY_TEMPERATURE) @@ -381,14 +373,12 @@ def extra_state_attributes(self): "current_humidity": self._current_humidity, "summer_simmer_index": SummerSimmer.index(self._current_temperature, self._current_humidity), "summer_simmer_perception": SummerSimmer.perception(self._current_temperature, self._current_humidity), - "warming_up_data": vars(self._warming_up_data) if self._warming_up_data is not None else None, - "warming_up_derivative": self._warming_up_derivative, "valves_open": self.valves_open, "heating_curve": self.heating_curve.value, "minimum_setpoint": self.minimum_setpoint, "requested_setpoint": self.requested_setpoint, "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, - "base_return_temperature": self._minimum_setpoint.base_return_temperature, + "base_boiler_temperature": self._minimum_setpoint.base_boiler_temperature, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -542,11 +532,6 @@ def relative_modulation_value(self) -> int: def relative_modulation_state(self) -> RelativeModulationState: return self._relative_modulation.state - @property - def warming_up(self) -> bool: - """Return True if we are warming up, False otherwise.""" - return self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME - @property def minimum_setpoint(self) -> float: if not self._dynamic_minimum_setpoint: @@ -740,18 +725,6 @@ async def _async_control_pid(self, reset: bool = False) -> None: outside_temperature=self.current_outside_temperature ) - # Since we are in the deadband, we can safely assume we are not warming up anymore - if self._warming_up_data and max_error <= DEADBAND: - # Calculate the derivative per hour - self._warming_up_derivative = calculate_derivative_per_hour( - self._warming_up_data.error, - self._warming_up_data.elapsed - ) - - # Notify that we are not warming anymore - _LOGGER.info("Reached deadband, turning off warming up.") - self._warming_up_data = None - self._areas.pids.update(self._coordinator.filtered_boiler_temperature) self.pid.update(max_error, self.heating_curve.value, self._coordinator.filtered_boiler_temperature) elif max_error != self.pid.last_error: @@ -761,11 +734,6 @@ async def _async_control_pid(self, reset: bool = False) -> None: self._calculated_setpoint = None self.pwm.reset() - # Determine if we are warming up - if self.max_error > DEADBAND: - self._warming_up_data = SatWarmingUp(self.max_error, self._coordinator.boiler_temperature) - _LOGGER.info("Outside of deadband, we are warming up") - self.async_write_ha_state() async def _async_control_setpoint(self, pwm_state: PWMState) -> None: @@ -789,8 +757,12 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: _LOGGER.info("Running PWM cycle with state: %s", pwm_state) if pwm_state == PWMState.ON and self.max_error > -DEADBAND: - self._setpoint = self.minimum_setpoint - _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self.minimum_setpoint) + if not self._dynamic_minimum_setpoint: + self._setpoint = self._coordinator.minimum_setpoint + else: + self._setpoint = self._coordinator.boiler_temperature - 2 + + _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: self._setpoint = MINIMUM_SETPOINT _LOGGER.debug("Setting setpoint to absolute minimum: %.1f°C", MINIMUM_SETPOINT) @@ -806,7 +778,7 @@ async def _async_control_relative_modulation(self) -> None: return # Update relative modulation state - await self._relative_modulation.update(self.warming_up, self.pwm.state) + await self._relative_modulation.update(self._warming_up, self.pwm.state) # Determine if the value needs to be updated if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: @@ -874,6 +846,26 @@ async def async_control_heating_loop(self, _time=None) -> None: # Apply low filter on requested setpoint self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) + # Handle warming-up logic + if self._warming_up: + # Check if the boiler temperature is decreasing, indicating warming-up is complete + if self._last_boiler_temperature is not None and self._last_boiler_temperature > self._coordinator.boiler_temperature: + self._warming_up = False + + _LOGGER.debug( + "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C", + self._last_boiler_temperature, + self._coordinator.boiler_temperature + ) + else: + # Update the last known boiler temperature + self._last_boiler_temperature = self._coordinator.boiler_temperature + + _LOGGER.debug( + "Warming-up in progress. Updated last boiler temperature to: %.1f°C", + self._last_boiler_temperature + ) + # Pulse Width Modulation if self.pulse_width_modulation_enabled: boiler_state = BoilerState( @@ -902,7 +894,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control our dynamic minimum setpoint if not self._coordinator.hot_water_active and self._coordinator.flame_active: # Calculate the base return temperature - if self.warming_up: + if self._warming_up: self._minimum_setpoint.warming_up(self._coordinator.return_temperature) # Calculate the dynamic minimum setpoint @@ -926,6 +918,9 @@ async def async_set_heater_state(self, state: DeviceState): _LOGGER.warning("Cannot turn on heater: no valves are open.") return + self._warming_up = True + self._last_boiler_temperature = None + elif state == DeviceState.OFF: if not self._coordinator.device_active: _LOGGER.info("Heater is already off. No action taken.") diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index d73dc41b..ea114365 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -12,7 +12,7 @@ class MinimumSetpoint: def __init__(self, adjustment_factor: float, configured_minimum_setpoint: float): self._store = None - self.base_return_temperature = None + self.base_boiler_temperature = None self.current_minimum_setpoint = None self.adjustment_factor = adjustment_factor @@ -22,28 +22,28 @@ async def async_initialize(self, hass: HomeAssistant) -> None: self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) data = await self._store.async_load() - if data and "base_return_temperature" in data: - self.base_return_temperature = data["base_return_temperature"] - _LOGGER.debug("Loaded base return temperature from storage.") + if data and "base_boiler_temperature" in data: + self.base_boiler_temperature = data["base_boiler_temperature"] + _LOGGER.debug("Loaded base boiler temperature from storage.") - def warming_up(self, return_temperature: float) -> None: - if self.base_return_temperature is not None and self.base_return_temperature > return_temperature: + def warming_up(self, boiler_temperature: float) -> None: + if self.base_boiler_temperature is not None and self.base_boiler_temperature > boiler_temperature: return # Use the new value if it's higher or none is set - self.base_return_temperature = return_temperature - _LOGGER.debug(f"Higher temperature set to: {return_temperature}.") + self.base_boiler_temperature = boiler_temperature + _LOGGER.debug(f"Higher temperature set to: {boiler_temperature}.") # Make sure to remember this value if self._store: self._store.async_delay_save(self._data_to_save) _LOGGER.debug("Stored base return temperature changes.") - def calculate(self, return_temperature: float) -> None: - if self.base_return_temperature is None: + def calculate(self, boiler_temperature: float) -> None: + if self.base_boiler_temperature is None: return - adjustment = (return_temperature - self.base_return_temperature) * self.adjustment_factor + adjustment = (boiler_temperature - self.base_boiler_temperature) * self.adjustment_factor self.current_minimum_setpoint = self.configured_minimum_setpoint + adjustment _LOGGER.debug("Calculated new minimum setpoint: %d°C", self.current_minimum_setpoint) @@ -52,4 +52,4 @@ def current(self) -> float: return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint def _data_to_save(self) -> dict: - return {"base_return_temperature": self.base_return_temperature} + return {"base_boiler_temperature": self.base_boiler_temperature} From f96ff0100bc235d094a98d0998a986bd2b53a20e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 15:35:04 +0100 Subject: [PATCH 002/194] Send MI=500 if it's an Ideal or Immergas boiler --- custom_components/sat/mqtt/opentherm.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index 215ea53d..2776a6ce 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -4,6 +4,7 @@ from . import SatMqttCoordinator from ..coordinator import DeviceState +from ..manufacturers.ideal import Ideal from ..manufacturers.immergas import Immergas STATE_ON = "ON" @@ -143,6 +144,9 @@ async def boot(self) -> None: await self._publish_command("PM=3") await self._publish_command("PM=48") + if isinstance(self.manufacturer, Ideal) or isinstance(self.manufacturer, Immergas): + await self._publish_command("MI=500") + def get_tracked_entities(self) -> list[str]: return [ DATA_SLAVE_MEMBERID, From 704428d917702e92d6e193c10aad599cc9f17c4a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 15:38:23 +0100 Subject: [PATCH 003/194] Typo? --- custom_components/sat/mqtt/opentherm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index 2776a6ce..12d6d74d 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -6,6 +6,7 @@ from ..coordinator import DeviceState from ..manufacturers.ideal import Ideal from ..manufacturers.immergas import Immergas +from ..manufacturers.intergas import Intergas STATE_ON = "ON" @@ -144,7 +145,7 @@ async def boot(self) -> None: await self._publish_command("PM=3") await self._publish_command("PM=48") - if isinstance(self.manufacturer, Ideal) or isinstance(self.manufacturer, Immergas): + if isinstance(self.manufacturer, Ideal) or isinstance(self.manufacturer, Intergas): await self._publish_command("MI=500") def get_tracked_entities(self) -> list[str]: From b9dbb9c106deb4a66d28867a7db09ff7466bdbb2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 18:30:23 +0100 Subject: [PATCH 004/194] Make the adjustment factor more aggressive --- custom_components/sat/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index bab445b0..aab77f85 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -104,7 +104,7 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, - CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR: 0.2, + CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR: 0.5, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], From cc8044b902bf7074f5aeb78c5420b8e402cfd8ad Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 22:10:22 +0100 Subject: [PATCH 005/194] Add support to have seperated modes for minimum setpoint --- custom_components/sat/climate.py | 16 ++---- custom_components/sat/minimum_setpoint.py | 62 ++++++++++------------- 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index a45780dc..425ec8b3 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -213,9 +213,6 @@ async def async_added_to_hass(self) -> None: # Register services await self._register_services() - # Initialize minimum setpoint system - await self._minimum_setpoint.async_initialize(self.hass) - # Initialize the area system await self._areas.async_added_to_hass(self.hass) @@ -378,7 +375,6 @@ def extra_state_attributes(self): "minimum_setpoint": self.minimum_setpoint, "requested_setpoint": self.requested_setpoint, "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, - "base_boiler_temperature": self._minimum_setpoint.base_boiler_temperature, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -892,13 +888,11 @@ async def async_control_heating_loop(self, _time=None) -> None: await self._areas.async_control_heating_loops() # Control our dynamic minimum setpoint - if not self._coordinator.hot_water_active and self._coordinator.flame_active: - # Calculate the base return temperature - if self._warming_up: - self._minimum_setpoint.warming_up(self._coordinator.return_temperature) - - # Calculate the dynamic minimum setpoint - self._minimum_setpoint.calculate(self._coordinator.return_temperature) + if not self._coordinator.hot_water_active: + if not self._warming_up: + self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) + else: + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) # If the setpoint is high and the HVAC is not off, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index ea114365..345347cd 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,55 +1,45 @@ import logging -from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store - _LOGGER = logging.getLogger(__name__) class MinimumSetpoint: - _STORAGE_VERSION = 1 - _STORAGE_KEY = "minimum_setpoint" - def __init__(self, adjustment_factor: float, configured_minimum_setpoint: float): - self._store = None - self.base_boiler_temperature = None + """Initialize the MinimumSetpoint class.""" + self._setpoint = None self.current_minimum_setpoint = None - self.adjustment_factor = adjustment_factor - self.configured_minimum_setpoint = configured_minimum_setpoint - - async def async_initialize(self, hass: HomeAssistant) -> None: - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + self.adjustment_factor: float = adjustment_factor + self.configured_minimum_setpoint: float = configured_minimum_setpoint - data = await self._store.async_load() - if data and "base_boiler_temperature" in data: - self.base_boiler_temperature = data["base_boiler_temperature"] - _LOGGER.debug("Loaded base boiler temperature from storage.") - - def warming_up(self, boiler_temperature: float) -> None: - if self.base_boiler_temperature is not None and self.base_boiler_temperature > boiler_temperature: + def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: + """Adjust the setpoint to trigger the boiler flame during warm-up if needed.""" + if flame_active: + _LOGGER.debug("Flame is already active. No adjustment needed.") return - # Use the new value if it's higher or none is set - self.base_boiler_temperature = boiler_temperature - _LOGGER.debug(f"Higher temperature set to: {boiler_temperature}.") + self.current_minimum_setpoint = boiler_temperature + 10 + _LOGGER.debug("Setpoint adjusted for warm-up: %.1f°C", self.current_minimum_setpoint) - # Make sure to remember this value - if self._store: - self._store.async_delay_save(self._data_to_save) - _LOGGER.debug("Stored base return temperature changes.") + def calculate(self, requested_setpoint: float, boiler_temperature: float) -> None: + """Calculate and adjust the minimum setpoint gradually toward the requested setpoint.""" + if self.current_minimum_setpoint is None: + self.current_minimum_setpoint = boiler_temperature + _LOGGER.debug("Initialized minimum setpoint to boiler temperature: %.1f°C", boiler_temperature) - def calculate(self, boiler_temperature: float) -> None: - if self.base_boiler_temperature is None: - return + old_setpoint = self.current_minimum_setpoint - adjustment = (boiler_temperature - self.base_boiler_temperature) * self.adjustment_factor - self.current_minimum_setpoint = self.configured_minimum_setpoint + adjustment + # Gradually adjust the setpoint toward the requested setpoint + if self.current_minimum_setpoint < requested_setpoint: + self.current_minimum_setpoint = min(self.current_minimum_setpoint + self.adjustment_factor, requested_setpoint) + else: + self.current_minimum_setpoint = max(self.current_minimum_setpoint - self.adjustment_factor, requested_setpoint) - _LOGGER.debug("Calculated new minimum setpoint: %d°C", self.current_minimum_setpoint) + _LOGGER.debug( + "Changing minimum setpoint: %.1f°C => %.1f°C (requested: %.1f°C, adjustment factor: %.1f)", + old_setpoint, self.current_minimum_setpoint, requested_setpoint, self.adjustment_factor + ) def current(self) -> float: + """Get the current minimum setpoint.""" return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint - - def _data_to_save(self) -> dict: - return {"base_boiler_temperature": self.base_boiler_temperature} From bdbb2b32b3b051c48f1c7216060dd7b4295d923f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 22:12:44 +0100 Subject: [PATCH 006/194] Making sure the boiler temperature isn't none --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 425ec8b3..86e134b1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -888,7 +888,7 @@ async def async_control_heating_loop(self, _time=None) -> None: await self._areas.async_control_heating_loops() # Control our dynamic minimum setpoint - if not self._coordinator.hot_water_active: + if not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: if not self._warming_up: self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) else: From d45361c884e940fc7261e10bfd926e53e1415312 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 22:43:55 +0100 Subject: [PATCH 007/194] Use more dynamic adjustments to the minimum setpoint --- custom_components/sat/climate.py | 5 ++++- custom_components/sat/config_flow.py | 4 ++-- custom_components/sat/const.py | 4 ++-- custom_components/sat/minimum_setpoint.py | 17 +++++++++++------ custom_components/sat/util.py | 4 ++-- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 86e134b1..2f195631 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -323,6 +323,9 @@ async def _restore_previous_state_or_set_defaults(self): if not self._hvac_mode: self._hvac_mode = HVACMode.OFF + if self.max_error > 0: + self._warming_up = True + self.async_write_ha_state() async def _register_services(self): @@ -756,7 +759,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._dynamic_minimum_setpoint: self._setpoint = self._coordinator.minimum_setpoint else: - self._setpoint = self._coordinator.boiler_temperature - 2 + self._setpoint = self._minimum_setpoint.current() - 2 _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index a40be06a..1b112904 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -570,8 +570,8 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: - schema[vol.Required(CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR, default=options[CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR])] = selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=0.5, step=0.1) + schema[vol.Required(CONF_MINIMUM_SETPOINT_ADJUSTMENT, default=options[CONF_MINIMUM_SETPOINT_ADJUSTMENT])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=1.0, step=0.1) ) if not options[CONF_AUTOMATIC_DUTY_CYCLE]: diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index aab77f85..a9026ea3 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -65,7 +65,7 @@ CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" -CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR = "minimum_setpoint_adjustment_factor" +CONF_MINIMUM_SETPOINT_ADJUSTMENT = "minimum_setpoint_adjustment" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" @@ -104,7 +104,7 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, - CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR: 0.5, + CONF_MINIMUM_SETPOINT_ADJUSTMENT: 0.5, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 345347cd..1e1c67f6 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -4,12 +4,13 @@ class MinimumSetpoint: - def __init__(self, adjustment_factor: float, configured_minimum_setpoint: float): + def __init__(self, adjustment: float, configured_minimum_setpoint: float): """Initialize the MinimumSetpoint class.""" self._setpoint = None - self.current_minimum_setpoint = None + self._adjustments = 0 + self._adjustment_factor = adjustment - self.adjustment_factor: float = adjustment_factor + self.current_minimum_setpoint = None self.configured_minimum_setpoint: float = configured_minimum_setpoint def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: @@ -18,6 +19,7 @@ def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: _LOGGER.debug("Flame is already active. No adjustment needed.") return + self._adjustments = 0 self.current_minimum_setpoint = boiler_temperature + 10 _LOGGER.debug("Setpoint adjusted for warm-up: %.1f°C", self.current_minimum_setpoint) @@ -28,18 +30,21 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non _LOGGER.debug("Initialized minimum setpoint to boiler temperature: %.1f°C", boiler_temperature) old_setpoint = self.current_minimum_setpoint + adjustment_factor = 0.0 if self._adjustments < 6 else self._adjustment_factor # Gradually adjust the setpoint toward the requested setpoint if self.current_minimum_setpoint < requested_setpoint: - self.current_minimum_setpoint = min(self.current_minimum_setpoint + self.adjustment_factor, requested_setpoint) + self.current_minimum_setpoint = min(self.current_minimum_setpoint + adjustment_factor, requested_setpoint) else: - self.current_minimum_setpoint = max(self.current_minimum_setpoint - self.adjustment_factor, requested_setpoint) + self.current_minimum_setpoint = max(self.current_minimum_setpoint - adjustment_factor, requested_setpoint) _LOGGER.debug( "Changing minimum setpoint: %.1f°C => %.1f°C (requested: %.1f°C, adjustment factor: %.1f)", - old_setpoint, self.current_minimum_setpoint, requested_setpoint, self.adjustment_factor + old_setpoint, self.current_minimum_setpoint, requested_setpoint, adjustment_factor ) + self._adjustments += 1 + def current(self) -> float: """Get the current minimum setpoint.""" return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 714212a1..338496b7 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -93,9 +93,9 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy def create_minimum_setpoint_controller(config_data, config_options) -> MinimumSetpoint: minimum_setpoint = config_data.get(CONF_MINIMUM_SETPOINT) - adjustment_factor = config_options.get(CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR) + adjustment = config_options.get(CONF_MINIMUM_SETPOINT_ADJUSTMENT) - return MinimumSetpoint(configured_minimum_setpoint=minimum_setpoint, adjustment_factor=adjustment_factor) + return MinimumSetpoint(configured_minimum_setpoint=minimum_setpoint, adjustment=adjustment) def snake_case(value: str) -> str: From 2e449ba4f7c25a27b147c5517b767a60f35ed47f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 22:46:41 +0100 Subject: [PATCH 008/194] Change when we "warming up" --- custom_components/sat/climate.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 2f195631..12fc0985 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -323,9 +323,6 @@ async def _restore_previous_state_or_set_defaults(self): if not self._hvac_mode: self._hvac_mode = HVACMode.OFF - if self.max_error > 0: - self._warming_up = True - self.async_write_ha_state() async def _register_services(self): @@ -907,6 +904,10 @@ async def async_set_heater_state(self, state: DeviceState): _LOGGER.debug("Attempting to set heater state to: %s", state) if state == DeviceState.ON: + if not self._coordinator.flame_active: + self._warming_up = True + self._last_boiler_temperature = None + if self._coordinator.device_active: _LOGGER.info("Heater is already active. No action taken.") return @@ -915,9 +916,6 @@ async def async_set_heater_state(self, state: DeviceState): _LOGGER.warning("Cannot turn on heater: no valves are open.") return - self._warming_up = True - self._last_boiler_temperature = None - elif state == DeviceState.OFF: if not self._coordinator.device_active: _LOGGER.info("Heater is already off. No action taken.") From 0f0ac2e5454ea196116e46bc5ff715557346db5c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 22:50:09 +0100 Subject: [PATCH 009/194] Cleanup --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 12fc0985..45bce37e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -756,7 +756,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._dynamic_minimum_setpoint: self._setpoint = self._coordinator.minimum_setpoint else: - self._setpoint = self._minimum_setpoint.current() - 2 + self._setpoint = self.adjusted_minimum_setpoint - 2 _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: From c53fa3f7ce0eeb1f01d774a89e25b7e6b94dd121 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 23:03:36 +0100 Subject: [PATCH 010/194] Change the order of execution --- custom_components/sat/climate.py | 14 +++++++------- custom_components/sat/minimum_setpoint.py | 3 ++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 45bce37e..5e6b381c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -875,6 +875,13 @@ async def async_control_heating_loop(self, _time=None) -> None: else: self.pwm.reset() + # Control our dynamic minimum setpoint + if not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: + if not self._warming_up: + self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) + else: + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -887,13 +894,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control our areas await self._areas.async_control_heating_loops() - # Control our dynamic minimum setpoint - if not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: - if not self._warming_up: - self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) - else: - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) - # If the setpoint is high and the HVAC is not off, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 1e1c67f6..7c428d16 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -30,10 +30,11 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non _LOGGER.debug("Initialized minimum setpoint to boiler temperature: %.1f°C", boiler_temperature) old_setpoint = self.current_minimum_setpoint + target_setpoint = min(requested_setpoint, boiler_temperature - 2) adjustment_factor = 0.0 if self._adjustments < 6 else self._adjustment_factor # Gradually adjust the setpoint toward the requested setpoint - if self.current_minimum_setpoint < requested_setpoint: + if self.current_minimum_setpoint < target_setpoint: self.current_minimum_setpoint = min(self.current_minimum_setpoint + adjustment_factor, requested_setpoint) else: self.current_minimum_setpoint = max(self.current_minimum_setpoint - adjustment_factor, requested_setpoint) From 5e01d6859bacded1dba506e5da9ae6739ccdc163 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 23:08:21 +0100 Subject: [PATCH 011/194] Cleaning up --- custom_components/sat/climate.py | 2 +- custom_components/sat/const.py | 1 + custom_components/sat/minimum_setpoint.py | 6 ++++-- custom_components/sat/mqtt/opentherm.py | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5e6b381c..6c96e33b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -756,7 +756,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._dynamic_minimum_setpoint: self._setpoint = self._coordinator.minimum_setpoint else: - self._setpoint = self.adjusted_minimum_setpoint - 2 + self._setpoint = self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index a9026ea3..ff88bf0c 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -16,6 +16,7 @@ MODE_SIMULATOR = "simulator" DEADBAND = 0.1 +BOILER_TEMPERATURE_OFFSET = 2 HEATER_STARTUP_TIMEFRAME = 180 MINIMUM_SETPOINT = 10 diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 7c428d16..c9872895 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,5 +1,7 @@ import logging +from .const import BOILER_TEMPERATURE_OFFSET + _LOGGER = logging.getLogger(__name__) @@ -30,8 +32,8 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non _LOGGER.debug("Initialized minimum setpoint to boiler temperature: %.1f°C", boiler_temperature) old_setpoint = self.current_minimum_setpoint - target_setpoint = min(requested_setpoint, boiler_temperature - 2) - adjustment_factor = 0.0 if self._adjustments < 6 else self._adjustment_factor + adjustment_factor = 0.0 if self._adjustments <= 5 else self._adjustment_factor + target_setpoint = min(requested_setpoint, boiler_temperature - BOILER_TEMPERATURE_OFFSET) # Gradually adjust the setpoint toward the requested setpoint if self.current_minimum_setpoint < target_setpoint: diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index 12d6d74d..d5f6bcdc 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -145,7 +145,7 @@ async def boot(self) -> None: await self._publish_command("PM=3") await self._publish_command("PM=48") - if isinstance(self.manufacturer, Ideal) or isinstance(self.manufacturer, Intergas): + if isinstance(self.manufacturer, (Ideal, Intergas)): await self._publish_command("MI=500") def get_tracked_entities(self) -> list[str]: From 5233d611c93201bf57cc2c42a0a66b9ef3747058 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 22 Dec 2024 23:12:52 +0100 Subject: [PATCH 012/194] Make sure we only calculate when requesting heat --- custom_components/sat/climate.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6c96e33b..1929e61b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -875,13 +875,6 @@ async def async_control_heating_loop(self, _time=None) -> None: else: self.pwm.reset() - # Control our dynamic minimum setpoint - if not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: - if not self._warming_up: - self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) - else: - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) - # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -897,6 +890,13 @@ async def async_control_heating_loop(self, _time=None) -> None: # If the setpoint is high and the HVAC is not off, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) + # Control our dynamic minimum setpoint + if self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: + if not self._warming_up: + self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) + else: + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + self.async_write_ha_state() async def async_set_heater_state(self, state: DeviceState): From 5b84bc202caa565489028a6e0423ac9a01dc3edb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:15:17 +0100 Subject: [PATCH 013/194] Improve the minimum setpoint more --- custom_components/sat/climate.py | 2 +- custom_components/sat/config_flow.py | 2 +- custom_components/sat/const.py | 4 +- custom_components/sat/minimum_setpoint.py | 81 +++++++++++++++-------- custom_components/sat/pwm.py | 10 +-- custom_components/sat/util.py | 4 +- 6 files changed, 64 insertions(+), 39 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 1929e61b..770d9e59 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -774,7 +774,7 @@ async def _async_control_relative_modulation(self) -> None: return # Update relative modulation state - await self._relative_modulation.update(self._warming_up, self.pwm.state) + await self._relative_modulation.update(self.max_error > DEADBAND, self.pwm.state) # Determine if the value needs to be updated if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 1b112904..464ccb7a 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -570,7 +570,7 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: - schema[vol.Required(CONF_MINIMUM_SETPOINT_ADJUSTMENT, default=options[CONF_MINIMUM_SETPOINT_ADJUSTMENT])] = selector.NumberSelector( + schema[vol.Required(CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR, default=options[CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR])] = selector.NumberSelector( selector.NumberSelectorConfig(min=0.1, max=1.0, step=0.1) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index ff88bf0c..653cf5f5 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -66,7 +66,7 @@ CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" -CONF_MINIMUM_SETPOINT_ADJUSTMENT = "minimum_setpoint_adjustment" +CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR = "minimum_setpoint_smoothing_factor" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" @@ -105,7 +105,7 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, - CONF_MINIMUM_SETPOINT_ADJUSTMENT: 0.5, + CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR: 0.2, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index c9872895..4057dbf9 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,4 +1,5 @@ import logging +from time import time from .const import BOILER_TEMPERATURE_OFFSET @@ -6,48 +7,76 @@ class MinimumSetpoint: - def __init__(self, adjustment: float, configured_minimum_setpoint: float): + def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 30): """Initialize the MinimumSetpoint class.""" - self._setpoint = None - self._adjustments = 0 - self._adjustment_factor = adjustment + self._last_adjustment_time = None + self._smoothing_factor = smoothing_factor + self._adjustment_delay = adjustment_delay - self.current_minimum_setpoint = None - self.configured_minimum_setpoint: float = configured_minimum_setpoint + self._current = None + self._configured = configured + + @staticmethod + def _calculate_threshold(boiler_temperature: float, target_setpoint: float) -> float: + """Calculate the threshold to determine adjustment method.""" + return max(1.0, 0.1 * abs(boiler_temperature - target_setpoint)) def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: - """Adjust the setpoint to trigger the boiler flame during warm-up if needed.""" + """Set the minimum setpoint to trigger the boiler flame during warm-up.""" if flame_active: - _LOGGER.debug("Flame is already active. No adjustment needed.") + _LOGGER.debug("Flame is already active. Skipping warm-up adjustment.") return - self._adjustments = 0 - self.current_minimum_setpoint = boiler_temperature + 10 - _LOGGER.debug("Setpoint adjusted for warm-up: %.1f°C", self.current_minimum_setpoint) + self._last_adjustment_time = time() + self._current = boiler_temperature + 10 + + _LOGGER.debug( + "Warm-up adjustment applied: %.1f°C (boiler temperature: %.1f°C)", + self._current, boiler_temperature + ) def calculate(self, requested_setpoint: float, boiler_temperature: float) -> None: - """Calculate and adjust the minimum setpoint gradually toward the requested setpoint.""" - if self.current_minimum_setpoint is None: - self.current_minimum_setpoint = boiler_temperature - _LOGGER.debug("Initialized minimum setpoint to boiler temperature: %.1f°C", boiler_temperature) + """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" + if self._current is None: + self._initialize_setpoint(boiler_temperature) - old_setpoint = self.current_minimum_setpoint - adjustment_factor = 0.0 if self._adjustments <= 5 else self._adjustment_factor + old_value = self._current target_setpoint = min(requested_setpoint, boiler_temperature - BOILER_TEMPERATURE_OFFSET) - # Gradually adjust the setpoint toward the requested setpoint - if self.current_minimum_setpoint < target_setpoint: - self.current_minimum_setpoint = min(self.current_minimum_setpoint + adjustment_factor, requested_setpoint) + # Determine adjustment method based on a threshold + threshold = self._calculate_threshold(boiler_temperature, target_setpoint) + adjustment_factor = 0.5 if self._should_apply_adjustment() else 0.0 + use_smoothing = abs(target_setpoint - self._current) > threshold + + if use_smoothing: + self._current += self._smoothing_factor * (target_setpoint - self._current) else: - self.current_minimum_setpoint = max(self.current_minimum_setpoint - adjustment_factor, requested_setpoint) + if self._current < target_setpoint: + self._current = min(self._current + adjustment_factor, target_setpoint) + else: + self._current = max(self._current - adjustment_factor, target_setpoint) _LOGGER.debug( - "Changing minimum setpoint: %.1f°C => %.1f°C (requested: %.1f°C, adjustment factor: %.1f)", - old_setpoint, self.current_minimum_setpoint, requested_setpoint, adjustment_factor + "Minimum setpoint adjusted (%.1f°C => %.1f°C). Type: %s, Target: %.1f°C, Threshold: %.1f, Adjustment Factor: %.1f", + old_value, self._current, + "smoothing" if use_smoothing else "incremental", + target_setpoint, threshold, adjustment_factor ) - self._adjustments += 1 + def _should_apply_adjustment(self) -> bool: + """Check if the adjustment factor can be applied based on delay.""" + if self._last_adjustment_time is None: + return False + + return (time() - self._last_adjustment_time) > self._adjustment_delay + + def _initialize_setpoint(self, boiler_temperature: float) -> None: + """Initialize the current minimum setpoint if it is not already set.""" + self._last_adjustment_time = time() + self._current = boiler_temperature + + _LOGGER.info("Initial minimum setpoint set to boiler temperature: %.1f°C", boiler_temperature) def current(self) -> float: - """Get the current minimum setpoint.""" - return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint + """Return the current minimum setpoint.""" + return self._current or self._configured diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 2494806e..7712b437 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -86,9 +86,7 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: # Update boiler temperature if heater has just started up if self._state == PWMState.ON: if elapsed <= HEATER_STARTUP_TIMEFRAME: - self._last_boiler_temperature = ( - self._alpha * boiler.temperature + (1 - self._alpha) * self._last_boiler_temperature - ) + self._last_boiler_temperature = (self._alpha * boiler.temperature + (1 - self._alpha) * self._last_boiler_temperature) _LOGGER.debug("Updated last boiler temperature with weighted average during startup phase.") else: @@ -96,8 +94,7 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: _LOGGER.debug("Updated last boiler temperature to %.1f°C", boiler.temperature) # State transitions for PWM - if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and ( - elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): + if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): if self._first_duty_cycle_start is None: self._first_duty_cycle_start = monotonic() @@ -112,8 +109,7 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: _LOGGER.info("Starting new duty cycle (ON state). Current CYCLES count: %d", self._cycles) return - if self._state != PWMState.OFF and ( - self._duty_cycle[0] < HEATER_STARTUP_TIMEFRAME or elapsed >= self._duty_cycle[0] or self._state == PWMState.IDLE): + if self._state != PWMState.OFF and (self._duty_cycle[0] < HEATER_STARTUP_TIMEFRAME or elapsed >= self._duty_cycle[0] or self._state == PWMState.IDLE): self._state = PWMState.OFF self._last_update = monotonic() _LOGGER.info("Duty cycle completed. Switching to OFF state.") diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 338496b7..0b04e3c5 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -93,9 +93,9 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy def create_minimum_setpoint_controller(config_data, config_options) -> MinimumSetpoint: minimum_setpoint = config_data.get(CONF_MINIMUM_SETPOINT) - adjustment = config_options.get(CONF_MINIMUM_SETPOINT_ADJUSTMENT) + smoothing_factor = config_options.get(CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR) - return MinimumSetpoint(configured_minimum_setpoint=minimum_setpoint, adjustment=adjustment) + return MinimumSetpoint(configured=minimum_setpoint, smoothing_factor=smoothing_factor) def snake_case(value: str) -> str: From cafcf53055b3cf8a3c19b250da6523715f3bdc83 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:16:46 +0100 Subject: [PATCH 014/194] Make sure we round the minimum setpoint --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 4057dbf9..8870ac5d 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -79,4 +79,4 @@ def _initialize_setpoint(self, boiler_temperature: float) -> None: def current(self) -> float: """Return the current minimum setpoint.""" - return self._current or self._configured + return round(self._current, 1) or self._configured From cceab92f9e2889c71de9bb3e0dd57449725a8bcd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:17:56 +0100 Subject: [PATCH 015/194] Some sanity checks --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 8870ac5d..f77a10b9 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -79,4 +79,4 @@ def _initialize_setpoint(self, boiler_temperature: float) -> None: def current(self) -> float: """Return the current minimum setpoint.""" - return round(self._current, 1) or self._configured + return round(self._current, 1) if self._current is not None else self._configured From 1c629c6ee2ff453a4e4d62f04744fb293aed7591 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:26:35 +0100 Subject: [PATCH 016/194] Improved the logic to turn off "warming up" --- custom_components/sat/climate.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 770d9e59..a9825594 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -844,18 +844,20 @@ async def async_control_heating_loop(self, _time=None) -> None: # Handle warming-up logic if self._warming_up: + boiler_temperature_change = self._last_boiler_temperature > self._coordinator.filtered_boiler_temperature + # Check if the boiler temperature is decreasing, indicating warming-up is complete - if self._last_boiler_temperature is not None and self._last_boiler_temperature > self._coordinator.boiler_temperature: + if self._last_boiler_temperature is not None and boiler_temperature_change > 2.0: self._warming_up = False _LOGGER.debug( "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C", self._last_boiler_temperature, - self._coordinator.boiler_temperature + self._coordinator.filtered_boiler_temperature ) else: # Update the last known boiler temperature - self._last_boiler_temperature = self._coordinator.boiler_temperature + self._last_boiler_temperature = self._coordinator.filtered_boiler_temperature _LOGGER.debug( "Warming-up in progress. Updated last boiler temperature to: %.1f°C", From 7677600425f09142cacbe0c2bf9f34f503126a1c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:27:15 +0100 Subject: [PATCH 017/194] Typo? --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index a9825594..b010520d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -844,7 +844,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Handle warming-up logic if self._warming_up: - boiler_temperature_change = self._last_boiler_temperature > self._coordinator.filtered_boiler_temperature + boiler_temperature_change = self._coordinator.filtered_boiler_temperature - self._last_boiler_temperature # Check if the boiler temperature is decreasing, indicating warming-up is complete if self._last_boiler_temperature is not None and boiler_temperature_change > 2.0: From e4310feed4f1fba39406f7188c09eb7f87d536c9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:28:34 +0100 Subject: [PATCH 018/194] Some sanity checks --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b010520d..b6312819 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -843,11 +843,11 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Handle warming-up logic - if self._warming_up: + if self._warming_up and self._coordinator.filtered_boiler_temperature is not None: boiler_temperature_change = self._coordinator.filtered_boiler_temperature - self._last_boiler_temperature # Check if the boiler temperature is decreasing, indicating warming-up is complete - if self._last_boiler_temperature is not None and boiler_temperature_change > 2.0: + if boiler_temperature_change > 2.0: self._warming_up = False _LOGGER.debug( From 36cc182adffbb76d50ed08aa0db05ce474d49d00 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:31:04 +0100 Subject: [PATCH 019/194] Some more sanity checks --- custom_components/sat/climate.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b6312819..f3236f32 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -844,20 +844,26 @@ async def async_control_heating_loop(self, _time=None) -> None: # Handle warming-up logic if self._warming_up and self._coordinator.filtered_boiler_temperature is not None: - boiler_temperature_change = self._coordinator.filtered_boiler_temperature - self._last_boiler_temperature + current_boiler_temperature = self._coordinator.filtered_boiler_temperature + + # Initialize the last boiler temperature if it's not already set + if self._last_boiler_temperature is None: + self._last_boiler_temperature = current_boiler_temperature + + # Calculate the change in boiler temperature + boiler_temperature_change = current_boiler_temperature - self._last_boiler_temperature - # Check if the boiler temperature is decreasing, indicating warming-up is complete if boiler_temperature_change > 2.0: + # The Warming-up phase is complete self._warming_up = False _LOGGER.debug( "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C", - self._last_boiler_temperature, - self._coordinator.filtered_boiler_temperature + self._last_boiler_temperature, current_boiler_temperature ) else: # Update the last known boiler temperature - self._last_boiler_temperature = self._coordinator.filtered_boiler_temperature + self._last_boiler_temperature = current_boiler_temperature _LOGGER.debug( "Warming-up in progress. Updated last boiler temperature to: %.1f°C", From 06140949d4e414fa0a01a12b90b8bbef94bde7c9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:40:41 +0100 Subject: [PATCH 020/194] Wait a bit longer for adjustments --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index f77a10b9..47eb2f65 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -7,7 +7,7 @@ class MinimumSetpoint: - def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 30): + def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 60): """Initialize the MinimumSetpoint class.""" self._last_adjustment_time = None self._smoothing_factor = smoothing_factor From 373608d9ee678687fe213c60db9e4269544fdd2e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:43:07 +0100 Subject: [PATCH 021/194] Do not use filtered temperatures since (should) change fast --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/minimum_setpoint.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index f3236f32..e181ba5a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -843,8 +843,8 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Handle warming-up logic - if self._warming_up and self._coordinator.filtered_boiler_temperature is not None: - current_boiler_temperature = self._coordinator.filtered_boiler_temperature + if self._warming_up and self._coordinator.boiler_temperature is not None: + current_boiler_temperature = self._coordinator.boiler_temperature # Initialize the last boiler temperature if it's not already set if self._last_boiler_temperature is None: diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 47eb2f65..f77a10b9 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -7,7 +7,7 @@ class MinimumSetpoint: - def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 60): + def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 30): """Initialize the MinimumSetpoint class.""" self._last_adjustment_time = None self._smoothing_factor = smoothing_factor From e3ffa51e4ff3d850627ec7b3b075fbfce0f91cc1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 20:55:16 +0100 Subject: [PATCH 022/194] Reduce the "change" --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e181ba5a..3ebd9c88 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -853,7 +853,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Calculate the change in boiler temperature boiler_temperature_change = current_boiler_temperature - self._last_boiler_temperature - if boiler_temperature_change > 2.0: + if boiler_temperature_change >= 1.0: # The Warming-up phase is complete self._warming_up = False From 8570be2868ae162a5c5b556d957f065638fc873e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 21:06:42 +0100 Subject: [PATCH 023/194] Change the order (again) --- custom_components/sat/climate.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3ebd9c88..47ef0845 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -883,6 +883,13 @@ async def async_control_heating_loop(self, _time=None) -> None: else: self.pwm.reset() + # Control our dynamic minimum setpoint + if self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: + if not self._warming_up: + self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) + else: + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -898,13 +905,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # If the setpoint is high and the HVAC is not off, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) - # Control our dynamic minimum setpoint - if self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: - if not self._warming_up: - self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) - else: - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) - self.async_write_ha_state() async def async_set_heater_state(self, state: DeviceState): From 433a4a2b02e0cdd367bed278ac9b8a9c16b8a94a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 21:08:41 +0100 Subject: [PATCH 024/194] Added some sanity checks --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 47ef0845..6e19090d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -884,7 +884,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self.pwm.reset() # Control our dynamic minimum setpoint - if self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: + if self._setpoint is not None and self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: if not self._warming_up: self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) else: From 67f16c752264fa474c4266058d5bf6f0aa9b2196 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 21:48:55 +0100 Subject: [PATCH 025/194] Improved the PWM indicator without the need for OPV --- custom_components/sat/climate.py | 23 ++++++++++------------- custom_components/sat/pwm.py | 7 +++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6e19090d..6a59241b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -518,7 +518,7 @@ def pulse_width_modulation_enabled(self) -> bool: if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True - return self._overshoot_protection and self._calculate_control_setpoint() < self.minimum_setpoint + return self._overshoot_protection and self.pwm.enabled @property def relative_modulation_value(self) -> int: @@ -747,7 +747,6 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) self._setpoint = self._calculated_setpoint - else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) @@ -870,18 +869,16 @@ async def async_control_heating_loop(self, _time=None) -> None: self._last_boiler_temperature ) - # Pulse Width Modulation - if self.pulse_width_modulation_enabled: - boiler_state = BoilerState( - flame_active=self._coordinator.flame_active, - device_active=self._coordinator.device_active, - hot_water_active=self._coordinator.hot_water_active, - temperature=self._coordinator.boiler_temperature - ) + # Create a value object that contains most boiler values + boiler_state = BoilerState( + flame_active=self._coordinator.flame_active, + device_active=self._coordinator.device_active, + hot_water_active=self._coordinator.hot_water_active, + temperature=self._coordinator.boiler_temperature + ) - await self.pwm.update(self._calculated_setpoint, boiler_state) - else: - self.pwm.reset() + # Pulse Width Modulation + await self.pwm.update(self._calculated_setpoint, boiler_state) # Control our dynamic minimum setpoint if self._setpoint is not None and self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 7712b437..d5adc537 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -203,6 +203,13 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug("Maximum duty cycle exceeded. Setting on_time: %d seconds, off_time: %d seconds.", on_time, off_time) return int(on_time), int(off_time) + @property + def enabled(self) -> bool: + if self._last_duty_cycle_percentage is None: + return False + + return self._last_duty_cycle_percentage <= self._max_duty_cycle_percentage + @property def state(self) -> PWMState: """Current PWM state.""" From f6e0a949ae60822cfc26c2e4af3218731729db8f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 23 Dec 2024 23:28:53 +0100 Subject: [PATCH 026/194] Improved indicator of when going to "warming up" --- custom_components/sat/climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6a59241b..00bc659c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -869,6 +869,10 @@ async def async_control_heating_loop(self, _time=None) -> None: self._last_boiler_temperature ) + if not self._coordinator.flame_active and self._calculated_setpoint > MINIMUM_SETPOINT: + self._warming_up = True + self._last_boiler_temperature = None + # Create a value object that contains most boiler values boiler_state = BoilerState( flame_active=self._coordinator.flame_active, @@ -881,7 +885,7 @@ async def async_control_heating_loop(self, _time=None) -> None: await self.pwm.update(self._calculated_setpoint, boiler_state) # Control our dynamic minimum setpoint - if self._setpoint is not None and self._setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: + if self._setpoint is not None and self._calculated_setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: if not self._warming_up: self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) else: @@ -909,10 +913,6 @@ async def async_set_heater_state(self, state: DeviceState): _LOGGER.debug("Attempting to set heater state to: %s", state) if state == DeviceState.ON: - if not self._coordinator.flame_active: - self._warming_up = True - self._last_boiler_temperature = None - if self._coordinator.device_active: _LOGGER.info("Heater is already active. No action taken.") return From 888b102ee5c29b3811445bd7c5e1bb42f60479e4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 15:41:04 +0100 Subject: [PATCH 027/194] Applied "boiler_temperature_change" fix --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 00bc659c..e6423af8 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -850,7 +850,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self._last_boiler_temperature = current_boiler_temperature # Calculate the change in boiler temperature - boiler_temperature_change = current_boiler_temperature - self._last_boiler_temperature + boiler_temperature_change = self._last_boiler_temperature - current_boiler_temperature if boiler_temperature_change >= 1.0: # The Warming-up phase is complete From 3531c5f965eb860f50b5f72976ca6278208a7d97 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 15:59:23 +0100 Subject: [PATCH 028/194] Add some error handling for Sentry --- custom_components/sat/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 6de2df7a..bf6a16a3 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -74,9 +74,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await asyncio.gather(hass.config_entries.async_unload_platforms(entry, PLATFORMS)) ) - if SENTRY in hass.data[DOMAIN]: - hass.data[DOMAIN][SENTRY].flush() - hass.data[DOMAIN][SENTRY].close() + try: + if SENTRY in hass.data[DOMAIN]: + hass.data[DOMAIN][SENTRY].flush() + hass.data[DOMAIN][SENTRY].close() + hass.data[DOMAIN].pop(SENTRY, None) + except Exception as ex: + _LOGGER.error("Error during Sentry cleanup: %s", str(ex)) # Remove the entry from the data dictionary if all components are unloaded successfully if unloaded: From ffef38f6f49f67799799c2fe1de3db0f3d6b1bf7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 17:41:38 +0100 Subject: [PATCH 029/194] Remove smoothing support --- custom_components/sat/climate.py | 5 +++-- custom_components/sat/config_flow.py | 5 ----- custom_components/sat/const.py | 1 - custom_components/sat/minimum_setpoint.py | 24 ++++++++--------------- custom_components/sat/util.py | 8 -------- 5 files changed, 11 insertions(+), 32 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e6423af8..a9a0bafa 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,10 +40,11 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity +from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, create_minimum_setpoint_controller +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -181,7 +182,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) # Create the Minimum Setpoint controller - self._minimum_setpoint = create_minimum_setpoint_controller(config_entry.data, config_options) + self._minimum_setpoint = MinimumSetpoint() # Create Relative Modulation controller self._relative_modulation = RelativeModulation(coordinator, self._heating_system) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 464ccb7a..ac981c1e 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -569,11 +569,6 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str - if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: - schema[vol.Required(CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR, default=options[CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR])] = selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=1.0, step=0.1) - ) - if not options[CONF_AUTOMATIC_DUTY_CYCLE]: schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 918c155b..fd995e4e 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -65,7 +65,6 @@ CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" -CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR = "minimum_setpoint_smoothing_factor" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index f77a10b9..46ca51db 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,20 +1,18 @@ import logging from time import time -from .const import BOILER_TEMPERATURE_OFFSET +from .const import BOILER_TEMPERATURE_OFFSET, MINIMUM_SETPOINT _LOGGER = logging.getLogger(__name__) class MinimumSetpoint: - def __init__(self, configured: float, smoothing_factor: float, adjustment_delay: int = 30): + def __init__(self, adjustment_delay: int = 30): """Initialize the MinimumSetpoint class.""" self._last_adjustment_time = None - self._smoothing_factor = smoothing_factor self._adjustment_delay = adjustment_delay self._current = None - self._configured = configured @staticmethod def _calculate_threshold(boiler_temperature: float, target_setpoint: float) -> float: @@ -46,21 +44,15 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non # Determine adjustment method based on a threshold threshold = self._calculate_threshold(boiler_temperature, target_setpoint) adjustment_factor = 0.5 if self._should_apply_adjustment() else 0.0 - use_smoothing = abs(target_setpoint - self._current) > threshold - if use_smoothing: - self._current += self._smoothing_factor * (target_setpoint - self._current) + if self._current < target_setpoint: + self._current = min(self._current + adjustment_factor, target_setpoint) else: - if self._current < target_setpoint: - self._current = min(self._current + adjustment_factor, target_setpoint) - else: - self._current = max(self._current - adjustment_factor, target_setpoint) + self._current = max(self._current - adjustment_factor, target_setpoint) _LOGGER.debug( - "Minimum setpoint adjusted (%.1f°C => %.1f°C). Type: %s, Target: %.1f°C, Threshold: %.1f, Adjustment Factor: %.1f", - old_value, self._current, - "smoothing" if use_smoothing else "incremental", - target_setpoint, threshold, adjustment_factor + "Minimum setpoint adjusted (%.1f°C => %.1f°C). Target: %.1f°C, Threshold: %.1f, Adjustment Factor: %.1f", + old_value, self._current, target_setpoint, threshold, adjustment_factor ) def _should_apply_adjustment(self) -> bool: @@ -79,4 +71,4 @@ def _initialize_setpoint(self, boiler_temperature: float) -> None: def current(self) -> float: """Return the current minimum setpoint.""" - return round(self._current, 1) if self._current is not None else self._configured + return round(self._current, 1) if self._current is not None else MINIMUM_SETPOINT diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 0b04e3c5..44f9f6c2 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -6,7 +6,6 @@ from .const import * from .heating_curve import HeatingCurve -from .minimum_setpoint import MinimumSetpoint from .pid import PID from .pwm import PWM @@ -91,13 +90,6 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, max_cycles=max_duty_cycles, force=force) -def create_minimum_setpoint_controller(config_data, config_options) -> MinimumSetpoint: - minimum_setpoint = config_data.get(CONF_MINIMUM_SETPOINT) - smoothing_factor = config_options.get(CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR) - - return MinimumSetpoint(configured=minimum_setpoint, smoothing_factor=smoothing_factor) - - def snake_case(value: str) -> str: return '_'.join( sub('([A-Z][a-z]+)', r' \1', From d40c96a54c8afedfa5bee94687f5847b8415953d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 17:42:18 +0100 Subject: [PATCH 030/194] Cleanup --- custom_components/sat/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index fd995e4e..1cdf03d6 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -103,7 +103,6 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, - CONF_MINIMUM_SETPOINT_SMOOTHING_FACTOR: 0.2, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], From c5e8041cbbdccded7d2635f4ed1d861631912861 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 17:48:28 +0100 Subject: [PATCH 031/194] Make sure we do a final adjustment the warming up is complete --- custom_components/sat/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index a9a0bafa..afaf9a34 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -857,6 +857,9 @@ async def async_control_heating_loop(self, _time=None) -> None: # The Warming-up phase is complete self._warming_up = False + # Do a final adjustment + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + _LOGGER.debug( "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C", self._last_boiler_temperature, current_boiler_temperature From 2a94fd41acf4fc751c13b8c023a7b856cfe3aa61 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 17:49:35 +0100 Subject: [PATCH 032/194] Making sure we reset the timer when warming up and the flame is on --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 46ca51db..d5e8ec9f 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -22,10 +22,10 @@ def _calculate_threshold(boiler_temperature: float, target_setpoint: float) -> f def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: """Set the minimum setpoint to trigger the boiler flame during warm-up.""" if flame_active: + self._last_adjustment_time = time() _LOGGER.debug("Flame is already active. Skipping warm-up adjustment.") return - self._last_adjustment_time = time() self._current = boiler_temperature + 10 _LOGGER.debug( From 86241019307a50a12d7b3d846f0000c8b811e224 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 20:17:50 +0100 Subject: [PATCH 033/194] More minimum setpoint fixes --- custom_components/sat/climate.py | 31 +++++++------ custom_components/sat/config_flow.py | 7 ++- custom_components/sat/const.py | 2 + custom_components/sat/minimum_setpoint.py | 53 ++++++++++++----------- custom_components/sat/mqtt/__init__.py | 2 +- custom_components/sat/mqtt/opentherm.py | 1 + custom_components/sat/util.py | 7 +++ 7 files changed, 61 insertions(+), 42 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index afaf9a34..4e6b29b3 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,11 +40,10 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity -from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, create_minimum_setpoint_controller ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -182,7 +181,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) # Create the Minimum Setpoint controller - self._minimum_setpoint = MinimumSetpoint() + self._minimum_setpoint = create_minimum_setpoint_controller(config_options) # Create Relative Modulation controller self._relative_modulation = RelativeModulation(coordinator, self._heating_system) @@ -747,7 +746,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: # Normal cycle without PWM _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) - self._setpoint = self._calculated_setpoint + self._setpoint = min(self._calculated_setpoint, self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET) else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) @@ -756,7 +755,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._dynamic_minimum_setpoint: self._setpoint = self._coordinator.minimum_setpoint else: - self._setpoint = self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET + self._setpoint = self.adjusted_minimum_setpoint _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: @@ -844,14 +843,12 @@ async def async_control_heating_loop(self, _time=None) -> None: # Handle warming-up logic if self._warming_up and self._coordinator.boiler_temperature is not None: - current_boiler_temperature = self._coordinator.boiler_temperature - # Initialize the last boiler temperature if it's not already set if self._last_boiler_temperature is None: - self._last_boiler_temperature = current_boiler_temperature + self._last_boiler_temperature = self._coordinator.boiler_temperature # Calculate the change in boiler temperature - boiler_temperature_change = self._last_boiler_temperature - current_boiler_temperature + boiler_temperature_change = self._last_boiler_temperature - self._coordinator.boiler_temperature if boiler_temperature_change >= 1.0: # The Warming-up phase is complete @@ -861,12 +858,12 @@ async def async_control_heating_loop(self, _time=None) -> None: self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) _LOGGER.debug( - "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C", - self._last_boiler_temperature, current_boiler_temperature + "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C, Change: %.1f°C", + self._last_boiler_temperature, self._coordinator.boiler_temperature, boiler_temperature_change ) else: # Update the last known boiler temperature - self._last_boiler_temperature = current_boiler_temperature + self._last_boiler_temperature = self._coordinator.boiler_temperature _LOGGER.debug( "Warming-up in progress. Updated last boiler temperature to: %.1f°C", @@ -891,9 +888,15 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control our dynamic minimum setpoint if self._setpoint is not None and self._calculated_setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: if not self._warming_up: - self._minimum_setpoint.calculate(self.requested_setpoint, self._coordinator.boiler_temperature) + self._minimum_setpoint.calculate( + requested_setpoint=self.requested_setpoint, + boiler_temperature=self._coordinator.boiler_temperature + ) else: - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + self._minimum_setpoint.warming_up( + flame_active=self._coordinator.flame_active, + boiler_temperature=self._coordinator.boiler_temperature + ) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index ac981c1e..3793f5a2 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -569,6 +569,11 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str + if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: + schema[vol.Required(CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT, default=options[CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=120, step=5) + ) + if not options[CONF_AUTOMATIC_DUTY_CYCLE]: schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() @@ -663,7 +668,7 @@ async def async_step_advanced(self, _user_input: dict[str, Any] | None = None): vol.Required(CONF_DYNAMIC_MINIMUM_SETPOINT, default=options[CONF_DYNAMIC_MINIMUM_SETPOINT]): bool, } - if options.get(CONF_MODE) in [MODE_MQTT_OPENTHERM, MODE_SERIAL, MODE_SIMULATOR]: + if self._config_entry.data.get(CONF_MODE) in [MODE_MQTT_OPENTHERM, MODE_SERIAL, MODE_SIMULATOR]: schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_MINIMUM_CONSUMPTION, default=options[CONF_MINIMUM_CONSUMPTION])] = selector.NumberSelector( diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 1cdf03d6..d05b186b 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -65,6 +65,7 @@ CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" +CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT = "minimum_setpoint_delay_adjustment" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" @@ -103,6 +104,7 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, + CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT: 30, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index d5e8ec9f..b3e263dd 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -4,71 +4,72 @@ from .const import BOILER_TEMPERATURE_OFFSET, MINIMUM_SETPOINT _LOGGER = logging.getLogger(__name__) +ADJUSTMENT_FACTOR = 0.5 class MinimumSetpoint: - def __init__(self, adjustment_delay: int = 30): + def __init__(self, adjustment_delay: int): """Initialize the MinimumSetpoint class.""" - self._last_adjustment_time = None + self._warmed_up_time = None self._adjustment_delay = adjustment_delay self._current = None - @staticmethod - def _calculate_threshold(boiler_temperature: float, target_setpoint: float) -> float: - """Calculate the threshold to determine adjustment method.""" - return max(1.0, 0.1 * abs(boiler_temperature - target_setpoint)) - def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: """Set the minimum setpoint to trigger the boiler flame during warm-up.""" if flame_active: - self._last_adjustment_time = time() _LOGGER.debug("Flame is already active. Skipping warm-up adjustment.") return self._current = boiler_temperature + 10 - _LOGGER.debug( - "Warm-up adjustment applied: %.1f°C (boiler temperature: %.1f°C)", + _LOGGER.info( + "Warm-up adjustment applied: %.1f°C (Boiler Temperature: %.1f°C)", self._current, boiler_temperature ) def calculate(self, requested_setpoint: float, boiler_temperature: float) -> None: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" - if self._current is None: + if self._current is None or self._warmed_up_time is None: self._initialize_setpoint(boiler_temperature) - old_value = self._current - target_setpoint = min(requested_setpoint, boiler_temperature - BOILER_TEMPERATURE_OFFSET) + if not self._should_apply_adjustment(): + _LOGGER.debug("Adjustment skipped. Waiting for adjustment delay (%d seconds).", self._adjustment_delay) + return - # Determine adjustment method based on a threshold - threshold = self._calculate_threshold(boiler_temperature, target_setpoint) - adjustment_factor = 0.5 if self._should_apply_adjustment() else 0.0 + old_value = self._current + target_setpoint = boiler_temperature - BOILER_TEMPERATURE_OFFSET if self._current < target_setpoint: - self._current = min(self._current + adjustment_factor, target_setpoint) + self._current = min(self._current + ADJUSTMENT_FACTOR, target_setpoint) else: - self._current = max(self._current - adjustment_factor, target_setpoint) + self._current = max(self._current - ADJUSTMENT_FACTOR, target_setpoint) - _LOGGER.debug( - "Minimum setpoint adjusted (%.1f°C => %.1f°C). Target: %.1f°C, Threshold: %.1f, Adjustment Factor: %.1f", - old_value, self._current, target_setpoint, threshold, adjustment_factor + _LOGGER.info( + "Minimum setpoint changed (%.1f°C => %.1f°C). Boiler Temperature: %.1f°C, Requested Setpoint: %.1f°C, Target: %.1f°C", + old_value, boiler_temperature, requested_setpoint, self._current, target_setpoint ) def _should_apply_adjustment(self) -> bool: """Check if the adjustment factor can be applied based on delay.""" - if self._last_adjustment_time is None: + if self._warmed_up_time is None: return False - return (time() - self._last_adjustment_time) > self._adjustment_delay + return (time() - self._warmed_up_time) > self._adjustment_delay def _initialize_setpoint(self, boiler_temperature: float) -> None: """Initialize the current minimum setpoint if it is not already set.""" - self._last_adjustment_time = time() + self._warmed_up_time = time() self._current = boiler_temperature - _LOGGER.info("Initial minimum setpoint set to boiler temperature: %.1f°C", boiler_temperature) + _LOGGER.info( + "Initial minimum setpoint set to boiler temperature: %.1f°C. Time: %.1f", + boiler_temperature, self._warmed_up_time + ) def current(self) -> float: """Return the current minimum setpoint.""" - return round(self._current, 1) if self._current is not None else MINIMUM_SETPOINT + if self._current is not None: + return round(self._current, 1) + + return MINIMUM_SETPOINT diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 65c7ef33..a540afb1 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -112,7 +112,7 @@ def _process_message_payload(self, key: str, payload): """Process and store the payload of a received MQTT message.""" self.data[key] = payload - async def _publish_command(self, payload: str, wait_time: float = 0.5): + async def _publish_command(self, payload: str, wait_time: float = 1.0): """Publish a command to the MQTT topic.""" topic = self._get_topic_for_publishing() diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index 40971146..96973159 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -173,6 +173,7 @@ def get_tracked_entities(self) -> list[str]: async def async_set_control_setpoint(self, value: float) -> None: await self._publish_command(f"CS={value}") + await self._publish_command(f"PM=25") await super().async_set_control_setpoint(value) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 44f9f6c2..8efa2b60 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -6,6 +6,7 @@ from .const import * from .heating_curve import HeatingCurve +from .minimum_setpoint import MinimumSetpoint from .pid import PID from .pwm import PWM @@ -90,6 +91,12 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, max_cycles=max_duty_cycles, force=force) +def create_minimum_setpoint_controller(config_options) -> MinimumSetpoint: + adjustment_delay = config_options.get(CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT) + + return MinimumSetpoint(adjustment_delay=adjustment_delay) + + def snake_case(value: str) -> str: return '_'.join( sub('([A-Z][a-z]+)', r' \1', From 6066e0e4e95c690a19e30f529a810c8f5ce20dd9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 20:20:15 +0100 Subject: [PATCH 034/194] Also assume the warming up is complete if we overshoot --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 4e6b29b3..f33daf0a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -850,7 +850,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Calculate the change in boiler temperature boiler_temperature_change = self._last_boiler_temperature - self._coordinator.boiler_temperature - if boiler_temperature_change >= 1.0: + if boiler_temperature_change >= 1.0 or self._coordinator.boiler_temperature > self._setpoint: # The Warming-up phase is complete self._warming_up = False From daa0d84000dd6002def55d7749ff389aecfa4895 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 20:45:50 +0100 Subject: [PATCH 035/194] Add support to loop when the boiler temperature has changed --- custom_components/sat/climate.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index f33daf0a..21a81ba0 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -192,6 +192,15 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn if self._simulation: _LOGGER.warning("Simulation mode!") + def async_track_coordinator_data(self): + """Track changes in the coordinator's boiler temperature and trigger the heating loop.""" + if self._last_boiler_temperature == self._coordinator.boiler_temperature: + return + + # Schedule an asynchronous task to control the heating loop + asyncio.create_task(self.async_control_heating_loop()) + self._last_boiler_temperature = self._coordinator.boiler_temperature + async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() @@ -227,6 +236,10 @@ async def _register_event_listeners(self): ) ) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_track_coordinator_data) + ) + self.async_on_remove( async_track_state_change_event( self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_changed @@ -872,7 +885,6 @@ async def async_control_heating_loop(self, _time=None) -> None: if not self._coordinator.flame_active and self._calculated_setpoint > MINIMUM_SETPOINT: self._warming_up = True - self._last_boiler_temperature = None # Create a value object that contains most boiler values boiler_state = BoilerState( From a48634fc7417e5bd85bf959ee3febd2c22f87a6c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 20:48:52 +0100 Subject: [PATCH 036/194] Ignore the warming up if we are turned off --- custom_components/sat/climate.py | 57 ++++++++++++++++---------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 21a81ba0..9ea15111 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -855,36 +855,37 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Handle warming-up logic - if self._warming_up and self._coordinator.boiler_temperature is not None: - # Initialize the last boiler temperature if it's not already set - if self._last_boiler_temperature is None: - self._last_boiler_temperature = self._coordinator.boiler_temperature - - # Calculate the change in boiler temperature - boiler_temperature_change = self._last_boiler_temperature - self._coordinator.boiler_temperature - - if boiler_temperature_change >= 1.0 or self._coordinator.boiler_temperature > self._setpoint: - # The Warming-up phase is complete - self._warming_up = False - - # Do a final adjustment - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) - - _LOGGER.debug( - "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C, Change: %.1f°C", - self._last_boiler_temperature, self._coordinator.boiler_temperature, boiler_temperature_change - ) - else: - # Update the last known boiler temperature - self._last_boiler_temperature = self._coordinator.boiler_temperature + if self._calculated_setpoint > MINIMUM_SETPOINT: + if self._warming_up and self._coordinator.boiler_temperature is not None: + # Initialize the last boiler temperature if it's not already set + if self._last_boiler_temperature is None: + self._last_boiler_temperature = self._coordinator.boiler_temperature + + # Calculate the change in boiler temperature + boiler_temperature_change = self._last_boiler_temperature - self._coordinator.boiler_temperature + + if boiler_temperature_change >= 1.0 or self._coordinator.boiler_temperature > self._setpoint: + # The Warming-up phase is complete + self._warming_up = False + + # Do a final adjustment + self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) + + _LOGGER.debug( + "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C, Change: %.1f°C", + self._last_boiler_temperature, self._coordinator.boiler_temperature, boiler_temperature_change + ) + else: + # Update the last known boiler temperature + self._last_boiler_temperature = self._coordinator.boiler_temperature - _LOGGER.debug( - "Warming-up in progress. Updated last boiler temperature to: %.1f°C", - self._last_boiler_temperature - ) + _LOGGER.debug( + "Warming-up in progress. Updated last boiler temperature to: %.1f°C", + self._last_boiler_temperature + ) - if not self._coordinator.flame_active and self._calculated_setpoint > MINIMUM_SETPOINT: - self._warming_up = True + if not self._coordinator.flame_active: + self._warming_up = True # Create a value object that contains most boiler values boiler_state = BoilerState( From cd9abb3a3fff070186c64d21364eb058685a80e6 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 24 Dec 2024 20:59:37 +0100 Subject: [PATCH 037/194] Cleanup --- custom_components/sat/minimum_setpoint.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index b3e263dd..c9af19a8 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -31,7 +31,8 @@ def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: def calculate(self, requested_setpoint: float, boiler_temperature: float) -> None: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" if self._current is None or self._warmed_up_time is None: - self._initialize_setpoint(boiler_temperature) + self._warmed_up_time = time() + self._current = requested_setpoint if not self._should_apply_adjustment(): _LOGGER.debug("Adjustment skipped. Waiting for adjustment delay (%d seconds).", self._adjustment_delay) @@ -57,16 +58,6 @@ def _should_apply_adjustment(self) -> bool: return (time() - self._warmed_up_time) > self._adjustment_delay - def _initialize_setpoint(self, boiler_temperature: float) -> None: - """Initialize the current minimum setpoint if it is not already set.""" - self._warmed_up_time = time() - self._current = boiler_temperature - - _LOGGER.info( - "Initial minimum setpoint set to boiler temperature: %.1f°C. Time: %.1f", - boiler_temperature, self._warmed_up_time - ) - def current(self) -> float: """Return the current minimum setpoint.""" if self._current is not None: From 7b4669354c751a90ba56b35f8f14247de29f9acc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 14:30:49 +0100 Subject: [PATCH 038/194] Typo? --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 9ea15111..37057c0f 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -759,7 +759,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: # Normal cycle without PWM _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) - self._setpoint = min(self._calculated_setpoint, self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET) + self._setpoint = max(self._calculated_setpoint, self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET) else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) From c151d190082c797099ad79d39fd77f109e77765c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 14:33:59 +0100 Subject: [PATCH 039/194] Fixed logging --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index c9af19a8..3c67e89a 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -48,7 +48,7 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non _LOGGER.info( "Minimum setpoint changed (%.1f°C => %.1f°C). Boiler Temperature: %.1f°C, Requested Setpoint: %.1f°C, Target: %.1f°C", - old_value, boiler_temperature, requested_setpoint, self._current, target_setpoint + old_value, self._current, boiler_temperature, requested_setpoint, target_setpoint ) def _should_apply_adjustment(self) -> bool: From d677414e9026716c135c788e8008460f9d7c7e45 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:10:32 +0100 Subject: [PATCH 040/194] Simplify and add support for device status --- custom_components/sat/climate.py | 101 +++++----------------- custom_components/sat/config_flow.py | 5 -- custom_components/sat/const.py | 2 - custom_components/sat/coordinator.py | 52 +++++++++-- custom_components/sat/minimum_setpoint.py | 47 ++-------- custom_components/sat/sensor.py | 15 ++++ custom_components/sat/util.py | 7 -- 7 files changed, 90 insertions(+), 139 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 37057c0f..ab9d4b2e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,10 +40,11 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity +from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, create_minimum_setpoint_controller +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -52,7 +53,6 @@ ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" ATTR_PRE_CUSTOM_TEMPERATURE = "pre_custom_temperature" ATTR_PRE_ACTIVITY_TEMPERATURE = "pre_activity_temperature" -ATTR_ADJUSTED_MINIMUM_SETPOINTS = "adjusted_minimum_setpoints" _LOGGER = logging.getLogger(__name__) @@ -122,9 +122,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensors = [] self._rooms = None self._setpoint = None - self._warming_up = False self._calculated_setpoint = None - self._last_boiler_temperature = None self._hvac_mode = None self._target_temperature = None @@ -171,35 +169,30 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) + # Create the Minimum Setpoint controller + self._minimum_setpoint = MinimumSetpoint() + # Create PID controller with given configuration options self.pid = create_pid_controller(config_options) + # Create Area controllers + self._areas = Areas(config_entry.data, config_options, self._climates) + + # Create Relative Modulation controller + self._relative_modulation = RelativeModulation(coordinator, self._heating_system) + # Create Heating Curve controller with given configuration options self.heating_curve = create_heating_curve_controller(config_entry.data, config_options) # Create PWM controller with given configuration options self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) - # Create the Minimum Setpoint controller - self._minimum_setpoint = create_minimum_setpoint_controller(config_options) - - # Create Relative Modulation controller - self._relative_modulation = RelativeModulation(coordinator, self._heating_system) - - # Create Area controllers - self._areas = Areas(config_entry.data, config_options, self._climates) - if self._simulation: _LOGGER.warning("Simulation mode!") def async_track_coordinator_data(self): - """Track changes in the coordinator's boiler temperature and trigger the heating loop.""" - if self._last_boiler_temperature == self._coordinator.boiler_temperature: - return - - # Schedule an asynchronous task to control the heating loop + """Track changes in the coordinator's data and trigger the heating loop.""" asyncio.create_task(self.async_control_heating_loop()) - self._last_boiler_temperature = self._coordinator.boiler_temperature async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" @@ -387,7 +380,6 @@ def extra_state_attributes(self): "heating_curve": self.heating_curve.value, "minimum_setpoint": self.minimum_setpoint, "requested_setpoint": self.requested_setpoint, - "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -543,14 +535,7 @@ def relative_modulation_state(self) -> RelativeModulationState: @property def minimum_setpoint(self) -> float: - if not self._dynamic_minimum_setpoint: - return self._coordinator.minimum_setpoint - - return min(self.adjusted_minimum_setpoint, self._coordinator.maximum_setpoint) - - @property - def adjusted_minimum_setpoint(self) -> float: - return self._minimum_setpoint.current() + return self._coordinator.minimum_setpoint def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" @@ -759,16 +744,22 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: # Normal cycle without PWM _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) - self._setpoint = max(self._calculated_setpoint, self.adjusted_minimum_setpoint - BOILER_TEMPERATURE_OFFSET) + + if self._dynamic_minimum_setpoint: + target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) + self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) + else: + self._setpoint = self._calculated_setpoint else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) if pwm_state == PWMState.ON and self.max_error > -DEADBAND: - if not self._dynamic_minimum_setpoint: - self._setpoint = self._coordinator.minimum_setpoint + if self._dynamic_minimum_setpoint: + target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature - 2) + self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) else: - self._setpoint = self.adjusted_minimum_setpoint + self._setpoint = self._coordinator.minimum_setpoint _LOGGER.debug("Setting setpoint to minimum: %.1f°C", self._setpoint) else: @@ -854,39 +845,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # Apply low filter on requested setpoint self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) - # Handle warming-up logic - if self._calculated_setpoint > MINIMUM_SETPOINT: - if self._warming_up and self._coordinator.boiler_temperature is not None: - # Initialize the last boiler temperature if it's not already set - if self._last_boiler_temperature is None: - self._last_boiler_temperature = self._coordinator.boiler_temperature - - # Calculate the change in boiler temperature - boiler_temperature_change = self._last_boiler_temperature - self._coordinator.boiler_temperature - - if boiler_temperature_change >= 1.0 or self._coordinator.boiler_temperature > self._setpoint: - # The Warming-up phase is complete - self._warming_up = False - - # Do a final adjustment - self._minimum_setpoint.warming_up(self._coordinator.flame_active, self._coordinator.boiler_temperature) - - _LOGGER.debug( - "Warming-up phase completed. Last boiler temperature: %.1f°C, Current boiler temperature: %.1f°C, Change: %.1f°C", - self._last_boiler_temperature, self._coordinator.boiler_temperature, boiler_temperature_change - ) - else: - # Update the last known boiler temperature - self._last_boiler_temperature = self._coordinator.boiler_temperature - - _LOGGER.debug( - "Warming-up in progress. Updated last boiler temperature to: %.1f°C", - self._last_boiler_temperature - ) - - if not self._coordinator.flame_active: - self._warming_up = True - # Create a value object that contains most boiler values boiler_state = BoilerState( flame_active=self._coordinator.flame_active, @@ -898,19 +856,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation await self.pwm.update(self._calculated_setpoint, boiler_state) - # Control our dynamic minimum setpoint - if self._setpoint is not None and self._calculated_setpoint > MINIMUM_SETPOINT and not self._coordinator.hot_water_active and self._coordinator.boiler_temperature is not None: - if not self._warming_up: - self._minimum_setpoint.calculate( - requested_setpoint=self.requested_setpoint, - boiler_temperature=self._coordinator.boiler_temperature - ) - else: - self._minimum_setpoint.warming_up( - flame_active=self._coordinator.flame_active, - boiler_temperature=self._coordinator.boiler_temperature - ) - # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 3793f5a2..aaf25d6e 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -569,11 +569,6 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str - if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: - schema[vol.Required(CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT, default=options[CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT])] = selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=120, step=5) - ) - if not options[CONF_AUTOMATIC_DUTY_CYCLE]: schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index d05b186b..1cdf03d6 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -65,7 +65,6 @@ CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" -CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT = "minimum_setpoint_delay_adjustment" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" @@ -104,7 +103,6 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, - CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT: 30, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 94f1e7d7..52f6b941 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -25,15 +25,20 @@ class DeviceState(str, Enum): OFF = "off" +class DeviceStatus(str, Enum): + PREHEATING = "preheating" + HEATING_UP = "heating_up" + AT_SETPOINT = "at_setpoint" + COOLING_DOWN = "cooling_down" + OVERSHOOT_HANDLING = "overshoot_handling" + + UNKNOWN = "unknown" + INITIALIZING = "initializing" + + class SatDataUpdateCoordinatorFactory: @staticmethod - def resolve( - hass: HomeAssistant, - mode: str, - device: str, - data: Mapping[str, Any], - options: Mapping[str, Any] | None = None - ) -> SatDataUpdateCoordinator: + def resolve(hass: HomeAssistant, mode: str, device: str, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> SatDataUpdateCoordinator: if mode == MODE_FAKE: from .fake import SatFakeCoordinator return SatFakeCoordinator(hass=hass, data=data, options=options) @@ -72,6 +77,7 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._data = data self._manufacturer = None + self._tracking_flame = False self._options = options or {} self._device_state = DeviceState.OFF self._simulation = bool(self._options.get(CONF_SIMULATION)) @@ -94,6 +100,29 @@ def device_state(self): """Return the current state of the device.""" return self._device_state + @property + def device_status(self): + """Return the current status of the device.""" + if self.boiler_temperature is None: + return DeviceStatus.INITIALIZING + + if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: + return DeviceStatus.COOLING_DOWN + + if self.setpoint == self.boiler_temperature: + return DeviceStatus.AT_SETPOINT + + if not self.flame_active and self.setpoint > self.boiler_temperature: + return DeviceStatus.PREHEATING + + if self._tracking_flame and self.flame_active and self.setpoint > self.boiler_temperature: + return DeviceStatus.HEATING_UP + + if not self._tracking_flame and self.flame_active: + return DeviceStatus.OVERSHOOT_HANDLING + + return DeviceStatus.UNKNOWN + @property def manufacturer(self) -> Manufacturer | None: if self.member_id is None: @@ -268,6 +297,15 @@ async def async_will_remove_from_hass(self) -> None: async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: """Control the heating loop for the device.""" current_time = datetime.now() + last_boiler_temperature = self.boiler_temperatures[-1][1] if len(self.boiler_temperatures) > 0 else None + + # Make sure we have valid value + if last_boiler_temperature is not None: + if not self.flame_active: + self._tracking_flame = True + + if self._tracking_flame and (self.setpoint - 5) < self.boiler_temperature < last_boiler_temperature: + self._tracking_flame = False # Make sure we have valid value if self.boiler_temperature is not None: diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 3c67e89a..383f78ef 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,45 +1,24 @@ import logging -from time import time -from .const import BOILER_TEMPERATURE_OFFSET, MINIMUM_SETPOINT +from .const import BOILER_TEMPERATURE_OFFSET _LOGGER = logging.getLogger(__name__) ADJUSTMENT_FACTOR = 0.5 class MinimumSetpoint: - def __init__(self, adjustment_delay: int): + def __init__(self): """Initialize the MinimumSetpoint class.""" - self._warmed_up_time = None - self._adjustment_delay = adjustment_delay - self._current = None - def warming_up(self, flame_active: bool, boiler_temperature: float) -> None: - """Set the minimum setpoint to trigger the boiler flame during warm-up.""" - if flame_active: - _LOGGER.debug("Flame is already active. Skipping warm-up adjustment.") - return - - self._current = boiler_temperature + 10 - - _LOGGER.info( - "Warm-up adjustment applied: %.1f°C (Boiler Temperature: %.1f°C)", - self._current, boiler_temperature - ) - - def calculate(self, requested_setpoint: float, boiler_temperature: float) -> None: + def calculate(self, requested_setpoint: float, boiler_temperature: float) -> float: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" - if self._current is None or self._warmed_up_time is None: - self._warmed_up_time = time() - self._current = requested_setpoint + target_setpoint = boiler_temperature - BOILER_TEMPERATURE_OFFSET - if not self._should_apply_adjustment(): - _LOGGER.debug("Adjustment skipped. Waiting for adjustment delay (%d seconds).", self._adjustment_delay) - return + if self._current is None: + self._current = target_setpoint old_value = self._current - target_setpoint = boiler_temperature - BOILER_TEMPERATURE_OFFSET if self._current < target_setpoint: self._current = min(self._current + ADJUSTMENT_FACTOR, target_setpoint) @@ -51,16 +30,4 @@ def calculate(self, requested_setpoint: float, boiler_temperature: float) -> Non old_value, self._current, boiler_temperature, requested_setpoint, target_setpoint ) - def _should_apply_adjustment(self) -> bool: - """Check if the adjustment factor can be applied based on delay.""" - if self._warmed_up_time is None: - return False - - return (time() - self._warmed_up_time) > self._adjustment_delay - - def current(self) -> float: - """Return the current minimum setpoint.""" - if self._current is not None: - return round(self._current, 1) - - return MINIMUM_SETPOINT + return self._current diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 095c3fec..cc790c05 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -42,6 +42,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a await simulator_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) _async_add_entities([ + SatBoilerSensor(coordinator, _config_entry), SatManufacturerSensor(coordinator, _config_entry), SatErrorValueSensor(coordinator, _config_entry, climate), SatHeatingCurveSensor(coordinator, _config_entry, climate), @@ -245,3 +246,17 @@ def native_value(self) -> str: @property def unique_id(self) -> str: return f"{self._config_entry.data.get(CONF_NAME).lower()}-manufacturer" + + +class SatBoilerSensor(SatEntity, SensorEntity): + @property + def name(self) -> str: + return f"Boiler Status" + + @property + def native_value(self) -> str: + return self._coordinator.device_status + + @property + def unique_id(self) -> str: + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-status" diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 8efa2b60..44f9f6c2 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -6,7 +6,6 @@ from .const import * from .heating_curve import HeatingCurve -from .minimum_setpoint import MinimumSetpoint from .pid import PID from .pwm import PWM @@ -91,12 +90,6 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, max_cycles=max_duty_cycles, force=force) -def create_minimum_setpoint_controller(config_options) -> MinimumSetpoint: - adjustment_delay = config_options.get(CONF_MINIMUM_SETPOINT_DELAY_ADJUSTMENT) - - return MinimumSetpoint(adjustment_delay=adjustment_delay) - - def snake_case(value: str) -> str: return '_'.join( sub('([A-Z][a-z]+)', r' \1', From b0c63e1fa7b287ff8ad6729b068bad3549449dab Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:17:01 +0100 Subject: [PATCH 041/194] Cleanup? --- custom_components/sat/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 52f6b941..af4eec9a 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -303,8 +303,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if last_boiler_temperature is not None: if not self.flame_active: self._tracking_flame = True - - if self._tracking_flame and (self.setpoint - 5) < self.boiler_temperature < last_boiler_temperature: + elif self._tracking_flame and self.boiler_temperature < last_boiler_temperature: self._tracking_flame = False # Make sure we have valid value From 3e4b40f93e5440a052dae96d733087d4dc8d8bc4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:23:06 +0100 Subject: [PATCH 042/194] Add extra "COOLING DOWN" state --- custom_components/sat/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index af4eec9a..7cea85ee 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -121,6 +121,9 @@ def device_status(self): if not self._tracking_flame and self.flame_active: return DeviceStatus.OVERSHOOT_HANDLING + if not self.flame_active and self.setpoint < self.boiler_temperature: + return DeviceStatus.COOLING_DOWN + return DeviceStatus.UNKNOWN @property From b210ef6e6b4fbb2c81ff5055f5541b9c44ca2f2f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:31:33 +0100 Subject: [PATCH 043/194] Re-added support to force the flame --- custom_components/sat/climate.py | 14 ++++++++++---- custom_components/sat/const.py | 1 - 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index ab9d4b2e..40a5b5f4 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -746,8 +746,11 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) if self._dynamic_minimum_setpoint: - target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) - self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) + if not self._coordinator.flame_active: + self._setpoint = self._coordinator.boiler_temperature + 10 + else: + target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) + self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) else: self._setpoint = self._calculated_setpoint else: @@ -756,8 +759,11 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON and self.max_error > -DEADBAND: if self._dynamic_minimum_setpoint: - target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature - 2) - self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) + if not self._coordinator.flame_active: + self._setpoint = self._coordinator.boiler_temperature + 10 + else: + target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature - 2) + self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) else: self._setpoint = self._coordinator.minimum_setpoint diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 1cdf03d6..83102aa6 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -15,7 +15,6 @@ MODE_SIMULATOR = "simulator" DEADBAND = 0.1 -BOILER_TEMPERATURE_OFFSET = 2 HEATER_STARTUP_TIMEFRAME = 180 MINIMUM_SETPOINT = 10 From 41f92e5c304628caf4ff9d3148358076fcb42371 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:33:21 +0100 Subject: [PATCH 044/194] Whoops --- custom_components/sat/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 83102aa6..1cdf03d6 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -15,6 +15,7 @@ MODE_SIMULATOR = "simulator" DEADBAND = 0.1 +BOILER_TEMPERATURE_OFFSET = 2 HEATER_STARTUP_TIMEFRAME = 180 MINIMUM_SETPOINT = 10 From 6b76350460942c2ea66413656e337bbc2f55842f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:38:25 +0100 Subject: [PATCH 045/194] Cleaning up --- custom_components/sat/sensor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index cc790c05..3142eba4 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -234,14 +234,15 @@ def unique_id(self) -> str: class SatManufacturerSensor(SatEntity, SensorEntity): @property def name(self) -> str: - return f"Boiler Manufacturer" + return "Boiler Manufacturer" @property def native_value(self) -> str: - if not (manufacturer := self._coordinator.manufacturer): - return "Unknown" + return self._coordinator.manufacturer.name - return manufacturer.name + @property + def available(self) -> bool: + return self._coordinator.manufacturer is not None @property def unique_id(self) -> str: @@ -251,12 +252,16 @@ def unique_id(self) -> str: class SatBoilerSensor(SatEntity, SensorEntity): @property def name(self) -> str: - return f"Boiler Status" + return "Boiler Status" @property def native_value(self) -> str: return self._coordinator.device_status + @property + def available(self) -> bool: + return self._coordinator.device_status is not None + @property def unique_id(self) -> str: return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-status" From 2cae5acb10eb97ef3d3e0f4a135e732d81283c29 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 16:44:38 +0100 Subject: [PATCH 046/194] Add support for "OVERSHOOT_STABILIZED" status --- custom_components/sat/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 7cea85ee..8a071ae2 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -31,6 +31,7 @@ class DeviceStatus(str, Enum): AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" OVERSHOOT_HANDLING = "overshoot_handling" + OVERSHOOT_STABILIZED = "overshoot_stabilized" UNKNOWN = "unknown" INITIALIZING = "initializing" @@ -119,6 +120,9 @@ def device_status(self): return DeviceStatus.HEATING_UP if not self._tracking_flame and self.flame_active: + if self.setpoint == self.boiler_temperature - 2: + return DeviceStatus.OVERSHOOT_STABILIZED + return DeviceStatus.OVERSHOOT_HANDLING if not self.flame_active and self.setpoint < self.boiler_temperature: From 71bfd1327b74397ba856d3a296ecbaf815fd628d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:04:15 +0100 Subject: [PATCH 047/194] No need to doubly subtract it --- custom_components/sat/minimum_setpoint.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 383f78ef..86b290c3 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,7 +1,5 @@ import logging -from .const import BOILER_TEMPERATURE_OFFSET - _LOGGER = logging.getLogger(__name__) ADJUSTMENT_FACTOR = 0.5 @@ -11,23 +9,21 @@ def __init__(self): """Initialize the MinimumSetpoint class.""" self._current = None - def calculate(self, requested_setpoint: float, boiler_temperature: float) -> float: + def calculate(self, target_setpoint: float, boiler_temperature: float) -> float: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" - target_setpoint = boiler_temperature - BOILER_TEMPERATURE_OFFSET - if self._current is None: - self._current = target_setpoint + self._current = boiler_temperature old_value = self._current - if self._current < target_setpoint: - self._current = min(self._current + ADJUSTMENT_FACTOR, target_setpoint) + if self._current < boiler_temperature: + self._current = min(self._current + ADJUSTMENT_FACTOR, boiler_temperature) else: - self._current = max(self._current - ADJUSTMENT_FACTOR, target_setpoint) + self._current = max(self._current - ADJUSTMENT_FACTOR, boiler_temperature) _LOGGER.info( - "Minimum setpoint changed (%.1f°C => %.1f°C). Boiler Temperature: %.1f°C, Requested Setpoint: %.1f°C, Target: %.1f°C", - old_value, self._current, boiler_temperature, requested_setpoint, target_setpoint + "Minimum setpoint changed (%.1f°C => %.1f°C). Boiler Temperature: %.1f°C, Target Setpoint: %.1f°C", + old_value, self._current, boiler_temperature, target_setpoint ) return self._current From 94e3d4a944ab8adcd15021db59908aabaed2cd50 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:06:35 +0100 Subject: [PATCH 048/194] Fix "OVERSHOOT_STABILIZED" status --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 8a071ae2..1d61a210 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -120,7 +120,7 @@ def device_status(self): return DeviceStatus.HEATING_UP if not self._tracking_flame and self.flame_active: - if self.setpoint == self.boiler_temperature - 2: + if self.setpoint == self.boiler_temperature: return DeviceStatus.OVERSHOOT_STABILIZED return DeviceStatus.OVERSHOOT_HANDLING From 99248b304a711469c69466c28444589971c8049e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:07:01 +0100 Subject: [PATCH 049/194] Fix "AT_SETPOINT" status --- custom_components/sat/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 1d61a210..b87ee63c 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -110,9 +110,6 @@ def device_status(self): if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: return DeviceStatus.COOLING_DOWN - if self.setpoint == self.boiler_temperature: - return DeviceStatus.AT_SETPOINT - if not self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.PREHEATING @@ -125,6 +122,9 @@ def device_status(self): return DeviceStatus.OVERSHOOT_HANDLING + if self.setpoint == self.boiler_temperature: + return DeviceStatus.AT_SETPOINT + if not self.flame_active and self.setpoint < self.boiler_temperature: return DeviceStatus.COOLING_DOWN From d567de726637cca04eae4588c420dd156319ecb1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:12:05 +0100 Subject: [PATCH 050/194] Typo? --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 86b290c3..9cb08609 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -16,7 +16,7 @@ def calculate(self, target_setpoint: float, boiler_temperature: float) -> float: old_value = self._current - if self._current < boiler_temperature: + if self._current < target_setpoint: self._current = min(self._current + ADJUSTMENT_FACTOR, boiler_temperature) else: self._current = max(self._current - ADJUSTMENT_FACTOR, boiler_temperature) From 00594d172e3c8e6852c4f77f374940ea5c690b24 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:12:27 +0100 Subject: [PATCH 051/194] Typo? --- custom_components/sat/minimum_setpoint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 9cb08609..2ca01bb7 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -12,7 +12,7 @@ def __init__(self): def calculate(self, target_setpoint: float, boiler_temperature: float) -> float: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" if self._current is None: - self._current = boiler_temperature + self._current = target_setpoint old_value = self._current From 65bf790fe545bb25decaf36e8162ab351581c910 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:17:48 +0100 Subject: [PATCH 052/194] Cleanup --- custom_components/sat/climate.py | 10 ++++++---- custom_components/sat/minimum_setpoint.py | 10 +++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 40a5b5f4..b1f711d1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -749,8 +749,9 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 else: - target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) - self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) + self._setpoint = self._minimum_setpoint.calculate( + min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) + ) else: self._setpoint = self._calculated_setpoint else: @@ -762,8 +763,9 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 else: - target_setpoint = min(self._calculated_setpoint, self._coordinator.boiler_temperature - 2) - self._setpoint = self._minimum_setpoint.calculate(target_setpoint, self._coordinator.boiler_temperature) + self._setpoint = self._minimum_setpoint.calculate( + self._coordinator.boiler_temperature - 2 + ) else: self._setpoint = self._coordinator.minimum_setpoint diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 2ca01bb7..7287e696 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -9,7 +9,7 @@ def __init__(self): """Initialize the MinimumSetpoint class.""" self._current = None - def calculate(self, target_setpoint: float, boiler_temperature: float) -> float: + def calculate(self, target_setpoint: float) -> float: """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" if self._current is None: self._current = target_setpoint @@ -17,13 +17,13 @@ def calculate(self, target_setpoint: float, boiler_temperature: float) -> float: old_value = self._current if self._current < target_setpoint: - self._current = min(self._current + ADJUSTMENT_FACTOR, boiler_temperature) + self._current = min(self._current + ADJUSTMENT_FACTOR, target_setpoint) else: - self._current = max(self._current - ADJUSTMENT_FACTOR, boiler_temperature) + self._current = max(self._current - ADJUSTMENT_FACTOR, target_setpoint) _LOGGER.info( - "Minimum setpoint changed (%.1f°C => %.1f°C). Boiler Temperature: %.1f°C, Target Setpoint: %.1f°C", - old_value, self._current, boiler_temperature, target_setpoint + "Minimum setpoint changed (%.1f°C => %.1f°C). Target Setpoint: %.1f°C", + old_value, self._current, target_setpoint ) return self._current From eb45ff2b09dedbc1880db0b958e4eb5ef4d9e4b1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:19:59 +0100 Subject: [PATCH 053/194] Fix "OVERSHOOT_STABILIZED" status --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index b87ee63c..8711dccc 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -117,7 +117,7 @@ def device_status(self): return DeviceStatus.HEATING_UP if not self._tracking_flame and self.flame_active: - if self.setpoint == self.boiler_temperature: + if self.setpoint == self.boiler_temperature - 2: return DeviceStatus.OVERSHOOT_STABILIZED return DeviceStatus.OVERSHOOT_HANDLING From c1c55f1999e3582022e8d9b053f7f106371b2d9e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:23:27 +0100 Subject: [PATCH 054/194] Only change the setpoint if we are overshooting --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b1f711d1..c1e3f13f 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -38,7 +38,7 @@ from .area import Areas, SENSOR_TEMPERATURE_ID from .boiler_state import BoilerState from .const import * -from .coordinator import SatDataUpdateCoordinator, DeviceState +from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState @@ -762,7 +762,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if self._dynamic_minimum_setpoint: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 - else: + elif self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._minimum_setpoint.calculate( self._coordinator.boiler_temperature - 2 ) From e6e822fc3b5c9e229821b4778130fadc0ad9007a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 17:29:33 +0100 Subject: [PATCH 055/194] Make sure we do not spam the boiler --- custom_components/sat/climate.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c1e3f13f..1b10005e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -123,6 +123,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._rooms = None self._setpoint = None self._calculated_setpoint = None + self._last_boiler_temperature = None self._hvac_mode = None self._target_temperature = None @@ -191,8 +192,13 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn _LOGGER.warning("Simulation mode!") def async_track_coordinator_data(self): - """Track changes in the coordinator's data and trigger the heating loop.""" + """Track changes in the coordinator's boiler temperature and trigger the heating loop.""" + if self._coordinator.boiler_temperature is not None and self._last_boiler_temperature == self._coordinator.boiler_temperature: + return + + # Schedule an asynchronous task to control the heating loop asyncio.create_task(self.async_control_heating_loop()) + self._last_boiler_temperature = self._coordinator.boiler_temperature async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" From 5276bedba3696f521631765037d0816aa7fa6fe0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 18:34:02 +0100 Subject: [PATCH 056/194] Improved normal cycle with overshooting --- custom_components/sat/climate.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 1b10005e..ced90d18 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -529,7 +529,10 @@ def pulse_width_modulation_enabled(self) -> bool: if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True - return self._overshoot_protection and self.pwm.enabled + if not self._overshoot_protection: + return False + + return self.pwm.enabled or self._coordinator.device_status in [DeviceStatus.OVERSHOOT_HANDLING, DeviceStatus.OVERSHOOT_STABILIZED] @property def relative_modulation_value(self) -> int: @@ -754,10 +757,10 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if self._dynamic_minimum_setpoint: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 + elif self._setpoint is None: + self._setpoint = self._calculated_setpoint else: - self._setpoint = self._minimum_setpoint.calculate( - min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) - ) + self._setpoint = round(self._alpha * self._calculated_setpoint + (1 - self._alpha) * self._setpoint, 1) else: self._setpoint = self._calculated_setpoint else: @@ -852,12 +855,8 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control the heating through the coordinator await self._coordinator.async_control_heating_loop(self) - if self._calculated_setpoint is None: - # Default to the calculated setpoint - self._calculated_setpoint = self._calculate_control_setpoint() - else: - # Apply low filter on requested setpoint - self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) + # Calculate the setpoint + self._calculated_setpoint = self._calculate_control_setpoint() # Create a value object that contains most boiler values boiler_state = BoilerState( From ddfd14fa5ef4763f59f36798d68e854bd946cefb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 18:40:53 +0100 Subject: [PATCH 057/194] Some sanity checks --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index ced90d18..27435f06 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -771,7 +771,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if self._dynamic_minimum_setpoint: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 - elif self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + elif self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._minimum_setpoint.calculate( self._coordinator.boiler_temperature - 2 ) From 5ccafba536337ffe187ddd7b0ee1b4e9c02e3085 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 18:56:44 +0100 Subject: [PATCH 058/194] No need to check flame when cooling down --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 8711dccc..95face6c 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -125,7 +125,7 @@ def device_status(self): if self.setpoint == self.boiler_temperature: return DeviceStatus.AT_SETPOINT - if not self.flame_active and self.setpoint < self.boiler_temperature: + if self.setpoint < self.boiler_temperature: return DeviceStatus.COOLING_DOWN return DeviceStatus.UNKNOWN From ac268a29b8648a47e9fd7b8209c65e1af8da798a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 19:05:07 +0100 Subject: [PATCH 059/194] Use the _calculated_setpoint again --- custom_components/sat/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 27435f06..49190c66 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -760,7 +760,11 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: elif self._setpoint is None: self._setpoint = self._calculated_setpoint else: - self._setpoint = round(self._alpha * self._calculated_setpoint + (1 - self._alpha) * self._setpoint, 1) + requested_setpoint = self._minimum_setpoint.calculate( + min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) + ) + + self._setpoint = round(self._alpha * requested_setpoint + (1 - self._alpha) * self._setpoint, 1) else: self._setpoint = self._calculated_setpoint else: From eb7ed674b605a12ea06bd3da97d251fc2fe1e941 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 26 Dec 2024 19:06:49 +0100 Subject: [PATCH 060/194] Better setpoint is NONE handling --- custom_components/sat/climate.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 49190c66..c625a75a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -757,14 +757,15 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if self._dynamic_minimum_setpoint: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 - elif self._setpoint is None: - self._setpoint = self._calculated_setpoint else: requested_setpoint = self._minimum_setpoint.calculate( min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) ) - self._setpoint = round(self._alpha * requested_setpoint + (1 - self._alpha) * self._setpoint, 1) + if self._setpoint is None: + self._setpoint = requested_setpoint + else: + self._setpoint = round(self._alpha * requested_setpoint + (1 - self._alpha) * self._setpoint, 1) else: self._setpoint = self._calculated_setpoint else: From f3633faa5bbb4bb4487f59063a3d9047140e6c2d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 27 Dec 2024 22:14:10 +0100 Subject: [PATCH 061/194] Improve overshoot handling --- custom_components/sat/__init__.py | 2 +- custom_components/sat/climate.py | 16 +++------------- custom_components/sat/coordinator.py | 11 ++++++++--- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 0f8b2206..fe8c3199 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): ) # Making sure everything is loaded - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].setup() + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].async_setup() # Forward entry setup for used platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c625a75a..bdb6224e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -754,20 +754,10 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) - if self._dynamic_minimum_setpoint: - if not self._coordinator.flame_active: - self._setpoint = self._coordinator.boiler_temperature + 10 - else: - requested_setpoint = self._minimum_setpoint.calculate( - min(self._calculated_setpoint, self._coordinator.boiler_temperature + 0.2) - ) - - if self._setpoint is None: - self._setpoint = requested_setpoint - else: - self._setpoint = round(self._alpha * requested_setpoint + (1 - self._alpha) * self._setpoint, 1) - else: + if not self._dynamic_minimum_setpoint or self._coordinator.flame_active: self._setpoint = self._calculated_setpoint + else: + self._setpoint = max(self._calculated_setpoint, self._coordinator.boiler_temperature + 10) else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 95face6c..e07744b8 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -289,7 +289,7 @@ def supports_maximum_setpoint_management(self): """ return False - async def setup(self) -> None: + async def async_setup(self) -> None: """Perform setup when the integration is about to be added to Home Assistant.""" pass @@ -310,8 +310,13 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if last_boiler_temperature is not None: if not self.flame_active: self._tracking_flame = True - elif self._tracking_flame and self.boiler_temperature < last_boiler_temperature: - self._tracking_flame = False + elif self._tracking_flame: + if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: + self._tracking_flame = False + _LOGGER.debug("No longer tracking flame due to temperature stabilizing below setpoint.") + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + self._tracking_flame = False + _LOGGER.warning("No longer tracking flame due to persistent overshooting above setpoint.") # Make sure we have valid value if self.boiler_temperature is not None: From 74cb40f107811d0d9e6b5b18c748872d3b8bd217 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 16:35:21 +0100 Subject: [PATCH 062/194] Cleaning up and only disable relative modulation in PWM --- custom_components/sat/climate.py | 18 ++++------- custom_components/sat/coordinator.py | 32 ++++++++++---------- custom_components/sat/minimum_setpoint.py | 22 +++++++------- custom_components/sat/relative_modulation.py | 15 +++------ 4 files changed, 37 insertions(+), 50 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index bdb6224e..51394479 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,7 +40,7 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity -from .minimum_setpoint import MinimumSetpoint +from .minimum_setpoint import SetpointAdjuster from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .summer_simmer import SummerSimmer @@ -170,8 +170,8 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) - # Create the Minimum Setpoint controller - self._minimum_setpoint = MinimumSetpoint() + # Create the Setpoint Adjuster controller + self._setpoint_adjuster = SetpointAdjuster() # Create PID controller with given configuration options self.pid = create_pid_controller(config_options) @@ -751,13 +751,9 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: elif not self.pulse_width_modulation_enabled or pwm_state == PWMState.IDLE: # Normal cycle without PWM + self._setpoint = self._calculated_setpoint _LOGGER.info("Pulse Width Modulation is disabled or in IDLE state. Running normal heating cycle.") _LOGGER.debug("Calculated setpoint for normal cycle: %.1f°C", self._calculated_setpoint) - - if not self._dynamic_minimum_setpoint or self._coordinator.flame_active: - self._setpoint = self._calculated_setpoint - else: - self._setpoint = max(self._calculated_setpoint, self._coordinator.boiler_temperature + 10) else: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) @@ -767,9 +763,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 elif self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: - self._setpoint = self._minimum_setpoint.calculate( - self._coordinator.boiler_temperature - 2 - ) + self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) else: self._setpoint = self._coordinator.minimum_setpoint @@ -789,7 +783,7 @@ async def _async_control_relative_modulation(self) -> None: return # Update relative modulation state - await self._relative_modulation.update(self.max_error > DEADBAND, self.pwm.state) + await self._relative_modulation.update(self.pwm.state) # Determine if the value needs to be updated if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index e07744b8..42fc3485 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -304,27 +304,27 @@ async def async_will_remove_from_hass(self) -> None: async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: """Control the heating loop for the device.""" current_time = datetime.now() - last_boiler_temperature = self.boiler_temperatures[-1][1] if len(self.boiler_temperatures) > 0 else None + last_boiler_temperature = self.boiler_temperatures[-1][1] if self.boiler_temperatures else None - # Make sure we have valid value + # Check and handle boiler temperature tracking if last_boiler_temperature is not None: - if not self.flame_active: - self._tracking_flame = True - elif self._tracking_flame: - if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: - self._tracking_flame = False - _LOGGER.debug("No longer tracking flame due to temperature stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: - self._tracking_flame = False - _LOGGER.warning("No longer tracking flame due to persistent overshooting above setpoint.") - - # Make sure we have valid value + if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: + self._tracking_boiler_temperature = False + _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + self._tracking_boiler_temperature = False + _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") + + # Append current boiler temperature if valid if self.boiler_temperature is not None: self.boiler_temperatures.append((current_time, self.boiler_temperature)) - # Clear up any values that are older than the specified age - while self.boiler_temperatures and current_time - self.boiler_temperatures[0][0] > timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE): - self.boiler_temperatures.pop() + # Remove old temperature records beyond the allowed age + self.boiler_temperatures = [ + (timestamp, temp) + for timestamp, temp in self.boiler_temperatures + if current_time - timestamp <= timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE) + ] async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 7287e696..d35183cb 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,29 +1,29 @@ import logging _LOGGER = logging.getLogger(__name__) -ADJUSTMENT_FACTOR = 0.5 +ADJUSTMENT_STEP = 0.5 # Renamed for clarity -class MinimumSetpoint: +class SetpointAdjuster: def __init__(self): - """Initialize the MinimumSetpoint class.""" + """Initialize the SetpointAdjuster with no current setpoint.""" self._current = None - def calculate(self, target_setpoint: float) -> float: - """Adjust the minimum setpoint based on the requested setpoint and boiler temperature.""" + def adjust(self, target_setpoint: float) -> float: + """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: self._current = target_setpoint - old_value = self._current + previous_setpoint = self._current if self._current < target_setpoint: - self._current = min(self._current + ADJUSTMENT_FACTOR, target_setpoint) - else: - self._current = max(self._current - ADJUSTMENT_FACTOR, target_setpoint) + self._current = min(self._current + ADJUSTMENT_STEP, target_setpoint) + elif self._current > target_setpoint: + self._current = max(self._current - ADJUSTMENT_STEP, target_setpoint) _LOGGER.info( - "Minimum setpoint changed (%.1f°C => %.1f°C). Target Setpoint: %.1f°C", - old_value, self._current, target_setpoint + "Setpoint updated: %.1f°C -> %.1f°C (Target: %.1f°C)", + previous_setpoint, self._current, target_setpoint ) return self._current diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 3a979dec..71743936 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -1,7 +1,7 @@ import logging from enum import Enum -from .const import MINIMUM_SETPOINT, HEATING_SYSTEM_HEAT_PUMP +from .const import MINIMUM_SETPOINT from .coordinator import SatDataUpdateCoordinator from .pwm import PWMState @@ -13,24 +13,21 @@ class RelativeModulationState(str, Enum): OFF = "off" COLD = "cold" HOT_WATER = "hot_water" - WARMING_UP = "warming_up" PULSE_WIDTH_MODULATION_OFF = "pulse_width_modulation_off" class RelativeModulation: def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): """Initialize instance variables""" - self._heating_system = heating_system self._pwm_state = None - self._warming_up = False self._coordinator = coordinator + self._heating_system = heating_system _LOGGER.debug("Relative Modulation initialized for heating system: %s", heating_system) - async def update(self, warming_up: bool, state: PWMState) -> None: + async def update(self, state: PWMState) -> None: """Update internal state with new data received from the coordinator""" self._pwm_state = state - self._warming_up = warming_up _LOGGER.debug("Updated Relative Modulation: enabled=%s, state=%s", self.enabled, self.state) @@ -45,10 +42,6 @@ def state(self) -> RelativeModulationState: if self._coordinator.hot_water_active: return RelativeModulationState.HOT_WATER - # If the heating system is currently in the process of warming up, it's considered WARMING_UP - if self._warming_up and self._heating_system != HEATING_SYSTEM_HEAT_PUMP: - return RelativeModulationState.WARMING_UP - # If the PWM state is in the ON state, it's considered PULSE_WIDTH_MODULATION_OFF if self._pwm_state != PWMState.ON: return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF @@ -60,4 +53,4 @@ def state(self) -> RelativeModulationState: def enabled(self) -> bool: """Check if the relative modulation is enabled based on its current state""" # Relative modulation is considered enabled if it's not in the OFF state or in the WARMING_UP state - return self.state != RelativeModulationState.OFF and self.state != RelativeModulationState.WARMING_UP + return self.state != RelativeModulationState.OFF From 7a16c252166bc6c5be03b8accfca1aab41905bcf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 16:36:12 +0100 Subject: [PATCH 063/194] Cleanup --- custom_components/sat/relative_modulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 71743936..d9a5f4c4 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -52,5 +52,5 @@ def state(self) -> RelativeModulationState: @property def enabled(self) -> bool: """Check if the relative modulation is enabled based on its current state""" - # Relative modulation is considered enabled if it's not in the OFF state or in the WARMING_UP state + # Relative modulation is considered enabled if it's not in the OFF state return self.state != RelativeModulationState.OFF From b5701c02aa75a9038a955c7c254a56eca994cf4d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 17:01:27 +0100 Subject: [PATCH 064/194] Make sure to use the correct default --- custom_components/sat/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index fe8c3199..76589991 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -19,7 +19,7 @@ COORDINATOR, CONF_MODE, CONF_DEVICE, - CONF_ERROR_MONITORING, + CONF_ERROR_MONITORING, OPTIONS_DEFAULTS, ) from .coordinator import SatDataUpdateCoordinatorFactory @@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][entry.entry_id] = {} # Setup error monitoring (if enabled) - if entry.options.get(CONF_ERROR_MONITORING, True): + if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]): await hass.async_add_executor_job(initialize_sentry, hass) # Resolve the coordinator by using the factory according to the mode From 04de1247a5a12961158857d1d91f0db57fceb04e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 19:47:14 +0100 Subject: [PATCH 065/194] Add missing an if-statement --- custom_components/sat/coordinator.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 42fc3485..37b8a7b6 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -308,12 +308,15 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Check and handle boiler temperature tracking if last_boiler_temperature is not None: - if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: - self._tracking_boiler_temperature = False - _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: - self._tracking_boiler_temperature = False - _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") + if not self.flame_active: + self._tracking_flame = True + elif self._tracking_flame: + if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: + self._tracking_boiler_temperature = False + _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + self._tracking_boiler_temperature = False + _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") # Append current boiler temperature if valid if self.boiler_temperature is not None: From df9004022a73080f0dff4aa47ad21450da8cf8f5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 19:47:41 +0100 Subject: [PATCH 066/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 37b8a7b6..cfc86850 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -309,7 +309,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Check and handle boiler temperature tracking if last_boiler_temperature is not None: if not self.flame_active: - self._tracking_flame = True + self._tracking_boiler_temperature = True elif self._tracking_flame: if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False From 13195388c2e486c81f3f2401b37a4e823e087da4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 19:48:55 +0100 Subject: [PATCH 067/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index cfc86850..2c2e7c26 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -310,7 +310,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if last_boiler_temperature is not None: if not self.flame_active: self._tracking_boiler_temperature = True - elif self._tracking_flame: + elif self._tracking_boiler_temperature: if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") From b6c2d5e2a43064e0cbad5fcfc1fddfc11074f9f5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 21:08:47 +0100 Subject: [PATCH 068/194] Improved overshooting detection --- custom_components/sat/climate.py | 8 ++++++++ custom_components/sat/coordinator.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 51394479..17eef6a6 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -635,10 +635,12 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: await self._async_control_pid(True) + await self._coordinator.reset_tracking_boiler_temperature() # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): await self._async_control_pid(True) + await self._coordinator.reset_tracking_boiler_temperature() # If the current temperature has changed, update the PID controller elif SENSOR_TEMPERATURE_ID not in new_state.attributes and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): @@ -924,6 +926,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the PID controller await self._async_control_pid(True) + # Reset the tracker so we re-detect overshooting + await self._coordinator.reset_tracking_boiler_temperature() + # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -995,6 +1000,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the PID controller await self._async_control_pid(True) + # Reset the tracker so we re-detect overshooting + await self._coordinator.reset_tracking_boiler_temperature() + # Write the state to Home Assistant self.async_write_ha_state() diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2c2e7c26..4aa7df15 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -78,9 +78,9 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._data = data self._manufacturer = None - self._tracking_flame = False self._options = options or {} self._device_state = DeviceState.OFF + self._tracking_boiler_temperature = None self._simulation = bool(self._options.get(CONF_SIMULATION)) self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) @@ -113,10 +113,10 @@ def device_status(self): if not self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.PREHEATING - if self._tracking_flame and self.flame_active and self.setpoint > self.boiler_temperature: + if self._tracking_boiler_temperature and self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.HEATING_UP - if not self._tracking_flame and self.flame_active: + if not self._tracking_boiler_temperature and self.flame_active: if self.setpoint == self.boiler_temperature - 2: return DeviceStatus.OVERSHOOT_STABILIZED @@ -289,6 +289,10 @@ def supports_maximum_setpoint_management(self): """ return False + async def reset_tracking_boiler_temperature(self): + """Reset the tracking boiler temperature, so we can re-detect an overshoot again.""" + self._tracking_boiler_temperature = None + async def async_setup(self) -> None: """Perform setup when the integration is about to be added to Home Assistant.""" pass @@ -308,7 +312,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Check and handle boiler temperature tracking if last_boiler_temperature is not None: - if not self.flame_active: + if not self.flame_active and self._tracking_boiler_temperature is None: self._tracking_boiler_temperature = True elif self._tracking_boiler_temperature: if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: From c27d09860e5c5e2f8043bbf59937561cf04d321c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 23:42:24 +0100 Subject: [PATCH 069/194] More cleaning --- custom_components/sat/climate.py | 2 +- .../sat/{minimum_setpoint.py => setpoint_adjuster.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename custom_components/sat/{minimum_setpoint.py => setpoint_adjuster.py} (100%) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 17eef6a6..481e432e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,9 +40,9 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity -from .minimum_setpoint import SetpointAdjuster from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState +from .setpoint_adjuster import SetpointAdjuster from .summer_simmer import SummerSimmer from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/setpoint_adjuster.py similarity index 100% rename from custom_components/sat/minimum_setpoint.py rename to custom_components/sat/setpoint_adjuster.py From 56f34ccd9d9571c62a9f46cfeae5384d2540d327 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 23:48:10 +0100 Subject: [PATCH 070/194] Improved relative modulation --- custom_components/sat/climate.py | 2 +- custom_components/sat/relative_modulation.py | 22 +++++++------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 481e432e..0a2393e7 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -785,7 +785,7 @@ async def _async_control_relative_modulation(self) -> None: return # Update relative modulation state - await self._relative_modulation.update(self.pwm.state) + await self._relative_modulation.update(self.pulse_width_modulation_enabled) # Determine if the value needs to be updated if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index d9a5f4c4..075d9c79 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -3,7 +3,6 @@ from .const import MINIMUM_SETPOINT from .coordinator import SatDataUpdateCoordinator -from .pwm import PWMState _LOGGER = logging.getLogger(__name__) @@ -13,44 +12,37 @@ class RelativeModulationState(str, Enum): OFF = "off" COLD = "cold" HOT_WATER = "hot_water" - PULSE_WIDTH_MODULATION_OFF = "pulse_width_modulation_off" + PULSE_WIDTH_MODULATION = "pulse_width_modulation" class RelativeModulation: def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): """Initialize instance variables""" - self._pwm_state = None self._coordinator = coordinator self._heating_system = heating_system + self._pulse_width_modulation_enabled = None _LOGGER.debug("Relative Modulation initialized for heating system: %s", heating_system) - async def update(self, state: PWMState) -> None: - """Update internal state with new data received from the coordinator""" - self._pwm_state = state - - _LOGGER.debug("Updated Relative Modulation: enabled=%s, state=%s", self.enabled, self.state) + async def update(self, pulse_width_modulation_enabled: bool) -> None: + """Update internal state with new internal data""" + self._pulse_width_modulation_enabled = pulse_width_modulation_enabled @property def state(self) -> RelativeModulationState: """Determine the current state of relative modulation based on coordinator and internal data""" - # If setpoint is not available or below the minimum threshold, it's considered COLD if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: return RelativeModulationState.COLD - # If hot water is actively being used, it's considered HOT_WATER if self._coordinator.hot_water_active: return RelativeModulationState.HOT_WATER - # If the PWM state is in the ON state, it's considered PULSE_WIDTH_MODULATION_OFF - if self._pwm_state != PWMState.ON: - return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF + if self._pulse_width_modulation_enabled: + return RelativeModulationState.PULSE_WIDTH_MODULATION - # Default case, when none of the above conditions are met, it's considered OFF return RelativeModulationState.OFF @property def enabled(self) -> bool: """Check if the relative modulation is enabled based on its current state""" - # Relative modulation is considered enabled if it's not in the OFF state return self.state != RelativeModulationState.OFF From cb0402f563e862a1221825520431bc0d6764a766 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 28 Dec 2024 23:52:28 +0100 Subject: [PATCH 071/194] Fixed method name --- custom_components/sat/mqtt/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index a540afb1..52a8e03e 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -31,7 +31,7 @@ def __init__(self, hass: HomeAssistant, device_id: str, data: Mapping[str, Any], def device_id(self) -> str: return self._device_id - async def setup(self): + async def async_setup(self): await self._load_stored_data() async def async_added_to_hass(self) -> None: From 3fa3fc55c38b5a085f9a5f54716d87e8bb7d607d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 01:21:18 +0100 Subject: [PATCH 072/194] Remember the minimum setpoint --- custom_components/sat/climate.py | 10 ++++++++++ custom_components/sat/setpoint_adjuster.py | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 0a2393e7..76edb30b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -634,11 +634,13 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: + self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): + self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() @@ -766,6 +768,8 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: self._setpoint = self._coordinator.boiler_temperature + 10 elif self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) + elif self._setpoint_adjuster.current is not None: + self._setpoint = self._setpoint_adjuster.current else: self._setpoint = self._coordinator.minimum_setpoint @@ -923,6 +927,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) return + # Reset the setpoint adjuster + self._setpoint_adjuster.reset() + # Reset the PID controller await self._async_control_pid(True) @@ -997,6 +1004,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Set the target temperature for the connected boiler await self._coordinator.async_set_control_thermostat_setpoint(temperature) + # Reset the setpoint adjuster + self._setpoint_adjuster.reset() + # Reset the PID controller await self._async_control_pid(True) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index d35183cb..99b79a17 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -9,6 +9,15 @@ def __init__(self): """Initialize the SetpointAdjuster with no current setpoint.""" self._current = None + @property + def current(self) -> float: + """Return the current setpoint.""" + return self._current + + def reset(self): + """Reset the setpoint.""" + self._current = None + def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: From cf2ea44463ff09cc45873f5ebc9e4edfc5d02404 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 12:36:11 +0100 Subject: [PATCH 073/194] Make PWM persistent till temperature change --- custom_components/sat/climate.py | 47 ++++++++++++++++++++++++-------- custom_components/sat/pwm.py | 7 ----- tests/test_climate.py | 9 ++++-- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 76edb30b..216849e3 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -53,6 +53,7 @@ ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" ATTR_PRE_CUSTOM_TEMPERATURE = "pre_custom_temperature" ATTR_PRE_ACTIVITY_TEMPERATURE = "pre_activity_temperature" +ATTR_PULSE_WIDTH_MODULATION_ENABLED = "pulse_width_modulation_enabled" _LOGGER = logging.getLogger(__name__) @@ -124,6 +125,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._setpoint = None self._calculated_setpoint = None self._last_boiler_temperature = None + self._pulse_width_modulation_enabled = False self._hvac_mode = None self._target_temperature = None @@ -313,6 +315,9 @@ async def _restore_previous_state_or_set_defaults(self): if old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE): self._pre_custom_temperature = old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE) + if old_state.attributes.get(ATTR_PULSE_WIDTH_MODULATION_ENABLED): + self._pulse_width_modulation_enabled = old_state.attributes.get(ATTR_PULSE_WIDTH_MODULATION_ENABLED) + if old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT): self.heating_curve.restore_autotune( old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT), @@ -532,7 +537,7 @@ def pulse_width_modulation_enabled(self) -> bool: if not self._overshoot_protection: return False - return self.pwm.enabled or self._coordinator.device_status in [DeviceStatus.OVERSHOOT_HANDLING, DeviceStatus.OVERSHOOT_STABILIZED] + return self._pulse_width_modulation_enabled @property def relative_modulation_value(self) -> int: @@ -634,12 +639,16 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: + self._pulse_width_modulation_enabled = False + self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): + self._pulse_width_modulation_enabled = False + self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() @@ -850,19 +859,29 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control the heating through the coordinator await self._coordinator.async_control_heating_loop(self) - # Calculate the setpoint - self._calculated_setpoint = self._calculate_control_setpoint() + if self._calculated_setpoint is None: + # Default to the calculated setpoint + self._calculated_setpoint = self._calculate_control_setpoint() + else: + # Apply low filter on requested setpoint + self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) - # Create a value object that contains most boiler values - boiler_state = BoilerState( - flame_active=self._coordinator.flame_active, - device_active=self._coordinator.device_active, - hot_water_active=self._coordinator.hot_water_active, - temperature=self._coordinator.boiler_temperature - ) + # Check for overshoot + if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + self._pulse_width_modulation_enabled = True # Pulse Width Modulation - await self.pwm.update(self._calculated_setpoint, boiler_state) + if self.pulse_width_modulation_enabled: + boiler_state = BoilerState( + flame_active=self._coordinator.flame_active, + device_active=self._coordinator.device_active, + hot_water_active=self._coordinator.hot_water_active, + temperature=self._coordinator.boiler_temperature + ) + + await self.pwm.update(self._calculated_setpoint, boiler_state) + else: + self.pwm.reset() # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -936,6 +955,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the tracker so we re-detect overshooting await self._coordinator.reset_tracking_boiler_temperature() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -1013,6 +1035,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the tracker so we re-detect overshooting await self._coordinator.reset_tracking_boiler_temperature() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Write the state to Home Assistant self.async_write_ha_state() diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index d5adc537..7712b437 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -203,13 +203,6 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug("Maximum duty cycle exceeded. Setting on_time: %d seconds, off_time: %d seconds.", on_time, off_time) return int(on_time), int(off_time) - @property - def enabled(self) -> bool: - if self._last_duty_cycle_percentage is None: - return False - - return self._last_duty_cycle_percentage <= self._max_duty_cycle_percentage - @property def state(self) -> PWMState: """Current PWM state.""" diff --git a/tests/test_climate.py b/tests/test_climate.py index c95bd225..99bc336e 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -24,6 +24,7 @@ }, { CONF_HEATING_CURVE_COEFFICIENT: 1.8, + CONF_FORCE_PULSE_WIDTH_MODULATION: True, }, { TEMPLATE_DOMAIN: [ @@ -67,7 +68,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: CONF_MAXIMUM_SETPOINT: 75 }, { - CONF_HEATING_CURVE_COEFFICIENT: 1.3 + CONF_HEATING_CURVE_COEFFICIENT: 1.3, + CONF_FORCE_PULSE_WIDTH_MODULATION: True, }, { TEMPLATE_DOMAIN: [ @@ -112,7 +114,8 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: CONF_MAXIMUM_SETPOINT: 75, }, { - CONF_HEATING_CURVE_COEFFICIENT: 0.9 + CONF_HEATING_CURVE_COEFFICIENT: 0.9, + CONF_FORCE_PULSE_WIDTH_MODULATION: True, }, { TEMPLATE_DOMAIN: [ @@ -137,7 +140,7 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: await climate.async_set_target_temperature(20.0) await climate.async_set_hvac_mode(HVACMode.HEAT) - assert climate.setpoint == 41 + assert climate.setpoint == 41.0 assert climate.heating_curve.value == 32.5 assert climate.requested_setpoint == 34.6 From a33ed9fa7d6a6db083c9a71415695831095b0798 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 12:56:42 +0100 Subject: [PATCH 074/194] Fix relative modulation --- custom_components/sat/relative_modulation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 075d9c79..41c69fd9 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -12,7 +12,7 @@ class RelativeModulationState(str, Enum): OFF = "off" COLD = "cold" HOT_WATER = "hot_water" - PULSE_WIDTH_MODULATION = "pulse_width_modulation" + PULSE_WIDTH_MODULATION_OFF = "pulse_width_modulation_off" class RelativeModulation: @@ -37,8 +37,8 @@ def state(self) -> RelativeModulationState: if self._coordinator.hot_water_active: return RelativeModulationState.HOT_WATER - if self._pulse_width_modulation_enabled: - return RelativeModulationState.PULSE_WIDTH_MODULATION + if not self._pulse_width_modulation_enabled: + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF return RelativeModulationState.OFF From ed1b2bed63fe233c1104bd09b9f0ace980ceb79f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 13:05:00 +0100 Subject: [PATCH 075/194] Listen to the flame instead --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 216849e3..45c45f50 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -464,7 +464,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if self._coordinator.device_state == DeviceState.OFF: + if not self._coordinator.flame_active: return HVACAction.IDLE return HVACAction.HEATING From 225f16abddbafbb03e1a6a5f4e195f13b46b33cf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 13:09:54 +0100 Subject: [PATCH 076/194] Revert the check --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 45c45f50..c378e8fc 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -464,7 +464,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if not self._coordinator.flame_active: + if not self._coordinator.device_active: return HVACAction.IDLE return HVACAction.HEATING From 34bf6227b6c0bd582d888440469249bf0993d356 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 13:59:48 +0100 Subject: [PATCH 077/194] Change the order --- custom_components/sat/relative_modulation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 41c69fd9..bf524f5c 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -31,15 +31,15 @@ async def update(self, pulse_width_modulation_enabled: bool) -> None: @property def state(self) -> RelativeModulationState: """Determine the current state of relative modulation based on coordinator and internal data""" - if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: - return RelativeModulationState.COLD - if self._coordinator.hot_water_active: return RelativeModulationState.HOT_WATER if not self._pulse_width_modulation_enabled: return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF + if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + return RelativeModulationState.COLD + return RelativeModulationState.OFF @property From 043d5ff6936cfb2777c7f6b47cb3cb1da8a17706 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 14:30:01 +0100 Subject: [PATCH 078/194] Add hot water status --- custom_components/sat/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 4aa7df15..65a92b0e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -26,6 +26,7 @@ class DeviceState(str, Enum): class DeviceStatus(str, Enum): + HOT_WATER = "hot_water" PREHEATING = "preheating" HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" @@ -107,6 +108,9 @@ def device_status(self): if self.boiler_temperature is None: return DeviceStatus.INITIALIZING + if self.hot_water_active: + return DeviceStatus.HOT_WATER + if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: return DeviceStatus.COOLING_DOWN From c788e695b8d8f6754c874c6e1cf629f177f06284 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 15:17:55 +0100 Subject: [PATCH 079/194] Reset the adjuster when the flame is off --- custom_components/sat/climate.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c378e8fc..d6be7ddc 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -641,7 +641,6 @@ async def _async_climate_changed(self, event: Event) -> None: if not old_state or new_state.state != old_state.state: self._pulse_width_modulation_enabled = False - self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() @@ -649,7 +648,6 @@ async def _async_climate_changed(self, event: Event) -> None: elif new_attrs.get("temperature") != old_attrs.get("temperature"): self._pulse_width_modulation_enabled = False - self._setpoint_adjuster.reset() await self._async_control_pid(True) await self._coordinator.reset_tracking_boiler_temperature() @@ -774,6 +772,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON and self.max_error > -DEADBAND: if self._dynamic_minimum_setpoint: if not self._coordinator.flame_active: + self._setpoint_adjuster.reset() self._setpoint = self._coordinator.boiler_temperature + 10 elif self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) @@ -946,9 +945,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) return - # Reset the setpoint adjuster - self._setpoint_adjuster.reset() - # Reset the PID controller await self._async_control_pid(True) @@ -1026,9 +1022,6 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Set the target temperature for the connected boiler await self._coordinator.async_set_control_thermostat_setpoint(temperature) - # Reset the setpoint adjuster - self._setpoint_adjuster.reset() - # Reset the PID controller await self._async_control_pid(True) From b6281f1f656a1cbb0c25b15ddcb1809917714dbf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:20:16 +0100 Subject: [PATCH 080/194] Reset overshoot detection when flame goes off --- custom_components/sat/climate.py | 14 ++------------ custom_components/sat/coordinator.py | 8 ++------ 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index d6be7ddc..8eb8878a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -639,17 +639,13 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: - self._pulse_width_modulation_enabled = False - await self._async_control_pid(True) - await self._coordinator.reset_tracking_boiler_temperature() + self._pulse_width_modulation_enabled = False # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): - self._pulse_width_modulation_enabled = False - await self._async_control_pid(True) - await self._coordinator.reset_tracking_boiler_temperature() + self._pulse_width_modulation_enabled = False # If the current temperature has changed, update the PID controller elif SENSOR_TEMPERATURE_ID not in new_state.attributes and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): @@ -948,9 +944,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the PID controller await self._async_control_pid(True) - # Reset the tracker so we re-detect overshooting - await self._coordinator.reset_tracking_boiler_temperature() - # Reset the pulse width modulation self._pulse_width_modulation_enabled = False @@ -1025,9 +1018,6 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the PID controller await self._async_control_pid(True) - # Reset the tracker so we re-detect overshooting - await self._coordinator.reset_tracking_boiler_temperature() - # Reset the pulse width modulation self._pulse_width_modulation_enabled = False diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 65a92b0e..6b3e8d2e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -81,7 +81,7 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._manufacturer = None self._options = options or {} self._device_state = DeviceState.OFF - self._tracking_boiler_temperature = None + self._tracking_boiler_temperature = True self._simulation = bool(self._options.get(CONF_SIMULATION)) self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) @@ -293,10 +293,6 @@ def supports_maximum_setpoint_management(self): """ return False - async def reset_tracking_boiler_temperature(self): - """Reset the tracking boiler temperature, so we can re-detect an overshoot again.""" - self._tracking_boiler_temperature = None - async def async_setup(self) -> None: """Perform setup when the integration is about to be added to Home Assistant.""" pass @@ -316,7 +312,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Check and handle boiler temperature tracking if last_boiler_temperature is not None: - if not self.flame_active and self._tracking_boiler_temperature is None: + if not self.flame_active: self._tracking_boiler_temperature = True elif self._tracking_boiler_temperature: if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: From aa68d54b2f0bcfcef2c8521b0f6f2f3a7dcce797 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:22:24 +0100 Subject: [PATCH 081/194] Make sure to keep the relative modulation off when in PWM --- custom_components/sat/relative_modulation.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index bf524f5c..500d2c5b 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -36,8 +36,7 @@ def state(self) -> RelativeModulationState: if not self._pulse_width_modulation_enabled: return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF - - if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + elif self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: return RelativeModulationState.COLD return RelativeModulationState.OFF From 5222a9816d4f8ddd1e6a1b61038ea7c669de1393 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:24:42 +0100 Subject: [PATCH 082/194] Make sure to keep the relative modulation off when in PWM --- custom_components/sat/relative_modulation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 500d2c5b..9f4f5893 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -35,9 +35,10 @@ def state(self) -> RelativeModulationState: return RelativeModulationState.HOT_WATER if not self._pulse_width_modulation_enabled: + if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + return RelativeModulationState.COLD + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF - elif self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: - return RelativeModulationState.COLD return RelativeModulationState.OFF From cf4ec07ef39b132667a2a84cd8deb953b1af0107 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:30:24 +0100 Subject: [PATCH 083/194] Round setpoint to zero decimals --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 8eb8878a..12b897e5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -486,7 +486,7 @@ def requested_setpoint(self) -> float: if self.heating_curve.value is None: return MINIMUM_SETPOINT - return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 0) @property def valves_open(self) -> bool: @@ -859,7 +859,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = self._calculate_control_setpoint() else: # Apply low filter on requested setpoint - self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) + self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 0) # Check for overshoot if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: From 9e4b53c174f9e72ff9cd0acf6020d8e079a18023 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:30:43 +0100 Subject: [PATCH 084/194] Detect overshooting earlier --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6b3e8d2e..cd5f5ccf 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -318,7 +318,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 1: self._tracking_boiler_temperature = False _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") From 2ac3adda14f8a16eafc783bb2728302871930e7b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:34:04 +0100 Subject: [PATCH 085/194] Cleanup --- custom_components/sat/translations/de.json | 2 -- custom_components/sat/translations/en.json | 2 -- custom_components/sat/translations/es.json | 2 -- custom_components/sat/translations/fr.json | 2 -- custom_components/sat/translations/it.json | 2 -- custom_components/sat/translations/nl.json | 2 -- custom_components/sat/translations/pt.json | 2 -- custom_components/sat/translations/sk.json | 2 -- 8 files changed, 16 deletions(-) diff --git a/custom_components/sat/translations/de.json b/custom_components/sat/translations/de.json index 261158b5..32e30aad 100644 --- a/custom_components/sat/translations/de.json +++ b/custom_components/sat/translations/de.json @@ -181,7 +181,6 @@ "heating_curve_version": "Version der Heizkurve", "integral": "Integral (kI)", "maximum_setpoint": "Maximaler Sollwert", - "minimum_setpoint_adjustment_factor": "Anpassungsfaktor für die Rücklauftemperatur", "pid_controller_version": "PID-Reglerversion", "proportional": "Proportional (kP)", "sync_with_thermostat": "Sollwert mit Thermostat synchronisieren", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "Der Koeffizient, der zur Anpassung der Heizkurve verwendet wird.", "integral": "Der Integralterm (kI) im PID-Regler, verantwortlich für die Reduzierung des stationären Fehlers.", "maximum_setpoint": "Die optimale Temperatur für einen effizienten Kesselbetrieb.", - "minimum_setpoint_adjustment_factor": "Dieser Faktor passt den Heizungssollwert basierend auf der Rücklauftemperatur des Kessels an, was die Heizungsreaktivität und -effizienz beeinflusst. Ein höherer Wert erhöht die Sensibilität gegenüber Temperaturänderungen und verbessert die Kontrolle über Komfort und Energieverbrauch. Empfohlener Startbereich liegt zwischen 0,1 und 0,5. Anpassen, um Ihrem System und Ihren Komfortpräferenzen zu entsprechen.", "proportional": "Der proportionale Term (kP) im PID-Regler, verantwortlich für die sofortige Reaktion auf Fehler.", "sync_with_thermostat": "Sollwert mit Thermostat synchronisieren, um eine koordinierte Temperaturregelung zu gewährleisten.", "window_sensors": "Kontaktsensoren, die das System auslösen, wenn ein Fenster oder eine Tür für eine bestimmte Zeit geöffnet ist." diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index a6e6b2dc..70b76964 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -181,7 +181,6 @@ "heating_curve_version": "Heating Curve Version", "integral": "Integral (kI)", "maximum_setpoint": "Maximum Setpoint", - "minimum_setpoint_adjustment_factor": "Adjustment Factor for Return Temperature", "pid_controller_version": "PID Controller Version", "proportional": "Proportional (kP)", "sync_with_thermostat": "Synchronize setpoint with thermostat", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "The coefficient used to adjust the heating curve.", "integral": "The integral term (kI) in the PID controller, responsible for reducing steady-state error.", "maximum_setpoint": "The optimal temperature for efficient boiler operation.", - "minimum_setpoint_adjustment_factor": "This factor adjusts the heating setpoint based on the boiler's return temperature, affecting heating responsiveness and efficiency. A higher value increases sensitivity to temperature changes, enhancing control over comfort and energy use. Recommended starting range is 0.1 to 0.5. Adjust to suit your system and comfort preferences.", "proportional": "The proportional term (kP) in the PID controller, responsible for the immediate response to errors.", "sync_with_thermostat": "Synchronize setpoint with thermostat to ensure coordinated temperature control.", "window_sensors": "Contact Sensors that trigger the system to react when a window or door is open for a period of time." diff --git a/custom_components/sat/translations/es.json b/custom_components/sat/translations/es.json index 3359efea..09d6557a 100644 --- a/custom_components/sat/translations/es.json +++ b/custom_components/sat/translations/es.json @@ -181,7 +181,6 @@ "heating_curve_version": "Versión de la Curva de Calefacción", "integral": "Integral (kI)", "maximum_setpoint": "Punto de Ajuste Máximo", - "minimum_setpoint_adjustment_factor": "Factor de ajuste del punto de consigna mínimo", "pid_controller_version": "Versión del controlador PID", "proportional": "Proporcional (kP)", "sync_with_thermostat": "Sincronizar punto de ajuste con el termostato", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "El coeficiente utilizado para ajustar la curva de calefacción.", "integral": "El término integral (kI) en el controlador PID, responsable de reducir el error en estado estacionario.", "maximum_setpoint": "La temperatura óptima para una operación eficiente de la caldera.", - "minimum_setpoint_adjustment_factor": "Este factor ajusta el punto de ajuste del calefactor basado en la temperatura de retorno de la caldera, afectando la capacidad de respuesta y eficiencia del calefactor. Un valor más alto aumenta la sensibilidad a los cambios de temperatura, mejorando el control sobre el confort y el uso de energía. El rango inicial recomendado es de 0,1 a 0,5. Ajuste para adaptarse a su sistema y preferencias de confort.", "proportional": "El término proporcional (kP) en el controlador PID, responsable de la respuesta inmediata a errores.", "sync_with_thermostat": "Sincronizar el punto de ajuste con el termostato para asegurar un control coordinado de la temperatura.", "window_sensors": "Sensores de Contacto que activan el sistema cuando una ventana o puerta está abierta durante un período." diff --git a/custom_components/sat/translations/fr.json b/custom_components/sat/translations/fr.json index 135ab9b0..37ba72df 100644 --- a/custom_components/sat/translations/fr.json +++ b/custom_components/sat/translations/fr.json @@ -181,7 +181,6 @@ "heating_curve_version": "Version de la Courbe de Chauffage", "integral": "Intégral (kI)", "maximum_setpoint": "Point de consigne maximal", - "minimum_setpoint_adjustment_factor": "Facteur d'ajustement du point de consigne minimal", "pid_controller_version": "Version du contrôleur PID", "proportional": "Proportionnel (kP)", "sync_with_thermostat": "Synchroniser le point de consigne avec le thermostat", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "Le coefficient utilisé pour ajuster la courbe de chauffage.", "integral": "Le terme intégral (kI) dans le régulateur PID, responsable de la réduction de l'erreur en régime permanent.", "maximum_setpoint": "La température optimale pour un fonctionnement efficace de la chaudière.", - "minimum_setpoint_adjustment_factor": "Ce facteur ajuste le point de consigne du chauffage en fonction de la température de retour de la chaudière, influençant la réactivité et l'efficacité du chauffage. Une valeur plus élevée augmente la sensibilité aux changements de température, améliorant le contrôle du confort et de l'utilisation de l'énergie. La plage de départ recommandée est de 0,1 à 0,5. Ajustez pour convenir à votre système et à vos préférences de confort.", "proportional": "Le terme proportionnel (kP) dans le régulateur PID, responsable de la réponse immédiate aux erreurs.", "sync_with_thermostat": "Synchroniser le point de consigne avec le thermostat pour assurer une régulation coordonnée de la température.", "window_sensors": "Capteurs de Contact qui déclenchent le système lorsqu'une fenêtre ou une porte est ouverte pendant une période." diff --git a/custom_components/sat/translations/it.json b/custom_components/sat/translations/it.json index 0d35eb57..769fd4b7 100644 --- a/custom_components/sat/translations/it.json +++ b/custom_components/sat/translations/it.json @@ -181,7 +181,6 @@ "heating_curve_version": "Versione della Curva di Riscaldamento", "integral": "Integrale (kI)", "maximum_setpoint": "Setpoint Massimo", - "minimum_setpoint_adjustment_factor": "Fattore di regolazione del punto di impostazione minimo", "pid_controller_version": "Versione del controllore PID", "proportional": "Proporzionale (kP)", "sync_with_thermostat": "Sincronizza setpoint con termostato", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "Il coefficiente utilizzato per regolare la curva di riscaldamento.", "integral": "Il termine integrale (kI) nel controllore PID, responsabile della riduzione dell'errore in stato stazionario.", "maximum_setpoint": "La temperatura ottimale per un funzionamento efficiente della caldaia.", - "minimum_setpoint_adjustment_factor": "Questo fattore regola il punto di impostazione del riscaldamento in base alla temperatura di ritorno della caldaia, influenzando la reattività e l'efficienza del riscaldamento. Un valore più alto aumenta la sensibilità ai cambiamenti di temperatura, migliorando il controllo del comfort e del consumo energetico. L'intervallo di partenza consigliato è da 0,1 a 0,5. Regolare per adattarsi al proprio sistema e alle preferenze di comfort.", "proportional": "Il termine proporzionale (kP) nel controllore PID, responsabile della risposta immediata agli errori.", "sync_with_thermostat": "Sincronizza il setpoint con il termostato per garantire un controllo coordinato della temperatura.", "window_sensors": "Sensori di Contatto che attivano il sistema quando una finestra o una porta è aperta per un periodo di tempo." diff --git a/custom_components/sat/translations/nl.json b/custom_components/sat/translations/nl.json index e877e522..f0aeb675 100644 --- a/custom_components/sat/translations/nl.json +++ b/custom_components/sat/translations/nl.json @@ -181,7 +181,6 @@ "heating_curve_version": "Versie van de Verwarmingscurve", "integral": "Integraal (kI)", "maximum_setpoint": "Maximaal Setpoint", - "minimum_setpoint_adjustment_factor": "Aanpassingsfactor voor de minimale instelwaarde", "pid_controller_version": "Versie van de PID-regelaar", "proportional": "Proportioneel (kP)", "sync_with_thermostat": "Synchroniseer setpoint met thermostaat", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "De coëfficiënt die wordt gebruikt om de verwarmingscurve aan te passen.", "integral": "De integraalterm (kI) in de PID-regelaar, verantwoordelijk voor het verminderen van de blijvende fout.", "maximum_setpoint": "De optimale temperatuur voor efficiënte werking van de ketel.", - "minimum_setpoint_adjustment_factor": "Deze factor past de instelwaarde voor verwarming aan op basis van de retourtemperatuur van de ketel, wat de responsiviteit en efficiëntie van de verwarming beïnvloedt. Een hogere waarde verhoogt de gevoeligheid voor temperatuurveranderingen, waardoor de controle over comfort en energieverbruik wordt verbeterd. Het aanbevolen startbereik is 0,1 tot 0,5. Aanpassen om aan uw systeem en comfortvoorkeuren te voldoen.", "proportional": "De proportionele term (kP) in de PID-regelaar, verantwoordelijk voor de directe reactie op fouten.", "sync_with_thermostat": "Synchroniseer het setpoint met de thermostaat om een gecoördineerde temperatuurregeling te waarborgen.", "window_sensors": "Contact Sensoren die het systeem activeren wanneer een raam of deur voor een periode geopend is." diff --git a/custom_components/sat/translations/pt.json b/custom_components/sat/translations/pt.json index 9034259f..1c6c9eab 100644 --- a/custom_components/sat/translations/pt.json +++ b/custom_components/sat/translations/pt.json @@ -181,7 +181,6 @@ "heating_curve_version": "Versão da Curva de Aquecimento", "integral": "Integral (kI)", "maximum_setpoint": "Setpoint Máximo", - "minimum_setpoint_adjustment_factor": "Fator de Ajuste para Temperatura de Retorno", "pid_controller_version": "Versão do Controlador PID", "proportional": "Proporcional (kP)", "sync_with_thermostat": "Sincronizar setpoint com termóstato", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "O coeficiente usado para ajustar a curva de aquecimento.", "integral": "O termo integral (kI) no controlador PID, responsável por reduzir o erro em regime permanente.", "maximum_setpoint": "A temperatura ótima para operação eficiente da caldeira.", - "minimum_setpoint_adjustment_factor": "Este fator ajusta o setpoint de aquecimento com base na temperatura de retorno da caldeira, afetando a resposta e eficiência do aquecimento. Um valor mais alto aumenta a sensibilidade às mudanças de temperatura, melhorando o controlo sobre o conforto e uso de energia. Faixa inicial recomendada é de 0.1 a 0.5. Ajuste para adequar ao seu sistema e preferências de conforto.", "proportional": "O termo proporcional (kP) no controlador PID, responsável pela resposta imediata a erros.", "sync_with_thermostat": "Sincronizar setpoint com termóstato para assegurar controlo de temperatura coordenado.", "window_sensors": "Sensores de Contato que ativam o sistema para reagir quando uma janela ou porta está aberta por um período de tempo." diff --git a/custom_components/sat/translations/sk.json b/custom_components/sat/translations/sk.json index 3579b7e7..4ccd4a39 100644 --- a/custom_components/sat/translations/sk.json +++ b/custom_components/sat/translations/sk.json @@ -181,7 +181,6 @@ "heating_curve_version": "Verzia vykurovacej krivky", "integral": "Integračná (kI)", "maximum_setpoint": "Maximálna požadovaná hodnota", - "minimum_setpoint_adjustment_factor": "Faktor nastavenia pre teplotu spiatočky", "pid_controller_version": "Verzia PID regulátora", "proportional": "Proporcionálna (kP)", "sync_with_thermostat": "Synchronizujte požadovanú hodnotu s termostatom", @@ -196,7 +195,6 @@ "heating_curve_coefficient": "Koeficient použitý na úpravu vykurovacej krivky.", "integral": "Integrálny člen (kI) v regulátore PID, zodpovedný za zníženie chyby v ustálenom stave.", "maximum_setpoint": "Optimálna teplota pre efektívnu prevádzku kotla.", - "minimum_setpoint_adjustment_factor": "Tento faktor upravuje žiadanú hodnotu vykurovania na základe teploty spiatočky kotla, čím ovplyvňuje odozvu a účinnosť vykurovania. Vyššia hodnota zvyšuje citlivosť na zmeny teploty, čím sa zlepšuje kontrola nad komfortom a spotrebou energie. Odporúčaný počiatočný rozsah je 0,1 až 0,5. Upravte ho tak, aby vyhovoval vášmu systému a preferenciám pohodlia.", "proportional": "Proporcionálny člen (kP) v PID regulátore, zodpovedný za okamžitú reakciu na chyby.", "sync_with_thermostat": "Synchronizujte požadovanú hodnotu s termostatom, aby ste zabezpečili koordinovanú reguláciu teploty.", "window_sensors": "Kontaktné senzory, ktoré spúšťajú reakciu systému, keď je okno alebo dvere otvorené po určitú dobu." From b2cf4da0893d0c8b160dafe1b066056d9ab5bf8e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 17:36:41 +0100 Subject: [PATCH 086/194] Fixed tests --- tests/test_climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index 99bc336e..f164e500 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -53,8 +53,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.heating_curve.value == 32.2 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 23.83 - assert climate.pwm.duty_cycle == (285, 914) + assert climate.pwm.last_duty_cycle_percentage == 22.82 + assert climate.pwm.duty_cycle == (273, 926) @pytest.mark.parametrize(*[ @@ -142,8 +142,8 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.setpoint == 41.0 assert climate.heating_curve.value == 32.5 - assert climate.requested_setpoint == 34.6 + assert climate.requested_setpoint == 35 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 53.62 - assert climate.pwm.duty_cycle == (643, 556) + assert climate.pwm.last_duty_cycle_percentage == 56.52 + assert climate.pwm.duty_cycle == (678, 521) From 251e1836357c9b2b68117013cf86050cd495f6e3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 20:20:48 +0100 Subject: [PATCH 087/194] Connect sooner to serial --- custom_components/sat/serial/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index e1b3f4b8..856875ea 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -167,11 +167,9 @@ async def async_connect(self) -> SatSerialCoordinator: return self - async def async_added_to_hass(self) -> None: + async def async_setup(self) -> None: await self.async_connect() - await super().async_added_to_hass() - async def async_will_remove_from_hass(self) -> None: self._api.unsubscribe(self.async_set_updated_data) From b5bb769584525c2d41676c85042c17059f8d92e9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 20:28:48 +0100 Subject: [PATCH 088/194] Do not reset the minimum setpoint --- custom_components/sat/climate.py | 7 +++---- custom_components/sat/setpoint_adjuster.py | 4 ---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 12b897e5..c01a43e1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -767,13 +767,12 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON and self.max_error > -DEADBAND: if self._dynamic_minimum_setpoint: - if not self._coordinator.flame_active: - self._setpoint_adjuster.reset() - self._setpoint = self._coordinator.boiler_temperature + 10 - elif self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + if self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current + elif not self._coordinator.flame_active: + self._setpoint = self._coordinator.boiler_temperature + 10 else: self._setpoint = self._coordinator.minimum_setpoint diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index 99b79a17..eb841bb0 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -14,10 +14,6 @@ def current(self) -> float: """Return the current setpoint.""" return self._current - def reset(self): - """Reset the setpoint.""" - self._current = None - def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: From 33d477a88fa271b02455c1beb12a8ff6bb6edb04 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 20:30:36 +0100 Subject: [PATCH 089/194] Only reset on target temperature change --- custom_components/sat/climate.py | 10 ++++++++++ custom_components/sat/setpoint_adjuster.py | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c01a43e1..236bb8ca 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -640,11 +640,15 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: await self._async_control_pid(True) + + self._setpoint_adjuster.reset() self._pulse_width_modulation_enabled = False # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): await self._async_control_pid(True) + + self._setpoint_adjuster.reset() self._pulse_width_modulation_enabled = False # If the current temperature has changed, update the PID controller @@ -943,6 +947,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the PID controller await self._async_control_pid(True) + # Reset the minimum setpoint + self._setpoint_adjuster.reset() + # Reset the pulse width modulation self._pulse_width_modulation_enabled = False @@ -1017,6 +1024,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the PID controller await self._async_control_pid(True) + # Reset the minimum setpoint + self._setpoint_adjuster.reset() + # Reset the pulse width modulation self._pulse_width_modulation_enabled = False diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index eb841bb0..99b79a17 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -14,6 +14,10 @@ def current(self) -> float: """Return the current setpoint.""" return self._current + def reset(self): + """Reset the setpoint.""" + self._current = None + def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: From 7dddf2d1a8e264542dca0f91cdd5b4e94e33ccf8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 23:20:47 +0100 Subject: [PATCH 090/194] Increase persistent overshooting check --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index cd5f5ccf..6b3e8d2e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -318,7 +318,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 1: + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: self._tracking_boiler_temperature = False _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") From a1b79464bd95c39697334a50c80a1a3ca6363113 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 23:25:07 +0100 Subject: [PATCH 091/194] Always pump when PWM is on --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 236bb8ca..f7fef743 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -893,8 +893,8 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control our areas await self._areas.async_control_heating_loops() - # If the setpoint is high and the HVAC is not off, turn on the heater - await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) + # If the setpoint is high or PWM is on, turn on the heater + await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT or self.pulse_width_modulation_enabled else DeviceState.OFF) self.async_write_ha_state() From 1341f6155b032cf0e5ce75df7f26ec6a863bac85 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 29 Dec 2024 23:40:58 +0100 Subject: [PATCH 092/194] Revert... --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index f7fef743..cd251863 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -894,7 +894,7 @@ async def async_control_heating_loop(self, _time=None) -> None: await self._areas.async_control_heating_loops() # If the setpoint is high or PWM is on, turn on the heater - await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT or self.pulse_width_modulation_enabled else DeviceState.OFF) + await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) self.async_write_ha_state() From 256437c9b9706ae5952ddcb92ea92c659c3cfbf5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:03:07 +0100 Subject: [PATCH 093/194] Change increment --- custom_components/sat/setpoint_adjuster.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index 99b79a17..0ff7f375 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -1,7 +1,6 @@ import logging _LOGGER = logging.getLogger(__name__) -ADJUSTMENT_STEP = 0.5 # Renamed for clarity class SetpointAdjuster: @@ -26,9 +25,9 @@ def adjust(self, target_setpoint: float) -> float: previous_setpoint = self._current if self._current < target_setpoint: - self._current = min(self._current + ADJUSTMENT_STEP, target_setpoint) + self._current = min(self._current + 0.1, target_setpoint) elif self._current > target_setpoint: - self._current = max(self._current - ADJUSTMENT_STEP, target_setpoint) + self._current = max(self._current - 2.0, target_setpoint) _LOGGER.info( "Setpoint updated: %.1f°C -> %.1f°C (Target: %.1f°C)", From 7debae08e88fe575b606732c9c8d19fd11b41936 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:03:19 +0100 Subject: [PATCH 094/194] Disable persistent overshooting for now --- custom_components/sat/coordinator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6b3e8d2e..60276b23 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -318,9 +318,6 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: - self._tracking_boiler_temperature = False - _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") # Append current boiler temperature if valid if self.boiler_temperature is not None: From 7bb85ba46bec9b1979c850f346840729ff52330d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:03:44 +0100 Subject: [PATCH 095/194] Increase decimals to one again --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index cd251863..7748b76f 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -486,7 +486,7 @@ def requested_setpoint(self) -> float: if self.heating_curve.value is None: return MINIMUM_SETPOINT - return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 0) + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) @property def valves_open(self) -> bool: @@ -862,7 +862,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = self._calculate_control_setpoint() else: # Apply low filter on requested setpoint - self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 0) + self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Check for overshoot if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: From 4d7552de154f7844ab55d0b71024a69459ce7465 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:08:35 +0100 Subject: [PATCH 096/194] Turn off PWM when we are above minimum setpoint --- custom_components/sat/climate.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 7748b76f..5934624e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -639,17 +639,13 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: - await self._async_control_pid(True) - self._setpoint_adjuster.reset() - self._pulse_width_modulation_enabled = False + await self._async_control_pid(True) # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): - await self._async_control_pid(True) - self._setpoint_adjuster.reset() - self._pulse_width_modulation_enabled = False + await self._async_control_pid(True) # If the current temperature has changed, update the PID controller elif SENSOR_TEMPERATURE_ID not in new_state.attributes and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): @@ -887,6 +883,10 @@ async def async_control_heating_loop(self, _time=None) -> None: # Set the relative modulation value, if supported await self._async_control_relative_modulation() + # Check if we are above minimum setpoint + if self._setpoint_adjuster.current is not None and self._calculated_setpoint > self._setpoint_adjuster.current: + self._pulse_width_modulation_enabled = False + # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) @@ -950,9 +950,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False - # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -1027,9 +1024,6 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False - # Write the state to Home Assistant self.async_write_ha_state() From 6986e4c7b7908f1f995174b0c493779862562336 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:31:16 +0100 Subject: [PATCH 097/194] Revert... --- custom_components/sat/climate.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5934624e..cd251863 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -486,7 +486,7 @@ def requested_setpoint(self) -> float: if self.heating_curve.value is None: return MINIMUM_SETPOINT - return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 0) @property def valves_open(self) -> bool: @@ -639,14 +639,18 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: - self._setpoint_adjuster.reset() await self._async_control_pid(True) + self._setpoint_adjuster.reset() + self._pulse_width_modulation_enabled = False + # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): - self._setpoint_adjuster.reset() await self._async_control_pid(True) + self._setpoint_adjuster.reset() + self._pulse_width_modulation_enabled = False + # If the current temperature has changed, update the PID controller elif SENSOR_TEMPERATURE_ID not in new_state.attributes and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): await self._async_control_pid() @@ -858,7 +862,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = self._calculate_control_setpoint() else: # Apply low filter on requested setpoint - self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) + self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 0) # Check for overshoot if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: @@ -883,10 +887,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # Set the relative modulation value, if supported await self._async_control_relative_modulation() - # Check if we are above minimum setpoint - if self._setpoint_adjuster.current is not None and self._calculated_setpoint > self._setpoint_adjuster.current: - self._pulse_width_modulation_enabled = False - # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) @@ -950,6 +950,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -1024,6 +1027,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Write the state to Home Assistant self.async_write_ha_state() From 1619de838ad946244d293431a0b5c7e5c685702e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:34:34 +0100 Subject: [PATCH 098/194] Optimize --- custom_components/sat/climate.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index cd251863..cfd3290c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -486,7 +486,7 @@ def requested_setpoint(self) -> float: if self.heating_curve.value is None: return MINIMUM_SETPOINT - return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 0) + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) @property def valves_open(self) -> bool: @@ -639,17 +639,13 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: - await self._async_control_pid(True) - self._setpoint_adjuster.reset() - self._pulse_width_modulation_enabled = False + await self._async_control_pid(True) # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): - await self._async_control_pid(True) - self._setpoint_adjuster.reset() - self._pulse_width_modulation_enabled = False + await self._async_control_pid(True) # If the current temperature has changed, update the PID controller elif SENSOR_TEMPERATURE_ID not in new_state.attributes and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): @@ -862,11 +858,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self._calculated_setpoint = self._calculate_control_setpoint() else: # Apply low filter on requested setpoint - self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 0) - - # Check for overshoot - if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: - self._pulse_width_modulation_enabled = True + self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Pulse Width Modulation if self.pulse_width_modulation_enabled: @@ -887,6 +879,12 @@ async def async_control_heating_loop(self, _time=None) -> None: # Set the relative modulation value, if supported await self._async_control_relative_modulation() + # Check if we are above minimum setpoint + if self._setpoint_adjuster.current is not None and self._calculated_setpoint > self._setpoint_adjuster.current: + self._pulse_width_modulation_enabled = False + elif self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + self._pulse_width_modulation_enabled = True + # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) @@ -950,9 +948,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False - # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -1027,9 +1022,6 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False - # Write the state to Home Assistant self.async_write_ha_state() From d45a79499848c3535e9f1d922fd4b25f4ba6f39e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:38:02 +0100 Subject: [PATCH 099/194] Fixed tests --- tests/test_climate.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index f164e500..99bc336e 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -53,8 +53,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.heating_curve.value == 32.2 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 22.82 - assert climate.pwm.duty_cycle == (273, 926) + assert climate.pwm.last_duty_cycle_percentage == 23.83 + assert climate.pwm.duty_cycle == (285, 914) @pytest.mark.parametrize(*[ @@ -142,8 +142,8 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.setpoint == 41.0 assert climate.heating_curve.value == 32.5 - assert climate.requested_setpoint == 35 + assert climate.requested_setpoint == 34.6 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 56.52 - assert climate.pwm.duty_cycle == (678, 521) + assert climate.pwm.last_duty_cycle_percentage == 53.62 + assert climate.pwm.duty_cycle == (643, 556) From 2ac2aac70bfe416fed05497760ff4adcbc4a5e8f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 12:46:47 +0100 Subject: [PATCH 100/194] Revert to "old" way --- custom_components/sat/climate.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index cfd3290c..59b7d095 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -860,6 +860,10 @@ async def async_control_heating_loop(self, _time=None) -> None: # Apply low filter on requested setpoint self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) + # Check for overshoot + if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + self._pulse_width_modulation_enabled = True + # Pulse Width Modulation if self.pulse_width_modulation_enabled: boiler_state = BoilerState( @@ -879,12 +883,6 @@ async def async_control_heating_loop(self, _time=None) -> None: # Set the relative modulation value, if supported await self._async_control_relative_modulation() - # Check if we are above minimum setpoint - if self._setpoint_adjuster.current is not None and self._calculated_setpoint > self._setpoint_adjuster.current: - self._pulse_width_modulation_enabled = False - elif self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: - self._pulse_width_modulation_enabled = True - # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) @@ -948,6 +946,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Collect which climates to control climates = self._main_climates[:] if self._sync_climates_with_mode: @@ -1022,6 +1023,9 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the minimum setpoint self._setpoint_adjuster.reset() + # Reset the pulse width modulation + self._pulse_width_modulation_enabled = False + # Write the state to Home Assistant self.async_write_ha_state() From 28e6bc738c8f72627146ee01147658a4891f5dd4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 13:02:32 +0100 Subject: [PATCH 101/194] ADd back persistent overshooting check --- custom_components/sat/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 60276b23..6b3e8d2e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -318,6 +318,9 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") + elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + self._tracking_boiler_temperature = False + _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") # Append current boiler temperature if valid if self.boiler_temperature is not None: From efe150eabe841fa3ef0a6082559dc25202baaece Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 13:10:00 +0100 Subject: [PATCH 102/194] No need to check setpoint --- custom_components/sat/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6b3e8d2e..9acdc953 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -315,10 +315,10 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if not self.flame_active: self._tracking_boiler_temperature = True elif self._tracking_boiler_temperature: - if self.setpoint > self.boiler_temperature and self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: + if self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.setpoint < self.boiler_temperature and self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: + elif self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: self._tracking_boiler_temperature = False _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") From 35f89bc4d6f7311996afe26774f3cd1196c237b8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 13:11:42 +0100 Subject: [PATCH 103/194] Try without persistent overshooting again --- custom_components/sat/coordinator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 9acdc953..8d20d101 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -318,9 +318,6 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") - elif self.boiler_temperature > last_boiler_temperature > self.setpoint + 3: - self._tracking_boiler_temperature = False - _LOGGER.warning("Stopped tracking boiler temperature: persistent overshooting above setpoint.") # Append current boiler temperature if valid if self.boiler_temperature is not None: From ead7164cff368c8aea18838b024726b8fe985b62 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 13:18:09 +0100 Subject: [PATCH 104/194] Increase range --- custom_components/sat/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 8d20d101..2f04bf34 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -314,8 +314,8 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non if last_boiler_temperature is not None: if not self.flame_active: self._tracking_boiler_temperature = True - elif self._tracking_boiler_temperature: - if self.setpoint - 3 < self.boiler_temperature < last_boiler_temperature: + elif self._tracking_boiler_temperature: + if self.setpoint > self.boiler_temperature and self.setpoint - 5 < self.boiler_temperature < last_boiler_temperature: self._tracking_boiler_temperature = False _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") From 038ef00998bdfc81c849166bf9a6709e7190367c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 15:50:52 +0100 Subject: [PATCH 105/194] Improve overshooting indicators and did some cleaning --- custom_components/sat/boiler.py | 106 ++++++++++++++++++++++++++ custom_components/sat/boiler_state.py | 52 ------------- custom_components/sat/climate.py | 2 +- custom_components/sat/const.py | 1 - custom_components/sat/coordinator.py | 24 +++--- custom_components/sat/pwm.py | 2 +- 6 files changed, 119 insertions(+), 68 deletions(-) create mode 100644 custom_components/sat/boiler.py delete mode 100644 custom_components/sat/boiler_state.py diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py new file mode 100644 index 00000000..b5ee68e6 --- /dev/null +++ b/custom_components/sat/boiler.py @@ -0,0 +1,106 @@ +import logging + +_LOGGER = logging.getLogger(__name__) + +STABILIZATION_MARGIN = 5 +EXCEED_SETPOINT_MARGIN = 2 + + +class BoilerState: + """ + Represents the operational state of a boiler, including activity, flame status, + hot water usage, and current temperature. + """ + + def __init__(self, device_active: bool, flame_active: bool, hot_water_active: bool, temperature: float): + """ + Initialize with the boiler's state parameters. + + :param device_active: Whether the boiler is currently operational. + :param flame_active: Whether the boiler's flame is ignited. + :param hot_water_active: Whether the boiler is heating water. + :param temperature: The current boiler temperature in Celsius. + """ + self._temperature = temperature + self._flame_active = flame_active + self._device_active = device_active + self._hot_water_active = hot_water_active + + @property + def device_active(self) -> bool: + """Indicates whether the boiler is running.""" + return self._device_active + + @property + def flame_active(self) -> bool: + """Indicates whether the flame is ignited.""" + return self._flame_active + + @property + def hot_water_active(self) -> bool: + """Indicates whether the boiler is heating water.""" + return self._hot_water_active + + @property + def temperature(self) -> float: + """The boiler's current temperature.""" + return self._temperature + + +class BoilerTemperatureTracker: + def __init__(self): + """Initialize the BoilerTemperatureTracker.""" + self._active = False + self._last_boiler_temperature = None + + def update(self, boiler_temperature: float, flame_active: bool, setpoint: float): + """Update the tracker based on the current boiler temperature, flame status, and setpoint.""" + if self._last_boiler_temperature is None: + self._last_boiler_temperature = boiler_temperature + _LOGGER.debug("Initialized last_boiler_temperature to %s.", boiler_temperature) + + if not flame_active: + self._handle_flame_inactive() + elif self._active: + self._handle_tracking(boiler_temperature, setpoint) + + self._last_boiler_temperature = boiler_temperature + _LOGGER.debug("Updated last_boiler_temperature to %s.", boiler_temperature) + + def _handle_flame_inactive(self): + """Handle the case where the flame is inactive.""" + if self.active: + return + + self._active = True + _LOGGER.debug("Flame inactive: Starting to track boiler temperature.") + + def _handle_tracking(self, boiler_temperature: float, setpoint: float): + """Handle boiler temperature tracking logic.""" + # Stop tracking if the boiler temperature stabilizes below the setpoint + if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: + return self._stop_tracking("Stabilizing below setpoint.", boiler_temperature, setpoint) + + # Stop tracking if the boiler temperature exceeds the setpoint significantly + if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: + return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) + + def _stop_tracking(self, reason: str, boiler_temperature: float, setpoint: float): + """Stop tracking and log the reason.""" + self._active = False + + _LOGGER.debug( + f"Stopped tracking boiler temperature: {reason} " + f"Setpoint: {setpoint}, Current: {boiler_temperature}, " + f"Last: {self._last_boiler_temperature}." + ) + + @property + def active(self) -> bool: + """Check if the tracker is currently active.""" + return self._active + + @property + def inactive(self) -> bool: + """Check if the tracker is currently inactive.""" + return not self._active diff --git a/custom_components/sat/boiler_state.py b/custom_components/sat/boiler_state.py deleted file mode 100644 index 23ef367a..00000000 --- a/custom_components/sat/boiler_state.py +++ /dev/null @@ -1,52 +0,0 @@ -class BoilerState: - """ - Represents the operational state of a boiler, including activity, flame status, - hot water usage, and current temperature. - """ - def __init__( - self, - device_active: bool, - flame_active: bool, - hot_water_active: bool, - temperature: float - ): - """ - Initialize with the boiler's state parameters. - - :param device_active: Whether the boiler is currently operational. - :param flame_active: Whether the boiler's flame is ignited. - :param hot_water_active: Whether the boiler is heating water. - :param temperature: The current boiler temperature in Celsius. - """ - self._device_active = device_active - self._flame_active = flame_active - self._hot_water_active = hot_water_active - self._temperature = temperature - - @property - def device_active(self) -> bool: - """ - Indicates whether the boiler is running. - """ - return self._device_active - - @property - def flame_active(self) -> bool: - """ - Indicates whether the flame is ignited. - """ - return self._flame_active - - @property - def hot_water_active(self) -> bool: - """ - Indicates whether the boiler is heating water. - """ - return self._hot_water_active - - @property - def temperature(self) -> float: - """ - The boiler's current temperature. - """ - return self._temperature diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 59b7d095..9c0e1fe6 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -36,7 +36,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .area import Areas, SENSOR_TEMPERATURE_ID -from .boiler_state import BoilerState +from .boiler import BoilerState from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 015c7644..b13d8e2d 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -15,7 +15,6 @@ MODE_SIMULATOR = "simulator" DEADBAND = 0.1 -BOILER_TEMPERATURE_OFFSET = 2 HEATER_STARTUP_TIMEFRAME = 180 MINIMUM_SETPOINT = 10 diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2f04bf34..c8ef050e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -10,6 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .boiler import BoilerTemperatureTracker from .const import * from .manufacturer import ManufacturerFactory, Manufacturer from .util import calculate_default_maximum_setpoint @@ -81,8 +82,8 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._manufacturer = None self._options = options or {} self._device_state = DeviceState.OFF - self._tracking_boiler_temperature = True self._simulation = bool(self._options.get(CONF_SIMULATION)) + self._boiler_temperature_tracker = BoilerTemperatureTracker() self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) super().__init__(hass, _LOGGER, name=DOMAIN) @@ -117,10 +118,10 @@ def device_status(self): if not self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.PREHEATING - if self._tracking_boiler_temperature and self.flame_active and self.setpoint > self.boiler_temperature: + if self._boiler_temperature_tracker.active and self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.HEATING_UP - if not self._tracking_boiler_temperature and self.flame_active: + if not self._boiler_temperature_tracker.active and self.flame_active: if self.setpoint == self.boiler_temperature - 2: return DeviceStatus.OVERSHOOT_STABILIZED @@ -308,16 +309,13 @@ async def async_will_remove_from_hass(self) -> None: async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: """Control the heating loop for the device.""" current_time = datetime.now() - last_boiler_temperature = self.boiler_temperatures[-1][1] if self.boiler_temperatures else None - - # Check and handle boiler temperature tracking - if last_boiler_temperature is not None: - if not self.flame_active: - self._tracking_boiler_temperature = True - elif self._tracking_boiler_temperature: - if self.setpoint > self.boiler_temperature and self.setpoint - 5 < self.boiler_temperature < last_boiler_temperature: - self._tracking_boiler_temperature = False - _LOGGER.debug("Stopped tracking boiler temperature: stabilizing below setpoint.") + + # Handle the temperature tracker + self._boiler_temperature_tracker.update( + setpoint=self.setpoint, + flame_active=self.flame_active, + boiler_temperature=self.boiler_temperature, + ) # Append current boiler temperature if valid if self.boiler_temperature is not None: diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 7712b437..1cbf0cdc 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,7 +3,7 @@ from time import monotonic from typing import Optional, Tuple -from .boiler_state import BoilerState +from .boiler import BoilerState from .const import HEATER_STARTUP_TIMEFRAME from .heating_curve import HeatingCurve From cb23e1570dd66c10e5271e56aa52e49bdf51d37f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 16:12:25 +0100 Subject: [PATCH 106/194] Dropped some logging --- custom_components/sat/boiler.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index b5ee68e6..6d515196 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -57,7 +57,6 @@ def update(self, boiler_temperature: float, flame_active: bool, setpoint: float) """Update the tracker based on the current boiler temperature, flame status, and setpoint.""" if self._last_boiler_temperature is None: self._last_boiler_temperature = boiler_temperature - _LOGGER.debug("Initialized last_boiler_temperature to %s.", boiler_temperature) if not flame_active: self._handle_flame_inactive() @@ -65,7 +64,6 @@ def update(self, boiler_temperature: float, flame_active: bool, setpoint: float) self._handle_tracking(boiler_temperature, setpoint) self._last_boiler_temperature = boiler_temperature - _LOGGER.debug("Updated last_boiler_temperature to %s.", boiler_temperature) def _handle_flame_inactive(self): """Handle the case where the flame is inactive.""" From 569fdcc85870554b4e84519c8233f70a005b20da Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 16:13:47 +0100 Subject: [PATCH 107/194] Improving booting without losing any states --- custom_components/sat/climate.py | 41 ++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 9c0e1fe6..6f9334cb 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -28,8 +28,8 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF -from homeassistant.core import HomeAssistant, ServiceCall, Event +from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import HomeAssistant, ServiceCall, Event, CoreState from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval @@ -217,8 +217,10 @@ async def async_added_to_hass(self) -> None: self._areas.heating_curves.update(self.current_outside_temperature) self.heating_curve.update(self.target_temperature, self.current_outside_temperature) - # Start control loop - await self.async_control_heating_loop() + if self.hass.state is not CoreState.running: + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_control_heating_loop) + else: + await self.async_control_heating_loop() # Register services await self._register_services() @@ -549,6 +551,9 @@ def relative_modulation_state(self) -> RelativeModulationState: @property def minimum_setpoint(self) -> float: + if self._dynamic_minimum_setpoint and self._setpoint_adjuster.current is not None: + return self._setpoint_adjuster.current + return self._coordinator.minimum_setpoint def _calculate_control_setpoint(self) -> float: @@ -564,6 +569,10 @@ def _calculate_control_setpoint(self) -> float: async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -577,6 +586,10 @@ async def _async_inside_sensor_changed(self, event: Event) -> None: async def _async_outside_entity_changed(self, event: Event) -> None: """Handle changes to the outside entity.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + if event.data.get("new_state") is None: return @@ -588,6 +601,10 @@ async def _async_outside_entity_changed(self, event: Event) -> None: async def _async_humidity_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -601,6 +618,10 @@ async def _async_humidity_sensor_changed(self, event: Event) -> None: async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + old_state = event.data.get("old_state") new_state = event.data.get("new_state") if new_state is None: @@ -615,6 +636,10 @@ async def _async_climate_changed(self, event: Event) -> None: If the state, target temperature, or current temperature of the climate entity has changed, update the PID controller and heating control. """ + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + # Get the new state of the climate entity new_state = event.data.get("new_state") @@ -673,6 +698,10 @@ async def _async_temperature_change(self, event: Event) -> None: async def _async_window_sensor_changed(self, event: Event) -> None: """Handle changes to the contact sensor entity.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -707,6 +736,10 @@ async def _async_window_sensor_changed(self, event: Event) -> None: async def _async_control_pid(self, reset: bool = False) -> None: """Control the PID controller.""" + # Ignore any events if are (still) booting up + if self.hass.state is not CoreState.running: + return + # We can't continue if we don't have a valid outside temperature if self.current_outside_temperature is None: return From cba74fa5c88dfb1754c99d08ef064f8bf2264ef0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 16:38:27 +0100 Subject: [PATCH 108/194] Do not check deadband in PWM --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6f9334cb..c91e4744 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -798,7 +798,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: # PWM is enabled and actively controlling the cycle _LOGGER.info("Running PWM cycle with state: %s", pwm_state) - if pwm_state == PWMState.ON and self.max_error > -DEADBAND: + if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: if self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) From 55aa80270c1dd41d3c2016c9d2d5708a175a6d5a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 16:49:54 +0100 Subject: [PATCH 109/194] Do not handle the boiler temperature when we are requesting hot water --- custom_components/sat/coordinator.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index c8ef050e..2a5fe058 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -311,11 +311,12 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non current_time = datetime.now() # Handle the temperature tracker - self._boiler_temperature_tracker.update( - setpoint=self.setpoint, - flame_active=self.flame_active, - boiler_temperature=self.boiler_temperature, - ) + if self.device_status is not DeviceStatus.HOT_WATER: + self._boiler_temperature_tracker.update( + setpoint=self.setpoint, + flame_active=self.flame_active, + boiler_temperature=self.boiler_temperature, + ) # Append current boiler temperature if valid if self.boiler_temperature is not None: From b445693076feedda141a8435638c36298633b5d1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 17:01:39 +0100 Subject: [PATCH 110/194] Make sure we also start with +10 in the adjuster --- custom_components/sat/setpoint_adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index 0ff7f375..d1ecb337 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -20,7 +20,7 @@ def reset(self): def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: - self._current = target_setpoint + self._current = target_setpoint + 10 previous_setpoint = self._current From 072bbc7506a5ec305ef232ba44ce41a05c5494e3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 17:04:02 +0100 Subject: [PATCH 111/194] Make sure we have a valid setpoint --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c91e4744..4e6b45ee 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -800,11 +800,11 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: - if self._setpoint is None or self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current - elif not self._coordinator.flame_active: + elif not self._coordinator.flame_active or self._setpoint is None: self._setpoint = self._coordinator.boiler_temperature + 10 else: self._setpoint = self._coordinator.minimum_setpoint From 65fd523e943cb4aebe18a442f822cf29261e8cba Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 17:06:29 +0100 Subject: [PATCH 112/194] Simple ignore when the setpoint is None --- custom_components/sat/climate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 4e6b45ee..e592a05c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -804,8 +804,10 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current - elif not self._coordinator.flame_active or self._setpoint is None: + elif not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 + elif self._setpoint is None: + return else: self._setpoint = self._coordinator.minimum_setpoint From 2654c32c55a26df523f596d8386aac42774b67cd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 17:07:57 +0100 Subject: [PATCH 113/194] Copy from the coordinator if the setpoint None --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e592a05c..5d744728 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -807,7 +807,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: elif not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 elif self._setpoint is None: - return + self._setpoint = self._coordinator.setpoint else: self._setpoint = self._coordinator.minimum_setpoint From 847a73eec88ec582d0fdf678398cee53047382e5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 17:10:24 +0100 Subject: [PATCH 114/194] Restore the setpoint if available --- custom_components/sat/climate.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5d744728..fb1cb44e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -47,10 +47,9 @@ from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds ATTR_ROOMS = "rooms" -ATTR_WARMING_UP = "warming_up_data" +ATTR_SETPOINT = "setpoint" ATTR_OPTIMAL_COEFFICIENT = "optimal_coefficient" ATTR_COEFFICIENT_DERIVATIVE = "coefficient_derivative" -ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" ATTR_PRE_CUSTOM_TEMPERATURE = "pre_custom_temperature" ATTR_PRE_ACTIVITY_TEMPERATURE = "pre_activity_temperature" ATTR_PULSE_WIDTH_MODULATION_ENABLED = "pulse_width_modulation_enabled" @@ -308,6 +307,9 @@ async def _restore_previous_state_or_set_defaults(self): if old_state.state: self._hvac_mode = old_state.state + if old_state.attributes.get(ATTR_SETPOINT): + self._setpoint = old_state.attributes.get(ATTR_SETPOINT) + if old_state.attributes.get(ATTR_PRESET_MODE): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) @@ -807,7 +809,8 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: elif not self._coordinator.flame_active: self._setpoint = self._coordinator.boiler_temperature + 10 elif self._setpoint is None: - self._setpoint = self._coordinator.setpoint + _LOGGER.debug("Setpoint not available.") + return else: self._setpoint = self._coordinator.minimum_setpoint From 5654213edf52a1d78c45924690504001598d3403 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 18:52:25 +0100 Subject: [PATCH 115/194] Cleanup --- custom_components/sat/pwm.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index e509569f..b127c2ee 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -95,9 +95,6 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: # State transitions for PWM if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): - if self._first_duty_cycle_start is None: - self._first_duty_cycle_start = monotonic() - if self._cycles >= self._max_cycles: _LOGGER.info("Reached max cycles per hour, preventing new duty cycle.") return From e26d7d5541ab90ede2aea96f525d677eb01f5a9a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 21:28:33 +0100 Subject: [PATCH 116/194] Introduce a helper file and delay the adjuster with 10 seconds after flame --- custom_components/sat/area.py | 3 +- custom_components/sat/binary_sensor.py | 12 ++-- custom_components/sat/climate.py | 5 +- custom_components/sat/config_flow.py | 2 +- custom_components/sat/coordinator.py | 20 ++++-- custom_components/sat/helpers.py | 70 +++++++++++++++++++++ custom_components/sat/mqtt/__init__.py | 2 +- custom_components/sat/mqtt/ems.py | 2 +- custom_components/sat/pid.py | 13 ++-- custom_components/sat/simulator/__init__.py | 2 +- custom_components/sat/util.py | 49 +-------------- 11 files changed, 104 insertions(+), 76 deletions(-) create mode 100644 custom_components/sat/helpers.py diff --git a/custom_components/sat/area.py b/custom_components/sat/area.py index b94925bf..fb3f2a87 100644 --- a/custom_components/sat/area.py +++ b/custom_components/sat/area.py @@ -5,10 +5,11 @@ from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State +from .helpers import float_value from .util import ( create_pwm_controller, create_pid_controller, - create_heating_curve_controller, float_value, + create_heating_curve_controller, ) SENSOR_TEMPERATURE_ID = "sensor_temperature_id" diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 4f938c87..d19bc43f 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,7 +1,7 @@ from __future__ import annotations -import asyncio import logging +from time import monotonic from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.components.climate import HVACAction @@ -14,6 +14,7 @@ from .climate import SatClimate from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS from .entity import SatClimateEntity +from .helpers import seconds_since from .serial import binary_sensor as serial_binary_sensor _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -57,18 +58,13 @@ def state_delayed(self, condition: bool) -> bool: return False if self._last_mismatch is None: - self._last_mismatch = self._get_current_time() + self._last_mismatch = monotonic() - if self._get_current_time() - self._last_mismatch >= self._delay: + if seconds_since(self._last_mismatch) >= self._delay: return True return False - @staticmethod - def _get_current_time() -> float: - """Get the current time in seconds since epoch.""" - return asyncio.get_event_loop().time() - class SatControlSetpointSynchroSensor(SatSynchroSensor, SatClimateEntity, BinarySensorEntity): def __init__(self, coordinator, _config_entry, climate): diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index fb1cb44e..e8533e22 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,11 +40,12 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity +from .helpers import convert_time_str_to_seconds, seconds_since from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .setpoint_adjuster import SetpointAdjuster from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller ATTR_ROOMS = "rooms" ATTR_SETPOINT = "setpoint" @@ -802,7 +803,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: - if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING and seconds_since(self._coordinator.flame_on_since) > 10: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index df3b0ded..8f8e5d4f 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -25,8 +25,8 @@ from . import SatDataUpdateCoordinatorFactory from .const import * from .coordinator import SatDataUpdateCoordinator +from .helpers import calculate_default_maximum_setpoint, snake_case from .overshoot_protection import OvershootProtection -from .util import calculate_default_maximum_setpoint, snake_case DEFAULT_NAME = "Living Room" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2a5fe058..2376e061 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -2,8 +2,9 @@ import logging from abc import abstractmethod -from datetime import datetime, timedelta +from datetime import datetime from enum import Enum +from time import monotonic from typing import TYPE_CHECKING, Mapping, Any, Optional from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN @@ -12,8 +13,8 @@ from .boiler import BoilerTemperatureTracker from .const import * +from .helpers import calculate_default_maximum_setpoint, seconds_since from .manufacturer import ManufacturerFactory, Manufacturer -from .util import calculate_default_maximum_setpoint if TYPE_CHECKING: from .climate import SatClimate @@ -80,6 +81,7 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._data = data self._manufacturer = None + self._flame_on_since = None self._options = options or {} self._device_state = DeviceState.OFF self._simulation = bool(self._options.get(CONF_SIMULATION)) @@ -164,6 +166,10 @@ def member_id(self) -> int | None: def flame_active(self) -> bool: return self.device_active + @property + def flame_on_since(self) -> datetime | None: + return self._flame_on_since + @property def hot_water_active(self) -> bool: return False @@ -308,7 +314,11 @@ async def async_will_remove_from_hass(self) -> None: async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: """Control the heating loop for the device.""" - current_time = datetime.now() + # Update Flame State + if not self.flame_active: + self._flame_on_since = None + elif self._flame_on_since is None: + self._flame_on_since = monotonic() # Handle the temperature tracker if self.device_status is not DeviceStatus.HOT_WATER: @@ -320,13 +330,13 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Append current boiler temperature if valid if self.boiler_temperature is not None: - self.boiler_temperatures.append((current_time, self.boiler_temperature)) + self.boiler_temperatures.append((monotonic(), self.boiler_temperature)) # Remove old temperature records beyond the allowed age self.boiler_temperatures = [ (timestamp, temp) for timestamp, temp in self.boiler_temperatures - if current_time - timestamp <= timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE) + if seconds_since(timestamp) <= MAX_BOILER_TEMPERATURE_AGE ] async def async_set_heater_state(self, state: DeviceState) -> None: diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py new file mode 100644 index 00000000..6c5aa282 --- /dev/null +++ b/custom_components/sat/helpers.py @@ -0,0 +1,70 @@ +from re import sub +from time import monotonic + +from homeassistant.util import dt + +from .const import HEATING_SYSTEM_UNDERFLOOR + + +def seconds_since(start_time: float | None) -> float: + """ + Calculate the elapsed time in seconds since a given start time using monotonic(). + If start_time is None, return 0. + + Args: + start_time (float or None): The reference start time, typically obtained from time.monotonic(). + If None, elapsed time is considered 0. + + Returns: + float: The elapsed time in seconds as a float. Returns 0 if start_time is None. + """ + if start_time is None: + return 0.0 + + return monotonic() - start_time + + +def convert_time_str_to_seconds(time_str: str) -> float: + """ + Convert a time string in the format 'HH:MM:SS' to seconds. + + Args: + time_str: A string representing a time in the format 'HH:MM:SS'. + + Returns: + float: The time in seconds. + """ + date_time = dt.parse_time(time_str) + # Calculate the number of seconds by multiplying the hours, minutes and seconds + return (date_time.hour * 3600) + (date_time.minute * 60) + date_time.second + + +def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: float): + """Calculates the derivative per hour based on the temperature error and time taken.""" + # Convert time taken from seconds to hours + time_taken_hours = time_taken_seconds / 3600 + + # Calculate the derivative per hour by dividing temperature error by time taken + return round(temperature_error / time_taken_hours, 2) + + +def calculate_default_maximum_setpoint(heating_system: str) -> int: + if heating_system == HEATING_SYSTEM_UNDERFLOOR: + return 50 + + return 55 + + +def snake_case(value: str) -> str: + return '_'.join( + sub('([A-Z][a-z]+)', r' \1', + sub('([A-Z]+)', r' \1', + value.replace('-', ' '))).split()).lower() + + +def float_value(value) -> float | None: + """Helper method to convert a value to float, handling possible errors.""" + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 52a8e03e..08233e2a 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -9,7 +9,7 @@ from ..const import CONF_MQTT_TOPIC from ..coordinator import SatDataUpdateCoordinator -from ..util import snake_case +from ..helpers import snake_case _LOGGER: logging.Logger = logging.getLogger(__name__) diff --git a/custom_components/sat/mqtt/ems.py b/custom_components/sat/mqtt/ems.py index c7ad1f3d..c7430105 100644 --- a/custom_components/sat/mqtt/ems.py +++ b/custom_components/sat/mqtt/ems.py @@ -5,7 +5,7 @@ from . import SatMqttCoordinator from ..coordinator import DeviceState -from ..util import float_value +from ..helpers import float_value DATA_ON = "on" DATA_OFF = "off" diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index eca7dbe2..a75b0c6b 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -6,6 +6,7 @@ from homeassistant.core import State from .const import * +from .helpers import seconds_since _LOGGER = logging.getLogger(__name__) @@ -82,8 +83,7 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f :param heating_curve_value: The current heating curve value. :param boiler_temperature: The current boiler temperature. """ - current_time = monotonic() - time_elapsed = current_time - self._last_updated + time_elapsed = seconds_since(self._last_updated) if error == self._last_error: return @@ -95,7 +95,7 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f self.update_derivative(error) self.update_history_size() - self._last_updated = current_time + self._last_updated = monotonic() self._time_elapsed = time_elapsed self._last_error = error @@ -142,22 +142,19 @@ def update_integral(self, error: float, heating_curve_value: float, force: bool if not force and monotonic() - self._last_interval_updated < self._integral_time_limit: return - current_time = monotonic() - time_elapsed = current_time - self._last_interval_updated - # Check if the integral gain `ki` is set if self.ki is None: return # Update the integral value - self._integral += self.ki * error * time_elapsed + self._integral += self.ki * error * seconds_since(self._last_interval_updated) # Clamp the integral value within the limit self._integral = min(self._integral, float(+heating_curve_value)) self._integral = max(self._integral, float(-heating_curve_value)) # Record the time of the latest update - self._last_interval_updated = current_time + self._last_interval_updated = monotonic() def update_derivative(self, error: float, alpha1: float = 0.8, alpha2: float = 0.6): """ diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index db8e7fd9..46d880e1 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -7,7 +7,7 @@ from ..const import CONF_SIMULATED_HEATING, CONF_SIMULATED_COOLING, MINIMUM_SETPOINT, CONF_SIMULATED_WARMING_UP, CONF_MAXIMUM_SETPOINT from ..coordinator import DeviceState, SatDataUpdateCoordinator -from ..util import convert_time_str_to_seconds +from ..helpers import convert_time_str_to_seconds if TYPE_CHECKING: from ..climate import SatClimate diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index c95eaef9..a220c909 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -1,45 +1,13 @@ -from re import sub from types import MappingProxyType from typing import Any -from homeassistant.util import dt - from .const import * from .heating_curve import HeatingCurve +from .helpers import convert_time_str_to_seconds from .pid import PID from .pwm import PWM -def convert_time_str_to_seconds(time_str: str) -> float: - """Convert a time string in the format 'HH:MM:SS' to seconds. - - Args: - time_str: A string representing a time in the format 'HH:MM:SS'. - - Returns: - float: The time in seconds. - """ - date_time = dt.parse_time(time_str) - # Calculate the number of seconds by multiplying the hours, minutes and seconds - return (date_time.hour * 3600) + (date_time.minute * 60) + date_time.second - - -def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: float): - """ - Calculates the derivative per hour based on the temperature error and time taken.""" - # Convert time taken from seconds to hours - time_taken_hours = time_taken_seconds / 3600 - # Calculate the derivative per hour by dividing temperature error by time taken - return round(temperature_error / time_taken_hours, 2) - - -def calculate_default_maximum_setpoint(heating_system: str) -> int: - if heating_system == HEATING_SYSTEM_UNDERFLOOR: - return 50 - - return 55 - - def create_pid_controller(config_options) -> PID: """Create and return a PID controller instance with the given configuration options.""" # Extract the configuration options @@ -90,18 +58,3 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy # Return a new PWM controller instance with the given configuration options return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, max_cycles=max_duty_cycles, force=force) - - -def snake_case(value: str) -> str: - return '_'.join( - sub('([A-Z][a-z]+)', r' \1', - sub('([A-Z]+)', r' \1', - value.replace('-', ' '))).split()).lower() - - -def float_value(value) -> float | None: - """Helper method to convert a value to float, handling possible errors.""" - try: - return float(value) - except (TypeError, ValueError): - return None From 74c7b8f90afd8096cb99e4b6f568ecee7560897f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 21:30:17 +0100 Subject: [PATCH 117/194] Moved some code --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/coordinator.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e8533e22..e811bdf5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -40,7 +40,7 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity -from .helpers import convert_time_str_to_seconds, seconds_since +from .helpers import convert_time_str_to_seconds from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .setpoint_adjuster import SetpointAdjuster @@ -803,7 +803,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: - if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING and seconds_since(self._coordinator.flame_on_since) > 10: + if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2376e061..20a22c55 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -123,7 +123,7 @@ def device_status(self): if self._boiler_temperature_tracker.active and self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.HEATING_UP - if not self._boiler_temperature_tracker.active and self.flame_active: + if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 10: if self.setpoint == self.boiler_temperature - 2: return DeviceStatus.OVERSHOOT_STABILIZED From aec0dbc6c887ace05d36385ba7cab4188593b978 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 21:33:38 +0100 Subject: [PATCH 118/194] Sanity checks --- custom_components/sat/helpers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index 6c5aa282..eb82acb3 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -44,6 +44,10 @@ def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: # Convert time taken from seconds to hours time_taken_hours = time_taken_seconds / 3600 + # Avoid division-by-zero error + if time_taken_hours == 0: + return 0 + # Calculate the derivative per hour by dividing temperature error by time taken return round(temperature_error / time_taken_hours, 2) From 1b143f7f79ab923a7a2dc23811736e195cf9402d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 22:01:48 +0100 Subject: [PATCH 119/194] Increase the adjuster a bit --- custom_components/sat/setpoint_adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index d1ecb337..c87a1dac 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -25,7 +25,7 @@ def adjust(self, target_setpoint: float) -> float: previous_setpoint = self._current if self._current < target_setpoint: - self._current = min(self._current + 0.1, target_setpoint) + self._current = min(self._current + 0.2, target_setpoint) elif self._current > target_setpoint: self._current = max(self._current - 2.0, target_setpoint) From ffbc5b5aa71aa74bb49d7753b659b85bd85be92c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 22:04:49 +0100 Subject: [PATCH 120/194] Increase it back to 0.5 --- custom_components/sat/setpoint_adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index c87a1dac..9f938edc 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -25,7 +25,7 @@ def adjust(self, target_setpoint: float) -> float: previous_setpoint = self._current if self._current < target_setpoint: - self._current = min(self._current + 0.2, target_setpoint) + self._current = min(self._current + 0.5, target_setpoint) elif self._current > target_setpoint: self._current = max(self._current - 2.0, target_setpoint) From da343dc895b96fee13b81a50645606763cb003ed Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 31 Dec 2024 22:39:44 +0100 Subject: [PATCH 121/194] Increase delay --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 20a22c55..f5118c41 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -123,7 +123,7 @@ def device_status(self): if self._boiler_temperature_tracker.active and self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.HEATING_UP - if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 10: + if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 30: if self.setpoint == self.boiler_temperature - 2: return DeviceStatus.OVERSHOOT_STABILIZED From 21e0f5c1d200512c7defdc4880b1d83c63b23eb0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 1 Jan 2025 21:57:00 +0100 Subject: [PATCH 122/194] Do not use offset for comparison --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index f5118c41..6b9ba849 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -124,7 +124,7 @@ def device_status(self): return DeviceStatus.HEATING_UP if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 30: - if self.setpoint == self.boiler_temperature - 2: + if self.setpoint == self.boiler_temperature: return DeviceStatus.OVERSHOOT_STABILIZED return DeviceStatus.OVERSHOOT_HANDLING From b72afcc19accded23ace187a8c6e61fbb4c4bc98 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 01:27:30 +0100 Subject: [PATCH 123/194] Add support for boiler temperature derivative and also wait for a zero derivative when tracking --- custom_components/sat/boiler.py | 39 ++++++++++++++++++---------- custom_components/sat/coordinator.py | 35 ++++++++++++++++++------- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 6d515196..7604ff31 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -8,8 +8,7 @@ class BoilerState: """ - Represents the operational state of a boiler, including activity, flame status, - hot water usage, and current temperature. + Represents the operational state of a boiler, including activity, flame status, hot water usage, and current temperature. """ def __init__(self, device_active: bool, flame_active: bool, hot_water_active: bool, temperature: float): @@ -51,9 +50,10 @@ class BoilerTemperatureTracker: def __init__(self): """Initialize the BoilerTemperatureTracker.""" self._active = False + self._warming_up = False self._last_boiler_temperature = None - def update(self, boiler_temperature: float, flame_active: bool, setpoint: float): + def update(self, boiler_temperature: float, boiler_temperature_derivative: float, flame_active: bool, setpoint: float): """Update the tracker based on the current boiler temperature, flame status, and setpoint.""" if self._last_boiler_temperature is None: self._last_boiler_temperature = boiler_temperature @@ -61,36 +61,47 @@ def update(self, boiler_temperature: float, flame_active: bool, setpoint: float) if not flame_active: self._handle_flame_inactive() elif self._active: - self._handle_tracking(boiler_temperature, setpoint) + self._handle_tracking(boiler_temperature, boiler_temperature_derivative, setpoint) self._last_boiler_temperature = boiler_temperature def _handle_flame_inactive(self): """Handle the case where the flame is inactive.""" - if self.active: + if self._active: return self._active = True + self._warming_up = True + _LOGGER.debug("Flame inactive: Starting to track boiler temperature.") - def _handle_tracking(self, boiler_temperature: float, setpoint: float): + def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle boiler temperature tracking logic.""" - # Stop tracking if the boiler temperature stabilizes below the setpoint - if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: - return self._stop_tracking("Stabilizing below setpoint.", boiler_temperature, setpoint) + if not self._warming_up and boiler_temperature_derivative == 0: + return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) - # Stop tracking if the boiler temperature exceeds the setpoint significantly if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) + if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: + return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) + + def _stop_warming_up(self, reason: str, boiler_temperature: float, setpoint: float): + """Stop the warming-up phase and log the reason.""" + self._warming_up = False + + _LOGGER.debug( + f"Warming up stopped: {reason} " + f"(Setpoint: {setpoint}, Current: {boiler_temperature}, Last: {self._last_boiler_temperature})." + ) + def _stop_tracking(self, reason: str, boiler_temperature: float, setpoint: float): - """Stop tracking and log the reason.""" + """Deactivate tracking and log the reason.""" self._active = False _LOGGER.debug( - f"Stopped tracking boiler temperature: {reason} " - f"Setpoint: {setpoint}, Current: {boiler_temperature}, " - f"Last: {self._last_boiler_temperature}." + f"Tracking stopped: {reason} " + f"(Setpoint: {setpoint}, Current: {boiler_temperature}, Last: {self._last_boiler_temperature})." ) @property diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6b9ba849..8f944e00 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -77,7 +77,7 @@ def resolve(hass: HomeAssistant, mode: str, device: str, data: Mapping[str, Any] class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: """Initialize.""" - self.boiler_temperatures = [] + self._boiler_temperatures = [] self._data = data self._manufacturer = None @@ -123,7 +123,7 @@ def device_status(self): if self._boiler_temperature_tracker.active and self.flame_active and self.setpoint > self.boiler_temperature: return DeviceStatus.HEATING_UP - if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 30: + if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 6: if self.setpoint == self.boiler_temperature: return DeviceStatus.OVERSHOOT_STABILIZED @@ -187,18 +187,32 @@ def return_temperature(self) -> float | None: return None @property - def filtered_boiler_temperature(self) -> float: + def boiler_temperature_filtered(self) -> float: # Not able to use if we do not have at least two values - if len(self.boiler_temperatures) < 2: + if len(self._boiler_temperatures) < 2: return self.boiler_temperature # Some noise filtering on the boiler temperature difference_boiler_temperature_sum = sum( - abs(j[1] - i[1]) for i, j in zip(self.boiler_temperatures, self.boiler_temperatures[1:]) + abs(j[1] - i[1]) for i, j in zip(self._boiler_temperatures, self._boiler_temperatures[1:]) ) # Average it and return it - return round(difference_boiler_temperature_sum / (len(self.boiler_temperatures) - 1), 2) + return round(difference_boiler_temperature_sum / (len(self._boiler_temperatures) - 1), 2) + + @property + def boiler_temperature_derivative(self) -> float: + if len(self._boiler_temperatures) < 1: + return 0.0 + + first_time, first_temperature = self._boiler_temperatures[0] + last_time, last_temperature = self._boiler_temperatures[-1] + time_delta = first_time - last_time + + if time_delta <= 0: + return 0.0 + + return (first_temperature - last_temperature) / time_delta @property def minimum_hot_water_setpoint(self) -> float: @@ -325,17 +339,18 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non self._boiler_temperature_tracker.update( setpoint=self.setpoint, flame_active=self.flame_active, - boiler_temperature=self.boiler_temperature, + boiler_temperature=round(self.boiler_temperature, 0), + boiler_temperature_derivative=round(self.boiler_temperature_derivative, 0) ) # Append current boiler temperature if valid if self.boiler_temperature is not None: - self.boiler_temperatures.append((monotonic(), self.boiler_temperature)) + self._boiler_temperatures.append((monotonic(), self.boiler_temperature)) # Remove old temperature records beyond the allowed age - self.boiler_temperatures = [ + self._boiler_temperatures = [ (timestamp, temp) - for timestamp, temp in self.boiler_temperatures + for timestamp, temp in self._boiler_temperatures if seconds_since(timestamp) <= MAX_BOILER_TEMPERATURE_AGE ] From 4555fcaba1ea8b8830fae4613aeb73d2aecf7ee3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 01:27:56 +0100 Subject: [PATCH 124/194] Add fallback to configured minimum setpoint when not using dynamic minimum setpoint --- custom_components/sat/climate.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e811bdf5..4dbb06c6 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -542,6 +542,9 @@ def pulse_width_modulation_enabled(self) -> bool: if not self._overshoot_protection: return False + if not self._dynamic_minimum_setpoint: + return self._coordinator.minimum_setpoint > self._calculated_setpoint + return self._pulse_width_modulation_enabled @property @@ -771,8 +774,8 @@ async def _async_control_pid(self, reset: bool = False) -> None: outside_temperature=self.current_outside_temperature ) - self._areas.pids.update(self._coordinator.filtered_boiler_temperature) - self.pid.update(max_error, self.heating_curve.value, self._coordinator.filtered_boiler_temperature) + self._areas.pids.update(self._coordinator.boiler_temperature_filtered) + self.pid.update(max_error, self.heating_curve.value, self._coordinator.boiler_temperature_filtered) elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") From e10243069f9610df47c5302656c5bd955ede2856 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 14:25:43 +0100 Subject: [PATCH 125/194] Check the device active state for hvac action --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b837e718..bbddf9cb 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -462,7 +462,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if self._coordinator.device_state == DeviceState.OFF: + if not self._coordinator.device_active: return HVACAction.IDLE return HVACAction.HEATING From df320f3dcc54b33271e33ac111f3a259948b16d4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 16:01:22 +0100 Subject: [PATCH 126/194] Add some extra manufacturers and add support for selecting it during setup --- custom_components/sat/config_flow.py | 57 +++++++++++--- custom_components/sat/const.py | 1 + custom_components/sat/coordinator.py | 43 ++++++----- custom_components/sat/manufacturer.py | 75 ++++++++++--------- custom_components/sat/manufacturers/atag.py | 7 ++ custom_components/sat/manufacturers/baxi.py | 7 ++ custom_components/sat/manufacturers/brotge.py | 7 ++ custom_components/sat/manufacturers/sime.py | 7 ++ custom_components/sat/translations/en.json | 6 ++ tests/test_manufacturer.py | 32 ++++++++ 10 files changed, 177 insertions(+), 65 deletions(-) create mode 100644 custom_components/sat/manufacturers/atag.py create mode 100644 custom_components/sat/manufacturers/baxi.py create mode 100644 custom_components/sat/manufacturers/brotge.py create mode 100644 custom_components/sat/manufacturers/sime.py create mode 100644 tests/test_manufacturer.py diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 4bf9979e..99516147 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -25,6 +25,7 @@ from . import SatDataUpdateCoordinatorFactory from .const import * from .coordinator import SatDataUpdateCoordinator +from .manufacturer import ManufacturerFactory, MANUFACTURERS from .overshoot_protection import OvershootProtection from .util import calculate_default_maximum_setpoint, snake_case @@ -147,7 +148,11 @@ async def async_step_mosquitto_opentherm(self, _user_input: dict[str, Any] | Non return await self.async_step_sensors() - return self._create_mqtt_form("mosquitto_opentherm", "OTGW", "otgw-XXXXXXXXXXXX") + default_device = None + if not self.data.get(CONF_DEVICE): + default_device = "otgw-XXXXXXXXXXXX" + + return self._create_mqtt_form("mosquitto_opentherm", "OTGW", default_device) async def async_step_mosquitto_ems(self, _user_input: dict[str, Any] | None = None): """Setup specific to EMS-ESP.""" @@ -157,7 +162,11 @@ async def async_step_mosquitto_ems(self, _user_input: dict[str, Any] | None = No return await self.async_step_sensors() - return self._create_mqtt_form("mosquitto_ems", "ems-esp") + default_device = None + if not self.data.get(CONF_DEVICE): + default_device = "ems-esp" + + return self._create_mqtt_form("mosquitto_ems", default_device) async def async_step_esphome(self, _user_input: dict[str, Any] | None = None): if _user_input is not None: @@ -344,7 +353,7 @@ async def async_step_automatic_gains(self, _user_input: dict[str, Any] | None = if not self.data[CONF_AUTOMATIC_GAINS]: return await self.async_step_pid_controller() - return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( last_step=False, @@ -367,7 +376,7 @@ async def async_step_calibrate(self, _user_input: dict[str, Any] | None = None): async def start_calibration(): try: coordinator = await self.async_create_coordinator() - await coordinator.async_added_to_hass() + await coordinator.async_setup() overshoot_protection = OvershootProtection(coordinator, self.data.get(CONF_HEATING_SYSTEM)) self.overshoot_protection_value = await overshoot_protection.calculate() @@ -430,9 +439,10 @@ async def async_step_overshoot_protection(self, _user_input: dict[str, Any] | No _user_input[CONF_MINIMUM_SETPOINT] ) - return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( + last_step=False, step_id="overshoot_protection", data_schema=vol.Schema({ vol.Required(CONF_MINIMUM_SETPOINT, default=self.data.get(CONF_MINIMUM_SETPOINT, OPTIONS_DEFAULTS[CONF_MINIMUM_SETPOINT])): selector.NumberSelector( @@ -446,9 +456,10 @@ async def async_step_pid_controller(self, _user_input: dict[str, Any] | None = N if _user_input is not None: self.data.update(_user_input) - return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( + last_step=False, step_id="pid_controller", data_schema=vol.Schema({ vol.Required(CONF_PROPORTIONAL, default=self.data.get(CONF_PROPORTIONAL, OPTIONS_DEFAULTS[CONF_PROPORTIONAL])): str, @@ -457,6 +468,34 @@ async def async_step_pid_controller(self, _user_input: dict[str, Any] | None = N }) ) + async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = None): + if _user_input is not None: + self.data.update(_user_input) + return await self.async_step_finish() + + coordinator = await self.async_create_coordinator() + await coordinator.async_setup() + + manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) + default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else None + + _LOGGER.debug(manufacturers) + + options = [] + for name, data in MANUFACTURERS.items(): + manufacturer = ManufacturerFactory.resolve_by_name(name) + options.append({"value": name, "label": manufacturer.name}) + + return self.async_show_form( + last_step=True, + step_id="manufacturer", + data_schema=vol.Schema({ + vol.Required(CONF_MANUFACTURER, default=default_manufacturer): selector.SelectSelector( + selector.SelectSelectorConfig(options=options) + ) + }) + ) + async def async_step_finish(self, _user_input: dict[str, Any] | None = None): if self.config_entry is not None: return self.async_update_reload_and_abort( @@ -479,12 +518,10 @@ async def async_create_coordinator(self) -> SatDataUpdateCoordinator: def _create_mqtt_form(self, step_id: str, default_topic: str, default_device: str = None): """Create a common MQTT configuration form.""" - schema = { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_MQTT_TOPIC, default=default_topic): str, - } + schema = {vol.Required(CONF_NAME, default=DEFAULT_NAME): str} if default_device: + schema[vol.Required(CONF_MQTT_TOPIC, default=default_topic)] = str schema[vol.Required(CONF_DEVICE, default=default_device)] = str return self.async_show_form( diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index e6f97610..e020c19b 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -28,6 +28,7 @@ CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" +CONF_MANUFACTURER = "manufacturer" CONF_ERROR_MONITORING = "error_monitoring" CONF_CYCLES_PER_HOUR = "cycles_per_hour" CONF_SIMULATED_HEATING = "simulated_heating" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index ac104ccf..d2be8bd6 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -11,7 +11,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import * -from .manufacturer import ManufacturerFactory, Manufacturer +from .manufacturer import Manufacturer, ManufacturerFactory from .util import calculate_default_maximum_setpoint if TYPE_CHECKING: @@ -68,14 +68,18 @@ def resolve( class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: """Initialize.""" - self.boiler_temperatures = [] + self._boiler_temperatures: list[tuple[datetime, float]] = [] - self._data = data - self._manufacturer = None - self._options = options or {} - self._device_state = DeviceState.OFF - self._simulation = bool(self._options.get(CONF_SIMULATION)) - self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) + self._data: Mapping[str, Any] = data + self._options: Mapping[str, Any] = options or {} + + self._manufacturer: Manufacturer | None = None + self._device_state: DeviceState = DeviceState.OFF + self._simulation: bool = bool(self._options.get(CONF_SIMULATION)) + self._heating_system: str = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) + + if data.get(CONF_MANUFACTURER) is not None: + self._manufacturer = ManufacturerFactory.resolve_by_name(data.get(CONF_MANUFACTURER)) super().__init__(hass, _LOGGER, name=DOMAIN) @@ -96,12 +100,6 @@ def device_state(self): @property def manufacturer(self) -> Manufacturer | None: - if self.member_id is None: - return None - - if self._manufacturer is None: - self._manufacturer = ManufacturerFactory().resolve(self.member_id) - return self._manufacturer @property @@ -142,16 +140,16 @@ def return_temperature(self) -> float | None: @property def filtered_boiler_temperature(self) -> float: # Not able to use if we do not have at least two values - if len(self.boiler_temperatures) < 2: + if len(self._boiler_temperatures) < 2: return self.boiler_temperature # Some noise filtering on the boiler temperature difference_boiler_temperature_sum = sum( - abs(j[1] - i[1]) for i, j in zip(self.boiler_temperatures, self.boiler_temperatures[1:]) + abs(j[1] - i[1]) for i, j in zip(self._boiler_temperatures, self._boiler_temperatures[1:]) ) # Average it and return it - return round(difference_boiler_temperature_sum / (len(self.boiler_temperatures) - 1), 2) + return round(difference_boiler_temperature_sum / (len(self._boiler_temperatures) - 1), 2) @property def minimum_hot_water_setpoint(self) -> float: @@ -269,13 +267,18 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non """Control the heating loop for the device.""" current_time = datetime.now() + # See if we can determine the manufacturer (deprecated) + if self._manufacturer is None and self.member_id is not None: + manufacturers = ManufacturerFactory.resolve_by_member_id(self.member_id) + self._manufacturer = manufacturers[0] if len(manufacturers) > 0 else None + # Make sure we have valid value if self.boiler_temperature is not None: - self.boiler_temperatures.append((current_time, self.boiler_temperature)) + self._boiler_temperatures.append((current_time, self.boiler_temperature)) # Clear up any values that are older than the specified age - while self.boiler_temperatures and current_time - self.boiler_temperatures[0][0] > timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE): - self.boiler_temperatures.pop() + while self._boiler_temperatures and current_time - self._boiler_temperatures[0][0] > timedelta(seconds=MAX_BOILER_TEMPERATURE_AGE): + self._boiler_temperatures.pop() async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 077ddd9a..2442e647 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -1,5 +1,22 @@ from abc import abstractmethod +from typing import List, Union + +MANUFACTURERS = { + "Simulator": {"module": "simulator", "class": "Simulator", "id": -1}, + "ATAG": {"module": "atag", "class": "ATAG", "id": 4}, + "Baxi": {"module": "baxi", "class": "Baxi", "id": 4}, + "Brotge": {"module": "brotge", "class": "Brotge", "id": 4}, + "Geminox": {"module": "geminox", "class": "Geminox", "id": 4}, + "Ideal": {"module": "ideal", "class": "Ideal", "id": 6}, + "Ferroli": {"module": "ferroli", "class": "Ferroli", "id": 9}, + "DeDietrich": {"module": "dedietrich", "class": "DeDietrich", "id": 11}, + "Immergas": {"module": "immergas", "class": "Immergas", "id": 27}, + "Sime": {"module": "sime", "class": "Sime", "id": 27}, + "Nefit": {"module": "nefit", "class": "Nefit", "id": 131}, + "Intergas": {"module": "intergas", "class": "Intergas", "id": 173}, +} + class Manufacturer: @property @@ -9,38 +26,26 @@ def name(self) -> str: class ManufacturerFactory: - @abstractmethod - def resolve(self, member_id: int) -> Manufacturer | None: - if member_id == -1: - from .manufacturers.simulator import Simulator - return Simulator() - - if member_id == 4: - from .manufacturers.geminox import Geminox - return Geminox() - - if member_id == 6: - from .manufacturers.ideal import Ideal - return Ideal() - - if member_id == 9: - from .manufacturers.ferroli import Ferroli - return Ferroli() - - if member_id == 11: - from .manufacturers.dedietrich import DeDietrich - return DeDietrich() - - if member_id == 27: - from .manufacturers.immergas import Immergas - return Immergas() - - if member_id == 131: - from .manufacturers.nefit import Nefit - return Nefit() - - if member_id == 173: - from .manufacturers.intergas import Intergas - return Intergas() - - return None + @staticmethod + def resolve_by_name(name: str) -> Union[Manufacturer, None]: + """Resolve a Manufacturer instance by its name.""" + manufacturer = MANUFACTURERS.get(name) + if not manufacturer: + return None + + return ManufacturerFactory._import_class(manufacturer["module"], manufacturer["class"])() + + @staticmethod + def resolve_by_member_id(member_id: int) -> List[Manufacturer]: + """Resolve a list of Manufacturer instances by member ID.""" + return [ + ManufacturerFactory._import_class(info["module"], info["class"])() + for name, info in MANUFACTURERS.items() + if info["id"] == member_id + ] + + @staticmethod + def _import_class(module_name: str, class_name: str): + """Dynamically import and return a Manufacturer class.""" + module = __import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]) + return getattr(module, class_name) diff --git a/custom_components/sat/manufacturers/atag.py b/custom_components/sat/manufacturers/atag.py new file mode 100644 index 00000000..1b8fc21e --- /dev/null +++ b/custom_components/sat/manufacturers/atag.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class ATAG(Manufacturer): + @property + def name(self) -> str: + return 'ATAG' diff --git a/custom_components/sat/manufacturers/baxi.py b/custom_components/sat/manufacturers/baxi.py new file mode 100644 index 00000000..a5c66e64 --- /dev/null +++ b/custom_components/sat/manufacturers/baxi.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Baxi(Manufacturer): + @property + def name(self) -> str: + return 'Baxi' diff --git a/custom_components/sat/manufacturers/brotge.py b/custom_components/sat/manufacturers/brotge.py new file mode 100644 index 00000000..e20115be --- /dev/null +++ b/custom_components/sat/manufacturers/brotge.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Brotge(Manufacturer): + @property + def name(self) -> str: + return 'BRÖTGE' diff --git a/custom_components/sat/manufacturers/sime.py b/custom_components/sat/manufacturers/sime.py new file mode 100644 index 00000000..7cc0ade9 --- /dev/null +++ b/custom_components/sat/manufacturers/sime.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Sime(Manufacturer): + @property + def name(self) -> str: + return 'Sime' diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index a6e6b2dc..1cbc07f3 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -140,6 +140,12 @@ "switch": "PID Thermostat (PWM On/Off mode)" }, "title": "Smart Autotune Thermostat (SAT)" + }, + "manufacturer": { + "description": "We have attempted to identify the most suitable manufacturer for your setup based on the available information. However, if this does not match your setup, you can select a different manufacturer from the list.", + "data": { + "manufacturer": "Manufacturer" + } } } }, diff --git a/tests/test_manufacturer.py b/tests/test_manufacturer.py new file mode 100644 index 00000000..2107517b --- /dev/null +++ b/tests/test_manufacturer.py @@ -0,0 +1,32 @@ +from custom_components.sat.manufacturer import MANUFACTURERS, ManufacturerFactory + + +def test_resolve_by_name(): + """Test resolving manufacturers by name.""" + for name, data in MANUFACTURERS.items(): + # Test valid name + manufacturer = ManufacturerFactory.resolve_by_name(name) + assert manufacturer is not None, f"Manufacturer '{name}' should not be None" + assert manufacturer.__class__.__name__ == data["class"] + + # Test invalid name + manufacturer = ManufacturerFactory.resolve_by_name("InvalidName") + assert manufacturer is None, "resolve_by_name should return None for invalid names" + + +def test_resolve_by_member_id(): + """Test resolving manufacturers by member ID.""" + member_id_to_names = {data["id"]: [] for data in MANUFACTURERS.values()} + for name, data in MANUFACTURERS.items(): + member_id_to_names[data["id"]].append(name) + + for member_id, names in member_id_to_names.items(): + manufacturers = ManufacturerFactory.resolve_by_member_id(member_id) + assert len(manufacturers) == len(names), f"Expected {len(names)} manufacturers for member ID {member_id}" + + for manufacturer in manufacturers: + assert manufacturer.__class__.__name__ in names, f"Manufacturer name '{manufacturer.name}' not expected for member ID {member_id}" + + # Test invalid member ID + manufacturers = ManufacturerFactory.resolve_by_member_id(999) + assert manufacturers == [], "resolve_by_member_id should return an empty list for invalid member IDs" From 76dd70493593b90e16955ad13f6dcc8560616536 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 16:17:49 +0100 Subject: [PATCH 127/194] Cleanup --- custom_components/sat/config_flow.py | 2 +- custom_components/sat/manufacturer.py | 4 ++-- custom_components/sat/translations/en.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 99516147..f90350a2 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -482,7 +482,7 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non _LOGGER.debug(manufacturers) options = [] - for name, data in MANUFACTURERS.items(): + for name, _info in MANUFACTURERS.items(): manufacturer = ManufacturerFactory.resolve_by_name(name) options.append({"value": name, "label": manufacturer.name}) diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 2442e647..2bb9f190 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -1,6 +1,6 @@ from abc import abstractmethod -from typing import List, Union +from typing import List, Optional MANUFACTURERS = { "Simulator": {"module": "simulator", "class": "Simulator", "id": -1}, @@ -27,7 +27,7 @@ def name(self) -> str: class ManufacturerFactory: @staticmethod - def resolve_by_name(name: str) -> Union[Manufacturer, None]: + def resolve_by_name(name: str) -> Optional[Manufacturer]: """Resolve a Manufacturer instance by its name.""" manufacturer = MANUFACTURERS.get(name) if not manufacturer: diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 1cbc07f3..43f2737e 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -142,7 +142,7 @@ "title": "Smart Autotune Thermostat (SAT)" }, "manufacturer": { - "description": "We have attempted to identify the most suitable manufacturer for your setup based on the available information. However, if this does not match your setup, you can select a different manufacturer from the list.", + "description": "We have attempted to identify the most suitable manufacturer for your heating system based on your gateway type and device information. However, if this does not match your setup, you can select a different manufacturer from the list.", "data": { "manufacturer": "Manufacturer" } From 8245bd506fc9d1e57b1e606e826530c34ba5c880 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 18:58:03 +0100 Subject: [PATCH 128/194] Make sure we also round up setpoint --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 8f944e00..9789cb0e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -337,8 +337,8 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Handle the temperature tracker if self.device_status is not DeviceStatus.HOT_WATER: self._boiler_temperature_tracker.update( - setpoint=self.setpoint, flame_active=self.flame_active, + setpoint=round(self.setpoint, 0), boiler_temperature=round(self.boiler_temperature, 0), boiler_temperature_derivative=round(self.boiler_temperature_derivative, 0) ) From 3b7bc47670323c565f4981999455f48387162b4b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 19:01:30 +0100 Subject: [PATCH 129/194] Sanity check --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 9789cb0e..5a75a7dd 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -335,7 +335,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non self._flame_on_since = monotonic() # Handle the temperature tracker - if self.device_status is not DeviceStatus.HOT_WATER: + if self.setpoint is not None and self.device_status is not DeviceStatus.HOT_WATER: self._boiler_temperature_tracker.update( flame_active=self.flame_active, setpoint=round(self.setpoint, 0), From 28c3253c97b9107697c53721bb8e45de3eefce9a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 20:26:38 +0100 Subject: [PATCH 130/194] Fix naming --- custom_components/sat/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index f90350a2..d3bcfda1 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -162,11 +162,11 @@ async def async_step_mosquitto_ems(self, _user_input: dict[str, Any] | None = No return await self.async_step_sensors() - default_device = None + default_topic = None if not self.data.get(CONF_DEVICE): - default_device = "ems-esp" + default_topic = "ems-esp" - return self._create_mqtt_form("mosquitto_ems", default_device) + return self._create_mqtt_form("mosquitto_ems", default_topic) async def async_step_esphome(self, _user_input: dict[str, Any] | None = None): if _user_input is not None: From 78ebc1a7c864e581640f5467bf95a3fa0fb5c683 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 20:36:36 +0100 Subject: [PATCH 131/194] Clean up --- custom_components/sat/config_flow.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index d3bcfda1..511364eb 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -148,11 +148,7 @@ async def async_step_mosquitto_opentherm(self, _user_input: dict[str, Any] | Non return await self.async_step_sensors() - default_device = None - if not self.data.get(CONF_DEVICE): - default_device = "otgw-XXXXXXXXXXXX" - - return self._create_mqtt_form("mosquitto_opentherm", "OTGW", default_device) + return self._create_mqtt_form("mosquitto_opentherm", "OTGW", "otgw-XXXXXXXXXXXX") async def async_step_mosquitto_ems(self, _user_input: dict[str, Any] | None = None): """Setup specific to EMS-ESP.""" @@ -162,11 +158,7 @@ async def async_step_mosquitto_ems(self, _user_input: dict[str, Any] | None = No return await self.async_step_sensors() - default_topic = None - if not self.data.get(CONF_DEVICE): - default_topic = "ems-esp" - - return self._create_mqtt_form("mosquitto_ems", default_topic) + return self._create_mqtt_form("mosquitto_ems", "ems-esp") async def async_step_esphome(self, _user_input: dict[str, Any] | None = None): if _user_input is not None: @@ -516,12 +508,14 @@ async def async_create_coordinator(self) -> SatDataUpdateCoordinator: hass=self.hass, data=self.data, mode=self.data[CONF_MODE], device=self.data[CONF_DEVICE] ) - def _create_mqtt_form(self, step_id: str, default_topic: str, default_device: str = None): + def _create_mqtt_form(self, step_id: str, default_topic: str = None, default_device: str = None): """Create a common MQTT configuration form.""" schema = {vol.Required(CONF_NAME, default=DEFAULT_NAME): str} - if default_device: + if default_topic and not self.data.get(CONF_MQTT_TOPIC): schema[vol.Required(CONF_MQTT_TOPIC, default=default_topic)] = str + + if default_device and not self.data.get(CONF_DEVICE): schema[vol.Required(CONF_DEVICE, default=default_device)] = str return self.async_show_form( From 33178bfd120f899ec7a48f44a1526c6c6bbe47d5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 4 Jan 2025 20:41:27 +0100 Subject: [PATCH 132/194] Make sure we disconnect from the coordinator --- custom_components/sat/config_flow.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 511364eb..22ef2ed0 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -468,10 +468,11 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non coordinator = await self.async_create_coordinator() await coordinator.async_setup() - manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) - default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else None - - _LOGGER.debug(manufacturers) + try: + manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) + default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else None + finally: + await coordinator.async_will_remove_from_hass() options = [] for name, _info in MANUFACTURERS.items(): From fe9e4ef22aa8b9ab1eb322d0b3d63f1903cc2787 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 14:11:20 +0100 Subject: [PATCH 133/194] Add support to track boiler temperature on setpoint decrease --- custom_components/sat/boiler.py | 37 ++++++++++++++++++++++++++-- custom_components/sat/climate.py | 5 ++++ custom_components/sat/coordinator.py | 4 +++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 7604ff31..e95f564b 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -1,5 +1,7 @@ import logging +from .const import MINIMUM_SETPOINT + _LOGGER = logging.getLogger(__name__) STABILIZATION_MARGIN = 5 @@ -51,18 +53,35 @@ def __init__(self): """Initialize the BoilerTemperatureTracker.""" self._active = False self._warming_up = False + self._adjusting_to_lower_setpoint = False + + self._last_setpoint = None self._last_boiler_temperature = None def update(self, boiler_temperature: float, boiler_temperature_derivative: float, flame_active: bool, setpoint: float): """Update the tracker based on the current boiler temperature, flame status, and setpoint.""" + if setpoint == MINIMUM_SETPOINT: + return + if self._last_boiler_temperature is None: self._last_boiler_temperature = boiler_temperature + if self._last_setpoint is None: + self._last_setpoint = setpoint + + # Detect if setpoint is decreasing + if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint: + self._adjusting_to_lower_setpoint = True + _LOGGER.debug("Setpoint decreased. Entering stabilization mode.") + if not flame_active: self._handle_flame_inactive() + elif self._adjusting_to_lower_setpoint: + self._handle_adjusting_to_lower_setpoint(boiler_temperature, boiler_temperature_derivative, setpoint) elif self._active: self._handle_tracking(boiler_temperature, boiler_temperature_derivative, setpoint) + self._last_setpoint = setpoint self._last_boiler_temperature = boiler_temperature def _handle_flame_inactive(self): @@ -78,13 +97,27 @@ def _handle_flame_inactive(self): def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle boiler temperature tracking logic.""" if not self._warming_up and boiler_temperature_derivative == 0: - return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) + return self._stop_warming_up("Temperature not changing.", boiler_temperature, setpoint) if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: - return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) + return self._stop_tracking("Stabilizing below setpoint.", boiler_temperature, setpoint) + + def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): + """Handle stabilization when adjusting to a lower setpoint.""" + if boiler_temperature <= setpoint and boiler_temperature_derivative == 0: + return self._stop_adjusting_to_lower_setpoint("Setpoint stabilization complete.", boiler_temperature, setpoint) + + def _stop_adjusting_to_lower_setpoint(self, reason: str, boiler_temperature: float, setpoint: float): + """Stop the adjustment to a lower setpoint and log the reason.""" + self._adjusting_to_lower_setpoint = False + + _LOGGER.debug( + f"Adjustment to lower setpoint stopped: {reason} " + f"(Setpoint: {setpoint}, Current: {boiler_temperature})." + ) def _stop_warming_up(self, reason: str, boiler_temperature: float, setpoint: float): """Stop the warming-up phase and log the reason.""" diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 4dbb06c6..2aaed47c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -377,6 +377,9 @@ def extra_state_attributes(self): "collected_errors": self.pid.num_errors, "integral_enabled": self.pid.integral_enabled, + "boiler_temperature_tracking": self._coordinator.boiler_temperature_tracking, + "boiler_temperature_derivative": self._coordinator.boiler_temperature_derivative, + "pre_custom_temperature": self._pre_custom_temperature, "pre_activity_temperature": self._pre_activity_temperature, @@ -399,9 +402,11 @@ def extra_state_attributes(self): "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, + "relative_modulation_value": self.relative_modulation_value, "relative_modulation_state": self.relative_modulation_state, "relative_modulation_enabled": self._relative_modulation.enabled, + "pulse_width_modulation_enabled": self.pulse_width_modulation_enabled, "pulse_width_modulation_state": self.pwm.state, "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle, diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 5a75a7dd..18abac6d 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -214,6 +214,10 @@ def boiler_temperature_derivative(self) -> float: return (first_temperature - last_temperature) / time_delta + @property + def boiler_temperature_tracking(self) -> bool: + return self._boiler_temperature_tracker.active + @property def minimum_hot_water_setpoint(self) -> float: return 30 From ecd2089ec50af09e202b0e58073be5802d778d9e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:02:54 +0100 Subject: [PATCH 134/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 18abac6d..4e90658e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -209,7 +209,7 @@ def boiler_temperature_derivative(self) -> float: last_time, last_temperature = self._boiler_temperatures[-1] time_delta = first_time - last_time - if time_delta <= 0: + if time_delta == 0: return 0.0 return (first_temperature - last_temperature) / time_delta From 727a0c60b24f5e824b9a06a99b941971a585aa7c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:05:36 +0100 Subject: [PATCH 135/194] Fixed derivative --- custom_components/sat/coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 4e90658e..fd2921a6 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -202,17 +202,17 @@ def boiler_temperature_filtered(self) -> float: @property def boiler_temperature_derivative(self) -> float: - if len(self._boiler_temperatures) < 1: + if len(self._boiler_temperatures) <= 1: return 0.0 first_time, first_temperature = self._boiler_temperatures[0] last_time, last_temperature = self._boiler_temperatures[-1] - time_delta = first_time - last_time + time_delta = last_time - first_time - if time_delta == 0: + if time_delta <= 0: return 0.0 - return (first_temperature - last_temperature) / time_delta + return (last_temperature - first_temperature) / time_delta @property def boiler_temperature_tracking(self) -> bool: From 58f3f434f3cd7a6050a6d298a5df50bd54e92186 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:11:14 +0100 Subject: [PATCH 136/194] Naming... --- custom_components/sat/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index fd2921a6..bc1bcd58 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -353,8 +353,8 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Remove old temperature records beyond the allowed age self._boiler_temperatures = [ - (timestamp, temp) - for timestamp, temp in self._boiler_temperatures + (timestamp, temperature) + for timestamp, temperature in self._boiler_temperatures if seconds_since(timestamp) <= MAX_BOILER_TEMPERATURE_AGE ] From 82ea1cbe68582b796ce5b9fd6cab0cbf0390f25d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:20:50 +0100 Subject: [PATCH 137/194] Make sure we round the derivative --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index bc1bcd58..3c95a130 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -212,7 +212,7 @@ def boiler_temperature_derivative(self) -> float: if time_delta <= 0: return 0.0 - return (last_temperature - first_temperature) / time_delta + return round((last_temperature - first_temperature) / time_delta, 2) @property def boiler_temperature_tracking(self) -> bool: From 3ff573afc6a0507867fd97d36cd5ce5aca1ef9b5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:22:38 +0100 Subject: [PATCH 138/194] Decrease the boiler temperature age --- custom_components/sat/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index b13d8e2d..1a2af6e5 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -22,7 +22,7 @@ MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 -MAX_BOILER_TEMPERATURE_AGE = 60 +MAX_BOILER_TEMPERATURE_AGE = 30 # Configuration and options CONF_MODE = "mode" From a990f94c7450d902bc868a125bb86c3fb318ecf9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:24:56 +0100 Subject: [PATCH 139/194] Make sure that the derivative can be None --- custom_components/sat/coordinator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 3c95a130..ccf19c48 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -201,7 +201,7 @@ def boiler_temperature_filtered(self) -> float: return round(difference_boiler_temperature_sum / (len(self._boiler_temperatures) - 1), 2) @property - def boiler_temperature_derivative(self) -> float: + def boiler_temperature_derivative(self) -> float | None: if len(self._boiler_temperatures) <= 1: return 0.0 @@ -210,7 +210,7 @@ def boiler_temperature_derivative(self) -> float: time_delta = last_time - first_time if time_delta <= 0: - return 0.0 + return None return round((last_temperature - first_temperature) / time_delta, 2) @@ -339,12 +339,12 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non self._flame_on_since = monotonic() # Handle the temperature tracker - if self.setpoint is not None and self.device_status is not DeviceStatus.HOT_WATER: + if self.setpoint is not None and self.boiler_temperature_derivative is not None and self.device_status is not DeviceStatus.HOT_WATER: self._boiler_temperature_tracker.update( flame_active=self.flame_active, setpoint=round(self.setpoint, 0), boiler_temperature=round(self.boiler_temperature, 0), - boiler_temperature_derivative=round(self.boiler_temperature_derivative, 0) + boiler_temperature_derivative=self.boiler_temperature_derivative ) # Append current boiler temperature if valid From 7f936fd8d47bed3a97dec869fdc584e69ab59356 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:28:54 +0100 Subject: [PATCH 140/194] Make sure we have enough for a derivative --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index ccf19c48..9eb13277 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -209,7 +209,7 @@ def boiler_temperature_derivative(self) -> float | None: last_time, last_temperature = self._boiler_temperatures[-1] time_delta = last_time - first_time - if time_delta <= 0: + if time_delta < 10: return None return round((last_temperature - first_temperature) / time_delta, 2) From e52e992a81650e9e24d2b7a7d204ae3ebb5f2cb0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:32:32 +0100 Subject: [PATCH 141/194] Typo? --- custom_components/sat/boiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index e95f564b..27a3148c 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -96,7 +96,7 @@ def _handle_flame_inactive(self): def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle boiler temperature tracking logic.""" - if not self._warming_up and boiler_temperature_derivative == 0: + if not self._warming_up and boiler_temperature_derivative != 0: return self._stop_warming_up("Temperature not changing.", boiler_temperature, setpoint) if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: From cd14637ba97d0c619b8d23ee8edf77d2db474055 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:34:19 +0100 Subject: [PATCH 142/194] Cleanup --- custom_components/sat/boiler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 27a3148c..5a899715 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -96,8 +96,11 @@ def _handle_flame_inactive(self): def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle boiler temperature tracking logic.""" - if not self._warming_up and boiler_temperature_derivative != 0: - return self._stop_warming_up("Temperature not changing.", boiler_temperature, setpoint) + if self._warming_up: + if boiler_temperature_derivative == 0: + return self._stop_warming_up("Temperature not changing.", boiler_temperature, setpoint) + + return if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) From 0c826f41e34567474121eb263cea0ecd14bb7ba8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 5 Jan 2025 19:35:32 +0100 Subject: [PATCH 143/194] Revert some code --- custom_components/sat/boiler.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 5a899715..e3c16a1a 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -96,17 +96,14 @@ def _handle_flame_inactive(self): def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle boiler temperature tracking logic.""" - if self._warming_up: - if boiler_temperature_derivative == 0: - return self._stop_warming_up("Temperature not changing.", boiler_temperature, setpoint) - - return + if not self._warming_up and boiler_temperature_derivative == 0: + return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: - return self._stop_tracking("Stabilizing below setpoint.", boiler_temperature, setpoint) + return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): """Handle stabilization when adjusting to a lower setpoint.""" From 10997ecfb66a32c9d4eb3a37ead2c17254b7e87d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 6 Jan 2025 19:49:31 +0100 Subject: [PATCH 144/194] Change the decrease of the adjuster --- custom_components/sat/setpoint_adjuster.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index 9f938edc..d4684fe0 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -27,7 +27,7 @@ def adjust(self, target_setpoint: float) -> float: if self._current < target_setpoint: self._current = min(self._current + 0.5, target_setpoint) elif self._current > target_setpoint: - self._current = max(self._current - 2.0, target_setpoint) + self._current = max(self._current - 1.0, target_setpoint) _LOGGER.info( "Setpoint updated: %.1f°C -> %.1f°C (Target: %.1f°C)", From 24eada93817c85fdc024f2ff32663e7d9f79b301 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 6 Jan 2025 23:39:27 +0100 Subject: [PATCH 145/194] Also reduce the EXCEED_SETPOINT_MARGIN --- custom_components/sat/boiler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index e3c16a1a..7eb30f85 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -5,7 +5,7 @@ _LOGGER = logging.getLogger(__name__) STABILIZATION_MARGIN = 5 -EXCEED_SETPOINT_MARGIN = 2 +EXCEED_SETPOINT_MARGIN = 0.1 class BoilerState: From 3f2aa986ba8141b04b9aec8b4033c232c987502a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 8 Jan 2025 19:22:07 +0100 Subject: [PATCH 146/194] Cleaning up and improved overshoot protection --- custom_components/sat/boiler.py | 14 +++- custom_components/sat/climate.py | 22 +++--- custom_components/sat/const.py | 2 +- custom_components/sat/coordinator.py | 77 ++++++++++++++------- custom_components/sat/fake/__init__.py | 6 ++ custom_components/sat/setpoint_adjuster.py | 10 ++- custom_components/sat/simulator/__init__.py | 6 +- 7 files changed, 91 insertions(+), 46 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 7eb30f85..16b67b74 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -69,10 +69,8 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float if self._last_setpoint is None: self._last_setpoint = setpoint - # Detect if setpoint is decreasing if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint: - self._adjusting_to_lower_setpoint = True - _LOGGER.debug("Setpoint decreased. Entering stabilization mode.") + self._handle_setpoint_decrease() if not flame_active: self._handle_flame_inactive() @@ -84,6 +82,16 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float self._last_setpoint = setpoint self._last_boiler_temperature = boiler_temperature + def _handle_setpoint_decrease(self): + if self._adjusting_to_lower_setpoint: + return + + self._active = True + self._warming_up = True + self._adjusting_to_lower_setpoint = True + + _LOGGER.debug("Setpoint decreased. Entering stabilization mode.") + def _handle_flame_inactive(self): """Handle the case where the flame is inactive.""" if self._active: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 2aaed47c..0fb86a15 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -377,6 +377,7 @@ def extra_state_attributes(self): "collected_errors": self.pid.num_errors, "integral_enabled": self.pid.integral_enabled, + "boiler_temperature_cold": self._coordinator.boiler_temperature_cold, "boiler_temperature_tracking": self._coordinator.boiler_temperature_tracking, "boiler_temperature_derivative": self._coordinator.boiler_temperature_derivative, @@ -811,7 +812,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: - if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + if self._coordinator.flame_active and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current @@ -867,6 +868,11 @@ async def _async_update_rooms_from_climates(self) -> None: if target_temperature is not None: self._rooms[entity_id] = float(target_temperature) + async def reset_control_state(self): + """Reset control state when major changes occur.""" + self._setpoint_adjuster.reset() + self._pulse_width_modulation_enabled = False + async def async_track_sensor_temperature(self, entity_id): """ Track the temperature of the sensor specified by the given entity_id. @@ -990,11 +996,8 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Reset the PID controller await self._async_control_pid(True) - # Reset the minimum setpoint - self._setpoint_adjuster.reset() - - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False + # Reset the climate + await self.reset_control_state() # Collect which climates to control climates = self._main_climates[:] @@ -1067,11 +1070,8 @@ async def async_set_target_temperature(self, temperature: float) -> None: # Reset the PID controller await self._async_control_pid(True) - # Reset the minimum setpoint - self._setpoint_adjuster.reset() - - # Reset the pulse width modulation - self._pulse_width_modulation_enabled = False + # Reset the climate + await self.reset_control_state() # Write the state to Home Assistant self.async_write_ha_state() diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 1a2af6e5..b13d8e2d 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -22,7 +22,7 @@ MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 -MAX_BOILER_TEMPERATURE_AGE = 30 +MAX_BOILER_TEMPERATURE_AGE = 60 # Configuration and options CONF_MODE = "mode" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 9eb13277..170d2e7a 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -2,7 +2,6 @@ import logging from abc import abstractmethod -from datetime import datetime from enum import Enum from time import monotonic from typing import TYPE_CHECKING, Mapping, Any, Optional @@ -33,8 +32,9 @@ class DeviceStatus(str, Enum): HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" + PUMP_STARTING = "pump_starting" + WAITING_FOR_FLAME = "waiting for flame" OVERSHOOT_HANDLING = "overshoot_handling" - OVERSHOOT_STABILIZED = "overshoot_stabilized" UNKNOWN = "unknown" INITIALIZING = "initializing" @@ -79,11 +79,12 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin """Initialize.""" self._boiler_temperatures = [] + self._flame_on_since = None + self._heater_on_since = None + self._data = data self._manufacturer = None - self._flame_on_since = None self._options = options or {} - self._device_state = DeviceState.OFF self._simulation = bool(self._options.get(CONF_SIMULATION)) self._boiler_temperature_tracker = BoilerTemperatureTracker() self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) @@ -100,11 +101,6 @@ def device_id(self) -> str: def device_type(self) -> str: pass - @property - def device_state(self): - """Return the current state of the device.""" - return self._device_state - @property def device_status(self): """Return the current status of the device.""" @@ -117,17 +113,29 @@ def device_status(self): if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: return DeviceStatus.COOLING_DOWN - if not self.flame_active and self.setpoint > self.boiler_temperature: - return DeviceStatus.PREHEATING + if self.device_active: + if ( + self.boiler_temperature_cold is not None + and self.boiler_temperature_cold > self.boiler_temperature + and self.boiler_temperature_derivative < 0 + ): + return DeviceStatus.PUMP_STARTING - if self._boiler_temperature_tracker.active and self.flame_active and self.setpoint > self.boiler_temperature: - return DeviceStatus.HEATING_UP + if self.setpoint > self.boiler_temperature: + if not self.flame_active: + return DeviceStatus.WAITING_FOR_FLAME - if not self._boiler_temperature_tracker.active and self.flame_active and seconds_since(self._flame_on_since) > 6: - if self.setpoint == self.boiler_temperature: - return DeviceStatus.OVERSHOOT_STABILIZED + if self.flame_active: + if ( + seconds_since(self._flame_on_since) <= 6 + or (self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature) + ): + return DeviceStatus.PREHEATING - return DeviceStatus.OVERSHOOT_HANDLING + if self._boiler_temperature_tracker.active: + return DeviceStatus.HEATING_UP + + return DeviceStatus.OVERSHOOT_HANDLING if self.setpoint == self.boiler_temperature: return DeviceStatus.AT_SETPOINT @@ -167,9 +175,13 @@ def flame_active(self) -> bool: return self.device_active @property - def flame_on_since(self) -> datetime | None: + def flame_on_since(self) -> float | None: return self._flame_on_since + @property + def heater_on_since(self) -> float | None: + return self._heater_on_since + @property def hot_water_active(self) -> bool: return False @@ -203,17 +215,30 @@ def boiler_temperature_filtered(self) -> float: @property def boiler_temperature_derivative(self) -> float | None: if len(self._boiler_temperatures) <= 1: - return 0.0 + return None - first_time, first_temperature = self._boiler_temperatures[0] + first_time, first_temperature = self._boiler_temperatures[-2] last_time, last_temperature = self._boiler_temperatures[-1] - time_delta = last_time - first_time - if time_delta < 10: + time_delta = last_time - first_time + if time_delta <= 0: return None return round((last_temperature - first_temperature) / time_delta, 2) + @property + def boiler_temperature_cold(self) -> float | None: + for timestamp, temperature in reversed(self._boiler_temperatures): + if self._heater_on_since is not None and timestamp > self._heater_on_since: + continue + + if self._flame_on_since is not None and timestamp > self._flame_on_since: + continue + + return temperature + + return None + @property def boiler_temperature_tracking(self) -> bool: return self._boiler_temperature_tracker.active @@ -347,9 +372,11 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non boiler_temperature_derivative=self.boiler_temperature_derivative ) - # Append current boiler temperature if valid + # Append current boiler temperature if valid and unique if self.boiler_temperature is not None: - self._boiler_temperatures.append((monotonic(), self.boiler_temperature)) + current_time = monotonic() + if not self._boiler_temperatures or self._boiler_temperatures[-1][0] != current_time: + self._boiler_temperatures.append((current_time, self.boiler_temperature)) # Remove old temperature records beyond the allowed age self._boiler_temperatures = [ @@ -360,7 +387,7 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" - self._device_state = state + self._heater_on_since = monotonic() if state == DeviceState.ON else None _LOGGER.info("Set central heater state %s", state) async def async_set_control_setpoint(self, value: float) -> None: diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index cbdf8372..764c1203 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -45,6 +45,7 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._maximum_setpoint = None self._hot_water_setpoint = None self._boiler_temperature = None + self._device_state = DeviceState.OFF self._relative_modulation_value = 100 super().__init__(hass, data, options) @@ -92,6 +93,11 @@ def supports_relative_modulation_management(self): async def async_set_boiler_temperature(self, value: float) -> None: self._boiler_temperature = value + async def async_set_heater_state(self, state: DeviceState) -> None: + self._device_state = state + + await super().async_set_heater_state(state) + async def async_set_control_setpoint(self, value: float) -> None: self._setpoint = value diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index d4684fe0..8e336ce0 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -2,6 +2,10 @@ _LOGGER = logging.getLogger(__name__) +DECREASE_STEP = 1.0 +INCREASE_STEP = 0.5 +INITIAL_OFFSET = 10 + class SetpointAdjuster: def __init__(self): @@ -20,14 +24,14 @@ def reset(self): def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: - self._current = target_setpoint + 10 + self._current = target_setpoint + INITIAL_OFFSET previous_setpoint = self._current if self._current < target_setpoint: - self._current = min(self._current + 0.5, target_setpoint) + self._current = min(self._current + INCREASE_STEP, target_setpoint) elif self._current > target_setpoint: - self._current = max(self._current - 1.0, target_setpoint) + self._current = max(self._current - DECREASE_STEP, target_setpoint) _LOGGER.info( "Setpoint updated: %.1f°C -> %.1f°C (Target: %.1f°C)", diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 46d880e1..7d0333ff 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -18,10 +18,10 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin """Initialize.""" super().__init__(hass, data, options) - self._started_on = None self._setpoint = MINIMUM_SETPOINT self._boiler_temperature = MINIMUM_SETPOINT + self._device_state = DeviceState.OFF self._heating = data.get(CONF_SIMULATED_HEATING) self._cooling = data.get(CONF_SIMULATED_COOLING) self._maximum_setpoint = data.get(CONF_MAXIMUM_SETPOINT) @@ -72,7 +72,7 @@ def member_id(self) -> int | None: return -1 async def async_set_heater_state(self, state: DeviceState) -> None: - self._started_on = monotonic() if state == DeviceState.ON else None + self._device_state = state await super().async_set_heater_state(state) @@ -116,7 +116,7 @@ def target(self): return self.minimum_setpoint # State check - if not self._started_on or (monotonic() - self._started_on) < self._warming_up: + if not self._heater_on_since or (monotonic() - self._heater_on_since) < self._warming_up: return MINIMUM_SETPOINT return self.setpoint From 99c880f1692003f9ad37b61f4eccaaf48a7e27f4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 10 Jan 2025 00:32:40 +0100 Subject: [PATCH 147/194] More validation and sanity checks --- custom_components/sat/climate.py | 22 ++++++++++++++++------ custom_components/sat/config_flow.py | 22 ++++++++++++++++------ custom_components/sat/coordinator.py | 2 +- custom_components/sat/heating_curve.py | 6 +++--- custom_components/sat/mqtt/__init__.py | 2 +- custom_components/sat/serial/__init__.py | 2 +- custom_components/sat/translations/en.json | 1 + custom_components/sat/validators.py | 12 ++++++++++++ 8 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 custom_components/sat/validators.py diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index bbddf9cb..1286856d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -690,7 +690,7 @@ async def _async_window_sensor_changed(self, event: Event) -> None: try: self._window_sensor_handle = asyncio.create_task(asyncio.sleep(self._window_minimum_open_time)) - self._pre_activity_temperature = self.target_temperature + self._pre_activity_temperature = self.target_temperature or self.min_temp await self._window_sensor_handle await self.async_set_preset_mode(PRESET_ACTIVITY) @@ -725,15 +725,16 @@ async def _async_control_pid(self, reset: bool = False) -> None: max_error = self.max_error # Make sure we use the latest heating curve value - self.heating_curve.update(self.target_temperature, self.current_outside_temperature) - self._areas.heating_curves.update(self.current_outside_temperature) + if self.target_temperature is not None: + self._areas.heating_curves.update(self.current_outside_temperature) + self.heating_curve.update(self.target_temperature, self.current_outside_temperature) # Update the PID controller with the maximum error if not reset: _LOGGER.info(f"Updating error value to {max_error} (Reset: False)") # Calculate an optimal heating curve when we are in the deadband - if -DEADBAND <= max_error <= DEADBAND: + if self.target_temperature is not None and -DEADBAND <= max_error <= DEADBAND: self.heating_curve.autotune( setpoint=self.requested_setpoint, target_temperature=self.target_temperature, @@ -752,8 +753,13 @@ async def _async_control_pid(self, reset: bool = False) -> None: _LOGGER.info("Reached deadband, turning off warming up.") self._warming_up_data = None - self._areas.pids.update(self._coordinator.filtered_boiler_temperature) - self.pid.update(max_error, self.heating_curve.value, self._coordinator.filtered_boiler_temperature) + # Update our PID controllers if we have valid values + if self._coordinator.filtered_boiler_temperature is not None: + self._areas.pids.update(self._coordinator.filtered_boiler_temperature) + + if self.heating_curve.value is not None: + self.pid.update(max_error, self.heating_curve.value, self._coordinator.filtered_boiler_temperature) + elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") @@ -969,6 +975,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Set the hvac mode for those climate devices for entity_id in climates: + state = self.hass.states.get(entity_id) + if state is None or hvac_mode not in state.attributes.get("hvac_modes"): + return + data = {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, data, blocking=True) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 4bf9979e..2a14e2a9 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -27,6 +27,7 @@ from .coordinator import SatDataUpdateCoordinator from .overshoot_protection import OvershootProtection from .util import calculate_default_maximum_setpoint, snake_case +from .validators import valid_serial_device DEFAULT_NAME = "Living Room" @@ -112,9 +113,9 @@ async def async_step_mqtt(self, discovery_info: MqttServiceInfo): async def async_step_mosquitto(self, _user_input: dict[str, Any] | None = None): """Entry step to select the MQTT mode and branch to specific setup.""" - self.errors = {} if _user_input is not None: + self.errors = {} self.data.update(_user_input) if self.data[CONF_MODE] == MODE_MQTT_OPENTHERM: @@ -178,18 +179,27 @@ async def async_step_esphome(self, _user_input: dict[str, Any] | None = None): ) async def async_step_serial(self, _user_input: dict[str, Any] | None = None): - self.errors = {} - if _user_input is not None: + self.errors = {} self.data.update(_user_input) self.data[CONF_MODE] = MODE_SERIAL + if not valid_serial_device(self.data[CONF_DEVICE]): + self.errors["base"] = "invalid_device" + return await self.async_step_serial() + gateway = OpenThermGateway() - if not await gateway.connect(port=self.data[CONF_DEVICE], skip_init=True, timeout=5): - await gateway.disconnect() + + try: + connected = await asyncio.wait_for(gateway.connection.connect(port=self.data[CONF_DEVICE]), timeout=5) + except asyncio.TimeoutError: + connected = False + + if not connected: self.errors["base"] = "connection" return await self.async_step_serial() + await gateway.disconnect() return await self.async_step_sensors() return self.async_show_form( @@ -367,7 +377,7 @@ async def async_step_calibrate(self, _user_input: dict[str, Any] | None = None): async def start_calibration(): try: coordinator = await self.async_create_coordinator() - await coordinator.async_added_to_hass() + await coordinator.async_setup() overshoot_protection = OvershootProtection(coordinator, self.data.get(CONF_HEATING_SYSTEM)) self.overshoot_protection_value = await overshoot_protection.calculate() diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index ac104ccf..e4cf181c 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -140,7 +140,7 @@ def return_temperature(self) -> float | None: return None @property - def filtered_boiler_temperature(self) -> float: + def filtered_boiler_temperature(self) -> float | None: # Not able to use if we do not have at least two values if len(self.boiler_temperatures) < 2: return self.boiler_temperature diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index 3197a95a..97221bcd 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -86,13 +86,13 @@ def base_offset(self) -> float: return 20 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 27.2 @property - def optimal_coefficient(self): + def optimal_coefficient(self) -> float | None: return self._optimal_coefficient @property - def coefficient_derivative(self): + def coefficient_derivative(self) -> float | None: return self._coefficient_derivative @property - def value(self): + def value(self) -> float | None: return self._last_heating_curve_value diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 52a8e03e..1e37e31d 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -16,7 +16,7 @@ STORAGE_VERSION = 1 -class SatMqttCoordinator(ABC, SatDataUpdateCoordinator): +class SatMqttCoordinator(SatDataUpdateCoordinator, ABC): """Base class to manage fetching data using MQTT.""" def __init__(self, hass: HomeAssistant, device_id: str, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 856875ea..61dbd62f 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -161,7 +161,7 @@ def get(self, key: str) -> Optional[Any]: async def async_connect(self) -> SatSerialCoordinator: try: - await self._api.connect(port=self._port, timeout=5) + await self._api.connect(port=int(self._port), timeout=5) except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: raise ConfigEntryNotReady(f"Could not connect to gateway at {self._port}: {exception}") from exception diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index a6e6b2dc..de03cfda 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -5,6 +5,7 @@ "reconfigure_successful": "Gateway has been re-configured." }, "error": { + "invalid_device": "This is an invalid device.", "connection": "Unable to connect to the gateway.", "mqtt_component": "The MQTT component is unavailable.", "unable_to_calibrate": "The calibration process has encountered an issue and could not be completed successfully. Please ensure that your heating system is functioning properly and that all required sensors are connected and working correctly.\n\nIf you continue to experience issues with calibration, consider contacting us for further assistance. We apologize for any inconvenience caused." diff --git a/custom_components/sat/validators.py b/custom_components/sat/validators.py new file mode 100644 index 00000000..e2850244 --- /dev/null +++ b/custom_components/sat/validators.py @@ -0,0 +1,12 @@ +from urllib.parse import urlparse + + +def valid_serial_device(value: str): + if value.startswith("socket://"): + parsed_url = urlparse(value) + if parsed_url.hostname and parsed_url.port: + return True + elif value.startswith("/dev/"): + return True + + return False From f36b878ffbd6b9cccda4465f9b40e72657181d12 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 10 Jan 2025 00:38:06 +0100 Subject: [PATCH 148/194] Sanity checks --- custom_components/sat/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 786a7737..4c954698 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -363,6 +363,10 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non elif self._flame_on_since is None: self._flame_on_since = monotonic() + # Nothing further to do without the temperature + if self.boiler_temperature is None: + return + # Handle the temperature tracker if self.setpoint is not None and self.boiler_temperature_derivative is not None and self.device_status is not DeviceStatus.HOT_WATER: self._boiler_temperature_tracker.update( From cf8997d8ce2619565ed4b28a408c757ee48205e4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 10 Jan 2025 01:02:20 +0100 Subject: [PATCH 149/194] More sanity checks --- custom_components/sat/climate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 1286856d..3c243083 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -900,7 +900,8 @@ async def async_control_heating_loop(self, _time=None) -> None: await self._async_control_relative_modulation() # Control the integral (if exceeded the time limit) - self.pid.update_integral(self.max_error, self.heating_curve.value) + if self.heating_curve.value is not None: + self.pid.update_integral(self.max_error, self.heating_curve.value) # Control our areas await self._areas.async_control_heating_loops() From 7707e8079868962e723bf2ea7141bf8c93376f92 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 14:09:51 +0100 Subject: [PATCH 150/194] Re-added Minimum Setpoint for legacy reasons --- custom_components/sat/climate.py | 72 ++++++++++------------ custom_components/sat/config_flow.py | 8 +++ custom_components/sat/const.py | 7 ++- custom_components/sat/minimum_setpoint.py | 55 +++++++++++++++++ custom_components/sat/translations/en.json | 1 + custom_components/sat/util.py | 13 +++- 6 files changed, 116 insertions(+), 40 deletions(-) create mode 100644 custom_components/sat/minimum_setpoint.py diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c3e73e0c..9579fff1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -45,7 +45,7 @@ from .relative_modulation import RelativeModulation, RelativeModulationState from .setpoint_adjuster import SetpointAdjuster from .summer_simmer import SummerSimmer -from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, create_minimum_setpoint_controller ATTR_ROOMS = "rooms" ATTR_SETPOINT = "setpoint" @@ -168,6 +168,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sync_climates_with_mode = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_MODE)) self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) self._maximum_relative_modulation = int(config_options.get(CONF_MAXIMUM_RELATIVE_MODULATION)) + self._minimum_setpoint_version = bool(config_options.get(CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION)) self._force_pulse_width_modulation = bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) @@ -187,6 +188,9 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn # Create Heating Curve controller with given configuration options self.heating_curve = create_heating_curve_controller(config_entry.data, config_options) + # Create the Minimum Setpoint controller + self._minimum_setpoint = create_minimum_setpoint_controller(config_entry.data, config_options) + # Create PWM controller with given configuration options self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) @@ -206,9 +210,6 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added.""" await super().async_added_to_hass() - # Register event listeners - await self._register_event_listeners() - # Restore previous state if available, or set default values await self._restore_previous_state_or_set_defaults() @@ -218,8 +219,10 @@ async def async_added_to_hass(self) -> None: self.heating_curve.update(self.target_temperature, self.current_outside_temperature) if self.hass.state is not CoreState.running: + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self._register_event_listeners) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_control_heating_loop) else: + await self._register_event_listeners() await self.async_control_heating_loop() # Register services @@ -231,7 +234,7 @@ async def async_added_to_hass(self) -> None: # Let the coordinator know we are ready await self._coordinator.async_added_to_hass() - async def _register_event_listeners(self): + async def _register_event_listeners(self, _time=None): """Register event listeners.""" self.async_on_remove( async_track_time_interval( @@ -545,12 +548,15 @@ def pulse_width_modulation_enabled(self) -> bool: if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True - if not self._overshoot_protection: + if not self._overshoot_protection or self._calculated_setpoint is None: return False if not self._dynamic_minimum_setpoint: return self._coordinator.minimum_setpoint > self._calculated_setpoint + if self._minimum_setpoint_version == 1: + return self._minimum_setpoint.current() > self._calculated_setpoint + return self._pulse_width_modulation_enabled @property @@ -598,10 +604,6 @@ async def _async_inside_sensor_changed(self, event: Event) -> None: async def _async_outside_entity_changed(self, event: Event) -> None: """Handle changes to the outside entity.""" - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - if event.data.get("new_state") is None: return @@ -613,10 +615,6 @@ async def _async_outside_entity_changed(self, event: Event) -> None: async def _async_humidity_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -631,9 +629,6 @@ async def _async_humidity_sensor_changed(self, event: Event) -> None: async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - old_state = event.data.get("old_state") new_state = event.data.get("new_state") if new_state is None: @@ -648,10 +643,6 @@ async def _async_climate_changed(self, event: Event) -> None: If the state, target temperature, or current temperature of the climate entity has changed, update the PID controller and heating control. """ - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - # Get the new state of the climate entity new_state = event.data.get("new_state") @@ -710,10 +701,6 @@ async def _async_temperature_change(self, event: Event) -> None: async def _async_window_sensor_changed(self, event: Event) -> None: """Handle changes to the contact sensor entity.""" - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -748,10 +735,6 @@ async def _async_window_sensor_changed(self, event: Event) -> None: async def _async_control_pid(self, reset: bool = False) -> None: """Control the PID controller.""" - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - # We can't continue if we don't have a valid outside temperature if self.current_outside_temperature is None: return @@ -818,15 +801,19 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: - if self._coordinator.flame_active and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: - self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) - elif self._setpoint_adjuster.current is not None: - self._setpoint = self._setpoint_adjuster.current - elif not self._coordinator.flame_active: - self._setpoint = self._coordinator.boiler_temperature + 10 - elif self._setpoint is None: - _LOGGER.debug("Setpoint not available.") - return + if self._minimum_setpoint_version == 1: + self._setpoint = self._minimum_setpoint.current() + + if self._minimum_setpoint_version == 2: + if self._coordinator.flame_active and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: + self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) + elif self._setpoint_adjuster.current is not None: + self._setpoint = self._setpoint_adjuster.current + elif not self._coordinator.flame_active: + self._setpoint = self._coordinator.boiler_temperature + 10 + elif self._setpoint is None: + _LOGGER.debug("Setpoint not available.") + return else: self._setpoint = self._coordinator.minimum_setpoint @@ -949,6 +936,15 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control our areas await self._areas.async_control_heating_loops() + # Control our dynamic minimum setpoint (version 1) + if not self._coordinator.hot_water_active and self._coordinator.flame_active: + # Calculate the base return temperature + if self._coordinator.device_status == DeviceStatus.HEATING_UP: + self._minimum_setpoint.warming_up(self._coordinator.return_temperature) + + # Calculate the dynamic minimum setpoint + self._minimum_setpoint.calculate(self._coordinator.return_temperature) + # If the setpoint is high or PWM is on, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 143ea508..dccec098 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -551,6 +551,14 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): ]) ) + if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: + schema[vol.Required(CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION, default=str(options[CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION]))] = selector.SelectSelector( + selector.SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=[ + selector.SelectOptionDict(value="1", label="Return Temperature"), + selector.SelectOptionDict(value="2", label="Boiler Temperature"), + ]) + ) + if len(self._config_entry.data.get(CONF_SECONDARY_CLIMATES, [])) > 0: schema[vol.Required(CONF_HEATING_MODE, default=str(options[CONF_HEATING_MODE]))] = selector.SelectSelector( selector.SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=[ diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index b13d8e2d..2d9dcb73 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -63,7 +63,6 @@ CONF_INSIDE_SENSOR_ENTITY_ID = "inside_sensor_entity_id" CONF_OUTSIDE_SENSOR_ENTITY_ID = "outside_sensor_entity_id" CONF_HUMIDITY_SENSOR_ENTITY_ID = "humidity_sensor_entity_id" -CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" CONF_HEATING_MODE = "heating_mode" CONF_HEATING_SYSTEM = "heating_system" @@ -72,6 +71,10 @@ CONF_PID_CONTROLLER_VERSION = "pid_controller_version" +CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint" +CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION = "dynamic_minimum_setpoint_version" +CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR = "minimum_setpoint_adjustment_factor" + CONF_MINIMUM_CONSUMPTION = "minimum_consumption" CONF_MAXIMUM_CONSUMPTION = "maximum_consumption" @@ -102,6 +105,7 @@ CONF_DERIVATIVE_TIME_WEIGHT: 2.5, CONF_OVERSHOOT_PROTECTION: False, CONF_DYNAMIC_MINIMUM_SETPOINT: False, + CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR: 0.2, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], @@ -143,6 +147,7 @@ CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_PID_CONTROLLER_VERSION: 3, + CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION: 1, } # Overshoot protection diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py new file mode 100644 index 00000000..d73dc41b --- /dev/null +++ b/custom_components/sat/minimum_setpoint.py @@ -0,0 +1,55 @@ +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +_LOGGER = logging.getLogger(__name__) + + +class MinimumSetpoint: + _STORAGE_VERSION = 1 + _STORAGE_KEY = "minimum_setpoint" + + def __init__(self, adjustment_factor: float, configured_minimum_setpoint: float): + self._store = None + self.base_return_temperature = None + self.current_minimum_setpoint = None + + self.adjustment_factor = adjustment_factor + self.configured_minimum_setpoint = configured_minimum_setpoint + + async def async_initialize(self, hass: HomeAssistant) -> None: + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + + data = await self._store.async_load() + if data and "base_return_temperature" in data: + self.base_return_temperature = data["base_return_temperature"] + _LOGGER.debug("Loaded base return temperature from storage.") + + def warming_up(self, return_temperature: float) -> None: + if self.base_return_temperature is not None and self.base_return_temperature > return_temperature: + return + + # Use the new value if it's higher or none is set + self.base_return_temperature = return_temperature + _LOGGER.debug(f"Higher temperature set to: {return_temperature}.") + + # Make sure to remember this value + if self._store: + self._store.async_delay_save(self._data_to_save) + _LOGGER.debug("Stored base return temperature changes.") + + def calculate(self, return_temperature: float) -> None: + if self.base_return_temperature is None: + return + + adjustment = (return_temperature - self.base_return_temperature) * self.adjustment_factor + self.current_minimum_setpoint = self.configured_minimum_setpoint + adjustment + + _LOGGER.debug("Calculated new minimum setpoint: %d°C", self.current_minimum_setpoint) + + def current(self) -> float: + return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint + + def _data_to_save(self) -> dict: + return {"base_return_temperature": self.base_return_temperature} diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 38a7108b..ac1190b1 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -182,6 +182,7 @@ "heating_curve_version": "Heating Curve Version", "integral": "Integral (kI)", "maximum_setpoint": "Maximum Setpoint", + "dynamic_minimum_setpoint_version": "Minimum Setpoint Controller Version", "pid_controller_version": "PID Controller Version", "proportional": "Proportional (kP)", "sync_with_thermostat": "Synchronize setpoint with thermostat", diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index a220c909..061fec8f 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -4,6 +4,7 @@ from .const import * from .heating_curve import HeatingCurve from .helpers import convert_time_str_to_seconds +from .minimum_setpoint import MinimumSetpoint from .pid import PID from .pwm import PWM @@ -37,6 +38,16 @@ def create_pid_controller(config_options) -> PID: ) +def create_minimum_setpoint_controller(config_data, config_options) -> MinimumSetpoint: + """Create and return a Minimum Setpoint controller instance with the given configuration options.""" + # Extract the configuration options + minimum_setpoint = config_data.get(CONF_MINIMUM_SETPOINT) + adjustment_factor = config_options.get(CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR) + + # Return a new Minimum Setpoint controller instance with the given configuration options + return MinimumSetpoint(configured_minimum_setpoint=minimum_setpoint, adjustment_factor=adjustment_factor) + + def create_heating_curve_controller(config_data, config_options) -> HeatingCurve: """Create and return a PID controller instance with the given configuration options.""" # Extract the configuration options @@ -44,7 +55,7 @@ def create_heating_curve_controller(config_data, config_options) -> HeatingCurve version = int(config_options.get(CONF_HEATING_CURVE_VERSION)) coefficient = float(config_options.get(CONF_HEATING_CURVE_COEFFICIENT)) - # Return a new heating Curve controller instance with the given configuration options + # Return a new Heating Curve controller instance with the given configuration options return HeatingCurve(heating_system=heating_system, coefficient=coefficient, version=version) From 88958c31ecdb39511519a6a3f077d0e2ce8c619a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 14:16:03 +0100 Subject: [PATCH 151/194] Cleanup --- custom_components/sat/climate.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 9579fff1..a25eaab7 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -587,10 +587,6 @@ def _calculate_control_setpoint(self) -> float: async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" - # Ignore any events if are (still) booting up - if self.hass.state is not CoreState.running: - return - new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -628,7 +624,6 @@ async def _async_humidity_sensor_changed(self, event: Event) -> None: async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" - # Ignore any events if are (still) booting up old_state = event.data.get("old_state") new_state = event.data.get("new_state") if new_state is None: From a9d5abfc3d7d1fd33893a2cb22aa070705d1e37c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 14:18:56 +0100 Subject: [PATCH 152/194] Cleanup --- custom_components/sat/climate.py | 2 +- custom_components/sat/config_flow.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index a25eaab7..33f9edf0 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -940,7 +940,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Calculate the dynamic minimum setpoint self._minimum_setpoint.calculate(self._coordinator.return_temperature) - # If the setpoint is high or PWM is on, turn on the heater + # If the setpoint is high, turn on the heater await self.async_set_heater_state(DeviceState.ON if self._setpoint > MINIMUM_SETPOINT else DeviceState.OFF) self.async_write_ha_state() diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index dccec098..7a88b254 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -588,6 +588,11 @@ async def async_step_general(self, _user_input: dict[str, Any] | None = None): schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str + if options[CONF_DYNAMIC_MINIMUM_SETPOINT]: + schema[vol.Required(CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR, default=options[CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=0.5, step=0.1) + ) + if not options[CONF_AUTOMATIC_DUTY_CYCLE]: schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() From 5bd3fa868139f5cde3878674278c488849add86e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 14:23:14 +0100 Subject: [PATCH 153/194] Cleanup --- custom_components/sat/climate.py | 12 ++++++++---- custom_components/sat/minimum_setpoint.py | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 33f9edf0..6d268f33 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -555,7 +555,7 @@ def pulse_width_modulation_enabled(self) -> bool: return self._coordinator.minimum_setpoint > self._calculated_setpoint if self._minimum_setpoint_version == 1: - return self._minimum_setpoint.current() > self._calculated_setpoint + return self._minimum_setpoint.current > self._calculated_setpoint return self._pulse_width_modulation_enabled @@ -569,8 +569,12 @@ def relative_modulation_state(self) -> RelativeModulationState: @property def minimum_setpoint(self) -> float: - if self._dynamic_minimum_setpoint and self._setpoint_adjuster.current is not None: - return self._setpoint_adjuster.current + if self._dynamic_minimum_setpoint: + if self._minimum_setpoint_version == 1 and self._minimum_setpoint.current is not None: + return self._minimum_setpoint.current + + if self._minimum_setpoint_version == 2 and self._setpoint_adjuster.current is not None: + return self._setpoint_adjuster.current return self._coordinator.minimum_setpoint @@ -797,7 +801,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if pwm_state == PWMState.ON: if self._dynamic_minimum_setpoint: if self._minimum_setpoint_version == 1: - self._setpoint = self._minimum_setpoint.current() + self._setpoint = self._minimum_setpoint.current if self._minimum_setpoint_version == 2: if self._coordinator.flame_active and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index d73dc41b..8cd42865 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -48,6 +48,7 @@ def calculate(self, return_temperature: float) -> None: _LOGGER.debug("Calculated new minimum setpoint: %d°C", self.current_minimum_setpoint) + @property def current(self) -> float: return self.current_minimum_setpoint if self.current_minimum_setpoint is not None else self.configured_minimum_setpoint From a44b2dcf73bf0e37eec842c76ec547e943c4b5cb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 14:39:07 +0100 Subject: [PATCH 154/194] Typo? --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6d268f33..f8e449d9 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -168,7 +168,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sync_climates_with_mode = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_MODE)) self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) self._maximum_relative_modulation = int(config_options.get(CONF_MAXIMUM_RELATIVE_MODULATION)) - self._minimum_setpoint_version = bool(config_options.get(CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION)) + self._minimum_setpoint_version = int(config_options.get(CONF_DYNAMIC_MINIMUM_SETPOINT_VERSION)) self._force_pulse_width_modulation = bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) From d851475a51945b3dff29c5f74c597991d155bb7a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 15:01:25 +0100 Subject: [PATCH 155/194] Add support for TURN_ON if the version HA supports it --- custom_components/sat/climate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3c243083..a9ecea6b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -143,6 +143,10 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn # Add features based on compatibility self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + # Conditionally add TURN_ON if it exists + if hasattr(ClimateEntityFeature, 'TURN_ON'): + self._attr_supported_features |= ClimateEntityFeature.TURN_ON + # Conditionally add TURN_OFF if it exists if hasattr(ClimateEntityFeature, 'TURN_OFF'): self._attr_supported_features |= ClimateEntityFeature.TURN_OFF From d1811497888a5f95288d04d7d55e4bfb641211b8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 17:54:52 +0100 Subject: [PATCH 156/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 74a42505..f9cda253 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -77,7 +77,7 @@ def resolve(hass: HomeAssistant, mode: str, device: str, data: Mapping[str, Any] class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: """Initialize.""" - self._boiler_temperatures: list[tuple[time, float]] = [] + self._boiler_temperatures: list[tuple[float, float]] = [] self._boiler_temperature_tracker = BoilerTemperatureTracker() self._flame_on_since = None From 7d079d398866f9a9eb8560ef056d58b0774b3504 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 18:21:25 +0100 Subject: [PATCH 157/194] Improved static typing --- custom_components/sat/area.py | 31 +++++++----- custom_components/sat/climate.py | 3 +- custom_components/sat/config_flow.py | 6 +-- custom_components/sat/coordinator.py | 2 +- custom_components/sat/esphome/__init__.py | 14 +++--- custom_components/sat/helpers.py | 6 +-- custom_components/sat/mqtt/__init__.py | 8 ++-- custom_components/sat/mqtt/ems.py | 4 +- custom_components/sat/mqtt/opentherm.py | 2 +- custom_components/sat/overshoot_protection.py | 8 ++-- custom_components/sat/pid.py | 48 +++++++++---------- custom_components/sat/pwm.py | 41 ++++++++-------- custom_components/sat/relative_modulation.py | 8 ++-- custom_components/sat/simulator/__init__.py | 4 +- custom_components/sat/switch/__init__.py | 3 +- 15 files changed, 99 insertions(+), 89 deletions(-) diff --git a/custom_components/sat/area.py b/custom_components/sat/area.py index fb3f2a87..58cafd07 100644 --- a/custom_components/sat/area.py +++ b/custom_components/sat/area.py @@ -5,7 +5,10 @@ from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State +from .heating_curve import HeatingCurve from .helpers import float_value +from .pid import PID +from .pwm import PWM from .util import ( create_pwm_controller, create_pid_controller, @@ -17,13 +20,13 @@ class Area: def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_id: str): - self._hass = None - self._entity_id = entity_id + self._entity_id: str = entity_id + self._hass: HomeAssistant | None = None # Create controllers with the given configuration options - self.pid = create_pid_controller(config_options) - self.heating_curve = create_heating_curve_controller(config_data, config_options) - self.pwm = create_pwm_controller(self.heating_curve, config_data, config_options) + self.pid: PID = create_pid_controller(config_options) + self.heating_curve: HeatingCurve = create_heating_curve_controller(config_data, config_options) + self.pwm: PWM = create_pwm_controller(self.heating_curve, config_data, config_options) @property def id(self) -> str: @@ -75,18 +78,20 @@ async def async_added_to_hass(self, hass: HomeAssistant): async def async_control_heating_loop(self, _time=None) -> None: """Asynchronously control the heating loop.""" - if (temperature_error := self.error) is not None: - # Control the integral (if exceeded the time limit) - self.pid.update_integral(temperature_error, self.heating_curve.value) + if self.error is None or self.heating_curve.value is None: + return + + # Control the integral (if exceeded the time limit) + self.pid.update_integral(self.error, self.heating_curve.value) class Areas: - def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_ids: list): + def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_ids: list[str]): """Initialize Areas with multiple Area instances using shared config data and options.""" - self._entity_ids = entity_ids - self._config_data = config_data - self._config_options = config_options - self._areas = [Area(config_data, config_options, entity_id) for entity_id in entity_ids] + self._entity_ids: list[str] = entity_ids + self._config_data: MappingProxyType[str, Any] = config_data + self._config_options: MappingProxyType[str, Any] = config_options + self._areas: list[Area] = [Area(config_data, config_options, entity_id) for entity_id in entity_ids] @property def errors(self) -> List[float]: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c6295dbf..b32360b1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -5,6 +5,7 @@ import logging from datetime import timedelta from time import monotonic, time +from typing import Optional from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import ( @@ -68,7 +69,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a class SatWarmingUp: - def __init__(self, error: float, boiler_temperature: float = None, started: int = None): + def __init__(self, error: float, boiler_temperature: Optional[float] = None, started: Optional[int] = None): self.error = error self.boiler_temperature = boiler_temperature self.started = started if started is not None else int(time()) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 8eec1cc9..68e721d9 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -1,7 +1,7 @@ """Adds config flow for SAT.""" import asyncio import logging -from typing import Any +from typing import Optional, Any import voluptuous as vol from homeassistant import config_entries @@ -25,8 +25,8 @@ from . import SatDataUpdateCoordinatorFactory from .const import * from .coordinator import SatDataUpdateCoordinator -from .manufacturer import ManufacturerFactory, MANUFACTURERS from .helpers import calculate_default_maximum_setpoint, snake_case +from .manufacturer import ManufacturerFactory, MANUFACTURERS from .overshoot_protection import OvershootProtection from .validators import valid_serial_device @@ -519,7 +519,7 @@ async def async_create_coordinator(self) -> SatDataUpdateCoordinator: hass=self.hass, data=self.data, mode=self.data[CONF_MODE], device=self.data[CONF_DEVICE] ) - def _create_mqtt_form(self, step_id: str, default_topic: str = None, default_device: str = None): + def _create_mqtt_form(self, step_id: str, default_topic: Optional[str] = None, default_device: Optional[str] = None): """Create a common MQTT configuration form.""" schema = {vol.Required(CONF_NAME, default=DEFAULT_NAME): str} diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index f9cda253..5d3cac13 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -354,7 +354,7 @@ async def async_will_remove_from_hass(self) -> None: """Run when an entity is removed from hass.""" pass - async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: + async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, _time=None) -> None: """Control the heating loop for the device.""" # Update Flame State if not self.flame_active: diff --git a/custom_components/sat/esphome/__init__.py b/custom_components/sat/esphome/__init__.py index 2a8585f6..84391d54 100644 --- a/custom_components/sat/esphome/__init__.py +++ b/custom_components/sat/esphome/__init__.py @@ -12,6 +12,8 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON, SERVICE_TURN_OFF from homeassistant.core import HomeAssistant, Event from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntry from homeassistant.helpers.event import async_track_state_change_event from ..coordinator import DeviceState, SatDataUpdateCoordinator, SatEntityCoordinator @@ -47,13 +49,13 @@ class SatEspHomeCoordinator(SatDataUpdateCoordinator, SatEntityCoordinator): def __init__(self, hass: HomeAssistant, device_id: str, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: super().__init__(hass, data, options) - self.data = {} + self.data: dict = {} - self._device = device_registry.async_get(hass).async_get(device_id) - self._mac_address = list(self._device.connections)[0][1] + self._device: DeviceEntry = device_registry.async_get(hass).async_get(device_id) + self._mac_address: str = list(self._device.connections)[0][1] - self._entity_registry = entity_registry.async_get(hass) - self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) + self._entity_registry: EntityRegistry = entity_registry.async_get(hass) + self._entities: list[RegistryEntry] = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) @property def device_id(self) -> str: @@ -113,7 +115,7 @@ def minimum_hot_water_setpoint(self) -> float: return super().minimum_hot_water_setpoint @property - def maximum_hot_water_setpoint(self) -> float | None: + def maximum_hot_water_setpoint(self) -> float: if (setpoint := self.get(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM)) is not None: return float(setpoint) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index eb82acb3..f80fc179 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -24,7 +24,7 @@ def seconds_since(start_time: float | None) -> float: return monotonic() - start_time -def convert_time_str_to_seconds(time_str: str) -> float: +def convert_time_str_to_seconds(time_str: str) -> int: """ Convert a time string in the format 'HH:MM:SS' to seconds. @@ -32,11 +32,11 @@ def convert_time_str_to_seconds(time_str: str) -> float: time_str: A string representing a time in the format 'HH:MM:SS'. Returns: - float: The time in seconds. + int: The time in seconds. """ date_time = dt.parse_time(time_str) # Calculate the number of seconds by multiplying the hours, minutes and seconds - return (date_time.hour * 3600) + (date_time.minute * 60) + date_time.second + return round((date_time.hour * 3600) + (date_time.minute * 60) + date_time.second, 0) def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: float): diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index d1b64e7a..46a023f1 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -22,10 +22,10 @@ class SatMqttCoordinator(SatDataUpdateCoordinator, ABC): def __init__(self, hass: HomeAssistant, device_id: str, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: super().__init__(hass, data, options) - self.data = {} - self._device_id = device_id - self._topic = data.get(CONF_MQTT_TOPIC) - self._store = Store(hass, STORAGE_VERSION, snake_case(f"{self.__class__.__name__}_{device_id}")) + self.data: dict = {} + self._device_id: str = device_id + self._topic: str = data.get(CONF_MQTT_TOPIC) + self._store: Store = Store(hass, STORAGE_VERSION, snake_case(f"{self.__class__.__name__}_{device_id}")) @property def device_id(self) -> str: diff --git a/custom_components/sat/mqtt/ems.py b/custom_components/sat/mqtt/ems.py index c7430105..3fd2308a 100644 --- a/custom_components/sat/mqtt/ems.py +++ b/custom_components/sat/mqtt/ems.py @@ -100,9 +100,9 @@ def member_id(self) -> int | None: # Not supported (yet) return None - async def boot(self) -> SatMqttCoordinator: + async def boot(self) -> None: # Nothing needs to be booted (yet) - return self + pass def get_tracked_entities(self) -> list[str]: return [DATA_BOILER_DATA] diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index cd577f50..1a62a50c 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -86,7 +86,7 @@ def minimum_hot_water_setpoint(self) -> float: return super().minimum_hot_water_setpoint @property - def maximum_hot_water_setpoint(self) -> float | None: + def maximum_hot_water_setpoint(self) -> float: if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: return float(setpoint) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 4fd7a38b..8fdda714 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -17,10 +17,10 @@ class OvershootProtection: def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): """Initialize OvershootProtection with a coordinator and heating system configuration.""" - self._alpha = 0.5 - self._coordinator = coordinator - self._stable_temperature = None - self._setpoint = OVERSHOOT_PROTECTION_SETPOINT.get(heating_system) + self._alpha: float = 0.5 + self._stable_temperature: float | None = None + self._coordinator: SatDataUpdateCoordinator = coordinator + self._setpoint: int = OVERSHOOT_PROTECTION_SETPOINT.get(heating_system) if self._setpoint is None: raise ValueError(f"Invalid heating system: {heating_system}") diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index a75b0c6b..66f0b946 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -43,38 +43,38 @@ def __init__(self, :param sample_time_limit: The minimum time interval between updates to the PID controller, in seconds. :param version: The version of math calculation for the controller. """ - self._kp = kp - self._ki = ki - self._kd = kd - self._version = version - self._deadband = deadband - self._history_size = max_history - self._heating_system = heating_system - self._automatic_gains = automatic_gains - self._automatic_gains_value = automatic_gain_value - self._derivative_time_weight = derivative_time_weight - self._heating_curve_coefficient = heating_curve_coefficient - - self._last_interval_updated = monotonic() - self._sample_time_limit = max(sample_time_limit, 1) - self._integral_time_limit = max(integral_time_limit, 1) + self._kp: float = kp + self._ki: float = ki + self._kd: float = kd + self._version: int = version + self._deadband: float = deadband + self._history_size: int = max_history + self._heating_system: str = heating_system + self._automatic_gains: bool = automatic_gains + self._automatic_gains_value: float = automatic_gain_value + self._derivative_time_weight: float = derivative_time_weight + self._heating_curve_coefficient: float = heating_curve_coefficient + + self._last_interval_updated: float = monotonic() + self._sample_time_limit: float = max(sample_time_limit, 1) + self._integral_time_limit: float = max(integral_time_limit, 1) self.reset() def reset(self) -> None: """Reset the PID controller.""" - self._last_error = 0.0 - self._time_elapsed = 0 - self._last_updated = monotonic() - self._last_heating_curve_value = 0 - self._last_boiler_temperature = None + self._last_error: float = 0.0 + self._time_elapsed: float = 0 + self._last_updated: float = monotonic() + self._last_heating_curve_value: float = 0 + self._last_boiler_temperature: float | None = None # Reset the integral and derivative - self._integral = 0.0 - self._raw_derivative = 0.0 + self._integral: float = 0.0 + self._raw_derivative: float = 0.0 # Reset all lists - self._times = deque(maxlen=self._history_size) - self._errors = deque(maxlen=self._history_size) + self._times: deque = deque(maxlen=self._history_size) + self._errors: deque = deque(maxlen=self._history_size) def update(self, error: float, heating_curve_value: float, boiler_temperature: float) -> None: """Update the PID controller with the current error, inside temperature, outside temperature, and heating curve value. diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index b127c2ee..7462a299 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -11,6 +11,7 @@ class PWMState(str, Enum): + """The current state of Pulse Width Modulation""" ON = "on" OFF = "off" IDLE = "idle" @@ -21,25 +22,25 @@ class PWM: def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, max_cycles: int, force: bool = False): """Initialize the PWM control.""" - self._alpha = 0.2 - self._force = force - self._last_boiler_temperature = None + self._alpha: float = 0.2 + self._force: bool = force + self._last_boiler_temperature: float | None = None - self._max_cycles = max_cycles - self._heating_curve = heating_curve - self._max_cycle_time = max_cycle_time - self._automatic_duty_cycle = automatic_duty_cycle + self._max_cycles: int = max_cycles + self._heating_curve: HeatingCurve = heating_curve + self._max_cycle_time: int = max_cycle_time + self._automatic_duty_cycle: bool = automatic_duty_cycle # Timing thresholds for duty cycle management - self._on_time_lower_threshold = 180 - self._on_time_upper_threshold = 3600 / self._max_cycles - self._on_time_max_threshold = self._on_time_upper_threshold * 2 + self._on_time_lower_threshold: float = 180 + self._on_time_upper_threshold: float = 3600 / self._max_cycles + self._on_time_max_threshold: float = self._on_time_upper_threshold * 2 # Duty cycle percentage thresholds - self._duty_cycle_lower_threshold = self._on_time_lower_threshold / self._on_time_upper_threshold - self._duty_cycle_upper_threshold = 1 - self._duty_cycle_lower_threshold - self._min_duty_cycle_percentage = self._duty_cycle_lower_threshold / 2 - self._max_duty_cycle_percentage = 1 - self._min_duty_cycle_percentage + self._duty_cycle_lower_threshold: float = self._on_time_lower_threshold / self._on_time_upper_threshold + self._duty_cycle_upper_threshold: float = 1 - self._duty_cycle_lower_threshold + self._min_duty_cycle_percentage: float = self._duty_cycle_lower_threshold / 2 + self._max_duty_cycle_percentage: float = 1 - self._min_duty_cycle_percentage _LOGGER.debug( "Initialized PWM control with duty cycle thresholds - Lower: %.2f%%, Upper: %.2f%%", @@ -50,13 +51,13 @@ def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_d def reset(self) -> None: """Reset the PWM control.""" - self._cycles = 0 - self._duty_cycle = None - self._state = PWMState.IDLE - self._last_update = monotonic() + self._cycles: int = 0 + self._state: PWMState = PWMState.IDLE + self._last_update: float = monotonic() + self._duty_cycle: Tuple[int, int] | None = None - self._first_duty_cycle_start = None - self._last_duty_cycle_percentage = None + self._first_duty_cycle_start: float | None = None + self._last_duty_cycle_percentage: float | None = None _LOGGER.info("PWM control reset to initial state.") diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 1b2d2656..ad77f1e4 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -18,9 +18,9 @@ class RelativeModulationState(str, Enum): class RelativeModulation: def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): """Initialize instance variables""" - self._coordinator = coordinator - self._heating_system = heating_system - self._pulse_width_modulation_enabled = None + self._heating_system: str = heating_system + self._pulse_width_modulation_enabled: bool = False + self._coordinator: SatDataUpdateCoordinator = coordinator _LOGGER.debug("Relative Modulation initialized for heating system: %s", heating_system) @@ -45,4 +45,4 @@ def state(self) -> RelativeModulationState: @property def enabled(self) -> bool: """Check if the relative modulation is enabled based on its current state""" - return self.state != RelativeModulationState.OFF \ No newline at end of file + return self.state != RelativeModulationState.OFF diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 7d0333ff..dc52f6e1 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from time import monotonic -from typing import TYPE_CHECKING, Mapping, Any +from typing import Optional, TYPE_CHECKING, Mapping, Any from homeassistant.core import HomeAssistant @@ -84,7 +84,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: self._maximum_setpoint = value await super().async_set_control_max_setpoint(value) - async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: + async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, _time=None) -> None: # Calculate the difference, so we know when to slowdown difference = abs(self._boiler_temperature - self.target) self.logger.debug(f"Target: {self.target}, Current: {self._boiler_temperature}, Difference: {difference}") diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index df8bcc8b..a9097b49 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry +from homeassistant.helpers.entity_registry import RegistryEntry from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -21,7 +22,7 @@ def __init__(self, hass: HomeAssistant, entity_id: str, data: Mapping[str, Any], """Initialize.""" super().__init__(hass, data, options) - self._entity = entity_registry.async_get(hass).async_get(entity_id) + self._entity: RegistryEntry = entity_registry.async_get(hass).async_get(entity_id) @property def device_id(self) -> str: From 41522c9188a73d362986c875902d0c4e72fcab64 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 18:32:47 +0100 Subject: [PATCH 158/194] Improved static typing --- custom_components/sat/climate.py | 6 +++--- custom_components/sat/simulator/__init__.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b32360b1..21d804c0 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -3,7 +3,7 @@ import asyncio import logging -from datetime import timedelta +from datetime import timedelta, datetime from time import monotonic, time from typing import Optional @@ -239,7 +239,7 @@ async def async_added_to_hass(self) -> None: # Let the coordinator know we are ready await self._coordinator.async_added_to_hass() - async def _register_event_listeners(self, _time=None): + async def _register_event_listeners(self, _time: Optional[datetime] = None): """Register event listeners.""" self.async_on_remove( async_track_time_interval( @@ -891,7 +891,7 @@ async def async_track_sensor_temperature(self, entity_id): self._sensors.append(entity_id) - async def async_control_heating_loop(self, _time=None) -> None: + async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" # If the current, target or outside temperature is not available, do nothing if self.current_temperature is None or self.target_temperature is None or self.current_outside_temperature is None: diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index dc52f6e1..8bc884f1 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from time import monotonic from typing import Optional, TYPE_CHECKING, Mapping, Any @@ -84,7 +85,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: self._maximum_setpoint = value await super().async_set_control_max_setpoint(value) - async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, _time=None) -> None: + async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, _time: Optional[datetime] = None) -> None: # Calculate the difference, so we know when to slowdown difference = abs(self._boiler_temperature - self.target) self.logger.debug(f"Target: {self.target}, Current: {self._boiler_temperature}, Difference: {difference}") From 729d658129b07c2e5ffb11c129a4f9112e78313b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 19:53:35 +0100 Subject: [PATCH 159/194] Try and init sentry, but do give up when unable --- custom_components/sat/__init__.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 76589991..71e86071 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -39,9 +39,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Create a new dictionary for this entry hass.data[DOMAIN][entry.entry_id] = {} - # Setup error monitoring (if enabled) - if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]): - await hass.async_add_executor_job(initialize_sentry, hass) + try: + # Setup error monitoring (if enabled) + if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]): + await hass.async_add_executor_job(initialize_sentry, hass) + except Exception as ex: + _LOGGER.error("Error during Sentry initialization: %s", str(ex)) # Resolve the coordinator by using the factory according to the mode hass.data[DOMAIN][entry.entry_id][COORDINATOR] = SatDataUpdateCoordinatorFactory().resolve( From e5982291b5e5a3200352f8376e562058386db166 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 20:04:54 +0100 Subject: [PATCH 160/194] Cleanup --- custom_components/sat/serial/__init__.py | 2 +- custom_components/sat/util.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 61dbd62f..e0c5856b 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -106,7 +106,7 @@ def minimum_hot_water_setpoint(self) -> float: return super().minimum_hot_water_setpoint @property - def maximum_hot_water_setpoint(self) -> float | None: + def maximum_hot_water_setpoint(self) -> float: if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: return float(setpoint) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 061fec8f..553a48ea 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -49,7 +49,7 @@ def create_minimum_setpoint_controller(config_data, config_options) -> MinimumSe def create_heating_curve_controller(config_data, config_options) -> HeatingCurve: - """Create and return a PID controller instance with the given configuration options.""" + """Create and return a Heating Curve controller instance with the given configuration options.""" # Extract the configuration options heating_system = config_data.get(CONF_HEATING_SYSTEM) version = int(config_options.get(CONF_HEATING_CURVE_VERSION)) From 21273bdb534829bd906570632b8c2dcb9dbcc7a0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Sat, 11 Jan 2025 20:05:25 +0100 Subject: [PATCH 161/194] Update custom_components/sat/helpers.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- custom_components/sat/helpers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index f80fc179..23d48ea0 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -33,10 +33,16 @@ def convert_time_str_to_seconds(time_str: str) -> int: Returns: int: The time in seconds. + + Raises: + ValueError: If the time string format is invalid. """ - date_time = dt.parse_time(time_str) - # Calculate the number of seconds by multiplying the hours, minutes and seconds - return round((date_time.hour * 3600) + (date_time.minute * 60) + date_time.second, 0) + try: + date_time = dt.parse_time(time_str) + # Calculate the number of seconds + return round((date_time.hour * 3600) + (date_time.minute * 60) + date_time.second, 0) + except ValueError as e: + raise ValueError(f"Invalid time format. Expected 'HH:MM:SS', got '{time_str}'") from e def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: float): From c77784a788e441bd55bd175bb9157d3c2804ef8c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 20:08:08 +0100 Subject: [PATCH 162/194] Update some docs --- custom_components/sat/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index 23d48ea0..af72dc1c 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -38,7 +38,9 @@ def convert_time_str_to_seconds(time_str: str) -> int: ValueError: If the time string format is invalid. """ try: + # Parse the input into a valid date time object date_time = dt.parse_time(time_str) + # Calculate the number of seconds return round((date_time.hour * 3600) + (date_time.minute * 60) + date_time.second, 0) except ValueError as e: From c796cf69ba32e544658d1bb2cd765b9eb55aab0e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 20:09:59 +0100 Subject: [PATCH 163/194] Update some docs --- custom_components/sat/helpers.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index af72dc1c..ea2dc0e2 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -25,18 +25,7 @@ def seconds_since(start_time: float | None) -> float: def convert_time_str_to_seconds(time_str: str) -> int: - """ - Convert a time string in the format 'HH:MM:SS' to seconds. - - Args: - time_str: A string representing a time in the format 'HH:MM:SS'. - - Returns: - int: The time in seconds. - - Raises: - ValueError: If the time string format is invalid. - """ + """Convert a time string in the format 'HH:MM:SS' to seconds.""" try: # Parse the input into a valid date time object date_time = dt.parse_time(time_str) @@ -61,6 +50,7 @@ def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: def calculate_default_maximum_setpoint(heating_system: str) -> int: + """Determine the default maximum temperature for a given heating system.""" if heating_system == HEATING_SYSTEM_UNDERFLOOR: return 50 @@ -68,6 +58,7 @@ def calculate_default_maximum_setpoint(heating_system: str) -> int: def snake_case(value: str) -> str: + """Transform a string from CamelCase or kebab-case to snake_case.""" return '_'.join( sub('([A-Z][a-z]+)', r' \1', sub('([A-Z]+)', r' \1', @@ -75,7 +66,7 @@ def snake_case(value: str) -> str: def float_value(value) -> float | None: - """Helper method to convert a value to float, handling possible errors.""" + """Safely convert a value to a float, returning None if conversion fails.""" try: return float(value) except (TypeError, ValueError): From b7317fad3b9862dbfa1b6859698faa01a2315d13 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 20:10:38 +0100 Subject: [PATCH 164/194] Update some docs --- custom_components/sat/helpers.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/custom_components/sat/helpers.py b/custom_components/sat/helpers.py index ea2dc0e2..bbf722dc 100644 --- a/custom_components/sat/helpers.py +++ b/custom_components/sat/helpers.py @@ -7,17 +7,7 @@ def seconds_since(start_time: float | None) -> float: - """ - Calculate the elapsed time in seconds since a given start time using monotonic(). - If start_time is None, return 0. - - Args: - start_time (float or None): The reference start time, typically obtained from time.monotonic(). - If None, elapsed time is considered 0. - - Returns: - float: The elapsed time in seconds as a float. Returns 0 if start_time is None. - """ + """Calculate the elapsed time in seconds since a given start time, returns zero if time is not valid.""" if start_time is None: return 0.0 From c319b71f5ef95d0566b43fcd63c4f18b8b699aa1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Jan 2025 20:52:02 +0100 Subject: [PATCH 165/194] Typo? --- custom_components/sat/pid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 66f0b946..e5bd06f6 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -245,7 +245,7 @@ def restore(self, state: State) -> None: if last_integral := state.attributes.get("integral"): self._integral = last_integral - if last_raw_derivative := state.attributes.get("raw_derivative"): + if last_raw_derivative := state.attributes.get("derivative_raw"): self._raw_derivative = last_raw_derivative if last_heating_curve := state.attributes.get("heating_curve"): From a9fec64fb6df694146377e2df0daaa4521f1f47e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Jan 2025 15:02:33 +0100 Subject: [PATCH 166/194] Add support to switch of PWM when the setpoint is above the minimum --- custom_components/sat/climate.py | 4 ++++ custom_components/sat/coordinator.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 21d804c0..af7bf768 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -914,6 +914,10 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._pulse_width_modulation_enabled = True + # Check if we are above the overshoot temperature + if self._coordinator.device_status == DeviceStatus.COOLING_DOWN and self._calculated_setpoint > self.minimum_setpoint: + self._pulse_width_modulation_enabled = False + # Pulse Width Modulation if self.pulse_width_modulation_enabled: boiler_state = BoilerState( diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 5d3cac13..ab9ec6c3 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -27,15 +27,17 @@ class DeviceState(str, Enum): class DeviceStatus(str, Enum): + FLAME_OFF = "flame_off" HOT_WATER = "hot_water" PREHEATING = "preheating" HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" PUMP_STARTING = "pump_starting" - WAITING_FOR_FLAME = "waiting for flame" + WAITING_FOR_FLAME = "waiting_for_flame" OVERSHOOT_HANDLING = "overshoot_handling" + OFF = "off" UNKNOWN = "unknown" INITIALIZING = "initializing" @@ -116,7 +118,7 @@ def device_status(self): return DeviceStatus.HOT_WATER if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: - return DeviceStatus.COOLING_DOWN + return DeviceStatus.OFF if self.device_active: if ( @@ -146,7 +148,10 @@ def device_status(self): return DeviceStatus.AT_SETPOINT if self.setpoint < self.boiler_temperature: - return DeviceStatus.COOLING_DOWN + if self.flame_active: + return DeviceStatus.COOLING_DOWN + + return DeviceStatus.FLAME_OFF return DeviceStatus.UNKNOWN From 28889f7ecf72eb7b4bb86acb2e891e862d8edf0c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Jan 2025 20:01:16 +0100 Subject: [PATCH 167/194] Improve heater timestamp --- custom_components/sat/coordinator.py | 13 +++++++++---- custom_components/sat/simulator/__init__.py | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index ab9ec6c3..3b1ae26f 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -83,7 +83,7 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._boiler_temperature_tracker = BoilerTemperatureTracker() self._flame_on_since = None - self._heater_on_since = None + self._device_on_since = None self._data: Mapping[str, Any] = data self._options: Mapping[str, Any] = options or {} @@ -184,7 +184,7 @@ def flame_on_since(self) -> float | None: @property def heater_on_since(self) -> float | None: - return self._heater_on_since + return self._device_on_since @property def hot_water_active(self) -> bool: @@ -233,7 +233,7 @@ def boiler_temperature_derivative(self) -> float | None: @property def boiler_temperature_cold(self) -> float | None: for timestamp, temperature in reversed(self._boiler_temperatures): - if self._heater_on_since is not None and timestamp > self._heater_on_since: + if self._device_on_since is not None and timestamp > self._device_on_since: continue if self._flame_on_since is not None and timestamp > self._flame_on_since: @@ -367,6 +367,12 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, elif self._flame_on_since is None: self._flame_on_since = monotonic() + # Update Device State + if not self.device_active: + self._device_on_since = None + elif self._device_on_since is None: + self._device_on_since = monotonic() + # See if we can determine the manufacturer (deprecated) if self._manufacturer is None and self.member_id is not None: manufacturers = ManufacturerFactory.resolve_by_member_id(self.member_id) @@ -400,7 +406,6 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" - self._heater_on_since = monotonic() if state == DeviceState.ON else None _LOGGER.info("Set central heater state %s", state) async def async_set_control_setpoint(self, value: float) -> None: diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 8bc884f1..316e2048 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -117,7 +117,7 @@ def target(self): return self.minimum_setpoint # State check - if not self._heater_on_since or (monotonic() - self._heater_on_since) < self._warming_up: + if not self._device_on_since or (monotonic() - self._device_on_since) < self._warming_up: return MINIMUM_SETPOINT return self.setpoint From acef4fa65d8db58ea33052b13ca1264c3088acab Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Jan 2025 20:15:59 +0100 Subject: [PATCH 168/194] Fixed the boiler's cold temperature --- custom_components/sat/climate.py | 1 + custom_components/sat/coordinator.py | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index af7bf768..3859b631 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -897,6 +897,7 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> if self.current_temperature is None or self.target_temperature is None or self.current_outside_temperature is None: return + # No need to do anything if we are not on if self.hvac_mode != HVACMode.HEAT: return diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 3b1ae26f..d65f70b6 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -233,13 +233,11 @@ def boiler_temperature_derivative(self) -> float | None: @property def boiler_temperature_cold(self) -> float | None: for timestamp, temperature in reversed(self._boiler_temperatures): - if self._device_on_since is not None and timestamp > self._device_on_since: - continue + if self._device_on_since is None or self._device_on_since > timestamp: + return temperature - if self._flame_on_since is not None and timestamp > self._flame_on_since: - continue - - return temperature + if self._flame_on_since is None or self._flame_on_since > timestamp: + return temperature return None From d252eba148a3009f4582e920f922ae7dc09adebd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 18:34:28 +0100 Subject: [PATCH 169/194] Fixed the cold temperature by tracking it constantly --- custom_components/sat/coordinator.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index d65f70b6..2383b93e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -79,6 +79,7 @@ def resolve(hass: HomeAssistant, mode: str, device: str, data: Mapping[str, Any] class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None: """Initialize.""" + self._boiler_temperature_cold: float | None = None self._boiler_temperatures: list[tuple[float, float]] = [] self._boiler_temperature_tracker = BoilerTemperatureTracker() @@ -232,14 +233,7 @@ def boiler_temperature_derivative(self) -> float | None: @property def boiler_temperature_cold(self) -> float | None: - for timestamp, temperature in reversed(self._boiler_temperatures): - if self._device_on_since is None or self._device_on_since > timestamp: - return temperature - - if self._flame_on_since is None or self._flame_on_since > timestamp: - return temperature - - return None + return self._boiler_temperature_cold @property def boiler_temperature_tracking(self) -> bool: @@ -402,6 +396,14 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, if seconds_since(timestamp) <= MAX_BOILER_TEMPERATURE_AGE ] + # Update the cold temperature of the boiler + for timestamp, temperature in reversed(self._boiler_temperatures): + if self._device_on_since is None or self._device_on_since > timestamp: + self._boiler_temperature_cold = temperature + + if self._flame_on_since is None or self._flame_on_since > timestamp: + self._boiler_temperature_cold = temperature + async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" _LOGGER.info("Set central heater state %s", state) From 25f19bb0ae256b707a8a9676a21e1b414bf0b3a3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 18:34:50 +0100 Subject: [PATCH 170/194] Make sure we do not disable PWM too fast --- custom_components/sat/climate.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3859b631..951d20d6 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -916,7 +916,11 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> self._pulse_width_modulation_enabled = True # Check if we are above the overshoot temperature - if self._coordinator.device_status == DeviceStatus.COOLING_DOWN and self._calculated_setpoint > self.minimum_setpoint: + if ( + self._setpoint_adjuster.current is not None and + self._coordinator.device_status == DeviceStatus.COOLING_DOWN and + self._calculated_setpoint > self._setpoint_adjuster.current + 2 + ): self._pulse_width_modulation_enabled = False # Pulse Width Modulation From 4ac1aae736afa76faa1f0f90a934b2ead3872246 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 18:56:37 +0100 Subject: [PATCH 171/194] Cleanup and some logging --- custom_components/sat/climate.py | 5 ++++- custom_components/sat/coordinator.py | 22 ++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 951d20d6..44cf0118 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -3,6 +3,7 @@ import asyncio import logging +import math from datetime import timedelta, datetime from time import monotonic, time from typing import Optional @@ -914,14 +915,16 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> # Check for overshoot if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: self._pulse_width_modulation_enabled = True + _LOGGER.info("Overshoot Handling detected, enabling Pulse Width Modulation.") # Check if we are above the overshoot temperature if ( self._setpoint_adjuster.current is not None and self._coordinator.device_status == DeviceStatus.COOLING_DOWN and - self._calculated_setpoint > self._setpoint_adjuster.current + 2 + math.floor(self._calculated_setpoint) > self._setpoint_adjuster.current + 2 ): self._pulse_width_modulation_enabled = False + _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") # Pulse Width Modulation if self.pulse_width_modulation_enabled: diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2383b93e..1dbe679f 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -134,10 +134,7 @@ def device_status(self): return DeviceStatus.WAITING_FOR_FLAME if self.flame_active: - if ( - seconds_since(self._flame_on_since) <= 6 - or (self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature) - ): + if self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.PREHEATING if self._boiler_temperature_tracker.active: @@ -148,9 +145,12 @@ def device_status(self): if self.setpoint == self.boiler_temperature: return DeviceStatus.AT_SETPOINT - if self.setpoint < self.boiler_temperature: + if self.boiler_temperature > self.setpoint: if self.flame_active: - return DeviceStatus.COOLING_DOWN + if self._is_flame_recent_or_cold_temperature_is_higher(): + return DeviceStatus.COOLING_DOWN + + return DeviceStatus.OVERSHOOT_HANDLING return DeviceStatus.FLAME_OFF @@ -432,6 +432,16 @@ async def async_set_control_thermostat_setpoint(self, value: float) -> None: """Control the setpoint temperature for the thermostat.""" pass + def _is_flame_recent_or_cold_temperature_is_higher(self): + """Check if the flame is recent or the cold temperature is higher than the boiler temperature.""" + if seconds_since(self._flame_on_since) <= 6: + return True + + if self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature: + return True + + return False + class SatEntityCoordinator(DataUpdateCoordinator): def get(self, domain: str, key: str) -> Optional[Any]: From f6146882ae66a1203957bb390f4e4755151a6eb1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 18:58:33 +0100 Subject: [PATCH 172/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 1dbe679f..b6b20e32 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -147,7 +147,7 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: - if self._is_flame_recent_or_cold_temperature_is_higher(): + if not self._boiler_temperature_tracker.active and self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.COOLING_DOWN return DeviceStatus.OVERSHOOT_HANDLING From 44594b376982f7cadb261e2764197bcddf7a5651 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 18:59:56 +0100 Subject: [PATCH 173/194] Cleanup --- custom_components/sat/boiler.py | 5 +---- custom_components/sat/coordinator.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 16b67b74..49d63963 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -70,7 +70,7 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float self._last_setpoint = setpoint if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint: - self._handle_setpoint_decrease() + self._handle_setpoint_decrease() if not flame_active: self._handle_flame_inactive() @@ -107,9 +107,6 @@ def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivat if not self._warming_up and boiler_temperature_derivative == 0: return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) - if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: - return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) - if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index b6b20e32..1dbe679f 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -147,7 +147,7 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: - if not self._boiler_temperature_tracker.active and self._is_flame_recent_or_cold_temperature_is_higher(): + if self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.COOLING_DOWN return DeviceStatus.OVERSHOOT_HANDLING From d16bec896bce3c3983c56b8a7ab109f595debf3f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 19:07:11 +0100 Subject: [PATCH 174/194] Add support for forcing a certain setpoint, so we don't do +10 on HA restart --- custom_components/sat/climate.py | 2 +- custom_components/sat/setpoint_adjuster.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 44cf0118..82dbea4f 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -815,7 +815,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current elif not self._coordinator.flame_active: - self._setpoint = self._coordinator.boiler_temperature + 10 + self._setpoint = self._setpoint_adjuster.force(self._coordinator.boiler_temperature + 10) elif self._setpoint is None: _LOGGER.debug("Setpoint not available.") return diff --git a/custom_components/sat/setpoint_adjuster.py b/custom_components/sat/setpoint_adjuster.py index 8e336ce0..276a7de4 100644 --- a/custom_components/sat/setpoint_adjuster.py +++ b/custom_components/sat/setpoint_adjuster.py @@ -21,10 +21,16 @@ def reset(self): """Reset the setpoint.""" self._current = None + def force(self, target_setpoint: float) -> float: + """Force setpoint.""" + self._current = target_setpoint + + return self._current + def adjust(self, target_setpoint: float) -> float: """Gradually adjust the current setpoint toward the target setpoint.""" if self._current is None: - self._current = target_setpoint + INITIAL_OFFSET + self._current = target_setpoint previous_setpoint = self._current From 74c597a9e3ace82ea716477048763cfe58f94aad Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 19:12:57 +0100 Subject: [PATCH 175/194] Make sure we wait for the tracking before "overshooting" --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 1dbe679f..2e382e37 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -147,7 +147,7 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: - if self._is_flame_recent_or_cold_temperature_is_higher(): + if self._boiler_temperature_tracker or self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.COOLING_DOWN return DeviceStatus.OVERSHOOT_HANDLING From b158a4ec78e5b4798f97804ef78378b21ca49ec7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 19:14:11 +0100 Subject: [PATCH 176/194] Re-added "Exceeds setpoint significantly" when tracking --- custom_components/sat/boiler.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 49d63963..16b67b74 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -70,7 +70,7 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float self._last_setpoint = setpoint if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint: - self._handle_setpoint_decrease() + self._handle_setpoint_decrease() if not flame_active: self._handle_flame_inactive() @@ -107,6 +107,9 @@ def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivat if not self._warming_up and boiler_temperature_derivative == 0: return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) + if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: + return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) + if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) From fb123cb3f5483a1a11ab87bdf151a84f560afd3f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 19:19:07 +0100 Subject: [PATCH 177/194] Add support for "OVERSHOOT_STABILIZED" --- custom_components/sat/coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2e382e37..63d6d937 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -36,6 +36,7 @@ class DeviceStatus(str, Enum): PUMP_STARTING = "pump_starting" WAITING_FOR_FLAME = "waiting_for_flame" OVERSHOOT_HANDLING = "overshoot_handling" + OVERSHOOT_STABILIZED = "overshoot_stabilized" OFF = "off" UNKNOWN = "unknown" @@ -150,6 +151,9 @@ def device_status(self): if self._boiler_temperature_tracker or self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.COOLING_DOWN + if self.boiler_temperature - 2 == self.setpoint: + return DeviceStatus.OVERSHOOT_STABILIZED + return DeviceStatus.OVERSHOOT_HANDLING return DeviceStatus.FLAME_OFF From e58ad9074169d9282d1c5500bffe55b27b80a5e0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 19:52:41 +0100 Subject: [PATCH 178/194] Relax the AT_SETPOINT status --- custom_components/sat/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 63d6d937..3db644bb 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -143,7 +143,7 @@ def device_status(self): return DeviceStatus.OVERSHOOT_HANDLING - if self.setpoint == self.boiler_temperature: + if abs(self.setpoint - self.boiler_temperature) <= DEADBAND: return DeviceStatus.AT_SETPOINT if self.boiler_temperature > self.setpoint: @@ -151,7 +151,7 @@ def device_status(self): if self._boiler_temperature_tracker or self._is_flame_recent_or_cold_temperature_is_higher(): return DeviceStatus.COOLING_DOWN - if self.boiler_temperature - 2 == self.setpoint: + if abs((self.boiler_temperature - 2) - self.setpoint) <= DEADBAND: return DeviceStatus.OVERSHOOT_STABILIZED return DeviceStatus.OVERSHOOT_HANDLING From 41b88ffe123a5e2407bc1405af8c5b822659ca39 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 20:52:07 +0100 Subject: [PATCH 179/194] Optimized Boiler Cold Temperature --- custom_components/sat/coordinator.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 3db644bb..ba83547a 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -401,12 +401,8 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, ] # Update the cold temperature of the boiler - for timestamp, temperature in reversed(self._boiler_temperatures): - if self._device_on_since is None or self._device_on_since > timestamp: - self._boiler_temperature_cold = temperature - - if self._flame_on_since is None or self._flame_on_since > timestamp: - self._boiler_temperature_cold = temperature + if boiler_temperature_cold := self._get_latest_boiler_cold_temperature(): + self._boiler_temperature_cold = boiler_temperature_cold async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" @@ -446,6 +442,17 @@ def _is_flame_recent_or_cold_temperature_is_higher(self): return False + def _get_latest_boiler_cold_temperature(self) -> float | None: + """Get the latest boiler cold temperature based on recent boiler temperatures.""" + for timestamp, temperature in reversed(self._boiler_temperatures): + if self._device_on_since is None or self._device_on_since > timestamp: + return temperature + + if self._flame_on_since is None or self._flame_on_since > timestamp: + return temperature + + return None + class SatEntityCoordinator(DataUpdateCoordinator): def get(self, domain: str, key: str) -> Optional[Any]: From 51274997fbbbf6b30f743fcac3f427bf0d9f061b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 21:24:30 +0100 Subject: [PATCH 180/194] Clean up the boiler status --- custom_components/sat/coordinator.py | 38 +++++++++++++--------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index ba83547a..6bc1b03e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -27,18 +27,18 @@ class DeviceState(str, Enum): class DeviceStatus(str, Enum): - FLAME_OFF = "flame_off" HOT_WATER = "hot_water" PREHEATING = "preheating" HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" + NEAR_SETPOINT = "near_setpoint" PUMP_STARTING = "pump_starting" WAITING_FOR_FLAME = "waiting_for_flame" OVERSHOOT_HANDLING = "overshoot_handling" OVERSHOOT_STABILIZED = "overshoot_stabilized" - OFF = "off" + IDLE = "idle" UNKNOWN = "unknown" INITIALIZING = "initializing" @@ -120,43 +120,36 @@ def device_status(self): return DeviceStatus.HOT_WATER if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: - return DeviceStatus.OFF + return DeviceStatus.IDLE if self.device_active: - if ( - self.boiler_temperature_cold is not None - and self.boiler_temperature_cold > self.boiler_temperature - and self.boiler_temperature_derivative < 0 - ): - return DeviceStatus.PUMP_STARTING - - if self.setpoint > self.boiler_temperature: - if not self.flame_active: - return DeviceStatus.WAITING_FOR_FLAME + if self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature: + if self.boiler_temperature_derivative < 0: + return DeviceStatus.PUMP_STARTING - if self.flame_active: - if self._is_flame_recent_or_cold_temperature_is_higher(): + if self._boiler_temperature_tracker.active and self.setpoint > self.boiler_temperature: return DeviceStatus.PREHEATING + if self.setpoint > self.boiler_temperature: + if self.flame_active: if self._boiler_temperature_tracker.active: return DeviceStatus.HEATING_UP return DeviceStatus.OVERSHOOT_HANDLING + return DeviceStatus.WAITING_FOR_FLAME + if abs(self.setpoint - self.boiler_temperature) <= DEADBAND: return DeviceStatus.AT_SETPOINT if self.boiler_temperature > self.setpoint: if self.flame_active: - if self._boiler_temperature_tracker or self._is_flame_recent_or_cold_temperature_is_higher(): - return DeviceStatus.COOLING_DOWN - - if abs((self.boiler_temperature - 2) - self.setpoint) <= DEADBAND: - return DeviceStatus.OVERSHOOT_STABILIZED + if self._boiler_temperature_tracker: + return DeviceStatus.NEAR_SETPOINT return DeviceStatus.OVERSHOOT_HANDLING - return DeviceStatus.FLAME_OFF + return DeviceStatus.WAITING_FOR_FLAME return DeviceStatus.UNKNOWN @@ -451,6 +444,9 @@ def _get_latest_boiler_cold_temperature(self) -> float | None: if self._flame_on_since is None or self._flame_on_since > timestamp: return temperature + if self._boiler_temperature_cold is not None: + return min(self.boiler_temperature, self._boiler_temperature_cold) + return None From aaba3a022951b7dba892a089eb742a883c69e2a0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 21:28:04 +0100 Subject: [PATCH 181/194] Typo? --- custom_components/sat/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6bc1b03e..50a4379e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -144,7 +144,7 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: - if self._boiler_temperature_tracker: + if self._boiler_temperature_tracker.active: return DeviceStatus.NEAR_SETPOINT return DeviceStatus.OVERSHOOT_HANDLING From 6e53f79379d30e37254f0a7ba2b8453c5594debe Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 21:31:27 +0100 Subject: [PATCH 182/194] Add HEATING_UP when we are below 2 degrees BT --- custom_components/sat/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 50a4379e..575ddf7e 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -145,6 +145,9 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: if self._boiler_temperature_tracker.active: + if self.boiler_temperature - self.setpoint > 2: + return DeviceStatus.HEATING_UP + return DeviceStatus.NEAR_SETPOINT return DeviceStatus.OVERSHOOT_HANDLING From 0a15b3c3f05bb87374bd8cc930aa07d20f966267 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 21:41:26 +0100 Subject: [PATCH 183/194] Removed "NEAR_SETPOINT" --- custom_components/sat/coordinator.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 575ddf7e..4284b4b9 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -32,7 +32,6 @@ class DeviceStatus(str, Enum): HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" - NEAR_SETPOINT = "near_setpoint" PUMP_STARTING = "pump_starting" WAITING_FOR_FLAME = "waiting_for_flame" OVERSHOOT_HANDLING = "overshoot_handling" @@ -145,10 +144,7 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: if self._boiler_temperature_tracker.active: - if self.boiler_temperature - self.setpoint > 2: - return DeviceStatus.HEATING_UP - - return DeviceStatus.NEAR_SETPOINT + return DeviceStatus.COOLING_DOWN return DeviceStatus.OVERSHOOT_HANDLING From 58b9b32684ab3cc2edaac3c909eca6c62233fa81 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 21:45:12 +0100 Subject: [PATCH 184/194] Re-added "NEAR_SETPOINT" --- custom_components/sat/coordinator.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 4284b4b9..650aa0ca 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -32,6 +32,7 @@ class DeviceStatus(str, Enum): HEATING_UP = "heating_up" AT_SETPOINT = "at_setpoint" COOLING_DOWN = "cooling_down" + NEAR_SETPOINT = "near_setpoint" PUMP_STARTING = "pump_starting" WAITING_FOR_FLAME = "waiting_for_flame" OVERSHOOT_HANDLING = "overshoot_handling" @@ -144,7 +145,10 @@ def device_status(self): if self.boiler_temperature > self.setpoint: if self.flame_active: if self._boiler_temperature_tracker.active: - return DeviceStatus.COOLING_DOWN + if self.boiler_temperature - self.setpoint > 2: + return DeviceStatus.COOLING_DOWN + + return DeviceStatus.NEAR_SETPOINT return DeviceStatus.OVERSHOOT_HANDLING From 85e02899baf89107f6015bedfd456f30ad620774 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 22:33:07 +0100 Subject: [PATCH 185/194] Drop obsolete method --- custom_components/sat/coordinator.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 650aa0ca..7a9e6cf9 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -428,16 +428,6 @@ async def async_set_control_thermostat_setpoint(self, value: float) -> None: """Control the setpoint temperature for the thermostat.""" pass - def _is_flame_recent_or_cold_temperature_is_higher(self): - """Check if the flame is recent or the cold temperature is higher than the boiler temperature.""" - if seconds_since(self._flame_on_since) <= 6: - return True - - if self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature: - return True - - return False - def _get_latest_boiler_cold_temperature(self) -> float | None: """Get the latest boiler cold temperature based on recent boiler temperatures.""" for timestamp, temperature in reversed(self._boiler_temperatures): From b9d259b39569bd06347595e9a2ff535040aaa2fa Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Jan 2025 22:36:05 +0100 Subject: [PATCH 186/194] Make we delay the adjuster with 6 seconds --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 82dbea4f..2d3dc181 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -42,7 +42,7 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity -from .helpers import convert_time_str_to_seconds +from .helpers import convert_time_str_to_seconds, seconds_since from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .setpoint_adjuster import SetpointAdjuster @@ -810,7 +810,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: self._setpoint = self._minimum_setpoint.current if self._minimum_setpoint_version == 2: - if self._coordinator.flame_active and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: + if self._coordinator.flame_active and seconds_since(self._coordinator.flame_on_since) > 6 and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) elif self._setpoint_adjuster.current is not None: self._setpoint = self._setpoint_adjuster.current From 511389964edb54a33f4183ff851855c5207e0e75 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 14 Jan 2025 22:10:16 +0100 Subject: [PATCH 187/194] Make sure we don't disable PWM too early --- custom_components/sat/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 2d3dc181..1a289b3f 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -921,7 +921,7 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> if ( self._setpoint_adjuster.current is not None and self._coordinator.device_status == DeviceStatus.COOLING_DOWN and - math.floor(self._calculated_setpoint) > self._setpoint_adjuster.current + 2 + math.floor(self._calculated_setpoint) > self._setpoint_adjuster.current + 5 ): self._pulse_width_modulation_enabled = False _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") From 1590d334c648781f79df1e8342418c0d6bb449b2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 14 Jan 2025 22:25:07 +0100 Subject: [PATCH 188/194] Cleanup --- custom_components/sat/climate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 1a289b3f..b01aa812 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -919,9 +919,8 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> # Check if we are above the overshoot temperature if ( - self._setpoint_adjuster.current is not None and self._coordinator.device_status == DeviceStatus.COOLING_DOWN and - math.floor(self._calculated_setpoint) > self._setpoint_adjuster.current + 5 + self._setpoint_adjuster.current is not None and math.floor(self._calculated_setpoint) > math.floor(self._setpoint_adjuster.current) ): self._pulse_width_modulation_enabled = False _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") From 7a18545d756bf0a58510704b4ff6b7da3c1e2b4a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 17 Jan 2025 22:52:16 +0100 Subject: [PATCH 189/194] Add support for a connected thermostat which may take control of hvac mode and the target temperature --- custom_components/sat/climate.py | 26 ++++++++++++++++++++++ custom_components/sat/config_flow.py | 15 ++++++++----- custom_components/sat/const.py | 4 +++- custom_components/sat/translations/en.json | 5 +++-- 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b01aa812..aff447fd 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -156,6 +156,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() + self.thermostat = config_entry.data.get(CONF_THERMOSTAT) self._climates = config_entry.data.get(CONF_SECONDARY_CLIMATES) or [] self._main_climates = config_entry.data.get(CONF_MAIN_CLIMATES) or [] self._window_sensors = config_entry.options.get(CONF_WINDOW_SENSORS) or [] @@ -264,6 +265,13 @@ async def _register_event_listeners(self, _time: Optional[datetime] = None): ) ) + if self.thermostat is not None: + self.async_on_remove( + async_track_state_change_event( + self.hass, self.thermostat, self._async_thermostat_changed + ) + ) + if self.humidity_sensor_entity_id is not None: self.async_on_remove( async_track_state_change_event( @@ -595,6 +603,24 @@ def _calculate_control_setpoint(self) -> float: # Ensure setpoint is limited to our max return min(requested_setpoint, self._coordinator.maximum_setpoint) + async def _async_thermostat_changed(self, event: Event) -> None: + """Handle changes to the connected thermostat.""" + old_state = event.data.get("old_state") + if old_state is None or old_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + new_state = event.data.get("new_state") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + if ( + old_state.state != new_state.state or + old_state.attributes.get("temperature") != new_state.attributes.get("temperature") + ): + _LOGGER.debug("Thermostat State Changed.") + await self.async_set_hvac_mode(new_state.state) + await self.async_set_target_temperature(new_state.attributes.get("temperature")) + async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" new_state = event.data.get("new_state") diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 68e721d9..ee4dd1a5 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -335,16 +335,19 @@ async def async_step_areas(self, _user_input: dict[str, Any] | None = None): return await self.async_step_automatic_gains() - climate_selector = selector.EntitySelector(selector.EntitySelectorConfig( - domain=CLIMATE_DOMAIN, multiple=True - )) - return self.async_show_form( last_step=False, step_id="areas", data_schema=vol.Schema({ - vol.Optional(CONF_MAIN_CLIMATES, default=self.data.get(CONF_MAIN_CLIMATES, [])): climate_selector, - vol.Optional(CONF_SECONDARY_CLIMATES, default=self.data.get(CONF_SECONDARY_CLIMATES, [])): climate_selector, + vol.Optional(CONF_THERMOSTAT, default=self.data.get(CONF_THERMOSTAT)): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN) + ), + vol.Optional(CONF_MAIN_CLIMATES, default=self.data.get(CONF_MAIN_CLIMATES, [])): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) + ), + vol.Optional(CONF_SECONDARY_CLIMATES, default=self.data.get(CONF_SECONDARY_CLIMATES, [])): selector.EntitySelector( + selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) + ), }) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index abff5a7d..8aaefd65 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -28,6 +28,7 @@ CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" +CONF_THERMOSTAT = "thermostat" CONF_MANUFACTURER = "manufacturer" CONF_ERROR_MONITORING = "error_monitoring" CONF_CYCLES_PER_HOUR = "cycles_per_hour" @@ -108,8 +109,9 @@ CONF_DYNAMIC_MINIMUM_SETPOINT: False, CONF_MINIMUM_SETPOINT_ADJUSTMENT_FACTOR: 0.2, - CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], + CONF_SECONDARY_CLIMATES: [], + CONF_SIMULATION: False, CONF_WINDOW_SENSORS: [], CONF_THERMAL_COMFORT: False, diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 2bc1f62f..0c5bfa14 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -16,10 +16,11 @@ "step": { "areas": { "data": { - "main_climates": "Primary", + "thermostat": "Thermostat", + "main_climates": "Radiators", "secondary_climates": "Rooms" }, - "description": "Settings related to climates, multi-room and temperature control. Primary climates are in the same room as the inside sensor and the rooms have their own target temperatures separate from the system.", + "description": "Settings related to thermostat, multi-room and temperature control. The radiators are in the same room as the inside sensor and the rooms have their own target temperatures separate from the system.", "title": "Areas" }, "automatic_gains": { From 7b930e1ef2cb57cf8e171ce999931a35e7ec6799 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 17 Jan 2025 23:14:25 +0100 Subject: [PATCH 190/194] Bump version --- custom_components/sat/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index d93de40e..946100fc 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -25,5 +25,5 @@ "pyotgw==2.2.2", "sentry-sdk==2.19.2" ], - "version": "4.0.0" + "version": "4.1.0" } \ No newline at end of file From 37ff9aa38faffd0627245dce0696eacd7dce1a52 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Jan 2025 13:52:19 +0100 Subject: [PATCH 191/194] Cleaning and moving some code --- custom_components/sat/climate.py | 23 +++++++++-------------- custom_components/sat/pid.py | 5 +---- custom_components/sat/pwm.py | 21 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b01aa812..470b14de 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -55,7 +55,6 @@ ATTR_COEFFICIENT_DERIVATIVE = "coefficient_derivative" ATTR_PRE_CUSTOM_TEMPERATURE = "pre_custom_temperature" ATTR_PRE_ACTIVITY_TEMPERATURE = "pre_activity_temperature" -ATTR_PULSE_WIDTH_MODULATION_ENABLED = "pulse_width_modulation_enabled" _LOGGER = logging.getLogger(__name__) @@ -127,7 +126,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._setpoint = None self._calculated_setpoint = None self._last_boiler_temperature = None - self._pulse_width_modulation_enabled = False self._hvac_mode = None self._target_temperature = None @@ -304,6 +302,7 @@ async def _restore_previous_state_or_set_defaults(self): old_state = await self.async_get_last_state() if old_state is not None: + self.pwm.restore(old_state) self.pid.restore(old_state) if self._target_temperature is None: @@ -329,9 +328,6 @@ async def _restore_previous_state_or_set_defaults(self): if old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE): self._pre_custom_temperature = old_state.attributes.get(ATTR_PRE_CUSTOM_TEMPERATURE) - if old_state.attributes.get(ATTR_PULSE_WIDTH_MODULATION_ENABLED): - self._pulse_width_modulation_enabled = old_state.attributes.get(ATTR_PULSE_WIDTH_MODULATION_ENABLED) - if old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT): self.heating_curve.restore_autotune( old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT), @@ -403,8 +399,10 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, + "summer_simmer_index": SummerSimmer.index(self._current_temperature, self._current_humidity), "summer_simmer_perception": SummerSimmer.perception(self._current_temperature, self._current_humidity), + "valves_open": self.valves_open, "heating_curve": self.heating_curve.value, "minimum_setpoint": self.minimum_setpoint, @@ -417,8 +415,8 @@ def extra_state_attributes(self): "relative_modulation_state": self.relative_modulation_state, "relative_modulation_enabled": self._relative_modulation.enabled, - "pulse_width_modulation_enabled": self.pulse_width_modulation_enabled, "pulse_width_modulation_state": self.pwm.state, + "pulse_width_modulation_enabled": self.pwm.enabled, "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle, } @@ -563,7 +561,7 @@ def pulse_width_modulation_enabled(self) -> bool: if self._minimum_setpoint_version == 1: return self._minimum_setpoint.current > self._calculated_setpoint - return self._pulse_width_modulation_enabled + return self.pwm.enabled @property def relative_modulation_value(self) -> int: @@ -692,10 +690,7 @@ async def _async_climate_changed(self, event: Event) -> None: await self.async_control_heating_loop() async def _async_temperature_change(self, event: Event) -> None: - """Handle changes to the climate sensor entity. - If the current temperature of the sensor entity has changed, - update the PID controller and heating control. - """ + """Handle changes to the climate sensor entity.""" new_state = event.data.get("new_state") if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -868,8 +863,8 @@ async def _async_update_rooms_from_climates(self) -> None: async def reset_control_state(self): """Reset control state when major changes occur.""" + self.pwm.disable() self._setpoint_adjuster.reset() - self._pulse_width_modulation_enabled = False async def async_track_sensor_temperature(self, entity_id): """ @@ -914,16 +909,16 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> # Check for overshoot if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: - self._pulse_width_modulation_enabled = True _LOGGER.info("Overshoot Handling detected, enabling Pulse Width Modulation.") + self.pwm.enable() # Check if we are above the overshoot temperature if ( self._coordinator.device_status == DeviceStatus.COOLING_DOWN and self._setpoint_adjuster.current is not None and math.floor(self._calculated_setpoint) > math.floor(self._setpoint_adjuster.current) ): - self._pulse_width_modulation_enabled = False _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") + self.pwm.disable() # Pulse Width Modulation if self.pulse_width_modulation_enabled: diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index e5bd06f6..5a9901ad 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -235,10 +235,7 @@ def update_history_size(self, alpha: float = 0.8): self._times = deque(self._times, maxlen=int(self._history_size)) def restore(self, state: State) -> None: - """Restore the PID controller from a saved state. - - state: The saved state of the PID controller to restore from. - """ + """Restore the PID controller from a saved state.""" if last_error := state.attributes.get("error"): self._last_error = last_error diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 7462a299..ad623d9e 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,6 +3,8 @@ from time import monotonic from typing import Optional, Tuple +from homeassistant.core import State + from .boiler import BoilerState from .const import HEATER_STARTUP_TIMEFRAME from .heating_curve import HeatingCurve @@ -51,6 +53,7 @@ def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_d def reset(self) -> None: """Reset the PWM control.""" + self._enabled = False self._cycles: int = 0 self._state: PWMState = PWMState.IDLE self._last_update: float = monotonic() @@ -61,6 +64,19 @@ def reset(self) -> None: _LOGGER.info("PWM control reset to initial state.") + def restore(self, state: State) -> None: + """Restore the PWM controller from a saved state.""" + if enabled := state.attributes.get("pulse_width_modulation_enabled"): + self._enabled = bool(enabled) + + def enable(self) -> None: + """Enable the PWM control.""" + self._enabled = True + + def disable(self) -> None: + """Disable the PWM control.""" + self._enabled = False + async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value or requested_setpoint is None or boiler.temperature is None: @@ -201,9 +217,12 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug("Maximum duty cycle exceeded. Setting on_time: %d seconds, off_time: %d seconds.", on_time, off_time) return int(on_time), int(off_time) + @property + def enabled(self) -> bool: + return self._enabled + @property def state(self) -> PWMState: - """Current PWM state.""" return self._state @property From 12086334aa8094da4f46d08754319070319048ea Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Jan 2025 14:13:36 +0100 Subject: [PATCH 192/194] Renamed the thermostat sync checkbox --- custom_components/sat/__init__.py | 4 ++++ custom_components/sat/climate.py | 4 ++-- custom_components/sat/config_flow.py | 2 +- custom_components/sat/const.py | 4 ++-- custom_components/sat/translations/de.json | 4 +--- custom_components/sat/translations/en.json | 4 +--- custom_components/sat/translations/es.json | 3 +-- custom_components/sat/translations/fr.json | 3 +-- custom_components/sat/translations/it.json | 3 +-- custom_components/sat/translations/nl.json | 3 +-- custom_components/sat/translations/pt.json | 3 +-- custom_components/sat/translations/sk.json | 4 +--- 12 files changed, 17 insertions(+), 24 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 71e86071..b81b0df5 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -190,6 +190,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_data["mode"] = "mqtt_opentherm" new_data["device"] = list(device.identifiers)[0][1] + if entry.version < 11: + if entry.data.get("sync_with_thermostat") is not None: + new_data["push_setpoint_to_thermostat"] = entry.data.get("sync_with_thermostat") + hass.config_entries.async_update_entry(entry, version=SatFlowHandler.VERSION, data=new_data, options=new_options) _LOGGER.info("Migration to version %s successful", entry.version) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b734b776..02ab8af1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -161,8 +161,8 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) - self._sync_with_thermostat = bool(config_entry.data.get(CONF_SYNC_WITH_THERMOSTAT)) self._overshoot_protection = bool(config_entry.data.get(CONF_OVERSHOOT_PROTECTION)) + self._push_setpoint_to_coordinator = bool(config_entry.data.get(CONF_PUSH_SETPOINT_TO_THERMOSTAT)) # User Configuration self._heating_mode = str(config_entry.options.get(CONF_HEATING_MODE)) @@ -1106,7 +1106,7 @@ async def async_set_target_temperature(self, temperature: float) -> None: data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - if self._sync_with_thermostat: + if self._push_setpoint_to_coordinator: # Set the target temperature for the connected boiler await self._coordinator.async_set_control_thermostat_setpoint(temperature) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index ee4dd1a5..19f0035f 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -670,8 +670,8 @@ async def async_step_presets(self, _user_input: dict[str, Any] | None = None): vol.Required(CONF_COMFORT_TEMPERATURE, default=options[CONF_COMFORT_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=options[CONF_SYNC_WITH_THERMOSTAT]): bool, vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=options[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, + vol.Required(CONF_PUSH_SETPOINT_TO_THERMOSTAT, default=options[CONF_PUSH_SETPOINT_TO_THERMOSTAT]): bool, }) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 8aaefd65..eda39eb9 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -42,7 +42,7 @@ CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" CONF_WINDOW_SENSORS = "window_sensors" -CONF_SYNC_WITH_THERMOSTAT = "sync_with_thermostat" +CONF_PUSH_SETPOINT_TO_THERMOSTAT = "push_setpoint_to_thermostat" CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" CONF_THERMAL_COMFORT = "thermal_comfort" CONF_SIMULATION = "simulation" @@ -116,7 +116,7 @@ CONF_WINDOW_SENSORS: [], CONF_THERMAL_COMFORT: False, CONF_HUMIDITY_SENSOR_ENTITY_ID: None, - CONF_SYNC_WITH_THERMOSTAT: False, + CONF_PUSH_SETPOINT_TO_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_MODE: True, CONF_SYNC_CLIMATES_WITH_PRESET: False, diff --git a/custom_components/sat/translations/de.json b/custom_components/sat/translations/de.json index 32e30aad..5adaf422 100644 --- a/custom_components/sat/translations/de.json +++ b/custom_components/sat/translations/de.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Maximaler Sollwert", "pid_controller_version": "PID-Reglerversion", "proportional": "Proportional (kP)", - "sync_with_thermostat": "Sollwert mit Thermostat synchronisieren", "window_sensors": "Kontaktsensoren", "heating_mode": "Heizbetrieb" }, @@ -196,7 +195,6 @@ "integral": "Der Integralterm (kI) im PID-Regler, verantwortlich für die Reduzierung des stationären Fehlers.", "maximum_setpoint": "Die optimale Temperatur für einen effizienten Kesselbetrieb.", "proportional": "Der proportionale Term (kP) im PID-Regler, verantwortlich für die sofortige Reaktion auf Fehler.", - "sync_with_thermostat": "Sollwert mit Thermostat synchronisieren, um eine koordinierte Temperaturregelung zu gewährleisten.", "window_sensors": "Kontaktsensoren, die das System auslösen, wenn ein Fenster oder eine Tür für eine bestimmte Zeit geöffnet ist." }, "description": "Allgemeine Einstellungen und Konfigurationen.", @@ -217,7 +215,7 @@ "comfort_temperature": "Komforttemperatur", "home_temperature": "Heimtemperatur", "sleep_temperature": "Schlafzimmertemperatur", - "sync_climates_with_preset": "Klimazonen mit Voreinstellung synchronisieren (schlafen/abwesend/aktiv)" + "push_setpoint_to_thermostat": "Sollwert mit Thermostat synchronisieren" }, "description": "Vordefinierte Temperatureinstellungen für verschiedene Szenarien oder Aktivitäten.", "title": "Voreinstellungen" diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 0c5bfa14..8ba425a3 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -192,7 +192,6 @@ "dynamic_minimum_setpoint_version": "Minimum Setpoint Controller Version", "pid_controller_version": "PID Controller Version", "proportional": "Proportional (kP)", - "sync_with_thermostat": "Synchronize setpoint with thermostat", "window_sensors": "Contact Sensors", "heating_mode": "Heating Mode" }, @@ -205,7 +204,6 @@ "integral": "The integral term (kI) in the PID controller, responsible for reducing steady-state error.", "maximum_setpoint": "The optimal temperature for efficient boiler operation.", "proportional": "The proportional term (kP) in the PID controller, responsible for the immediate response to errors.", - "sync_with_thermostat": "Synchronize setpoint with thermostat to ensure coordinated temperature control.", "window_sensors": "Contact Sensors that trigger the system to react when a window or door is open for a period of time." }, "description": "General settings and configurations.", @@ -226,7 +224,7 @@ "comfort_temperature": "Comfort Temperature", "home_temperature": "Home Temperature", "sleep_temperature": "Sleep Temperature", - "sync_with_thermostat": "Synchronize with thermostat attached to the boiler", + "push_setpoint_to_thermostat": "Synchronize with thermostat attached to the boiler", "sync_climates_with_preset": "Synchronize climates with preset (sleep / away / activity)" }, "description": "Predefined temperature settings for different scenarios or activities.", diff --git a/custom_components/sat/translations/es.json b/custom_components/sat/translations/es.json index 09d6557a..d4614495 100644 --- a/custom_components/sat/translations/es.json +++ b/custom_components/sat/translations/es.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Punto de Ajuste Máximo", "pid_controller_version": "Versión del controlador PID", "proportional": "Proporcional (kP)", - "sync_with_thermostat": "Sincronizar punto de ajuste con el termostato", "window_sensors": "Sensores de Contacto", "heating_mode": "Modo de calefacción" }, @@ -196,7 +195,6 @@ "integral": "El término integral (kI) en el controlador PID, responsable de reducir el error en estado estacionario.", "maximum_setpoint": "La temperatura óptima para una operación eficiente de la caldera.", "proportional": "El término proporcional (kP) en el controlador PID, responsable de la respuesta inmediata a errores.", - "sync_with_thermostat": "Sincronizar el punto de ajuste con el termostato para asegurar un control coordinado de la temperatura.", "window_sensors": "Sensores de Contacto que activan el sistema cuando una ventana o puerta está abierta durante un período." }, "description": "Configuraciones y ajustes generales.", @@ -217,6 +215,7 @@ "comfort_temperature": "Temperatura de Confort", "home_temperature": "Temperatura de Casa", "sleep_temperature": "Temperatura de Sueño", + "push_setpoint_to_thermostat": "Sincronizar punto de ajuste con el termostato", "sync_climates_with_preset": "Sincronizar climas con preajuste (sueño / ausencia / actividad)" }, "description": "Configuraciones de temperatura predefinidas para diferentes escenarios o actividades.", diff --git a/custom_components/sat/translations/fr.json b/custom_components/sat/translations/fr.json index 37ba72df..f6ef23da 100644 --- a/custom_components/sat/translations/fr.json +++ b/custom_components/sat/translations/fr.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Point de consigne maximal", "pid_controller_version": "Version du contrôleur PID", "proportional": "Proportionnel (kP)", - "sync_with_thermostat": "Synchroniser le point de consigne avec le thermostat", "window_sensors": "Capteurs de Contact", "heating_mode": "Mode chauffage" }, @@ -196,7 +195,6 @@ "integral": "Le terme intégral (kI) dans le régulateur PID, responsable de la réduction de l'erreur en régime permanent.", "maximum_setpoint": "La température optimale pour un fonctionnement efficace de la chaudière.", "proportional": "Le terme proportionnel (kP) dans le régulateur PID, responsable de la réponse immédiate aux erreurs.", - "sync_with_thermostat": "Synchroniser le point de consigne avec le thermostat pour assurer une régulation coordonnée de la température.", "window_sensors": "Capteurs de Contact qui déclenchent le système lorsqu'une fenêtre ou une porte est ouverte pendant une période." }, "description": "Paramètres et configurations généraux.", @@ -217,6 +215,7 @@ "comfort_temperature": "Température Confort", "home_temperature": "Température Maison", "sleep_temperature": "Température Sommeil", + "push_setpoint_to_thermostat": "Synchroniser le point de consigne avec le thermostat", "sync_climates_with_preset": "Synchroniser les climats avec le préréglage (sommeil / absence / activité)" }, "description": "Paramètres de température prédéfinis pour différents scénarios ou activités.", diff --git a/custom_components/sat/translations/it.json b/custom_components/sat/translations/it.json index 769fd4b7..397b2b09 100644 --- a/custom_components/sat/translations/it.json +++ b/custom_components/sat/translations/it.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Setpoint Massimo", "pid_controller_version": "Versione del controllore PID", "proportional": "Proporzionale (kP)", - "sync_with_thermostat": "Sincronizza setpoint con termostato", "window_sensors": "Sensori Contatto", "heating_mode": "Modalità riscaldamento" }, @@ -196,7 +195,6 @@ "integral": "Il termine integrale (kI) nel controllore PID, responsabile della riduzione dell'errore in stato stazionario.", "maximum_setpoint": "La temperatura ottimale per un funzionamento efficiente della caldaia.", "proportional": "Il termine proporzionale (kP) nel controllore PID, responsabile della risposta immediata agli errori.", - "sync_with_thermostat": "Sincronizza il setpoint con il termostato per garantire un controllo coordinato della temperatura.", "window_sensors": "Sensori di Contatto che attivano il sistema quando una finestra o una porta è aperta per un periodo di tempo." }, "description": "Impostazioni e configurazioni generali.", @@ -217,6 +215,7 @@ "comfort_temperature": "Temperatura Comfort", "home_temperature": "Temperatura Casa", "sleep_temperature": "Temperatura Sonno", + "push_setpoint_to_thermostat": "Sincronizza setpoint con termostato", "sync_climates_with_preset": "Sincronizza climi con preimpostazione (sonno / assente / attività)" }, "description": "Impostazioni di temperatura predefinite per diversi scenari o attività.", diff --git a/custom_components/sat/translations/nl.json b/custom_components/sat/translations/nl.json index f0aeb675..8f3195be 100644 --- a/custom_components/sat/translations/nl.json +++ b/custom_components/sat/translations/nl.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Maximaal Setpoint", "pid_controller_version": "Versie van de PID-regelaar", "proportional": "Proportioneel (kP)", - "sync_with_thermostat": "Synchroniseer setpoint met thermostaat", "window_sensors": "Contact Sensoren", "heating_mode": "Verwarmingsmodus" }, @@ -196,7 +195,6 @@ "integral": "De integraalterm (kI) in de PID-regelaar, verantwoordelijk voor het verminderen van de blijvende fout.", "maximum_setpoint": "De optimale temperatuur voor efficiënte werking van de ketel.", "proportional": "De proportionele term (kP) in de PID-regelaar, verantwoordelijk voor de directe reactie op fouten.", - "sync_with_thermostat": "Synchroniseer het setpoint met de thermostaat om een gecoördineerde temperatuurregeling te waarborgen.", "window_sensors": "Contact Sensoren die het systeem activeren wanneer een raam of deur voor een periode geopend is." }, "description": "Algemene instellingen en configuraties.", @@ -217,6 +215,7 @@ "comfort_temperature": "Comfort Temperatuur", "home_temperature": "Thuis Temperatuur", "sleep_temperature": "Slaap Temperatuur", + "push_setpoint_to_thermostat": "Synchroniseer setpoint met thermostaat", "sync_climates_with_preset": "Synchroniseer klimaten met voorinstelling (slaap / afwezig / activiteit)" }, "description": "Vooraf gedefinieerde temperatuurinstellingen voor verschillende scenario's of activiteiten.", diff --git a/custom_components/sat/translations/pt.json b/custom_components/sat/translations/pt.json index 3243d58e..4e03695c 100644 --- a/custom_components/sat/translations/pt.json +++ b/custom_components/sat/translations/pt.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Setpoint Máximo", "pid_controller_version": "Versão do Controlador PID", "proportional": "Proporcional (kP)", - "sync_with_thermostat": "Sincronizar setpoint com termóstato", "window_sensors": "Sensores de Contato", "heating_mode": "Modo de Aquecimento" }, @@ -196,7 +195,6 @@ "integral": "O termo integral (kI) no controlador PID, responsável por reduzir o erro em regime permanente.", "maximum_setpoint": "A temperatura ótima para operação eficiente da caldeira.", "proportional": "O termo proporcional (kP) no controlador PID, responsável pela resposta imediata a erros.", - "sync_with_thermostat": "Sincronizar setpoint com termóstato para assegurar controlo de temperatura coordenado.", "window_sensors": "Sensores de Contato que ativam o sistema para reagir quando uma janela ou porta está aberta por um período de tempo." }, "description": "Definições e configurações gerais.", @@ -217,6 +215,7 @@ "comfort_temperature": "Temperatura de Conforto", "home_temperature": "Temperatura em Casa", "sleep_temperature": "Temperatura de Sono", + "push_setpoint_to_thermostat": "Sincronizar setpoint com termóstato", "sync_climates_with_preset": "Sincronizar climas com predefinição (sono / ausente / atividade)" }, "description": "Definições de temperatura predefinidas para diferentes cenários ou atividades.", diff --git a/custom_components/sat/translations/sk.json b/custom_components/sat/translations/sk.json index b8956c48..3b9da6d4 100644 --- a/custom_components/sat/translations/sk.json +++ b/custom_components/sat/translations/sk.json @@ -183,7 +183,6 @@ "maximum_setpoint": "Maximálna požadovaná hodnota", "pid_controller_version": "Verzia PID regulátora", "proportional": "Proporcionálna (kP)", - "sync_with_thermostat": "Synchronizujte požadovanú hodnotu s termostatom", "window_sensors": "Kontaktné senzory", "heating_mode": "Režim vykurovania" }, @@ -196,7 +195,6 @@ "integral": "Integrálny člen (kI) v regulátore PID, zodpovedný za zníženie chyby v ustálenom stave.", "maximum_setpoint": "Optimálna teplota pre efektívnu prevádzku kotla.", "proportional": "Proporcionálny člen (kP) v PID regulátore, zodpovedný za okamžitú reakciu na chyby.", - "sync_with_thermostat": "Synchronizujte požadovanú hodnotu s termostatom, aby ste zabezpečili koordinovanú reguláciu teploty.", "window_sensors": "Kontaktné senzory, ktoré spúšťajú reakciu systému, keď je okno alebo dvere otvorené po určitú dobu." }, "description": "Všeobecné nastavenia a konfigurácie.", @@ -217,7 +215,7 @@ "comfort_temperature": "Komfortná teplota", "home_temperature": "Domáca teplota", "sleep_temperature": "Teplota spánku", - "sync_with_thermostat": "Synchronizujte s termostatom pripojeným ku kotlu", + "push_setpoint_to_thermostat": "Synchronizujte s termostatom pripojeným ku kotlu", "sync_climates_with_preset": "Synchronizujte klímu s predvoľbou (spánok / preč / aktivita)" }, "description": "Preddefinované nastavenia teploty pre rôzne scenáre alebo činnosti.", From fdf09de18bc516361b5b2447b99a0f4769f4b013 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Jan 2025 14:14:17 +0100 Subject: [PATCH 193/194] Typo? --- custom_components/sat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 02ab8af1..9950af80 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -162,7 +162,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) self._overshoot_protection = bool(config_entry.data.get(CONF_OVERSHOOT_PROTECTION)) - self._push_setpoint_to_coordinator = bool(config_entry.data.get(CONF_PUSH_SETPOINT_TO_THERMOSTAT)) + self._push_setpoint_to_thermostat = bool(config_entry.data.get(CONF_PUSH_SETPOINT_TO_THERMOSTAT)) # User Configuration self._heating_mode = str(config_entry.options.get(CONF_HEATING_MODE)) @@ -1106,7 +1106,7 @@ async def async_set_target_temperature(self, temperature: float) -> None: data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - if self._push_setpoint_to_coordinator: + if self._push_setpoint_to_thermostat: # Set the target temperature for the connected boiler await self._coordinator.async_set_control_thermostat_setpoint(temperature) From a74d44d27ed5efd72f6af5c944ec9b174640b1f9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Jan 2025 14:49:20 +0100 Subject: [PATCH 194/194] Add support for "maximum_setpoint_value" --- custom_components/sat/coordinator.py | 4 ++++ custom_components/sat/esphome/__init__.py | 7 +++++++ custom_components/sat/mqtt/opentherm.py | 8 ++++++++ custom_components/sat/overshoot_protection.py | 4 ++-- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 7a9e6cf9..53424183 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -251,6 +251,10 @@ def maximum_hot_water_setpoint(self) -> float: def relative_modulation_value(self) -> float | None: return None + @property + def maximum_setpoint_value(self) -> float | None: + return self.maximum_setpoint + @property def boiler_capacity(self) -> float | None: return None diff --git a/custom_components/sat/esphome/__init__.py b/custom_components/sat/esphome/__init__.py index 84391d54..620dff16 100644 --- a/custom_components/sat/esphome/__init__.py +++ b/custom_components/sat/esphome/__init__.py @@ -100,6 +100,13 @@ def setpoint(self) -> float | None: return None + @property + def maximum_setpoint_value(self) -> float | None: + if (setpoint := self.get(NUMBER_DOMAIN, DATA_MAX_CH_SETPOINT)) is not None: + return float(setpoint) + + return super().maximum_setpoint_value + @property def hot_water_setpoint(self) -> float | None: if (setpoint := self.get(NUMBER_DOMAIN, DATA_DHW_SETPOINT)) is not None: diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index 1a62a50c..bac1dcde 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -13,6 +13,7 @@ DATA_FLAME_ACTIVE = "flame" DATA_DHW_SETPOINT = "TdhwSet" DATA_CONTROL_SETPOINT = "TSet" +DATA_MAXIMUM_CONTROL_SETPOINT = "MaxTSet" DATA_REL_MOD_LEVEL = "RelModLevel" DATA_BOILER_TEMPERATURE = "Tboiler" DATA_RETURN_TEMPERATURE = "Tret" @@ -71,6 +72,13 @@ def setpoint(self) -> float | None: return None + @property + def maximum_setpoint_value(self) -> float | None: + if (setpoint := self.data.get(DATA_MAXIMUM_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return super().maximum_setpoint_value + @property def hot_water_setpoint(self) -> float | None: if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 8fdda714..87a77337 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -20,7 +20,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): self._alpha: float = 0.5 self._stable_temperature: float | None = None self._coordinator: SatDataUpdateCoordinator = coordinator - self._setpoint: int = OVERSHOOT_PROTECTION_SETPOINT.get(heating_system) + self._setpoint: int = min(OVERSHOOT_PROTECTION_SETPOINT.get(heating_system), coordinator.maximum_setpoint_value) if self._setpoint is None: raise ValueError(f"Invalid heating system: {heating_system}") @@ -108,7 +108,7 @@ async def _trigger_heating_cycle(self, is_ready: bool) -> None: await asyncio.sleep(SLEEP_INTERVAL) await self._coordinator.async_control_heating_loop() - async def _get_setpoint(self, is_ready) -> float: + async def _get_setpoint(self, is_ready: bool) -> float: """Get the setpoint for the heating cycle.""" return self._setpoint if not is_ready or self._coordinator.relative_modulation_value > 0 else self._coordinator.boiler_temperature