diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..9714cdb5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: Alexwijn +buy_me_a_coffee: alexwijn diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index b81b0df5..cb19dde3 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -17,11 +17,16 @@ CLIMATE, SENTRY, COORDINATOR, + OPTIONS_DEFAULTS, CONF_MODE, CONF_DEVICE, - CONF_ERROR_MONITORING, OPTIONS_DEFAULTS, + CONF_ERROR_MONITORING, + SERVICE_RESET_INTEGRAL, + SERVICE_PULSE_WIDTH_MODULATION, ) from .coordinator import SatDataUpdateCoordinatorFactory +from .services import async_register_services +from .util import get_climate_entities _LOGGER: logging.Logger = logging.getLogger(__name__) PLATFORMS = [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN] @@ -57,6 +62,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Forward entry setup for used platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register the services + await async_register_services(hass) + # Add an update listener for this entry entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/custom_components/sat/area.py b/custom_components/sat/area.py index f6c06122..05bb306c 100644 --- a/custom_components/sat/area.py +++ b/custom_components/sat/area.py @@ -9,9 +9,7 @@ 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, create_heating_curve_controller, ) @@ -27,7 +25,6 @@ def __init__(self, config_data: MappingProxyType[str, Any], config_options: Mapp # Create controllers with the given configuration 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: diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index d19bc43f..1834d3b4 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_setpoint_management: _async_add_entities([SatControlSetpointSynchroSensor(coordinator, _config_entry, climate)]) - if coordinator.supports_relative_modulation_management: + if coordinator.supports_relative_modulation: _async_add_entities([SatRelativeModulationSynchroSensor(coordinator, _config_entry, climate)]) if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0: diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 16b67b74..0aa0d211 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -1,11 +1,30 @@ import logging +from enum import Enum +from typing import Optional from .const import MINIMUM_SETPOINT _LOGGER = logging.getLogger(__name__) STABILIZATION_MARGIN = 5 -EXCEED_SETPOINT_MARGIN = 0.1 +EXCEED_SETPOINT_MARGIN = 1.0 + + +class BoilerStatus(str, Enum): + 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" + + IDLE = "idle" + UNKNOWN = "unknown" + INITIALIZING = "initializing" class BoilerState: @@ -13,30 +32,36 @@ 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. + def __init__(self, device_active: bool, device_status: BoilerStatus, flame_active: bool, flame_on_since: Optional[int], hot_water_active: bool, temperature: float): + """Initialize with the boiler's state parameters.""" + self._flame_active: bool = flame_active + self._hot_water_active: bool = hot_water_active - :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 + self._temperature: float = temperature + self._device_active: bool = device_active + self._device_status: BoilerStatus = device_status + self._flame_on_since: Optional[int] = flame_on_since @property def device_active(self) -> bool: """Indicates whether the boiler is running.""" return self._device_active + @property + def device_status(self) -> BoilerStatus: + """Indicates the boiler status.""" + return self._device_status + @property def flame_active(self) -> bool: """Indicates whether the flame is ignited.""" return self._flame_active + @property + def flame_on_since(self) -> Optional[int]: + """Indicates when the flame has been ignited.""" + return self._flame_on_since + @property def hot_water_active(self) -> bool: """Indicates whether the boiler is heating water.""" @@ -70,7 +95,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,10 +132,10 @@ 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 boiler_temperature - EXCEED_SETPOINT_MARGIN > setpoint: + return self._stop_tracking("Exceeds setpoint.", boiler_temperature, setpoint) - if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: + if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature + 1 < self._last_boiler_temperature: 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): diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6f74c920..d54d7a4e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -31,21 +31,21 @@ 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, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant, ServiceCall, Event, CoreState +from homeassistant.core import HomeAssistant, 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 from homeassistant.helpers.restore_state import RestoreEntity from .area import Areas, SENSOR_TEMPERATURE_ID -from .boiler import BoilerState +from .boiler import BoilerStatus from .const import * -from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus +from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity -from .helpers import convert_time_str_to_seconds, seconds_since +from .helpers import convert_time_str_to_seconds +from .manufacturers.geminox import Geminox 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, create_minimum_setpoint_controller @@ -179,9 +179,6 @@ 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 Setpoint Adjuster controller - self._setpoint_adjuster = SetpointAdjuster() - # Create PID controller with given configuration options self.pid = create_pid_controller(config_options) @@ -198,7 +195,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn 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) + self.pwm = create_pwm_controller(self.heating_curve, coordinator.supports_relative_modulation_management, config_entry.data, config_options) if self._simulation: _LOGGER.warning("Simulation mode!") @@ -231,9 +228,6 @@ async def async_added_to_hass(self) -> None: await self._register_event_listeners() await self.async_control_heating_loop() - # Register services - await self._register_services() - # Initialize the area system await self.areas.async_added_to_hass(self.hass) @@ -361,14 +355,6 @@ async def _restore_previous_state_or_set_defaults(self): self.async_write_ha_state() - async def _register_services(self): - async def reset_integral(_call: ServiceCall): - """Service to reset the integral part of the PID controller.""" - self.pid.reset() - self.areas.pids.reset() - - self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) - @property def name(self): """Return the friendly name of the sensor.""" @@ -574,7 +560,10 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_value(self) -> int: - return self._maximum_relative_modulation if self._relative_modulation.enabled else MINIMUM_RELATIVE_MOD + if not self._relative_modulation.enabled and self._coordinator.supports_relative_modulation_management: + return MINIMUM_RELATIVE_MODULATION + + return self._maximum_relative_modulation @property def relative_modulation_state(self) -> RelativeModulationState: @@ -586,8 +575,8 @@ def minimum_setpoint(self) -> float: 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 + if self._minimum_setpoint_version == 2: + return self.pwm.setpoint return self._coordinator.minimum_setpoint @@ -697,12 +686,10 @@ 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) # 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) # If the current temperature has changed, update the PID controller @@ -832,15 +819,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 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 - elif not self._coordinator.flame_active: - self._setpoint = self._setpoint_adjuster.force(self._coordinator.boiler_temperature + 10) - elif self._setpoint is None: - _LOGGER.debug("Setpoint not available.") - return + self._setpoint = self.pwm.setpoint else: self._setpoint = self._coordinator.minimum_setpoint @@ -855,19 +834,26 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: async def _async_control_relative_modulation(self) -> None: """Control the relative modulation value based on the conditions.""" - if not self._coordinator.supports_relative_modulation_management: + if not self._coordinator.supports_relative_modulation: _LOGGER.debug("Relative modulation management is not supported. Skipping control.") return # Update relative modulation state await self._relative_modulation.update(self.pulse_width_modulation_enabled) + # Retrieve the relative modulation + relative_modulation_value = self.relative_modulation_value + + # Apply some filters based on the manufacturer + if isinstance(self._coordinator.manufacturer, Geminox): + relative_modulation_value = max(10, relative_modulation_value) + # Determine if the value needs to be updated - if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: - _LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", self.relative_modulation_value) + if self._coordinator.maximum_relative_modulation_value == relative_modulation_value: + _LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", relative_modulation_value) return - await self._coordinator.async_set_control_max_relative_modulation(self.relative_modulation_value) + await self._coordinator.async_set_control_max_relative_modulation(relative_modulation_value) async def _async_update_rooms_from_climates(self) -> None: """Update the temperature setpoint for each room based on their associated climate entity.""" @@ -891,7 +877,6 @@ 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() async def async_track_sensor_temperature(self, entity_id): """ @@ -935,30 +920,18 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> 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: + if self._coordinator.device_status == BoilerStatus.OVERSHOOT_HANDLING: _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) - ): + if self._coordinator.device_status == BoilerStatus.COOLING_DOWN and math.floor(self._calculated_setpoint) > math.floor(self.pwm.setpoint): _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") self.pwm.disable() - # Pulse Width Modulation + # Update Pulse Width Modulation when enabled 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() + await self.pwm.update(self._coordinator.state, self._calculated_setpoint) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -974,9 +947,9 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> 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: + if self._minimum_setpoint_version == 1 and not self._coordinator.hot_water_active and self._coordinator.flame_active: # Calculate the base return temperature - if self._coordinator.device_status == DeviceStatus.HEATING_UP: + if self._coordinator.device_status == BoilerStatus.HEATING_UP: self._minimum_setpoint.warming_up(self._coordinator.return_temperature) # Calculate the dynamic minimum setpoint diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 27c20a21..40082409 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -354,6 +354,9 @@ 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() + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -440,6 +443,9 @@ async def async_step_overshoot_protection(self, _user_input: dict[str, Any] | No _user_input[CONF_MINIMUM_SETPOINT] ) + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -457,6 +463,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) + + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -479,14 +489,14 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non try: manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) - default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else None + default_manufacturer = manufacturers[0].friendly_name if len(manufacturers) > 0 else -1 finally: await coordinator.async_will_remove_from_hass() options = [] - for name, _info in MANUFACTURERS.items(): + for name in MANUFACTURERS: manufacturer = ManufacturerFactory.resolve_by_name(name) - options.append({"value": name, "label": manufacturer.name}) + options.append({"value": name, "label": manufacturer.friendly_name}) return self.async_show_form( last_step=True, diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index ceb0953b..2d36e9ec 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -19,8 +19,8 @@ MINIMUM_SETPOINT = 10 MAXIMUM_SETPOINT = 65 -MINIMUM_RELATIVE_MOD = 0 -MAXIMUM_RELATIVE_MOD = 100 +MINIMUM_RELATIVE_MODULATION = 0 +MAXIMUM_RELATIVE_MODULATION = 100 MAX_BOILER_TEMPERATURE_AGE = 60 @@ -166,6 +166,7 @@ # Services SERVICE_RESET_INTEGRAL = "reset_integral" +SERVICE_PULSE_WIDTH_MODULATION = "pulse_width_modulation" SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index dfc91e1c..ac24967d 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -10,10 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .boiler import BoilerTemperatureTracker +from .boiler import BoilerTemperatureTracker, BoilerState, BoilerStatus from .const import * from .helpers import calculate_default_maximum_setpoint, seconds_since from .manufacturer import Manufacturer, ManufacturerFactory +from .manufacturers.geminox import Geminox +from .manufacturers.ideal import Ideal +from .manufacturers.intergas import Intergas +from .manufacturers.nefit import Nefit if TYPE_CHECKING: from .climate import SatClimate @@ -26,23 +30,6 @@ class DeviceState(str, Enum): OFF = "off" -class DeviceStatus(str, Enum): - 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" - - IDLE = "idle" - 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: @@ -91,7 +78,6 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin 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)) @@ -114,47 +100,60 @@ def device_type(self) -> str: def device_status(self): """Return the current status of the device.""" if self.boiler_temperature is None: - return DeviceStatus.INITIALIZING + return BoilerStatus.INITIALIZING if self.hot_water_active: - return DeviceStatus.HOT_WATER + return BoilerStatus.HOT_WATER if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: - return DeviceStatus.IDLE + return BoilerStatus.IDLE if self.device_active: if self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature: - if self.boiler_temperature_derivative is not None and self.boiler_temperature_derivative < 0: - return DeviceStatus.PUMP_STARTING + if self.boiler_temperature_derivative is not None and self.boiler_temperature_derivative <= 0: + return BoilerStatus.PUMP_STARTING if self._boiler_temperature_tracker.active and self.setpoint > self.boiler_temperature: - return DeviceStatus.PREHEATING + return BoilerStatus.PREHEATING if self.setpoint > self.boiler_temperature: if self.flame_active: if self._boiler_temperature_tracker.active: - return DeviceStatus.HEATING_UP + return BoilerStatus.HEATING_UP - return DeviceStatus.OVERSHOOT_HANDLING + return BoilerStatus.OVERSHOOT_HANDLING - return DeviceStatus.WAITING_FOR_FLAME + return BoilerStatus.WAITING_FOR_FLAME if abs(self.setpoint - self.boiler_temperature) <= DEADBAND: - return DeviceStatus.AT_SETPOINT + return BoilerStatus.AT_SETPOINT 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.COOLING_DOWN + return BoilerStatus.COOLING_DOWN - return DeviceStatus.NEAR_SETPOINT + return BoilerStatus.NEAR_SETPOINT - return DeviceStatus.OVERSHOOT_HANDLING + return BoilerStatus.OVERSHOOT_HANDLING - return DeviceStatus.WAITING_FOR_FLAME + return BoilerStatus.WAITING_FOR_FLAME - return DeviceStatus.UNKNOWN + return BoilerStatus.UNKNOWN + + @property + def state(self) -> BoilerState: + return BoilerState( + flame_active=self.flame_active, + flame_on_since=self.flame_on_since, + + device_active=self.device_active, + device_status=self.device_status, + + temperature=self.boiler_temperature, + hot_water_active=self.hot_water_active, + ) @property def manufacturer(self) -> Manufacturer | None: @@ -296,17 +295,17 @@ def minimum_relative_modulation_value(self) -> float | None: def maximum_relative_modulation_value(self) -> float | None: return None + @property + def minimum_setpoint(self) -> float: + """Return the minimum setpoint temperature before the device starts to overshoot.""" + return float(self._data.get(CONF_MINIMUM_SETPOINT)) + @property def maximum_setpoint(self) -> float: """Return the maximum setpoint temperature that the device can support.""" default_maximum_setpoint = calculate_default_maximum_setpoint(self._heating_system) return float(self._options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) - @property - def minimum_setpoint(self) -> float: - """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._data.get(CONF_MINIMUM_SETPOINT)) - @property def supports_setpoint_management(self): """Returns whether the device supports setting a boiler setpoint. @@ -325,6 +324,18 @@ def supports_hot_water_setpoint_management(self): """ return False + @property + def supports_relative_modulation(self): + """Returns whether the device supports having relative modulation value. + + This property is used to determine whether the coordinator can retrieve the relative modulation value from the device. + If a device doesn't support the relative modulation value, the coordinator won't be able to retrieve the value. + """ + if isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit)): + return False + + return True + @property def supports_relative_modulation_management(self): """Returns whether the device supports setting a relative modulation value. @@ -332,7 +343,10 @@ def supports_relative_modulation_management(self): This property is used to determine whether the coordinator can send a relative modulation value to the device. If a device doesn't support relative modulation management, the coordinator won't be able to control the value. """ - return False + if isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit)): + return False + + return True @property def supports_maximum_setpoint_management(self): @@ -379,7 +393,7 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = 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: + if self.setpoint is not None and self.boiler_temperature_derivative is not None and self.device_status is not BoilerStatus.HOT_WATER: self._boiler_temperature_tracker.update( flame_active=self.flame_active, setpoint=round(self.setpoint, 0), @@ -403,6 +417,8 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, # Update the cold temperature of the boiler if boiler_temperature_cold := self._get_latest_boiler_cold_temperature(): self._boiler_temperature_cold = boiler_temperature_cold + elif self._boiler_temperature_cold is not None: + self._boiler_temperature_cold = min(self.boiler_temperature, self._boiler_temperature_cold) async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" @@ -434,17 +450,16 @@ async def async_set_control_thermostat_setpoint(self, value: float) -> None: 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 + max_temperature = None - if self._flame_on_since is None or self._flame_on_since > timestamp: - return temperature + for timestamp, temperature in self._boiler_temperatures: + is_before_device_on = self._device_on_since is None or timestamp < self._device_on_since + is_before_flame_on = self._flame_on_since is None or timestamp < self._flame_on_since - if self._boiler_temperature_cold is not None: - return min(self.boiler_temperature, self._boiler_temperature_cold) + if is_before_device_on and is_before_flame_on: + max_temperature = max(max_temperature, temperature) if max_temperature is not None else temperature - return None + return max_temperature class SatEntityCoordinator(DataUpdateCoordinator): diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index 0e6a4963..528f2d60 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -27,7 +27,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn def device_info(self): manufacturer = "Unknown" if self._coordinator.manufacturer is not None: - manufacturer = self._coordinator.manufacturer.name + manufacturer = self._coordinator.manufacturer.friendly_name return DeviceInfo( name=NAME, diff --git a/custom_components/sat/esphome/__init__.py b/custom_components/sat/esphome/__init__.py index 620dff16..e0fef7ed 100644 --- a/custom_components/sat/esphome/__init__.py +++ b/custom_components/sat/esphome/__init__.py @@ -78,7 +78,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self): + def supports_relative_modulation(self): return True @property diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index 764c1203..325ddf2f 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -90,6 +90,10 @@ def supports_relative_modulation_management(self): return self.config.supports_relative_modulation_management + @property + def supports_relative_modulation(self): + return self.supports_relative_modulation_management + async def async_set_boiler_temperature(self, value: float) -> None: self._boiler_temperature = value diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 500c8eb0..7bdac462 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -1,30 +1,41 @@ -from abc import abstractmethod +from abc import ABC, abstractmethod +from typing import Optional, List, Type -from typing import List, Optional +from custom_components.sat.helpers import snake_case 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}, - "Vaillant": {"module": "vaillant", "class": "Vaillant", "id": 24}, - "Immergas": {"module": "immergas", "class": "Immergas", "id": 27}, - "Sime": {"module": "sime", "class": "Sime", "id": 27}, - "Viessmann": {"module": "viessmann", "class": "Viessmann", "id": 33}, - "Radiant": {"module": "radiant", "class": "Radiant", "id": 41}, - "Nefit": {"module": "nefit", "class": "Nefit", "id": 131}, - "Intergas": {"module": "intergas", "class": "Intergas", "id": 173}, + "Atag": 4, + "Baxi": 4, + "Brotge": 4, + "DeDietrich": 4, + "Ferroli": 9, + "Geminox": 4, + "Ideal": 6, + "Immergas": 27, + "Intergas": 173, + "Itho": 29, + "Nefit": 131, + "Radiant": 41, + "Remeha": 11, + "Sime": 27, + "Vaillant": 24, + "Viessmann": 33, + "Worcester": 95, + "Other": -1, } -class Manufacturer: +class Manufacturer(ABC): + def __init__(self): + self._member_id = MANUFACTURERS.get(type(self).__name__) + + @property + def member_id(self) -> int: + return self._member_id + @property @abstractmethod - def name(self) -> str: + def friendly_name(self) -> str: pass @@ -32,23 +43,21 @@ class ManufacturerFactory: @staticmethod def resolve_by_name(name: str) -> Optional[Manufacturer]: """Resolve a Manufacturer instance by its name.""" - manufacturer = MANUFACTURERS.get(name) - if not manufacturer: + if not (member_id := MANUFACTURERS.get(name)): return None - return ManufacturerFactory._import_class(manufacturer["module"], manufacturer["class"])() + return ManufacturerFactory._import_class(snake_case(name), name)() @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 + ManufacturerFactory._import_class(snake_case(name), name)() + for name, value in MANUFACTURERS.items() + if member_id == value ] @staticmethod - def _import_class(module_name: str, class_name: str): + def _import_class(module_name: str, class_name: str) -> Type[Manufacturer]: """Dynamically import and return a Manufacturer class.""" - module = __import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]) - return getattr(module, class_name) + return getattr(__import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]), class_name) diff --git a/custom_components/sat/manufacturers/atag.py b/custom_components/sat/manufacturers/atag.py index 1b8fc21e..d6a9fd39 100644 --- a/custom_components/sat/manufacturers/atag.py +++ b/custom_components/sat/manufacturers/atag.py @@ -1,7 +1,7 @@ from ..manufacturer import Manufacturer -class ATAG(Manufacturer): +class Atag(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'ATAG' diff --git a/custom_components/sat/manufacturers/baxi.py b/custom_components/sat/manufacturers/baxi.py index a5c66e64..9b2b1c52 100644 --- a/custom_components/sat/manufacturers/baxi.py +++ b/custom_components/sat/manufacturers/baxi.py @@ -3,5 +3,5 @@ class Baxi(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Baxi' diff --git a/custom_components/sat/manufacturers/brotge.py b/custom_components/sat/manufacturers/brotge.py index e20115be..20b639c6 100644 --- a/custom_components/sat/manufacturers/brotge.py +++ b/custom_components/sat/manufacturers/brotge.py @@ -3,5 +3,5 @@ class Brotge(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'BRÖTGE' diff --git a/custom_components/sat/manufacturers/dedietrich.py b/custom_components/sat/manufacturers/de_dietrich.py similarity index 76% rename from custom_components/sat/manufacturers/dedietrich.py rename to custom_components/sat/manufacturers/de_dietrich.py index 42e4b3b2..02290a7e 100644 --- a/custom_components/sat/manufacturers/dedietrich.py +++ b/custom_components/sat/manufacturers/de_dietrich.py @@ -3,5 +3,5 @@ class DeDietrich(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'De Dietrich' diff --git a/custom_components/sat/manufacturers/ferroli.py b/custom_components/sat/manufacturers/ferroli.py index 05ad5ce6..6cc58b47 100644 --- a/custom_components/sat/manufacturers/ferroli.py +++ b/custom_components/sat/manufacturers/ferroli.py @@ -3,5 +3,5 @@ class Ferroli(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Ferroli' diff --git a/custom_components/sat/manufacturers/geminox.py b/custom_components/sat/manufacturers/geminox.py index 358e83a4..68d217e0 100644 --- a/custom_components/sat/manufacturers/geminox.py +++ b/custom_components/sat/manufacturers/geminox.py @@ -3,5 +3,5 @@ class Geminox(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Geminox' diff --git a/custom_components/sat/manufacturers/ideal.py b/custom_components/sat/manufacturers/ideal.py index 18ebf5d4..b92e9a92 100644 --- a/custom_components/sat/manufacturers/ideal.py +++ b/custom_components/sat/manufacturers/ideal.py @@ -3,5 +3,5 @@ class Ideal(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Ideal' diff --git a/custom_components/sat/manufacturers/immergas.py b/custom_components/sat/manufacturers/immergas.py index 08207733..ba0d685f 100644 --- a/custom_components/sat/manufacturers/immergas.py +++ b/custom_components/sat/manufacturers/immergas.py @@ -3,5 +3,5 @@ class Immergas(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Immergas' diff --git a/custom_components/sat/manufacturers/intergas.py b/custom_components/sat/manufacturers/intergas.py index a6c98760..2f40f150 100644 --- a/custom_components/sat/manufacturers/intergas.py +++ b/custom_components/sat/manufacturers/intergas.py @@ -3,5 +3,5 @@ class Intergas(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Intergas' diff --git a/custom_components/sat/manufacturers/itho.py b/custom_components/sat/manufacturers/itho.py new file mode 100644 index 00000000..1483700a --- /dev/null +++ b/custom_components/sat/manufacturers/itho.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Itho(Manufacturer): + @property + def friendly_name(self) -> str: + return 'Itho' diff --git a/custom_components/sat/manufacturers/nefit.py b/custom_components/sat/manufacturers/nefit.py index 4f2b9669..cf0bf1c8 100644 --- a/custom_components/sat/manufacturers/nefit.py +++ b/custom_components/sat/manufacturers/nefit.py @@ -3,5 +3,5 @@ class Nefit(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Nefit' diff --git a/custom_components/sat/manufacturers/other.py b/custom_components/sat/manufacturers/other.py new file mode 100644 index 00000000..4bb57af4 --- /dev/null +++ b/custom_components/sat/manufacturers/other.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Other(Manufacturer): + @property + def friendly_name(self) -> str: + return 'Other' diff --git a/custom_components/sat/manufacturers/radiant.py b/custom_components/sat/manufacturers/radiant.py index 43a6e4e8..bcedcaef 100644 --- a/custom_components/sat/manufacturers/radiant.py +++ b/custom_components/sat/manufacturers/radiant.py @@ -3,5 +3,5 @@ class Radiant(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Radiant' diff --git a/custom_components/sat/manufacturers/remeha.py b/custom_components/sat/manufacturers/remeha.py new file mode 100644 index 00000000..4f5120bf --- /dev/null +++ b/custom_components/sat/manufacturers/remeha.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Remeha(Manufacturer): + @property + def friendly_name(self) -> str: + return 'Remeha' diff --git a/custom_components/sat/manufacturers/sime.py b/custom_components/sat/manufacturers/sime.py index 7cc0ade9..9d34ddc3 100644 --- a/custom_components/sat/manufacturers/sime.py +++ b/custom_components/sat/manufacturers/sime.py @@ -3,5 +3,5 @@ class Sime(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Sime' diff --git a/custom_components/sat/manufacturers/simulator.py b/custom_components/sat/manufacturers/simulator.py deleted file mode 100644 index b4ae0293..00000000 --- a/custom_components/sat/manufacturers/simulator.py +++ /dev/null @@ -1,7 +0,0 @@ -from ..manufacturer import Manufacturer - - -class Simulator(Manufacturer): - @property - def name(self) -> str: - return 'Simulator' diff --git a/custom_components/sat/manufacturers/vaillant.py b/custom_components/sat/manufacturers/vaillant.py index 3e521938..47af38cb 100644 --- a/custom_components/sat/manufacturers/vaillant.py +++ b/custom_components/sat/manufacturers/vaillant.py @@ -3,5 +3,5 @@ class Vaillant(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Vaillant' diff --git a/custom_components/sat/manufacturers/viessmann.py b/custom_components/sat/manufacturers/viessmann.py index 41ad7db7..a9084e5f 100644 --- a/custom_components/sat/manufacturers/viessmann.py +++ b/custom_components/sat/manufacturers/viessmann.py @@ -3,5 +3,5 @@ class Viessmann(Manufacturer): @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Viessmann' diff --git a/custom_components/sat/manufacturers/worcester.py b/custom_components/sat/manufacturers/worcester.py new file mode 100644 index 00000000..ff6b3e8a --- /dev/null +++ b/custom_components/sat/manufacturers/worcester.py @@ -0,0 +1,7 @@ +from ..manufacturer import Manufacturer + + +class Worcester(Manufacturer): + @property + def friendly_name(self) -> str: + return 'Worcester Bosch' diff --git a/custom_components/sat/mqtt/ems.py b/custom_components/sat/mqtt/ems.py index 3fd2308a..f9400ea2 100644 --- a/custom_components/sat/mqtt/ems.py +++ b/custom_components/sat/mqtt/ems.py @@ -48,7 +48,7 @@ def supports_maximum_setpoint_management(self) -> bool: return True @property - def supports_relative_modulation_management(self) -> bool: + def supports_relative_modulation(self) -> bool: return True @property diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index bac1dcde..26be6dc6 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -50,7 +50,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self): + def supports_relative_modulation(self): return True @property diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 87a77337..b3413e97 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -2,7 +2,7 @@ import logging import time -from .const import OVERSHOOT_PROTECTION_SETPOINT, MINIMUM_SETPOINT, DEADBAND, MAXIMUM_RELATIVE_MOD +from .const import OVERSHOOT_PROTECTION_SETPOINT, MINIMUM_SETPOINT, DEADBAND, MAXIMUM_RELATIVE_MODULATION from .coordinator import DeviceState, SatDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ async def _trigger_heating_cycle(self, is_ready: bool) -> None: """Trigger a heating cycle with the coordinator.""" await self._coordinator.async_set_heater_state(DeviceState.ON) await self._coordinator.async_set_control_setpoint(await self._get_setpoint(is_ready)) - await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MOD) + await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MODULATION) await asyncio.sleep(SLEEP_INTERVAL) await self._coordinator.async_control_heating_loop() diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index ad623d9e..4c2a7ce1 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -5,9 +5,10 @@ from homeassistant.core import State -from .boiler import BoilerState -from .const import HEATER_STARTUP_TIMEFRAME +from .boiler import BoilerState, BoilerStatus +from .const import HEATER_STARTUP_TIMEFRAME, MINIMUM_SETPOINT from .heating_curve import HeatingCurve +from .setpoint_adjuster import SetpointAdjuster _LOGGER = logging.getLogger(__name__) @@ -19,23 +20,38 @@ class PWMState(str, Enum): IDLE = "idle" +class Cycles: + """Encapsulates settings related to cycle time and maximum cycles.""" + + def __init__(self, maximum: int, maximum_time: int): + self._maximum = maximum + self._maximum_time = maximum_time + + @property + def maximum_time(self) -> int: + return self._maximum_time + + @property + def maximum(self) -> int: + return self._maximum + + class PWM: """Implements Pulse Width Modulation (PWM) control for managing boiler operations.""" - def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, max_cycles: int, force: bool = False): + def __init__(self, cycles: Cycles, heating_curve: HeatingCurve, supports_relative_modulation_management: bool, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._alpha: float = 0.2 self._force: bool = force self._last_boiler_temperature: float | None = None - self._max_cycles: int = max_cycles + self._cycles: Cycles = 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: float = 180 - self._on_time_upper_threshold: float = 3600 / self._max_cycles + self._on_time_upper_threshold: float = 3600 / max(1, self._cycles.maximum) self._on_time_max_threshold: float = self._on_time_upper_threshold * 2 # Duty cycle percentage thresholds @@ -44,9 +60,14 @@ def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_d self._min_duty_cycle_percentage: float = self._duty_cycle_lower_threshold / 2 self._max_duty_cycle_percentage: float = 1 - self._min_duty_cycle_percentage + # Initialize some helpers + self._setpoint: Optional[float] = None + self._setpoint_adjuster = SetpointAdjuster() + self._setpoint_offset: int = 0.5 if supports_relative_modulation_management else 1 + _LOGGER.debug( - "Initialized PWM control with duty cycle thresholds - Lower: %.2f%%, Upper: %.2f%%", - self._duty_cycle_lower_threshold * 100, self._duty_cycle_upper_threshold * 100 + "Initialized PWM control with duty cycle thresholds - Lower: %.2f%%, Upper: %.2f%%, Offset: %d°C", + self._duty_cycle_lower_threshold * 100, self._duty_cycle_upper_threshold * 100, self._setpoint_offset ) self.reset() @@ -54,7 +75,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._current_cycle: int = 0 self._state: PWMState = PWMState.IDLE self._last_update: float = monotonic() self._duty_cycle: Tuple[int, int] | None = None @@ -75,9 +96,11 @@ def enable(self) -> None: def disable(self) -> None: """Disable the PWM control.""" + self.reset() self._enabled = False + self._setpoint_adjuster.reset() - async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: + async def update(self, boiler: BoilerState, requested_setpoint: float) -> 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: self._state = PWMState.IDLE @@ -93,7 +116,7 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: _LOGGER.debug("Initialized last boiler temperature to %.1f°C", boiler.temperature) if self._first_duty_cycle_start is None or (monotonic() - self._first_duty_cycle_start) > 3600: - self._cycles = 0 + self._current_cycle = 0 self._first_duty_cycle_start = monotonic() _LOGGER.info("CYCLES count reset for the rolling hour.") @@ -110,17 +133,25 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: self._last_boiler_temperature = boiler.temperature _LOGGER.debug("Updated last boiler temperature to %.1f°C", boiler.temperature) + # Control the adjusted setpoint + if boiler.flame_active and boiler.temperature >= self._last_boiler_temperature and boiler.device_status != BoilerStatus.PUMP_STARTING: + self._setpoint = self._setpoint_adjuster.adjust(boiler.temperature - self._setpoint_offset) + elif self._setpoint_adjuster.current is not None: + self._setpoint = self._setpoint_adjuster.current + elif not boiler.flame_active: + self._setpoint = self._setpoint_adjuster.force(boiler.temperature + 10) + # 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._cycles >= self._max_cycles: + if self._current_cycle >= self._cycles.maximum: _LOGGER.info("Reached max cycles per hour, preventing new duty cycle.") return - self._cycles += 1 + self._current_cycle += 1 self._state = PWMState.ON self._last_update = monotonic() self._last_boiler_temperature = boiler.temperature - _LOGGER.info("Starting new duty cycle (ON state). Current CYCLES count: %d", self._cycles) + _LOGGER.info("Starting new duty cycle (ON state). Current CYCLES count: %d", self._current_cycle) 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): @@ -150,8 +181,8 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) # If automatic duty cycle control is disabled if not self._automatic_duty_cycle: - on_time = self._last_duty_cycle_percentage * self._max_cycle_time - off_time = (1 - self._last_duty_cycle_percentage) * self._max_cycle_time + on_time = self._last_duty_cycle_percentage * self._cycles.maximum_time + off_time = (1 - self._last_duty_cycle_percentage) * self._cycles.maximum_time _LOGGER.debug( "Calculated on_time: %.0f seconds, off_time: %.0f seconds.", @@ -183,7 +214,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "Low duty cycle range, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -194,7 +225,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "Mid-range duty cycle, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -205,7 +236,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "High duty cycle range, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -234,3 +265,8 @@ def duty_cycle(self) -> Optional[Tuple[int, int]]: def last_duty_cycle_percentage(self) -> Optional[float]: """Returns the last calculated duty cycle percentage.""" return round(self._last_duty_cycle_percentage * 100, 2) if self._last_duty_cycle_percentage is not None else None + + @property + def setpoint(self) -> float: + """Returns the adjusted setpoint when running an ON duty cycle.""" + return self._setpoint or MINIMUM_SETPOINT diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index ad77f1e4..6795a406 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -34,13 +34,13 @@ def state(self) -> RelativeModulationState: if self._coordinator.hot_water_active: 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 + if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + return RelativeModulationState.COLD - return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF + if self._pulse_width_modulation_enabled: + return RelativeModulationState.OFF - return RelativeModulationState.OFF + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF @property def enabled(self) -> bool: diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 3142eba4..dcebc2ab 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -238,7 +238,7 @@ def name(self) -> str: @property def native_value(self) -> str: - return self._coordinator.manufacturer.name + return self._coordinator.manufacturer.friendly_name @property def available(self) -> bool: diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index d5b627e3..23b80805 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -67,7 +67,7 @@ def supports_maximum_setpoint_management(self) -> bool: return True @property - def supports_relative_modulation_management(self) -> bool: + def supports_relative_modulation(self) -> bool: return True @property diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py new file mode 100644 index 00000000..de41a258 --- /dev/null +++ b/custom_components/sat/services.py @@ -0,0 +1,49 @@ +import logging + +import voluptuous as vol +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, SERVICE_RESET_INTEGRAL, SERVICE_PULSE_WIDTH_MODULATION +from .util import get_climate_entities + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_register_services(hass: HomeAssistant) -> None: + async def reset_integral(call: ServiceCall): + """Service to reset the integral part of the PID controller.""" + target_entities = call.data.get("entity_id", []) + + for climate in get_climate_entities(hass, target_entities): + _LOGGER.info("Reset Integral action called for %s", climate.entity_id) + + climate.pid.reset() + climate.areas.pids.reset() + + hass.services.async_register( + DOMAIN, + service=SERVICE_RESET_INTEGRAL, + service_func=reset_integral, + schema=vol.Schema({vol.Required("entity_id"): list[str]}) + ) + + async def pulse_width_modulation(call: ServiceCall): + """Service to enable or disable Pulse Width Modulation.""" + enabled = call.data.get("enabled") + target_entities = call.data.get("entity_id", []) + + for climate in get_climate_entities(hass, target_entities): + _LOGGER.info("Pulse Width Modulation action called for %s with enabled=%s", climate.entity_id, enabled) + + if enabled: + climate.pwm.enable() + else: + climate.pwm.disable() + + hass.services.async_register( + DOMAIN, + service=SERVICE_PULSE_WIDTH_MODULATION, + service_func=pulse_width_modulation, + schema=vol.Schema({vol.Required("entity_id"): list[str], vol.Required("enabled"): cv.boolean}) + ) diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index be968678..5c697ada 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -1,3 +1,15 @@ -clear_integral: - name: Clear Integral - description: "This service clears the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." \ No newline at end of file +reset_integral: + target: + entity: + domain: climate + integration: "sat" + +pulse_width_modulation: + target: + entity: + domain: climate + integration: "sat" + fields: + enabled: + required: true + example: true diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 316e2048..dab11868 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -45,7 +45,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self) -> float | None: + def supports_relative_modulation(self) -> float | None: return True @property diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 8ba425a3..9682c215 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -250,5 +250,21 @@ "title": "System Configuration" } } + }, + "services": { + "reset_integral": { + "name": "Reset Integral", + "description": "This service resets the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." + }, + "pulse_width_modulation": { + "name": "Pulse Width Modulation", + "description": "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again.", + "fields": { + "enabled": { + "name": "Enabled", + "description": "The state you want to force." + } + } + } } } \ No newline at end of file diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 553a48ea..a5680c67 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -1,12 +1,21 @@ +from __future__ import annotations + from types import MappingProxyType from typing import Any +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry 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 +from .pwm import PWM, Cycles + +if TYPE_CHECKING: + from .climate import SatClimate def create_pid_controller(config_options) -> PID: @@ -59,7 +68,7 @@ def create_heating_curve_controller(config_data, config_options) -> HeatingCurve return HeatingCurve(heating_system=heating_system, coefficient=coefficient, version=version) -def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any]) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, supports_relative_modulation_management: bool, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any]) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options max_duty_cycles = int(config_options.get(CONF_CYCLES_PER_HOUR)) @@ -67,5 +76,28 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy max_cycle_time = int(convert_time_str_to_seconds(config_options.get(CONF_DUTY_CYCLE))) force = bool(config_data.get(CONF_MODE) == MODE_SWITCH) or bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + # Extra settings + cycles = Cycles(maximum=max_duty_cycles, maximum_time=max_cycle_time) + # 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) + return PWM(heating_curve=heating_curve, cycles=cycles, automatic_duty_cycle=automatic_duty_cycle, supports_relative_modulation_management=supports_relative_modulation_management, force=force) + + +def get_climate_entities(hass: "HomeAssistant", entity_ids: list[str]) -> list["SatClimate"]: + """Retrieve climate entities for the given entity IDs.""" + entities = [] + for entity_id in entity_ids: + registry = entity_registry.async_get(hass) + + if not (entry := registry.async_get(entity_id)): + continue + + if not (config_entry := hass.data[DOMAIN].get(entry.config_entry_id)): + continue + + if not (climate := config_entry.get(CLIMATE)): + continue + + entities.append(climate) + + return entities diff --git a/tests/test_manufacturer.py b/tests/test_manufacturer.py index 2107517b..f1689d0a 100644 --- a/tests/test_manufacturer.py +++ b/tests/test_manufacturer.py @@ -7,7 +7,7 @@ def test_resolve_by_name(): # 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"] + assert manufacturer.__class__.__name__ == name # Test invalid name manufacturer = ManufacturerFactory.resolve_by_name("InvalidName") @@ -16,16 +16,17 @@ def test_resolve_by_name(): 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) + member_id_to_names = {member_id: [] for name, member_id in MANUFACTURERS.items()} + for name, member_id in MANUFACTURERS.items(): + member_id_to_names[member_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}" + assert manufacturer.member_id == member_id, f"Expected {manufacturer.member_id} for member ID {member_id}" + assert manufacturer.__class__.__name__ in names, f"Manufacturer name '{manufacturer.friendly_name}' not expected for member ID {member_id}" # Test invalid member ID manufacturers = ManufacturerFactory.resolve_by_member_id(999)