Skip to content

Commit

Permalink
Merge pull request #33 from Alexwijn/develop
Browse files Browse the repository at this point in the history
Master
  • Loading branch information
Alexwijn authored Jan 30, 2024
2 parents 31f52d4 + 4c948a9 commit 9cd9a24
Show file tree
Hide file tree
Showing 20 changed files with 1,244 additions and 104 deletions.
5 changes: 4 additions & 1 deletion custom_components/sat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry):

# Resolve the coordinator by using the factory according to the mode
_hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve(
hass=_hass, config_entry=_entry, mode=_entry.data.get(CONF_MODE), device=_entry.data.get(CONF_DEVICE)
hass=_hass, data=_entry.data, options=_entry.options, mode=_entry.data.get(CONF_MODE), device=_entry.data.get(CONF_DEVICE)
)

# Forward entry setup for climate and other platforms
Expand Down Expand Up @@ -140,6 +140,9 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool
new_data[CONF_OVERSHOOT_PROTECTION] = _entry.options.get("overshoot_protection")
del new_options["overshoot_protection"]

if _entry.version < 6:
new_options[CONF_HEATING_CURVE_VERSION] = 1

_entry.version = SatFlowHandler.VERSION
_hass.config_entries.async_update_entry(_entry, data=new_data, options=new_options)

Expand Down
28 changes: 17 additions & 11 deletions custom_components/sat/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@

import asyncio
import logging
from collections import deque
from datetime import timedelta
from statistics import mean
from time import monotonic, time
from typing import List

Expand Down Expand Up @@ -121,10 +119,11 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn
# Create dictionary mapping preset keys to temperature values
self._presets = {key: config_options[value] for key, value in conf_presets.items() if key in conf_presets}

self._alpha = 0.2
self._sensors = []
self._rooms = None
self._setpoint = None
self._outputs = deque(maxlen=50)
self._calculated_setpoint = None

self._warming_up_data = None
self._warming_up_derivative = None
Expand Down Expand Up @@ -269,13 +268,14 @@ async def _restore_previous_state_or_set_defaults(self):
old_state = await self.async_get_last_state()

if old_state is not None:
self.pid.restore(old_state)

if self._target_temperature is None:
if old_state.attributes.get(ATTR_TEMPERATURE) is None:
self.pid.setpoint = self.min_temp
self._target_temperature = self.min_temp
_LOGGER.warning("Undefined target temperature, falling back to %s", self._target_temperature, )
else:
self.pid.restore(old_state)
self._target_temperature = float(old_state.attributes[ATTR_TEMPERATURE])

if old_state.state:
Expand Down Expand Up @@ -774,7 +774,7 @@ async def _async_control_pid(self, reset: bool = False) -> None:
_LOGGER.info(f"Updating error value to {max_error} (Reset: True)")

self.pid.update_reset(error=max_error, heating_curve_value=self.heating_curve.value)
self._outputs.clear()
self._calculated_setpoint = None
self.pwm.reset()

# Determine if we are warming up
Expand All @@ -787,17 +787,14 @@ async def _async_control_pid(self, reset: bool = False) -> None:
async def _async_control_setpoint(self, pwm_state: PWMState) -> None:
"""Control the setpoint of the heating system."""
if self.hvac_mode == HVACMode.HEAT:
self._outputs.append(self._calculate_control_setpoint())

if not self.pulse_width_modulation_enabled or pwm_state == pwm_state.IDLE:
_LOGGER.info("Running Normal cycle")
setpoint = round(mean(list(self._outputs)[-5:]), 1)
self._setpoint = max(self.minimum_setpoint, setpoint)
self._setpoint = self._calculated_setpoint
else:
_LOGGER.info(f"Running PWM cycle: {pwm_state}")
self._setpoint = self.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT
else:
self._outputs.clear()
self._calculated_setpoint = None
self._setpoint = MINIMUM_SETPOINT

await self._coordinator.async_set_control_setpoint(self._setpoint)
Expand Down Expand Up @@ -857,9 +854,18 @@ 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)

# Pulse Width Modulation
if self.pulse_width_modulation_enabled:
await self.pwm.update(self._calculate_control_setpoint(), self._coordinator.boiler_temperature)
await self.pwm.update(self._calculated_setpoint, self._coordinator.boiler_temperature)
else:
self.pwm.reset()

# Set the control setpoint to make sure we always stay in control
await self._async_control_setpoint(self.pwm.state)
Expand Down
49 changes: 22 additions & 27 deletions custom_components/sat/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.config_entries import ConfigEntry, SOURCE_USER
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MAJOR_VERSION, MINOR_VERSION
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import selector, device_registry, entity_registry
from homeassistant.helpers.selector import SelectSelectorMode
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from pyotgw import OpenThermGateway

Expand All @@ -34,7 +35,7 @@

class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for SAT."""
VERSION = 5
VERSION = 6
MINOR_VERSION = 0

calibration = None
Expand Down Expand Up @@ -384,25 +385,9 @@ async def async_step_finish(self, _user_input=None):
return self.async_create_entry(title=self.data[CONF_NAME], data=self.data)

async def async_create_coordinator(self) -> SatDataUpdateCoordinator:
# Set up the config entry parameters, since they differ per version
config_params = {
"version": self.VERSION,
"domain": DOMAIN,
"title": self.data[CONF_NAME],
"data": self.data,
"source": SOURCE_USER,
}

# Check Home Assistant version and add parameters accordingly
if MAJOR_VERSION >= 2024:
config_params["minor_version"] = self.MINOR_VERSION

# Create a new config to use
config = ConfigEntry(**config_params)

# Resolve the coordinator by using the factory according to the mode
return await SatDataUpdateCoordinatorFactory().resolve(
hass=self.hass, config_entry=config, mode=self.data[CONF_MODE], device=self.data[CONF_DEVICE]
hass=self.hass, data=self.data, mode=self.data[CONF_MODE], device=self.data[CONF_DEVICE]
)

async def _enable_overshoot_protection(self, overshoot_protection_value: float):
Expand Down Expand Up @@ -438,22 +423,32 @@ async def async_step_general(self, _user_input=None) -> FlowResult:
default_maximum_setpoint = calculate_default_maximum_setpoint(self._config_entry.data.get(CONF_HEATING_SYSTEM))
maximum_setpoint = float(options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint))

schema[vol.Required(CONF_HEATING_CURVE_VERSION, default=str(options[CONF_HEATING_CURVE_VERSION]))] = selector.SelectSelector(
selector.SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=[
selector.SelectOptionDict(value="1", label="Classic Curve"),
selector.SelectOptionDict(value="2", label="Quantum Curve")
])
)

schema[vol.Required(CONF_MAXIMUM_SETPOINT, default=maximum_setpoint)] = selector.NumberSelector(
selector.NumberSelectorConfig(min=10, max=100, step=1, unit_of_measurement="°C")
)

if not options[CONF_AUTOMATIC_GAINS]:
schema[vol.Required(CONF_PROPORTIONAL, default=options[CONF_PROPORTIONAL])] = str
schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str
schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str

schema[vol.Required(CONF_HEATING_CURVE_COEFFICIENT, default=options[CONF_HEATING_CURVE_COEFFICIENT])] = selector.NumberSelector(
selector.NumberSelectorConfig(min=0.1, max=12, step=0.1)
)

schema[vol.Required(CONF_AUTOMATIC_GAINS_VALUE, default=options[CONF_AUTOMATIC_GAINS_VALUE])] = selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=5, step=1)
)
if options[CONF_AUTOMATIC_GAINS]:
schema[vol.Required(CONF_AUTOMATIC_GAINS_VALUE, default=options[CONF_AUTOMATIC_GAINS_VALUE])] = selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=5, step=1)
)
schema[vol.Required(CONF_DERIVATIVE_TIME_WEIGHT, default=options[CONF_DERIVATIVE_TIME_WEIGHT])] = selector.NumberSelector(
selector.NumberSelectorConfig(min=1, max=6, step=1)
)
else:
schema[vol.Required(CONF_PROPORTIONAL, default=options[CONF_PROPORTIONAL])] = str
schema[vol.Required(CONF_INTEGRAL, default=options[CONF_INTEGRAL])] = str
schema[vol.Required(CONF_DERIVATIVE, default=options[CONF_DERIVATIVE])] = str

if not options[CONF_AUTOMATIC_DUTY_CYCLE]:
schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector()
Expand Down
4 changes: 4 additions & 0 deletions custom_components/sat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
CONF_AUTOMATIC_GAINS = "automatic_gains"
CONF_AUTOMATIC_DUTY_CYCLE = "automatic_duty_cycle"
CONF_AUTOMATIC_GAINS_VALUE = "automatic_gains_value"
CONF_DERIVATIVE_TIME_WEIGHT = "derivative_time_weight"
CONF_CLIMATE_VALVE_OFFSET = "climate_valve_offset"
CONF_SENSOR_MAX_VALUE_AGE = "sensor_max_value_age"
CONF_OVERSHOOT_PROTECTION = "overshoot_protection"
Expand All @@ -61,6 +62,7 @@
CONF_DYNAMIC_MINIMUM_SETPOINT = "dynamic_minimum_setpoint"

CONF_HEATING_SYSTEM = "heating_system"
CONF_HEATING_CURVE_VERSION = "heating_curve_version"
CONF_HEATING_CURVE_COEFFICIENT = "heating_curve_coefficient"

CONF_MINIMUM_CONSUMPTION = "minimum_consumption"
Expand All @@ -86,6 +88,7 @@
CONF_AUTOMATIC_GAINS: True,
CONF_AUTOMATIC_DUTY_CYCLE: True,
CONF_AUTOMATIC_GAINS_VALUE: 5.0,
CONF_DERIVATIVE_TIME_WEIGHT: 6.0,
CONF_OVERSHOOT_PROTECTION: False,
CONF_DYNAMIC_MINIMUM_SETPOINT: False,

Expand Down Expand Up @@ -123,6 +126,7 @@
CONF_SLEEP_TEMPERATURE: 15,
CONF_COMFORT_TEMPERATURE: 20,

CONF_HEATING_CURVE_VERSION: 2,
CONF_HEATING_CURVE_COEFFICIENT: 1.0,
CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS,
}
Expand Down
53 changes: 30 additions & 23 deletions custom_components/sat/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
from __future__ import annotations

import logging
import typing
from abc import abstractmethod
from datetime import datetime, timedelta
from enum import Enum
from typing import TYPE_CHECKING, Mapping, Any

from homeassistant.components.climate import HVACMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import *
from .util import calculate_default_maximum_setpoint

if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from .climate import SatClimate

_LOGGER: logging.Logger = logging.getLogger(__name__)
Expand All @@ -27,38 +26,46 @@ class DeviceState(str, Enum):

class SatDataUpdateCoordinatorFactory:
@staticmethod
async def resolve(hass: HomeAssistant, config_entry: ConfigEntry, mode: str, device: str) -> SatDataUpdateCoordinator:
async 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, config_entry)
return SatFakeCoordinator(hass=hass, data=data, options=options)

if mode == MODE_SIMULATOR:
from .simulator import SatSimulatorCoordinator
return SatSimulatorCoordinator(hass, config_entry)
return SatSimulatorCoordinator(hass=hass, data=data, options=options)

if mode == MODE_MQTT:
from .mqtt import SatMqttCoordinator
return SatMqttCoordinator(hass, config_entry, device)

if mode == MODE_SERIAL:
from .serial import SatSerialCoordinator
return await SatSerialCoordinator(hass, config_entry, device).async_connect()
return SatMqttCoordinator(hass=hass, device_id=device, data=data, options=options)

if mode == MODE_SWITCH:
from .switch import SatSwitchCoordinator
return SatSwitchCoordinator(hass, config_entry, device)
return SatSwitchCoordinator(hass=hass, entity_id=device, data=data, options=options)

if mode == MODE_SERIAL:
from .serial import SatSerialCoordinator
return await SatSerialCoordinator(hass=hass, port=device, data=data, options=options).async_connect()

raise Exception(f'Invalid mode[{mode}]')


class SatDataUpdateCoordinator(DataUpdateCoordinator):
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None:
"""Initialize."""
self.boiler_temperatures = []
self._config_entry = config_entry

self._data = data
self._options = options
self._device_state = DeviceState.OFF
self._simulation = bool(config_entry.data.get(CONF_SIMULATION))
self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN))
self._simulation = bool(data.get(CONF_SIMULATION))
self._heating_system = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN))

super().__init__(hass, _LOGGER, name=DOMAIN)

Expand Down Expand Up @@ -164,12 +171,12 @@ def maximum_relative_modulation_value(self) -> float | None:
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._config_entry.options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint))
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._config_entry.data.get(CONF_MINIMUM_SETPOINT))
return float(self._data.get(CONF_MINIMUM_SETPOINT))

@property
def supports_setpoint_management(self):
Expand Down Expand Up @@ -234,27 +241,27 @@ 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.logger.info("Set central heater state %s", state)
_LOGGER.info("Set central heater state %s", state)

async def async_set_control_setpoint(self, value: float) -> None:
"""Control the boiler setpoint temperature for the device."""
if self.supports_setpoint_management:
self.logger.info("Set control boiler setpoint to %d", value)
_LOGGER.info("Set control boiler setpoint to %d", value)

async def async_set_control_hot_water_setpoint(self, value: float) -> None:
"""Control the DHW setpoint temperature for the device."""
if self.supports_hot_water_setpoint_management:
self.logger.info("Set control hot water setpoint to %d", value)
_LOGGER.info("Set control hot water setpoint to %d", value)

async def async_set_control_max_setpoint(self, value: float) -> None:
"""Control the maximum setpoint temperature for the device."""
if self.supports_maximum_setpoint_management:
self.logger.info("Set maximum setpoint to %d", value)
_LOGGER.info("Set maximum setpoint to %d", value)

async def async_set_control_max_relative_modulation(self, value: int) -> None:
"""Control the maximum relative modulation for the device."""
if self.supports_relative_modulation_management:
self.logger.info("Set maximum relative modulation to %d", value)
_LOGGER.info("Set maximum relative modulation to %d", value)

async def async_set_control_thermostat_setpoint(self, value: float) -> None:
"""Control the setpoint temperature for the thermostat."""
Expand Down
8 changes: 4 additions & 4 deletions custom_components/sat/fake/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from __future__ import annotations, annotations

import logging
from typing import Mapping, Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

from ..coordinator import DeviceState, SatDataUpdateCoordinator
Expand All @@ -27,7 +27,7 @@ def __init__(
class SatFakeCoordinator(SatDataUpdateCoordinator):
"""Class to manage to fetch data from the OTGW Gateway using mqtt."""

def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> None:
self.data = {}
self.config = SatFakeConfig(True)

Expand All @@ -37,7 +37,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
self._boiler_temperature = None
self._relative_modulation_value = 100

super().__init__(hass, config_entry)
super().__init__(hass, data, options)

@property
def setpoint(self) -> float | None:
Expand Down Expand Up @@ -99,4 +99,4 @@ async def async_set_control_max_relative_modulation(self, value: int) -> None:
async def async_set_control_max_setpoint(self, value: float) -> None:
self._maximum_setpoint = value

await super().async_set_control_max_setpoint(value)
await super().async_set_control_max_setpoint(value)
Loading

0 comments on commit 9cd9a24

Please sign in to comment.