From aea3228467f3df7e8bcceee3c5883a44a6397006 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 00:39:18 +0200 Subject: [PATCH 001/237] Initial support for multiple coordinators and cleaned up a bit --- configuration.yaml | 14 + custom_components/sat/__init__.py | 121 +++------ custom_components/sat/binary_sensor.py | 8 +- custom_components/sat/climate.py | 255 ++++-------------- custom_components/sat/config_flow.py | 86 ++++-- custom_components/sat/const.py | 8 + .../sat/coordinators/__init__.py | 62 +++++ .../sat/coordinators/opentherm.py | 216 +++++++++++++++ custom_components/sat/coordinators/switch.py | 34 +++ custom_components/sat/device.py | 6 + custom_components/sat/heating_curve.py | 2 +- custom_components/sat/manifest.json | 36 +-- custom_components/sat/number.py | 8 +- custom_components/sat/overshoot_protection.py | 4 +- custom_components/sat/pwm.py | 14 +- custom_components/sat/sensor.py | 12 +- custom_components/sat/store.py | 37 +++ custom_components/sat/translations/en.json | 18 ++ 18 files changed, 586 insertions(+), 355 deletions(-) create mode 100644 custom_components/sat/coordinators/__init__.py create mode 100644 custom_components/sat/coordinators/opentherm.py create mode 100644 custom_components/sat/coordinators/switch.py create mode 100644 custom_components/sat/device.py create mode 100644 custom_components/sat/store.py diff --git a/configuration.yaml b/configuration.yaml index b9e41242..44b9b3a4 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -50,6 +50,20 @@ input_boolean: name: Heater icon: mdi:heater +switch: + - platform: template + switches: + heater: + value_template: "{{ is_state('input_boolean.heater', 'on') }}" + turn_on: + service: input_boolean.turn_on + target: + entity_id: input_boolean.heater + turn_off: + service: input_boolean.turn_off + target: + entity_id: input_boolean.heater + climate: - platform: generic_thermostat name: Heater diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index a5877b8e..380e1fb2 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -1,16 +1,14 @@ import asyncio import logging -from typing import Optional, Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.storage import Store -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from pyotgw import OpenThermGateway from serial import SerialException from .const import * +from .store import SatConfigStore _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -25,20 +23,29 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): if _hass.data.get(DOMAIN) is None: _hass.data.setdefault(DOMAIN, {}) - try: - client = OpenThermGateway() - await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) - except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: - raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {ex}") from ex + store = SatConfigStore(_hass, _entry) + await store.async_initialize() - _hass.data[DOMAIN][_entry.entry_id] = { - COORDINATOR: SatDataUpdateCoordinator(_hass, client=client), - } + if _entry.data.get(CONF_MODE) == MODE_SWITCH: + from custom_components.sat.coordinators.switch import SatSwitchCoordinator + _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatSwitchCoordinator(_hass, store)} + if _entry.data.get(CONF_MODE) == MODE_OPENTHERM: + try: + client = OpenThermGateway() + await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) + except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: + raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {ex}") from ex + + from custom_components.sat.coordinators.opentherm import SatOpenThermCoordinator + _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatOpenThermCoordinator(_hass, store, client)} + + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, SENSOR)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, NUMBER)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, BINARY_SENSOR)) + + _LOGGER.debug(_hass.data[DOMAIN][_entry.entry_id]) await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, SENSOR)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, NUMBER)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, BINARY_SENSOR)) _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) @@ -47,15 +54,25 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - unloaded = all( - await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), - _hass.config_entries.async_forward_entry_unload(_entry, SENSOR), - _hass.config_entries.async_forward_entry_unload(_entry, NUMBER), - _hass.config_entries.async_forward_entry_unload(_entry, BINARY_SENSOR), - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].cleanup() + unloaded = True + + if _entry.data.get(CONF_MODE) == MODE_OPENTHERM: + unloaded = all( + await asyncio.gather( + _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), + _hass.config_entries.async_forward_entry_unload(_entry, SENSOR), + _hass.config_entries.async_forward_entry_unload(_entry, NUMBER), + _hass.config_entries.async_forward_entry_unload(_entry, BINARY_SENSOR), + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].cleanup() + ) + ) + + if _entry.data.get(CONF_MODE) == MODE_SWITCH: + unloaded = all( + await asyncio.gather( + _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), + ) ) - ) if unloaded: _hass.data[DOMAIN].pop(_entry.entry_id) @@ -67,63 +84,3 @@ async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: """Reload config entry.""" await async_unload_entry(_hass, _entry) await async_setup_entry(_hass, _entry) - - -class SatDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway.""" - - def __init__(self, hass: HomeAssistant, client: OpenThermGateway) -> None: - """Initialize.""" - self.api = client - self.api.subscribe(self._async_coroutine) - - super().__init__(hass, _LOGGER, name=DOMAIN) - - async def _async_update_data(self): - """Update data via library.""" - try: - return await self.api.get_status() - except Exception as exception: - raise UpdateFailed() from exception - - async def _async_coroutine(self, data): - self.async_set_updated_data(data) - - async def cleanup(self): - """Cleanup and disconnect.""" - self.api.unsubscribe(self._async_coroutine) - - await self.api.set_control_setpoint(0) - await self.api.set_max_relative_mod("-") - await self.api.disconnect() - - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. - - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None - - -class SatConfigStore: - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN - - def __init__(self, hass): - self._hass = hass - self._data = None - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) - - async def async_initialize(self): - if (data := await self._store.async_load()) is None: - data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: None} - - self._data = data - - def retrieve_overshoot_protection_value(self): - return self._data[STORAGE_OVERSHOOT_PROTECTION_VALUE] - - def store_overshoot_protection_value(self, value: float): - self._data[STORAGE_OVERSHOOT_PROTECTION_VALUE] = value - self._store.async_delay_save(lambda: self._data, 1.0) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index f8aa4418..8b2386c9 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id -from . import SatDataUpdateCoordinator from .climate import SatClimate from .const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO +from .coordinators.opentherm import SatOpenThermCoordinator from .entity import SatEntity _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ class SatBinarySensor(SatEntity, BinarySensorEntity): def __init__( self, - coordinator: SatDataUpdateCoordinator, + coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry, key: str, source: str, @@ -104,7 +104,7 @@ def unique_id(self): class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, climate: SatClimate, config_entry: ConfigEntry): + def __init__(self, coordinator: SatOpenThermCoordinator, climate: SatClimate, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator @@ -149,7 +149,7 @@ def unique_id(self): class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: + def __init__(self, coordinator: SatOpenThermCoordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: """Initialize the Central Heating Synchro sensor.""" super().__init__(coordinator, config_entry) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 69d7000f..71afb500 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -22,7 +22,6 @@ SERVICE_SET_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, ) -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -32,13 +31,13 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt -from . import SatDataUpdateCoordinator, SatConfigStore from .const import * +from .device import DeviceState from .entity import SatEntity from .heating_curve import HeatingCurve -from .overshoot_protection import OvershootProtection from .pid import PID from .pwm import PWM, PWMState +from .store import SatConfigStore ATTR_ROOMS = "rooms" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -87,52 +86,41 @@ def create_heating_curve_controller(options) -> HeatingCurve: def create_pwm_controller(heating_curve: HeatingCurve, store: SatConfigStore, options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options + force = bool(options.get(CONF_MODE) == MODE_SWITCH) automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) # Return a new PWM controller instance with the given configuration options - return PWM(store=store, heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle) + return PWM(store=store, heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): """Set up the SatClimate device.""" - store = SatConfigStore(hass) - await store.async_initialize() - - climate = SatClimate( - hass.data[DOMAIN][config_entry.entry_id][COORDINATOR], - store, - config_entry, - hass.config.units.temperature_unit - ) + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + climate = SatClimate(coordinator, config_entry, hass.config.units.temperature_unit) async_add_devices([climate]) hass.data[DOMAIN][config_entry.entry_id][CLIMATE] = climate class SatClimate(SatEntity, ClimateEntity, RestoreEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, config_entry: ConfigEntry, unit: str): + def __init__(self, coordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) - self._store = store self._coordinator = coordinator - self._config_entry = config_entry - - # Get configuration options and update with default values - options = OPTIONS_DEFAULTS.copy() - options.update(config_entry.options) + self._store = coordinator.store # Create dictionary mapping preset keys to temperature options conf_presets = {p: f"{p}_temperature" for p in (PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} # Create dictionary mapping preset keys to temperature values - self._presets = {key: options[value] for key, value in conf_presets.items() if value in options} + self._presets = {key: self._store.options[value] for key, value in conf_presets.items() if value in self._store.options} # Create PID controller with given configuration options - self._pid = create_pid_controller(options) + self._pid = create_pid_controller(self._store.options) # Get inside sensor entity ID - self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID) + self.inside_sensor_entity_id = self._store.options.get(CONF_INSIDE_SENSOR_ENTITY_ID) # Get inside sensor entity state inside_sensor_entity = coordinator.hass.states.get(self.inside_sensor_entity_id) @@ -150,17 +138,15 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, self.outside_sensor_entities = [self.outside_sensor_entities] # Create Heating Curve controller with given configuration options - self._heating_curve = create_heating_curve_controller(options) + self._heating_curve = create_heating_curve_controller(self._store.options) # Create PWM controller with given configuration options - self._pwm = create_pwm_controller(self._heating_curve, self._store, options) + self._pwm = create_pwm_controller(self._heating_curve, self._store, self._store.options) self._sensors = [] self._rooms = None self._setpoint = None self._warming_up = False - self._max_relative_mod = None - self._is_device_active = False self._outputs = deque(maxlen=50) self._hvac_mode = None @@ -168,17 +154,17 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, store: SatConfigStore, self._saved_target_temperature = None self._overshoot_protection_calculate = False - self._climates = options.get(CONF_CLIMATES) - self._main_climates = options.get(CONF_MAIN_CLIMATES) + self._climates = self._store.options.get(CONF_CLIMATES) + self._main_climates = self._store.options.get(CONF_MAIN_CLIMATES) - self._simulation = bool(options.get(CONF_SIMULATION)) - self._heating_system = str(options.get(CONF_HEATING_SYSTEM)) - self._overshoot_protection = bool(options.get(CONF_OVERSHOOT_PROTECTION)) - self._climate_valve_offset = float(options.get(CONF_CLIMATE_VALVE_OFFSET)) - self._target_temperature_step = float(options.get(CONF_TARGET_TEMPERATURE_STEP)) - self._sync_climates_with_preset = bool(options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) - self._force_pulse_width_modulation = bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - self._sensor_max_value_age = convert_time_str_to_seconds(options.get(CONF_SENSOR_MAX_VALUE_AGE)) + self._simulation = bool(self._store.options.get(CONF_SIMULATION)) + self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) + self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) + self._climate_valve_offset = float(self._store.options.get(CONF_CLIMATE_VALVE_OFFSET)) + self._target_temperature_step = float(self._store.options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._sync_climates_with_preset = bool(self._store.options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) + self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + self._sensor_max_value_age = convert_time_str_to_seconds(self._store.options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() @@ -279,93 +265,10 @@ async def async_added_to_hass(self) -> None: self.async_write_ha_state() await self._async_control_heating() - await self._async_control_max_setpoint() - - if self._overshoot_protection and self._store.retrieve_overshoot_protection_value() is None: - self._overshoot_protection = False - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled overshoot protection because no overshoot value has been found." - }) - - if self._force_pulse_width_modulation and self._store.retrieve_overshoot_protection_value() is None: - self._force_pulse_width_modulation = False - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled forced pulse width modulation because no overshoot value has been found." - }) - - async def start_overshoot_protection_calculation(_call: ServiceCall): - """Service to start the overshoot protection calculation process. - - This process will activate overshoot protection by turning on the heater and setting the control setpoint to - a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data - points. If the difference between the current return water temperature and the mean is small, it will - deactivate overshoot protection and store the calculated value. - """ - if self._overshoot_protection_calculate: - _LOGGER.warning("[Overshoot Protection] Calculation already in progress.") - return - - self._is_device_active = True - self._overshoot_protection_calculate = True - - saved_hvac_mode = self._hvac_mode - saved_target_temperature = self._target_temperature - - saved_target_temperatures = {} - for entity_id in self._climates: - if state := self.hass.states.get(entity_id): - saved_target_temperatures[entity_id] = float(state.attributes.get("temperature")) - - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - self._hvac_mode = HVACMode.HEAT - await self._async_set_setpoint(30) - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." - }) - - overshoot_protection_value = await OvershootProtection(self._coordinator).calculate(_call.data.get("solution")) - self._overshoot_protection_calculate = False - - await self.async_set_hvac_mode(saved_hvac_mode) - - await self._async_control_max_setpoint() - await self._async_set_setpoint(saved_target_temperature) - - for entity_id in self._climates: - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - if overshoot_protection_value is None: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Timed out waiting for stable temperature" - }) - else: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" - }) - - # Turn the overshoot protection settings back on - self._overshoot_protection = bool(self._config_entry.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._config_entry.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - - # Store the new value - self._store.store_overshoot_protection_value(overshoot_protection_value) - - self.hass.services.async_register(DOMAIN, "start_overshoot_protection_calculation", start_overshoot_protection_calculation) async def set_overshoot_protection_value(_call: ServiceCall): """Service to set the overshoot protection value.""" - self._store.store_overshoot_protection_value(_call.data.get("value")) + self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, _call.data.get("value")) self.hass.services.async_register(DOMAIN, "overshoot_protection_value", set_overshoot_protection_value) @@ -424,7 +327,6 @@ def extra_state_attributes(self): "setpoint": self._setpoint, "warming_up": self._warming_up, "valves_open": self.valves_open, - "max_relative_mod": self._max_relative_mod, "heating_curve": self._heating_curve.value, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self._heating_curve.optimal_coefficient, @@ -432,7 +334,7 @@ def extra_state_attributes(self): "pulse_width_modulation_state": self._pwm.state, "pulse_width_modulation_duty_cycle": self._pwm.duty_cycle, "overshoot_protection_calculating": self._overshoot_protection_calculate, - "overshoot_protection_value": self._store.retrieve_overshoot_protection_value(), + "overshoot_protection_value": self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE), } @property @@ -494,7 +396,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if not self._is_device_active: + if not self._coordinator.device_state == DeviceState.ON: return HVACAction.IDLE return HVACAction.HEATING @@ -572,13 +474,14 @@ def _pulse_width_modulation_enabled(self) -> bool: """Return True if pulse width modulation is enabled, False otherwise. If an overshoot protection value is not set, pulse width modulation is disabled. + If we are a coordinator that doesn't support it, it is enabled. If pulse width modulation is forced on, it is enabled. If overshoot protection is enabled, and we are below the overshoot protection value. """ - if (overshoot_protection_value := self._store.retrieve_overshoot_protection_value()) is None: + if (overshoot_protection_value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is None: return False - if self._force_pulse_width_modulation: + if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True if self._overshoot_protection: @@ -603,7 +506,7 @@ def _calculate_control_setpoint(self) -> float: requested_setpoint = max(requested_setpoint, self._heating_curve.value) # Ensure setpoint is limited to our max - setpoint = min(requested_setpoint, self._get_maximum_setpoint()) + setpoint = min(requested_setpoint, self._coordinator.maximum_setpoint) # Ensure setpoint is at least 10 return round(max(setpoint, MINIMUM_SETPOINT), 1) @@ -611,35 +514,6 @@ def _calculate_control_setpoint(self) -> float: def _get_requested_setpoint(self): return self._heating_curve.value + self._pid.output - def _get_maximum_setpoint(self) -> float: - if self._heating_system == HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES: - return 75.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES: - return 65.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: - return 55.0 - - if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: - return 50.0 - - def _calculate_max_relative_mod(self) -> int: - if bool(self._coordinator.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)): - return MAXIMUM_RELATIVE_MOD - - if self._setpoint <= MINIMUM_SETPOINT: - return MAXIMUM_RELATIVE_MOD - - if self._overshoot_protection and not self._force_pulse_width_modulation: - if self._setpoint is None or (overshoot_protection_value := self._store.retrieve_overshoot_protection_value()) is None: - return MAXIMUM_RELATIVE_MOD - - if abs(self.max_error) > 0.1 and self._setpoint >= (overshoot_protection_value - 2): - return MAXIMUM_RELATIVE_MOD - - return MINIMUM_RELATIVE_MOD - async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" new_state = event.data.get("new_state") @@ -740,9 +614,8 @@ async def _async_control_heating(self, _time=None) -> None: if self.current_temperature is None or self.target_temperature is None or self.current_outside_temperature is None: return - # Make sure the boiler is off when the climate is off, and do nothing else - if self.hvac_mode == HVACMode.OFF and bool(self._coordinator.get(gw_vars.DATA_MASTER_CH_ENABLED)): - await self._async_control_heater(False) + # Control the heating through the coordinator + await self._coordinator.async_control_heating(self) # Pulse Width Modulation if self._overshoot_protection or self._force_pulse_width_modulation: @@ -751,33 +624,25 @@ async def _async_control_heating(self, _time=None) -> None: # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self._pwm.state) - # Set the max relative mod - await self._async_control_max_relative_mod() - # Control the integral (if exceeded the time limit) self._pid.update_integral(self.max_error, self._heating_curve.value) - if self._is_device_active: + if self._coordinator.device_state == DeviceState.ON: # If the setpoint is too low or the valves are closed or HVAC is off, turn off the heater if self._setpoint <= MINIMUM_SETPOINT or not self.valves_open or self.hvac_mode == HVACMode.OFF: - await self._async_control_heater(False) + await self._coordinator.async_set_heater_state(DeviceState.OFF) else: - await self._async_control_heater(True) - else: + await self._coordinator.async_set_heater_state(DeviceState.ON) + + if self._coordinator.device_state == DeviceState.OFF: # If the setpoint is high and the valves are open and the HVAC is not off, turn on the heater if self._setpoint > MINIMUM_SETPOINT and self.valves_open and self.hvac_mode != HVACMode.OFF: - await self._async_control_heater(True) + await self._coordinator.async_set_heater_state(DeviceState.ON) else: - await self._async_control_heater(False) + await self._coordinator.async_set_heater_state(DeviceState.OFF) self.async_write_ha_state() - async def _async_control_max_setpoint(self) -> None: - _LOGGER.info(f"Set max setpoint to {self._get_maximum_setpoint()}") - - if not self._simulation: - await self._coordinator.api.set_max_ch_setpoint(self._get_maximum_setpoint()) - async def _async_control_pid(self, reset: bool = False): """Control the PID controller.""" # We can't continue if we don't have a valid outside temperature @@ -830,23 +695,11 @@ async def _async_control_pid(self, reset: bool = False): self.async_write_ha_state() - async def _async_control_heater(self, enabled: bool) -> None: - """Control the state of the central heating.""" - if enabled: - await self._async_control_pid(True) - - if not self._simulation: - await self._coordinator.api.set_ch_enable_bit(int(enabled)) - - self._is_device_active = enabled - - _LOGGER.info("Set central heating to %d", enabled) - async def _async_control_setpoint(self, pwm_state: PWMState): """Control the setpoint of the heating system.""" if self.hvac_mode == HVACMode.HEAT: if self._pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: - self._setpoint = self._store.retrieve_overshoot_protection_value() if pwm_state == pwm_state.ON else MINIMUM_SETPOINT + self._setpoint = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) if pwm_state == pwm_state.ON else MINIMUM_SETPOINT _LOGGER.info(f"Running pulse width modulation cycle: {pwm_state}") else: self._outputs.append(self._calculate_control_setpoint()) @@ -856,25 +709,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState): self._outputs.clear() self._setpoint = MINIMUM_SETPOINT - if not self._simulation: - await self._coordinator.api.set_control_setpoint(self._setpoint) - - _LOGGER.info("Set control setpoint to %d", self._setpoint) - - async def _async_control_max_relative_mod(self): - """Control the max relative mod of the heating system.""" - if self.hvac_mode == HVACMode.HEAT: - self._max_relative_mod = self._calculate_max_relative_mod() - else: - self._max_relative_mod = 100 - - if float(self._coordinator.get(gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD)) == self._max_relative_mod: - return - - if not self._simulation: - await self._coordinator.api.set_max_relative_mod(self._max_relative_mod) - - _LOGGER.info("Set max relative mod to %d", self._max_relative_mod) + await self._coordinator.async_control_setpoint(self._setpoint) async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" @@ -891,7 +726,7 @@ async def async_set_temperature(self, **kwargs) -> None: return await self.async_set_preset_mode(preset) self._attr_preset_mode = PRESET_NONE - await self._async_set_setpoint(temperature) + await self.async_set_target_temperature(temperature) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the thermostat.""" @@ -910,7 +745,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Reset the preset mode if `PRESET_NONE` is given if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE - await self._async_set_setpoint(self._saved_target_temperature) + await self.async_set_target_temperature(self._saved_target_temperature) else: # Set the HVAC mode to `HEAT` if it is currently `OFF` if self.hvac_mode == HVACMode.OFF: @@ -922,7 +757,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Set the preset mode and target temperature self._attr_preset_mode = preset_mode - await self._async_set_setpoint(self._presets[preset_mode]) + await self.async_set_target_temperature(self._presets[preset_mode]) # Set the temperature for each room, when enabled if self._sync_climates_with_preset: @@ -938,7 +773,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: target_temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - async def _async_set_setpoint(self, temperature: float): + async def async_set_target_temperature(self, temperature: float): """Set the temperature setpoint for all main climates.""" if self._target_temperature == temperature: return diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index ffa1ff1d..8d2d5cb8 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -6,6 +6,7 @@ from homeassistant.components import dhcp from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback @@ -27,6 +28,13 @@ def __init__(self): self._data = {} self._errors = {} + async def async_step_user(self, _user_input=None) -> FlowResult: + """Handle user flow.""" + return self.async_show_menu( + step_id="user", + menu_options=["opentherm", "switch"] + ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" _LOGGER.debug("Discovered OTGW at [%s]", discovery_info.ip) @@ -36,28 +44,26 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes await self.async_set_unique_id(discovery_info.ip) self._abort_if_unique_id_configured(updates={CONF_DEVICE: discovery_info.ip}, reload_on_update=True) - return await self.async_step_user() + return await self.async_step_opentherm() - async def async_step_user(self, _user_input=None) -> FlowResult: + async def async_step_opentherm(self, _user_input=None): self._errors = {} if _user_input is not None: self._data.update(_user_input) + self._data[CONF_MODE] = MODE_OPENTHERM if not await self._test_gateway_connection(): self._errors["base"] = "auth" - return await self.async_step_gateway_setup() + return await self.async_step_opentherm() await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() return await self.async_step_sensors_setup() - return await self.async_step_gateway_setup() - - async def async_step_gateway_setup(self): return self.async_show_form( - step_id="user", + step_id="opentherm", last_step=False, errors=self._errors, data_schema=vol.Schema({ @@ -66,6 +72,29 @@ async def async_step_gateway_setup(self): }), ) + async def async_step_switch(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SWITCH + + await self.async_set_unique_id(self._data[CONF_SWITCH], raise_on_progress=False) + + self._abort_if_unique_id_configured() + + return await self.async_step_sensors_setup() + + return self.async_show_form( + step_id="switch", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default="Living Room"): str, + vol.Required(CONF_SWITCH): selector.EntitySelector( + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN]) + ) + }), + ) + async def async_step_sensors(self, _user_input=None): self._errors = {} @@ -104,6 +133,7 @@ class SatOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: ConfigEntry): self._config_entry = config_entry self._options = dict(config_entry.options) + self._mode = config_entry.data.get(CONF_MODE) async def async_step_init(self, _user_input=None): return await self.async_step_user(_user_input) @@ -132,7 +162,10 @@ async def async_step_general(self, _user_input=None) -> FlowResult: vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=defaults[CONF_TARGET_TEMPERATURE_STEP]): selector.NumberSelector( selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) ), - vol.Required(CONF_HEATING_SYSTEM, default=defaults[CONF_HEATING_SYSTEM]): selector.SelectSelector( + } + + if self._mode == MODE_OPENTHERM: + schema[vol.Required(CONF_HEATING_SYSTEM, default=defaults[CONF_HEATING_SYSTEM])] = selector.SelectSelector( selector.SelectSelectorConfig(options=[ {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, {"value": HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES, "label": "Radiators ( Medium Temperatures )"}, @@ -140,7 +173,11 @@ async def async_step_general(self, _user_input=None) -> FlowResult: {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"} ]) ) - } + + if self._mode == MODE_SWITCH: + schema[vol.Required(CONF_SETPOINT, default=50)] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=100, step=1) + ) if not defaults.get(CONF_AUTOMATIC_GAINS): schema[vol.Required(CONF_PROPORTIONAL, default=defaults.get(CONF_PROPORTIONAL))] = str @@ -204,20 +241,27 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: return await self.update_options(_user_input) defaults = await self.get_options() + + schema = { + vol.Required(CONF_SIMULATION, default=defaults[CONF_SIMULATION]): bool, + vol.Required(CONF_AUTOMATIC_GAINS, default=defaults.get(CONF_AUTOMATIC_GAINS)): bool, + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=defaults.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, + } + + if self._mode == MODE_OPENTHERM: + schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=defaults[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool + schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=defaults[CONF_OVERSHOOT_PROTECTION])] = bool + + schema[vol.Required(CONF_SAMPLE_TIME, default=defaults.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=defaults.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() + + schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=defaults[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=-1, max=1, step=0.1) + ) + return self.async_show_form( step_id="advanced", - data_schema=vol.Schema({ - vol.Required(CONF_SIMULATION, default=defaults[CONF_SIMULATION]): bool, - vol.Required(CONF_AUTOMATIC_GAINS, default=defaults.get(CONF_AUTOMATIC_GAINS)): bool, - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=defaults.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, - vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=defaults[CONF_FORCE_PULSE_WIDTH_MODULATION]): bool, - vol.Required(CONF_OVERSHOOT_PROTECTION, default=defaults[CONF_OVERSHOOT_PROTECTION]): bool, - vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=defaults[CONF_CLIMATE_VALVE_OFFSET]): selector.NumberSelector( - selector.NumberSelectorConfig(min=-1, max=1, step=0.1) - ), - vol.Required(CONF_SAMPLE_TIME, default=defaults.get(CONF_SAMPLE_TIME)): selector.TimeSelector(), - vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=defaults.get(CONF_SENSOR_MAX_VALUE_AGE)): selector.TimeSelector(), - }) + data_schema=vol.Schema(schema) ) async def update_options(self, _user_input) -> FlowResult: diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 28aee71c..e2a733a7 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -17,6 +17,9 @@ VERSION = "0.0.1" COORDINATOR = "coordinator" +MODE_SWITCH = "mode_switch" +MODE_OPENTHERM = "mode_opentherm" + UNIT_KW = "kW" UNIT_L_MIN = f"L/{TIME_MINUTES}" @@ -43,8 +46,11 @@ BINARY_SENSOR = "binary_sensor" # Configuration and options +CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" +CONF_SWITCH = "switch" +CONF_SETPOINT = "setpoint" CONF_CLIMATES = "climates" CONF_MAIN_CLIMATES = "main_climates" CONF_SIMULATION = "simulation" @@ -78,6 +84,7 @@ HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES = "radiator_high_temperatures" OPTIONS_DEFAULTS = { + CONF_MODE: MODE_OPENTHERM, CONF_PROPORTIONAL: "45", CONF_INTEGRAL: "0", CONF_DERIVATIVE: "6000", @@ -89,6 +96,7 @@ CONF_AUTOMATIC_DUTY_CYCLE: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, + CONF_SETPOINT: 80, CONF_OVERSHOOT_PROTECTION: False, CONF_FORCE_PULSE_WIDTH_MODULATION: False, diff --git a/custom_components/sat/coordinators/__init__.py b/custom_components/sat/coordinators/__init__.py new file mode 100644 index 00000000..bdc5acb0 --- /dev/null +++ b/custom_components/sat/coordinators/__init__.py @@ -0,0 +1,62 @@ +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from ..climate import SatClimate +from ..const import * +from ..device import DeviceState +from ..store import SatConfigStore + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SatDataUpdateCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: + """Initialize.""" + self._store = store + self._device_state = DeviceState.OFF + self._simulation = bool(self._store.options.get(CONF_SIMULATION)) + self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) + + super().__init__(hass, _LOGGER, name=DOMAIN) + + @property + def store(self): + return self._store + + @property + def supports_setpoint_management(self): + return False + + @property + def device_state(self): + return self._device_state + + @property + def maximum_setpoint(self) -> float: + if self._heating_system == HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES: + return 75.0 + + if self._heating_system == HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES: + return 65.0 + + if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: + return 55.0 + + if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: + return 50.0 + + async def async_added_to_hass(self, climate: SatClimate) -> None: + pass + + async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + pass + + async def async_control_setpoint(self, value: float) -> None: + if self.supports_setpoint_management: + self.logger.info("Set control setpoint to %d", value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + self._device_state = state + self.logger.info("Set central heater state %s", state) diff --git a/custom_components/sat/coordinators/opentherm.py b/custom_components/sat/coordinators/opentherm.py new file mode 100644 index 00000000..81e366ad --- /dev/null +++ b/custom_components/sat/coordinators/opentherm.py @@ -0,0 +1,216 @@ +from typing import Optional, Any + +from homeassistant.components.climate import ( + HVACMode, + SERVICE_SET_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.update_coordinator import UpdateFailed +from pyotgw import OpenThermGateway + +from . import SatDataUpdateCoordinator +from ..climate import SatClimate +from ..const import * +from ..device import DeviceState +from ..overshoot_protection import OvershootProtection +from ..store import SatConfigStore + + +class SatOpenThermCoordinator(SatDataUpdateCoordinator): + """Class to manage fetching data from the OTGW Gateway.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenThermGateway) -> None: + """Initialize.""" + super().__init__(hass, store) + + self.api = client + self.api.subscribe(self._async_coroutine) + + self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) + self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + + @property + def supports_setpoint_management(self): + return True + + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. + + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None + + async def async_added_to_hass(self, climate: SatClimate) -> None: + """Run when entity about to be added.""" + await self._async_control_max_setpoint() + + if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._overshoot_protection = False + + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Smart Autotune Thermostat", + "message": "Disabled overshoot protection because no overshoot value has been found." + }) + + if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._force_pulse_width_modulation = False + + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Smart Autotune Thermostat", + "message": "Disabled forced pulse width modulation because no overshoot value has been found." + }) + + async def start_overshoot_protection_calculation(_call: ServiceCall): + """Service to start the overshoot protection calculation process. + + This process will activate overshoot protection by turning on the heater and setting the control setpoint to + a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data + points. If the difference between the current return water temperature and the mean is small, it will + deactivate overshoot protection and store the calculated value. + """ + if self._overshoot_protection_calculate: + self.logger.warning("[Overshoot Protection] Calculation already in progress.") + return + + self._device_state = DeviceState.ON + self._overshoot_protection_calculate = True + + saved_hvac_mode = climate.hvac_mode + saved_target_temperature = climate.target_temperature + + saved_target_temperatures = {} + for entity_id in self._store.options.get(CONF_CLIMATES): + if state := self.hass.states.get(entity_id): + saved_target_temperatures[entity_id] = float(state.attributes.get("temperature")) + + data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} + await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + + await climate.async_set_target_temperature(30) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Overshoot Protection Calculation", + "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." + }) + + overshoot_protection_value = await OvershootProtection(self).calculate(_call.data.get("solution")) + self._overshoot_protection_calculate = False + + await climate.async_set_hvac_mode(saved_hvac_mode) + + await self._async_control_max_setpoint() + await climate.async_set_target_temperature(saved_target_temperature) + + for entity_id in self._store.options.get(CONF_CLIMATES): + data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} + await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + + if overshoot_protection_value is None: + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Overshoot Protection Calculation", + "message": f"Timed out waiting for stable temperature" + }) + else: + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Overshoot Protection Calculation", + "message": f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" + }) + + # Turn the overshoot protection settings back on + self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) + self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + + # Store the new value + self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) + + self.hass.services.async_register(DOMAIN, "start_overshoot_protection_calculation", start_overshoot_protection_calculation) + + async def cleanup(self) -> None: + """Cleanup and disconnect.""" + self.api.unsubscribe(self._async_coroutine) + + await self.api.set_control_setpoint(0) + await self.api.set_max_relative_mod("-") + await self.api.disconnect() + + async def _async_update_data(self): + """Update data via library.""" + try: + return await self.api.get_status() + except Exception as exception: + raise UpdateFailed() from exception + + async def _async_coroutine(self, data): + self.async_set_updated_data(data) + + async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + """Control the max relative mod of the heating system.""" + await super().async_control_heating(climate) + + if climate.hvac_mode == HVACMode.OFF and bool(self.get(gw_vars.DATA_MASTER_CH_ENABLED)): + await self.async_set_heater_state(DeviceState.OFF) + + await self._async_control_max_relative_mod(climate) + + async def _async_control_max_relative_mod(self, climate: SatClimate, _time=None) -> None: + max_relative_mod = self._calculate_max_relative_mod(climate) + if float(self.get(gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD)) == max_relative_mod: + return + + if not self._simulation: + await self.api.set_max_relative_mod(max_relative_mod) + + self.logger.info("Set max relative mod to %d", max_relative_mod) + + async def _async_control_max_setpoint(self) -> None: + if not self._simulation: + await self.api.set_max_ch_setpoint(self.maximum_setpoint) + + self.logger.info(f"Set max setpoint to {self.maximum_setpoint}") + + async def async_control_setpoint(self, value: float) -> None: + if not self._simulation: + await self.api.set_control_setpoint(value) + + await super().async_control_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + """Control the state of the central heating.""" + if not self._simulation: + await self.api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) + + await super().async_set_heater_state(state) + + def _calculate_max_relative_mod(self, climate: SatClimate) -> int: + """Calculate the maximum relative modulation for the heating system. + + This method determines the maximum relative modulation that should be used for the heating system, based on the current + climate conditions and system configuration. If the heating system is currently in heat mode, or if domestic hot water + is active, or if the setpoint is below a certain minimum value, the maximum relative modulation is returned as a constant value. + + Otherwise, if overshoot protection is enabled and certain conditions are met, the maximum relative modulation is also set + to a constant value. Otherwise, we return the minimum relative modulation. + + Args: + climate: A `SatClimate` object representing the current climate conditions. + + Returns: + An integer representing the maximum relative modulation for the heating system. + """ + setpoint = float(self.get(gw_vars.DATA_CONTROL_SETPOINT)) + + if climate.hvac_mode == HVACMode.HEAT or bool(self.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: + return MAXIMUM_RELATIVE_MOD + + if self._overshoot_protection and not self._force_pulse_width_modulation: + overshoot_protection_value = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) + + if overshoot_protection_value is None or (abs(climate.max_error) > 0.1 and setpoint >= (overshoot_protection_value - 2)): + return MAXIMUM_RELATIVE_MOD + + return MINIMUM_RELATIVE_MOD diff --git a/custom_components/sat/coordinators/switch.py b/custom_components/sat/coordinators/switch.py new file mode 100644 index 00000000..a0d872b6 --- /dev/null +++ b/custom_components/sat/coordinators/switch.py @@ -0,0 +1,34 @@ +from homeassistant.components.climate import HVACMode +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from . import SatDataUpdateCoordinator +from ..climate import SatClimate +from ..const import * +from ..device import DeviceState +from ..store import SatConfigStore + + +class SatSwitchCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: + """Initialize.""" + super().__init__(hass, store) + self._entity_id = self._store.options.get(CONF_SWITCH) + + async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + """Control the max relative mod of the heating system.""" + await super().async_control_heating(climate) + + if climate.hvac_mode == HVACMode.OFF and self.hass.states.get(self._entity_id).state != "OFF": + await self.async_set_heater_state(DeviceState.OFF) + + async def async_set_heater_state(self, state: DeviceState) -> None: + """Control the state of the central heating.""" + if not self._simulation: + service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF + await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) + + await super().async_set_heater_state(state) diff --git a/custom_components/sat/device.py b/custom_components/sat/device.py new file mode 100644 index 00000000..ee2921b4 --- /dev/null +++ b/custom_components/sat/device.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class DeviceState(Enum): + ON = "on" + OFF = "off" diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index 3761aa4d..0c7f07d7 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -1,7 +1,7 @@ from collections import deque from statistics import mean -from custom_components.sat.const import * +from .const import * class HeatingCurve: diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index a049c84b..035ecfd8 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -1,20 +1,20 @@ { - "domain": "sat", - "name": "Smart Autotune Thermostat", - "codeowners": [ - "@Alexwijn" - ], - "config_flow": true, - "dhcp": [ - { - "hostname": "otgw" - } - ], - "documentation": "https://github.com/Alexwijn/SAT", - "iot_class": "local_polling", - "issue_tracker": "https://github.com/Alexwijn/SAT/issues", - "requirements": [ - "pyotgw==2.1.3" - ], - "version": "1.0.0" + "domain": "sat", + "name": "Smart Autotune Thermostat", + "codeowners": [ + "@Alexwijn" + ], + "config_flow": true, + "dhcp": [ + { + "hostname": "otgw" + } + ], + "documentation": "https://github.com/Alexwijn/SAT", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/Alexwijn/SAT/issues", + "requirements": [ + "pyotgw==2.1.3" + ], + "version": "2.0.0" } diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index c0cf4867..a0ab2eff 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -1,10 +1,10 @@ -import pyotgw.vars as gw_vars from homeassistant.components.number import NumberEntity, NumberDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from custom_components.sat import SatDataUpdateCoordinator, CONF_NAME, COORDINATOR, DOMAIN -from custom_components.sat.entity import SatEntity +from .const import * +from .coordinators.opentherm import SatOpenThermCoordinator +from .entity import SatEntity async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): @@ -13,7 +13,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn class SatHotWaterSetpointEntity(SatEntity, NumberEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): + def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index e5cc35b4..be066ca3 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -2,8 +2,8 @@ import logging from collections import deque -from custom_components.sat import SatDataUpdateCoordinator from .const import * +from .coordinators.opentherm import SatOpenThermCoordinator SOLUTION_AUTOMATIC = "auto" SOLUTION_WITH_MODULATION = "with_modulation" @@ -19,7 +19,7 @@ class OvershootProtection: - def __init__(self, coordinator: SatDataUpdateCoordinator): + def __init__(self, coordinator: SatOpenThermCoordinator): self._coordinator = coordinator async def calculate(self, solution: str) -> float | None: diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 3530a763..c7bb8202 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,8 +3,9 @@ from time import monotonic from typing import Optional, Tuple -from custom_components.sat import SatConfigStore -from custom_components.sat.heating_curve import HeatingCurve +from .const import * +from .heating_curve import HeatingCurve +from .store import SatConfigStore _LOGGER = logging.getLogger(__name__) @@ -26,8 +27,9 @@ class PWMState(Enum): class PWM: """A class for implementing Pulse Width Modulation (PWM) control.""" - def __init__(self, store: SatConfigStore, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool): + def __init__(self, store: SatConfigStore, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" + self._force = force self._store = store self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time @@ -49,7 +51,7 @@ async def update(self, setpoint: float) -> None: _LOGGER.warning("Invalid heating curve value") return - if setpoint is None or setpoint > self._store.retrieve_overshoot_protection_value(): + if not self._force and (setpoint is None or setpoint > self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)): self._state = PWMState.IDLE self._last_update = monotonic() _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value") @@ -84,8 +86,8 @@ async def update(self, setpoint: float) -> None: def _calculate_duty_cycle(self, setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset - overshoot_protection = self._store.retrieve_overshoot_protection_value() - duty_cycle_percentage = (setpoint - base_offset) / (overshoot_protection - base_offset) + overshoot_protection = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) + duty_cycle_percentage = min((setpoint - base_offset) / (overshoot_protection - base_offset), 1) _LOGGER.debug("Requested setpoint %.1f", setpoint) _LOGGER.debug("Calculated duty cycle %.0f%%", duty_cycle_percentage * 100) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index a1f8b7c5..e20ae08c 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,15 +1,13 @@ """Sensor platform for SAT.""" import logging -import pyotgw.vars as gw_vars -from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT, SensorDeviceClass +from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id -from . import SatDataUpdateCoordinator -from .const import SENSOR_INFO, DOMAIN, COORDINATOR, TRANSLATE_SOURCE, CONF_NAME +from .const import * +from .coordinators.opentherm import SatOpenThermCoordinator from .entity import SatEntity _LOGGER = logging.getLogger(__name__) @@ -47,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn class SatSensor(SatEntity, SensorEntity): def __init__( self, - coordinator: SatDataUpdateCoordinator, + coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry, key: str, source: str, @@ -110,7 +108,7 @@ def unique_id(self): class SatCurrentPowerSensor(SatEntity, SensorEntity): - def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): + def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator diff --git a/custom_components/sat/store.py b/custom_components/sat/store.py new file mode 100644 index 00000000..d3b206ca --- /dev/null +++ b/custom_components/sat/store.py @@ -0,0 +1,37 @@ +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import * + + +class SatConfigStore: + _STORAGE_VERSION = 1 + _STORAGE_KEY = DOMAIN + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + self._hass = hass + self._data = None + self._options = None + self._config_entry = config_entry + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + + async def async_initialize(self): + if (data := await self._store.async_load()) is None: + data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: self._config_entry.data.get(CONF_SETPOINT)} + + self._data = data + self._options = OPTIONS_DEFAULTS.copy() + self._options.update(self._config_entry.data) + self._options.update(self._config_entry.options) + + def get(self, key: str): + return self._data[key] + + def update(self, key: str, value: float): + self._data[key] = value + self._store.async_delay_save(lambda: self._data, 1.0) + + @property + def options(self): + return self._options diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index e73b061f..729d4c25 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -3,11 +3,28 @@ "step": { "user": { "title": "Smart Autotune Thermostat (SAT)", + "description": "SAT is a smart thermostat that is capable of auto-tuning itself to optimize temperature control. Select the appropriate mode that matches your heating system.", + "menu_options": { + "opentherm": "OpenTherm gateway", + "switch": "PID Thermostat with PWM ( ON/OFF )" + } + }, + "opentherm": { + "title": "OpenTherm gateway", + "description": "Please fill in the following details to set up the OpenTherm gateway:", "data": { "name": "Name", "device": "Path or URL" } }, + "switch": { + "title": "PID Thermostat with PWM ( ON/OFF )", + "description": "Please fill in the following details to setup the switch:", + "data": { + "name": "Name", + "switch": "Switch" + } + }, "sensors": { "title": "Configure sensors", "data": { @@ -36,6 +53,7 @@ "general": { "title": "General", "data": { + "setpoint": "Setpoint", "integral": "Integral", "derivative": "Derivative", "proportional": "Proportional", From ae1a7235a55f2bcf3181c0a0fc1c10378bab9ae9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 00:46:27 +0200 Subject: [PATCH 002/237] Do not import the Coordinator for now (circular issues) --- custom_components/sat/binary_sensor.py | 7 +++---- custom_components/sat/number.py | 3 +-- custom_components/sat/overshoot_protection.py | 3 +-- custom_components/sat/sensor.py | 14 ++------------ 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 8b2386c9..dbc9c92e 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -11,7 +11,6 @@ from .climate import SatClimate from .const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO -from .coordinators.opentherm import SatOpenThermCoordinator from .entity import SatEntity _LOGGER = logging.getLogger(__name__) @@ -52,7 +51,7 @@ class SatBinarySensor(SatEntity, BinarySensorEntity): def __init__( self, - coordinator: SatOpenThermCoordinator, + coordinator, config_entry: ConfigEntry, key: str, source: str, @@ -104,7 +103,7 @@ def unique_id(self): class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, climate: SatClimate, config_entry: ConfigEntry): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator @@ -149,7 +148,7 @@ def unique_id(self): class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: """Initialize the Central Heating Synchro sensor.""" super().__init__(coordinator, config_entry) diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index a0ab2eff..9048730c 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -3,7 +3,6 @@ from homeassistant.core import HomeAssistant from .const import * -from .coordinators.opentherm import SatOpenThermCoordinator from .entity import SatEntity @@ -13,7 +12,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn class SatHotWaterSetpointEntity(SatEntity, NumberEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): + def __init__(self, coordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index be066ca3..7e10c119 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -3,7 +3,6 @@ from collections import deque from .const import * -from .coordinators.opentherm import SatOpenThermCoordinator SOLUTION_AUTOMATIC = "auto" SOLUTION_WITH_MODULATION = "with_modulation" @@ -19,7 +18,7 @@ class OvershootProtection: - def __init__(self, coordinator: SatOpenThermCoordinator): + def __init__(self, coordinator): self._coordinator = coordinator async def calculate(self, solution: str) -> float | None: diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index e20ae08c..960cf4dd 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -7,7 +7,6 @@ from homeassistant.helpers.entity import async_generate_entity_id from .const import * -from .coordinators.opentherm import SatOpenThermCoordinator from .entity import SatEntity _LOGGER = logging.getLogger(__name__) @@ -43,16 +42,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn class SatSensor(SatEntity, SensorEntity): - def __init__( - self, - coordinator: SatOpenThermCoordinator, - config_entry: ConfigEntry, - key: str, - source: str, - device_class: str, - unit: str, - friendly_name_format: str - ): + def __init__(self, coordinator, config_entry: ConfigEntry, key: str, source: str, device_class: str, unit: str, friendly_name_format: str): super().__init__(coordinator, config_entry) self.entity_id = async_generate_entity_id( @@ -108,7 +98,7 @@ def unique_id(self): class SatCurrentPowerSensor(SatEntity, SensorEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): + def __init__(self, coordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) self._coordinator = coordinator From f087119398ab80af41c4bd7641601ec258ef3509 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 15:00:26 +0200 Subject: [PATCH 003/237] More cleaning up things and fixed the circular issues --- configuration.yaml | 35 ++- custom_components/sat/__init__.py | 54 ++--- custom_components/sat/binary_sensor.py | 196 +---------------- custom_components/sat/climate.py | 26 +-- .../sat/{store.py => config_store.py} | 0 custom_components/sat/const.py | 5 +- .../__init__.py => coordinator.py} | 18 +- custom_components/sat/device.py | 6 - custom_components/sat/number.py | 66 +----- custom_components/sat/opentherm/__init__.py | 24 +++ .../sat/opentherm/binary_sensor.py | 201 ++++++++++++++++++ .../opentherm.py => opentherm/coordinator.py} | 20 +- custom_components/sat/opentherm/number.py | 66 ++++++ .../{ => opentherm}/overshoot_protection.py | 5 +- custom_components/sat/opentherm/sensor.py | 153 +++++++++++++ custom_components/sat/pwm.py | 11 +- custom_components/sat/sensor.py | 155 +------------- custom_components/sat/switch/__init__.py | 16 ++ .../switch.py => switch/coordinator.py} | 15 +- 19 files changed, 566 insertions(+), 506 deletions(-) rename custom_components/sat/{store.py => config_store.py} (100%) rename custom_components/sat/{coordinators/__init__.py => coordinator.py} (87%) delete mode 100644 custom_components/sat/device.py create mode 100644 custom_components/sat/opentherm/__init__.py create mode 100644 custom_components/sat/opentherm/binary_sensor.py rename custom_components/sat/{coordinators/opentherm.py => opentherm/coordinator.py} (96%) create mode 100644 custom_components/sat/opentherm/number.py rename custom_components/sat/{ => opentherm}/overshoot_protection.py (97%) create mode 100644 custom_components/sat/opentherm/sensor.py create mode 100644 custom_components/sat/switch/__init__.py rename custom_components/sat/{coordinators/switch.py => switch/coordinator.py} (80%) diff --git a/configuration.yaml b/configuration.yaml index 44b9b3a4..9cce5856 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -10,6 +10,20 @@ homeassistant: climate.heater: sensor_temperature_id: "sensor.heater_temperature" +switch: + - platform: template + switches: + heater: + value_template: "{{ is_state('input_boolean.heater', 'on') }}" + turn_on: + service: input_boolean.turn_on + target: + entity_id: input_boolean.heater + turn_off: + service: input_boolean.turn_off + target: + entity_id: input_boolean.heater + template: sensor: - unit_of_measurement: °C @@ -49,24 +63,3 @@ input_boolean: heater: name: Heater icon: mdi:heater - -switch: - - platform: template - switches: - heater: - value_template: "{{ is_state('input_boolean.heater', 'on') }}" - turn_on: - service: input_boolean.turn_on - target: - entity_id: input_boolean.heater - turn_off: - service: input_boolean.turn_off - target: - entity_id: input_boolean.heater - -climate: - - platform: generic_thermostat - name: Heater - unique_id: heater - heater: input_boolean.heater - target_sensor: sensor.heater_temperature \ No newline at end of file diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 380e1fb2..3fff9d64 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -3,12 +3,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from pyotgw import OpenThermGateway -from serial import SerialException +from . import opentherm, switch +from .config_store import SatConfigStore from .const import * -from .store import SatConfigStore _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -23,29 +21,18 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): if _hass.data.get(DOMAIN) is None: _hass.data.setdefault(DOMAIN, {}) - store = SatConfigStore(_hass, _entry) - await store.async_initialize() + _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} + await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() if _entry.data.get(CONF_MODE) == MODE_SWITCH: - from custom_components.sat.coordinators.switch import SatSwitchCoordinator - _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatSwitchCoordinator(_hass, store)} + await _hass.async_add_job(switch.async_setup_entry(_hass, _entry)) if _entry.data.get(CONF_MODE) == MODE_OPENTHERM: - try: - client = OpenThermGateway() - await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) - except (asyncio.TimeoutError, ConnectionError, SerialException) as ex: - raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {ex}") from ex + await _hass.async_add_job(opentherm.async_setup_entry(_hass, _entry)) - from custom_components.sat.coordinators.opentherm import SatOpenThermCoordinator - _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatOpenThermCoordinator(_hass, store, client)} - - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, SENSOR)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, NUMBER)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, BINARY_SENSOR)) - - _LOGGER.debug(_hass.data[DOMAIN][_entry.entry_id]) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [ + CLIMATE, SENSOR, NUMBER, BINARY_SENSOR + ])) _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) @@ -54,25 +41,12 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: """Handle removal of an entry.""" - unloaded = True - - if _entry.data.get(CONF_MODE) == MODE_OPENTHERM: - unloaded = all( - await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), - _hass.config_entries.async_forward_entry_unload(_entry, SENSOR), - _hass.config_entries.async_forward_entry_unload(_entry, NUMBER), - _hass.config_entries.async_forward_entry_unload(_entry, BINARY_SENSOR), - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].cleanup() - ) - ) - - if _entry.data.get(CONF_MODE) == MODE_SWITCH: - unloaded = all( - await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), - ) + unloaded = all( + await asyncio.gather( + _hass.config_entries.async_forward_entry_unload(_entry, _entry.data.get(CONF_MODE)), + _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), ) + ) if unloaded: _hass.data[DOMAIN].pop(_entry.entry_id) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index dbc9c92e..c4a39da1 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,195 +1,11 @@ -"""Binary Sensor platform for SAT.""" -import logging - -import pyotgw.vars as gw_vars -from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT -from homeassistant.components.climate import HVACAction from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import async_generate_entity_id - -from .climate import SatClimate -from .const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO -from .entity import SatEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - """Setup sensor platform.""" - climate = hass.data[DOMAIN][config_entry.entry_id][CLIMATE] - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" - - # Create list of devices to be added - sensors = [ - SatControlSetpointSynchroSensor(coordinator, climate, config_entry), - SatCentralHeatingSynchroSensor(coordinator, climate, config_entry), - ] - - # Iterate through sensor information - for key, info in BINARY_SENSOR_INFO.items(): - device_class = info[0] - status_sources = info[2] - friendly_name_format = info[1] - - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: - continue - - if coordinator.data[source].get(key) is not None: - sensors.append(SatBinarySensor(coordinator, config_entry, key, source, device_class, friendly_name_format)) - - # Add all devices - async_add_entities(sensors) - - -class SatBinarySensor(SatEntity, BinarySensorEntity): - _attr_should_poll = False - - def __init__( - self, - coordinator, - config_entry: ConfigEntry, - key: str, - source: str, - device_class: str, - friendly_name_format: str - ): - super().__init__(coordinator, config_entry) - - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass - ) - - self._key = key - self._source = source - self._coordinator = coordinator - self._device_class = device_class - self._config_entry = config_entry - - if TRANSLATE_SOURCE[source] is not None: - friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" - - self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) - - @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None - - @property - def is_on(self): - """Return the state of the device.""" - return self._coordinator.data[self._source].get(self._key) - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME.lower())}-{self._source}-{self._key}" - - -class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - - self._coordinator = coordinator - self._climate = climate - - @property - def name(self): - """Return the friendly name of the sensor.""" - return "Control Setpoint Synchro" - - @property - def device_class(self): - """Return the device class.""" - return BinarySensorDeviceClass.PROBLEM - - @property - def available(self): - """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: - return False - - return True - - @property - def is_on(self): - """Return the state of the sensor.""" - boiler_setpoint = float(self._coordinator.data[gw_vars.BOILER].get(gw_vars.DATA_CONTROL_SETPOINT) or 0) - climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or boiler_setpoint) - - return not ( - self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or - round(climate_setpoint, 1) == round(boiler_setpoint, 1) - ) - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" - - -class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: - """Initialize the Central Heating Synchro sensor.""" - super().__init__(coordinator, config_entry) - - self._coordinator = coordinator - self._climate = climate - - @property - def name(self) -> str: - """Return the friendly name of the sensor.""" - return "Central Heating Synchro" - - @property - def device_class(self) -> str: - """Return the device class.""" - return BinarySensorDeviceClass.PROBLEM - - @property - def available(self) -> bool: - """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: - return False - - return True +from homeassistant.helpers.entity_platform import AddEntitiesCallback - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - boiler = self._coordinator.data[gw_vars.BOILER] - boiler_central_heating = bool(boiler.get(gw_vars.DATA_MASTER_CH_ENABLED)) - climate_hvac_action = self._climate.state_attributes.get("hvac_action") +from .const import CONF_MODE, MODE_OPENTHERM +from .opentherm import binary_sensor - return not ( - (climate_hvac_action == HVACAction.OFF and not boiler_central_heating) or - (climate_hvac_action == HVACAction.IDLE and not boiler_central_heating) or - (climate_hvac_action == HVACAction.HEATING and boiler_central_heating) - ) - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + await binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 71afb500..46815ed5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -1,4 +1,6 @@ """Climate platform for SAT.""" +from __future__ import annotations + import logging from collections import deque from datetime import timedelta @@ -27,17 +29,18 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, Event +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 homeassistant.util import dt +from .config_store import SatConfigStore from .const import * -from .device import DeviceState +from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity from .heating_curve import HeatingCurve from .pid import PID from .pwm import PWM, PWMState -from .store import SatConfigStore ATTR_ROOMS = "rooms" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -94,17 +97,17 @@ def create_pwm_controller(heating_curve: HeatingCurve, store: SatConfigStore, op return PWM(store=store, heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_devices): +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_devices: AddEntitiesCallback): """Set up the SatClimate device.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - climate = SatClimate(coordinator, config_entry, hass.config.units.temperature_unit) + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] + climate = SatClimate(coordinator, _config_entry, _hass.config.units.temperature_unit) - async_add_devices([climate]) - hass.data[DOMAIN][config_entry.entry_id][CLIMATE] = climate + _async_add_devices([climate]) + _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] = climate class SatClimate(SatEntity, ClimateEntity, RestoreEntity): - def __init__(self, coordinator, config_entry: ConfigEntry, unit: str): + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) self._coordinator = coordinator @@ -506,13 +509,10 @@ def _calculate_control_setpoint(self) -> float: requested_setpoint = max(requested_setpoint, self._heating_curve.value) # Ensure setpoint is limited to our max - setpoint = min(requested_setpoint, self._coordinator.maximum_setpoint) - - # Ensure setpoint is at least 10 - return round(max(setpoint, MINIMUM_SETPOINT), 1) + return min(requested_setpoint, self._coordinator.maximum_setpoint) def _get_requested_setpoint(self): - return self._heating_curve.value + self._pid.output + return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" diff --git a/custom_components/sat/store.py b/custom_components/sat/config_store.py similarity index 100% rename from custom_components/sat/store.py rename to custom_components/sat/config_store.py diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index e2a733a7..17c778bb 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -16,9 +16,10 @@ DOMAIN = "sat" VERSION = "0.0.1" COORDINATOR = "coordinator" +CONFIG_STORE = "config_store" -MODE_SWITCH = "mode_switch" -MODE_OPENTHERM = "mode_opentherm" +MODE_SWITCH = "switch" +MODE_OPENTHERM = "opentherm" UNIT_KW = "kW" UNIT_L_MIN = f"L/{TIME_MINUTES}" diff --git a/custom_components/sat/coordinators/__init__.py b/custom_components/sat/coordinator.py similarity index 87% rename from custom_components/sat/coordinators/__init__.py rename to custom_components/sat/coordinator.py index bdc5acb0..e436bd63 100644 --- a/custom_components/sat/coordinators/__init__.py +++ b/custom_components/sat/coordinator.py @@ -1,16 +1,26 @@ +from __future__ import annotations + import logging +import typing +from enum import Enum from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from ..climate import SatClimate -from ..const import * -from ..device import DeviceState -from ..store import SatConfigStore +from .config_store import SatConfigStore +from .const import * + +if typing.TYPE_CHECKING: + from .climate import SatClimate _LOGGER: logging.Logger = logging.getLogger(__name__) +class DeviceState(Enum): + ON = "on" + OFF = "off" + + class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" diff --git a/custom_components/sat/device.py b/custom_components/sat/device.py deleted file mode 100644 index ee2921b4..00000000 --- a/custom_components/sat/device.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import Enum - - -class DeviceState(Enum): - ON = "on" - OFF = "off" diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index 9048730c..980979d1 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -1,65 +1,11 @@ -from homeassistant.components.number import NumberEntity, NumberDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import * -from .entity import SatEntity +from .const import CONF_MODE, MODE_OPENTHERM +from .opentherm import number -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) - - -class SatHotWaterSetpointEntity(SatEntity, NumberEntity): - def __init__(self, coordinator, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - - self._coordinator = coordinator - - @property - def name(self) -> str | None: - return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" - - @property - def device_class(self): - """Return the device class.""" - return NumberDeviceClass.TEMPERATURE - - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-dhw-setpoint" - - @property - def icon(self) -> str | None: - return "mdi:thermometer" - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement in native units.""" - return "°C" - - @property - def native_value(self): - """Return the state of the device in native units.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_DHW_SETPOINT] - - @property - def native_min_value(self) -> float: - """Return the minimum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MIN_SETP] - - @property - def native_max_value(self) -> float: - """Return the maximum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MAX_SETP] - - async def async_set_native_value(self, value: float) -> None: - """Update the setpoint.""" - await self._coordinator.api.set_dhw_setpoint(value) +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + await number.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/opentherm/__init__.py b/custom_components/sat/opentherm/__init__.py new file mode 100644 index 00000000..d32b9037 --- /dev/null +++ b/custom_components/sat/opentherm/__init__.py @@ -0,0 +1,24 @@ +import asyncio +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from pyotgw import OpenThermGateway +from serial import SerialException + +from .coordinator import SatOpenThermCoordinator +from ..const import * + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): + try: + client = OpenThermGateway() + await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) + except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: + raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {exception}") from exception + + store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatOpenThermCoordinator(_hass, store, client) diff --git a/custom_components/sat/opentherm/binary_sensor.py b/custom_components/sat/opentherm/binary_sensor.py new file mode 100644 index 00000000..cebedd66 --- /dev/null +++ b/custom_components/sat/opentherm/binary_sensor.py @@ -0,0 +1,201 @@ +"""Binary Sensor platform for SAT.""" +from __future__ import annotations + +import logging +import typing + +import pyotgw.vars as gw_vars +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT +from homeassistant.components.climate import HVACAction +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id + +from .coordinator import SatOpenThermCoordinator +from ..const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO +from ..entity import SatEntity + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + climate = hass.data[DOMAIN][config_entry.entry_id][CLIMATE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" + + # Create list of devices to be added + sensors = [ + SatControlSetpointSynchroSensor(coordinator, climate, config_entry), + SatCentralHeatingSynchroSensor(coordinator, climate, config_entry), + ] + + # Iterate through sensor information + for key, info in BINARY_SENSOR_INFO.items(): + device_class = info[0] + status_sources = info[2] + friendly_name_format = info[1] + + # Check if the sensor should be added based on its availability and thermostat presence + for source in status_sources: + if source == gw_vars.THERMOSTAT and has_thermostat is False: + continue + + if coordinator.data[source].get(key) is not None: + sensors.append(SatBinarySensor(coordinator, config_entry, key, source, device_class, friendly_name_format)) + + # Add all devices + async_add_entities(sensors) + + +class SatBinarySensor(SatEntity, BinarySensorEntity): + _attr_should_poll = False + + def __init__( + self, + coordinator: SatOpenThermCoordinator, + config_entry: ConfigEntry, + key: str, + source: str, + device_class: str, + friendly_name_format: str + ): + super().__init__(coordinator, config_entry) + + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + ) + + self._key = key + self._source = source + self._coordinator = coordinator + self._device_class = device_class + self._config_entry = config_entry + + if TRANSLATE_SOURCE[source] is not None: + friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + + self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self._friendly_name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + + @property + def is_on(self): + """Return the state of the device.""" + return self._coordinator.data[self._source].get(self._key) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME.lower())}-{self._source}-{self._key}" + + +class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): + + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._coordinator = coordinator + self._climate = climate + + @property + def name(self): + """Return the friendly name of the sensor.""" + return "Control Setpoint Synchro" + + @property + def device_class(self): + """Return the device class.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def available(self): + """Return availability of the sensor.""" + if self._climate is None: + return False + + if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: + return False + + return True + + @property + def is_on(self): + """Return the state of the sensor.""" + boiler_setpoint = float(self._coordinator.data[gw_vars.BOILER].get(gw_vars.DATA_CONTROL_SETPOINT) or 0) + climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or boiler_setpoint) + + return not ( + self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or + round(climate_setpoint, 1) == round(boiler_setpoint, 1) + ) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" + + +class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: + """Initialize the Central Heating Synchro sensor.""" + super().__init__(coordinator, config_entry) + + self._coordinator = coordinator + self._climate = climate + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return "Central Heating Synchro" + + @property + def device_class(self) -> str: + """Return the device class.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def available(self) -> bool: + """Return availability of the sensor.""" + if self._climate is None: + return False + + if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: + return False + + return True + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + boiler = self._coordinator.data[gw_vars.BOILER] + boiler_central_heating = bool(boiler.get(gw_vars.DATA_MASTER_CH_ENABLED)) + climate_hvac_action = self._climate.state_attributes.get("hvac_action") + + return not ( + (climate_hvac_action == HVACAction.OFF and not boiler_central_heating) or + (climate_hvac_action == HVACAction.IDLE and not boiler_central_heating) or + (climate_hvac_action == HVACAction.HEATING and boiler_central_heating) + ) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" diff --git a/custom_components/sat/coordinators/opentherm.py b/custom_components/sat/opentherm/coordinator.py similarity index 96% rename from custom_components/sat/coordinators/opentherm.py rename to custom_components/sat/opentherm/coordinator.py index 81e366ad..0690949d 100644 --- a/custom_components/sat/coordinators/opentherm.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -1,22 +1,21 @@ +from __future__ import annotations + +import typing from typing import Optional, Any -from homeassistant.components.climate import ( - HVACMode, - SERVICE_SET_TEMPERATURE, - DOMAIN as CLIMATE_DOMAIN, -) +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.update_coordinator import UpdateFailed from pyotgw import OpenThermGateway -from . import SatDataUpdateCoordinator -from ..climate import SatClimate +from ..config_store import SatConfigStore from ..const import * -from ..device import DeviceState -from ..overshoot_protection import OvershootProtection -from ..store import SatConfigStore +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +if typing.TYPE_CHECKING: + from ..climate import SatClimate class SatOpenThermCoordinator(SatDataUpdateCoordinator): @@ -98,6 +97,7 @@ async def start_overshoot_protection_calculation(_call: ServiceCall): "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." }) + from .overshoot_protection import OvershootProtection overshoot_protection_value = await OvershootProtection(self).calculate(_call.data.get("solution")) self._overshoot_protection_calculate = False diff --git a/custom_components/sat/opentherm/number.py b/custom_components/sat/opentherm/number.py new file mode 100644 index 00000000..6facb5bf --- /dev/null +++ b/custom_components/sat/opentherm/number.py @@ -0,0 +1,66 @@ +from homeassistant.components.number import NumberEntity, NumberDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .coordinator import SatOpenThermCoordinator +from ..const import * +from ..entity import SatEntity + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) + + +class SatHotWaterSetpointEntity(SatEntity, NumberEntity): + def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._coordinator = coordinator + + @property + def name(self) -> str | None: + return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return NumberDeviceClass.TEMPERATURE + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-dhw-setpoint" + + @property + def icon(self) -> str | None: + return "mdi:thermometer" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def native_value(self): + """Return the state of the device in native units.""" + return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_DHW_SETPOINT] + + @property + def native_min_value(self) -> float: + """Return the minimum accepted temperature.""" + return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MIN_SETP] + + @property + def native_max_value(self) -> float: + """Return the maximum accepted temperature.""" + return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MAX_SETP] + + async def async_set_native_value(self, value: float) -> None: + """Update the setpoint.""" + await self._coordinator.api.set_dhw_setpoint(value) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/opentherm/overshoot_protection.py similarity index 97% rename from custom_components/sat/overshoot_protection.py rename to custom_components/sat/opentherm/overshoot_protection.py index 7e10c119..d753fbcb 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/opentherm/overshoot_protection.py @@ -2,7 +2,8 @@ import logging from collections import deque -from .const import * +from .coordinator import SatOpenThermCoordinator +from ..const import * SOLUTION_AUTOMATIC = "auto" SOLUTION_WITH_MODULATION = "with_modulation" @@ -18,7 +19,7 @@ class OvershootProtection: - def __init__(self, coordinator): + def __init__(self, coordinator: SatOpenThermCoordinator): self._coordinator = coordinator async def calculate(self, solution: str) -> float | None: diff --git a/custom_components/sat/opentherm/sensor.py b/custom_components/sat/opentherm/sensor.py new file mode 100644 index 00000000..7a375a35 --- /dev/null +++ b/custom_components/sat/opentherm/sensor.py @@ -0,0 +1,153 @@ +"""Sensor platform for SAT.""" +import logging + +from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id + +from .coordinator import SatOpenThermCoordinator +from ..const import * +from ..entity import SatEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" + + # Create list of devices to be added + devices = [SatCurrentPowerSensor(coordinator, config_entry)] + + # Iterate through sensor information + for key, info in SENSOR_INFO.items(): + unit = info[1] + device_class = info[0] + status_sources = info[3] + friendly_name_format = info[2] + + # Check if the sensor should be added based on its availability and thermostat presence + for source in status_sources: + if source == gw_vars.THERMOSTAT and has_thermostat is False: + continue + + if coordinator.data[source].get(key) is not None: + devices.append(SatSensor(coordinator, config_entry, key, source, device_class, unit, friendly_name_format)) + + # Add all devices + async_add_entities(devices) + + +class SatSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry, key: str, source: str, device_class: str, unit: str, friendly_name_format: str): + super().__init__(coordinator, config_entry) + + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + ) + + self._key = key + self._unit = unit + self._source = source + self._coordinator = coordinator + self._device_class = device_class + self._config_entry = config_entry + + if TRANSLATE_SOURCE[source] is not None: + friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + + self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) + + @property + def name(self): + """Return the friendly name of the sensor.""" + return self._friendly_name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + + @property + def native_value(self): + """Return the state of the device.""" + value = self._coordinator.data[self._source].get(self._key) + if isinstance(value, float): + value = f"{value:2.1f}" + + return value + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._source}-{self._key}" + + +class SatCurrentPowerSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._coordinator = coordinator + + @property + def name(self) -> str | None: + return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.POWER + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfPower.KILO_WATT + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current capacity of the boiler in kW. + """ + # Get the data of the boiler from the coordinator + boiler = self._coordinator.data[gw_vars.BOILER] + + # If the flame is off, return 0 kW + if bool(boiler.get(gw_vars.DATA_SLAVE_FLAME_ON)) is False: + return 0 + + # Get the relative modulation level from the data + relative_modulation = float(boiler.get(gw_vars.DATA_REL_MOD_LEVEL) or 0) + + # Get the maximum capacity from the data + if (maximum_capacity := float(boiler.get(gw_vars.DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: + return 0 + + # Get and calculate the minimum capacity from the data + minimum_capacity = maximum_capacity / (100 / float(boiler.get(gw_vars.DATA_SLAVE_MIN_MOD_LEVEL))) + + # Calculate and return the current capacity in kW + return minimum_capacity + (((maximum_capacity - minimum_capacity) / 100) * relative_modulation) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index c7bb8202..af65fdbe 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,9 +3,9 @@ from time import monotonic from typing import Optional, Tuple +from .config_store import SatConfigStore from .const import * from .heating_curve import HeatingCurve -from .store import SatConfigStore _LOGGER = logging.getLogger(__name__) @@ -81,18 +81,21 @@ async def update(self, setpoint: float) -> None: _LOGGER.debug("Finished duty cycle.") return - _LOGGER.debug("Cycle time elapsed %.0f seconds", elapsed) + _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) def _calculate_duty_cycle(self, setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset overshoot_protection = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - duty_cycle_percentage = min((setpoint - base_offset) / (overshoot_protection - base_offset), 1) + + duty_cycle_percentage = (setpoint - base_offset) / (overshoot_protection - base_offset) + duty_cycle_percentage = min(duty_cycle_percentage, 1) + duty_cycle_percentage = max(duty_cycle_percentage, 0) _LOGGER.debug("Requested setpoint %.1f", setpoint) _LOGGER.debug("Calculated duty cycle %.0f%%", duty_cycle_percentage * 100) - if not self._automatic_duty_cycle and duty_cycle_percentage >= 0: + if not self._automatic_duty_cycle: return int(duty_cycle_percentage * self._max_cycle_time), int((1 - duty_cycle_percentage) * self._max_cycle_time) if duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 960cf4dd..8ae84629 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,154 +1,11 @@ -"""Sensor platform for SAT.""" -import logging - -from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import async_generate_entity_id - -from .const import * -from .entity import SatEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" - - # Create list of devices to be added - devices = [ - SatCurrentPowerSensor(coordinator, config_entry), - ] - - # Iterate through sensor information - for key, info in SENSOR_INFO.items(): - unit = info[1] - device_class = info[0] - status_sources = info[3] - friendly_name_format = info[2] - - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: - continue - - if coordinator.data[source].get(key) is not None: - devices.append(SatSensor(coordinator, config_entry, key, source, device_class, unit, friendly_name_format)) - - # Add all devices - async_add_entities(devices) - - -class SatSensor(SatEntity, SensorEntity): - def __init__(self, coordinator, config_entry: ConfigEntry, key: str, source: str, device_class: str, unit: str, friendly_name_format: str): - super().__init__(coordinator, config_entry) - - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass - ) - - self._key = key - self._unit = unit - self._source = source - self._coordinator = coordinator - self._device_class = device_class - self._config_entry = config_entry - - if TRANSLATE_SOURCE[source] is not None: - friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" - - self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) - - @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None - - @property - def native_value(self): - """Return the state of the device.""" - value = self._coordinator.data[self._source].get(self._key) - if isinstance(value, float): - value = f"{value:2.1f}" - - return value - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._source}-{self._key}" - - -class SatCurrentPowerSensor(SatEntity, SensorEntity): - - def __init__(self, coordinator, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - - self._coordinator = coordinator - - @property - def name(self) -> str | None: - return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" - - @property - def device_class(self): - """Return the device class.""" - return SensorDeviceClass.POWER - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return UnitOfPower.KILO_WATT - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None - - @property - def native_value(self) -> float: - """Return the state of the device in native units. - - In this case, the state represents the current capacity of the boiler in kW. - """ - # Get the data of the boiler from the coordinator - boiler = self._coordinator.data[gw_vars.BOILER] - - # If the flame is off, return 0 kW - if bool(boiler.get(gw_vars.DATA_SLAVE_FLAME_ON)) is False: - return 0 - - # Get the relative modulation level from the data - relative_modulation = float(boiler.get(gw_vars.DATA_REL_MOD_LEVEL) or 0) - - # Get the maximum capacity from the data - if (maximum_capacity := float(boiler.get(gw_vars.DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: - return 0 +from homeassistant.helpers.entity_platform import AddEntitiesCallback - # Get and calculate the minimum capacity from the data - minimum_capacity = maximum_capacity / (100 / float(boiler.get(gw_vars.DATA_SLAVE_MIN_MOD_LEVEL))) +from .const import CONF_MODE, MODE_OPENTHERM +from .opentherm import sensor - # Calculate and return the current capacity in kW - return minimum_capacity + (((maximum_capacity - minimum_capacity) / 100) * relative_modulation) - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" +async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + await sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py new file mode 100644 index 00000000..f02977d6 --- /dev/null +++ b/custom_components/sat/switch/__init__.py @@ -0,0 +1,16 @@ +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .coordinator import SatSwitchCoordinator +from ..const import * + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): + _LOGGER.debug("Setting up Switch integration") + + store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] + _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatSwitchCoordinator(_hass, store)} diff --git a/custom_components/sat/coordinators/switch.py b/custom_components/sat/switch/coordinator.py similarity index 80% rename from custom_components/sat/coordinators/switch.py rename to custom_components/sat/switch/coordinator.py index a0d872b6..fe759d92 100644 --- a/custom_components/sat/coordinators/switch.py +++ b/custom_components/sat/switch/coordinator.py @@ -1,13 +1,18 @@ +from __future__ import annotations + +import typing + from homeassistant.components.climate import HVACMode from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON, ATTR_ENTITY_ID +from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from . import SatDataUpdateCoordinator -from ..climate import SatClimate +from ..config_store import SatConfigStore from ..const import * -from ..device import DeviceState -from ..store import SatConfigStore +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +if typing.TYPE_CHECKING: + from ..climate import SatClimate class SatSwitchCoordinator(SatDataUpdateCoordinator): From 14257a43d47ebb12a93822e89ca2ea294c17fa5e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 15:21:30 +0200 Subject: [PATCH 004/237] Add support to load the "module" dynamically based on the mode --- custom_components/sat/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 3fff9d64..1c117b17 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -1,5 +1,6 @@ import asyncio import logging +import sys from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant @@ -24,11 +25,8 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() - if _entry.data.get(CONF_MODE) == MODE_SWITCH: - await _hass.async_add_job(switch.async_setup_entry(_hass, _entry)) - - if _entry.data.get(CONF_MODE) == MODE_OPENTHERM: - await _hass.async_add_job(opentherm.async_setup_entry(_hass, _entry)) + module = getattr(sys.modules[__name__], _entry.data.get(CONF_MODE)) + await _hass.async_add_job(module.async_setup_entry, _hass, _entry) await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [ CLIMATE, SENSOR, NUMBER, BINARY_SENSOR From 1516108ef521410d01c3563caf753ae57d685b11 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 15:23:02 +0200 Subject: [PATCH 005/237] Make sure we prefix it --- custom_components/sat/binary_sensor.py | 4 ++-- custom_components/sat/number.py | 4 ++-- custom_components/sat/sensor.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index c4a39da1..da41dde4 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -3,9 +3,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_MODE, MODE_OPENTHERM -from .opentherm import binary_sensor +from .opentherm import binary_sensor as opentherm_binary_sensor async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: - await binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + await opentherm_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index 980979d1..81a5c455 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -3,9 +3,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_MODE, MODE_OPENTHERM -from .opentherm import number +from .opentherm import number as opentherm_number async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: - await number.async_setup_entry(_hass, _config_entry, _async_add_entities) + await opentherm_number.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 8ae84629..de95b7f3 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -3,9 +3,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_MODE, MODE_OPENTHERM -from .opentherm import sensor +from .opentherm import sensor as opentherm_sensor async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: - await sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + await opentherm_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) From 3b34227aa367719e74f30f3c1d9ffb298e3c01c6 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 15:33:21 +0200 Subject: [PATCH 006/237] Cleanup and making sure the ClimateEntity is loaded --- custom_components/sat/__init__.py | 6 +++--- custom_components/sat/climate.py | 1 - custom_components/sat/entity.py | 5 ++++- custom_components/sat/opentherm/binary_sensor.py | 5 ----- custom_components/sat/opentherm/number.py | 2 -- custom_components/sat/opentherm/sensor.py | 3 --- 6 files changed, 7 insertions(+), 15 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 1c117b17..f8ca5016 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -28,9 +28,9 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): module = getattr(sys.modules[__name__], _entry.data.get(CONF_MODE)) await _hass.async_add_job(module.async_setup_entry, _hass, _entry) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [ - CLIMATE, SENSOR, NUMBER, BINARY_SENSOR - ])) + # Make sure the climate is available and loaded before loading the other platforms + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR, NUMBER, BINARY_SENSOR])) _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 46815ed5..cd374b7a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -110,7 +110,6 @@ class SatClimate(SatEntity, ClimateEntity, RestoreEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) - self._coordinator = coordinator self._store = coordinator.store # Create dictionary mapping preset keys to temperature options diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index b6ed072f..d5e2550e 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -1,17 +1,20 @@ """SatEntity class""" import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, NAME, VERSION, CONF_NAME +from .coordinator import SatDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__name__) class SatEntity(CoordinatorEntity): - def __init__(self, coordinator, config_entry): + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): super().__init__(coordinator) + self._coordinator = coordinator self._config_entry = config_entry @property diff --git a/custom_components/sat/opentherm/binary_sensor.py b/custom_components/sat/opentherm/binary_sensor.py index cebedd66..6c1db0f0 100644 --- a/custom_components/sat/opentherm/binary_sensor.py +++ b/custom_components/sat/opentherm/binary_sensor.py @@ -72,7 +72,6 @@ def __init__( self._key = key self._source = source - self._coordinator = coordinator self._device_class = device_class self._config_entry = config_entry @@ -111,8 +110,6 @@ class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) - - self._coordinator = coordinator self._climate = climate @property @@ -157,8 +154,6 @@ class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: """Initialize the Central Heating Synchro sensor.""" super().__init__(coordinator, config_entry) - - self._coordinator = coordinator self._climate = climate @property diff --git a/custom_components/sat/opentherm/number.py b/custom_components/sat/opentherm/number.py index 6facb5bf..c1c95434 100644 --- a/custom_components/sat/opentherm/number.py +++ b/custom_components/sat/opentherm/number.py @@ -16,8 +16,6 @@ class SatHotWaterSetpointEntity(SatEntity, NumberEntity): def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) - self._coordinator = coordinator - @property def name(self) -> str | None: return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" diff --git a/custom_components/sat/opentherm/sensor.py b/custom_components/sat/opentherm/sensor.py index 7a375a35..a573775b 100644 --- a/custom_components/sat/opentherm/sensor.py +++ b/custom_components/sat/opentherm/sensor.py @@ -51,7 +51,6 @@ def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEnt self._key = key self._unit = unit self._source = source - self._coordinator = coordinator self._device_class = device_class self._config_entry = config_entry @@ -100,8 +99,6 @@ class SatCurrentPowerSensor(SatEntity, SensorEntity): def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) - self._coordinator = coordinator - @property def name(self) -> str | None: return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" From 22c7f6b82c79f26216b61fa1898055741c77f28f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:32:06 +0200 Subject: [PATCH 007/237] More cleaning --- custom_components/sat/__init__.py | 40 +++++- custom_components/sat/binary_sensor.py | 6 + custom_components/sat/climate.py | 120 ++++++++++-------- custom_components/sat/const.py | 5 + custom_components/sat/coordinator.py | 33 ++++- custom_components/sat/number.py | 6 + .../sat/opentherm/coordinator.py | 119 +++++------------ custom_components/sat/opentherm/services.py | 82 ++++++++++++ custom_components/sat/sensor.py | 6 + custom_components/sat/switch/coordinator.py | 4 +- 10 files changed, 270 insertions(+), 151 deletions(-) create mode 100644 custom_components/sat/opentherm/services.py diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index f8ca5016..956d893e 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -13,32 +13,53 @@ async def async_setup(_hass: HomeAssistant, __config: Config): - """Set up this integration using YAML is not supported.""" + """ + Set up this integration using YAML is not supported. + + This function is not needed for this integration, but it is required by the Home Assistant framework. + """ return True async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - """Set up this integration using UI.""" + """ + Set up this integration using UI. + + This function is called by Home Assistant when the integration is set up using the UI. + """ + + # Create a new dictionary for this entry if it doesn't exist if _hass.data.get(DOMAIN) is None: _hass.data.setdefault(DOMAIN, {}) + # Create a new config store for this entry and initialize it _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() + # Get the module name from the config entry data and import it dynamically module = getattr(sys.modules[__name__], _entry.data.get(CONF_MODE)) + + # Call the async_setup_entry function of the module await _hass.async_add_job(module.async_setup_entry, _hass, _entry) - # Make sure the climate is available and loaded before loading the other platforms + # Forward entry setup for climate and other platforms await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR, NUMBER, BINARY_SENSOR])) + # Add an update listener for this entry _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) return True async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" + """ + Handle removal of an entry. + + This function is called by Home Assistant when the integration is being removed. + """ + + # Unload the entry and its dependent components unloaded = all( await asyncio.gather( _hass.config_entries.async_forward_entry_unload(_entry, _entry.data.get(CONF_MODE)), @@ -46,6 +67,7 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: ) ) + # Remove the entry from the data dictionary if all components are unloaded successfully if unloaded: _hass.data[DOMAIN].pop(_entry.entry_id) @@ -53,6 +75,14 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: - """Reload config entry.""" + """ + Reload config entry. + + This function is called by Home Assistant when the integration configuration is updated. + """ + + # Unload the entry and its dependent components await async_unload_entry(_hass, _entry) + + # Set up the entry again await async_setup_entry(_hass, _entry) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index da41dde4..47209a4a 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -7,5 +7,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + """ + Add binary sensors for the OpenTherm protocol if the integration is set to use it. + """ + + # Check if integration is set to use the OpenTherm protocol if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + # Call function to set up OpenTherm binary sensors await opentherm_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index cd374b7a..617dcf2e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -185,6 +185,27 @@ 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() + + # Update heating curve if outside temperature is available + if self.current_outside_temperature is not None: + self._heating_curve.update(self.target_temperature, self.current_outside_temperature) + + # Start control loop + await self._async_control_heating_loop() + + # Register services + await self._register_services() + + # Let the coordinator know we are ready + await self._coordinator.async_added_to_hass(self) + + async def _register_event_listeners(self): + """Register event listeners.""" self.async_on_remove( async_track_state_change_event( self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_changed @@ -193,7 +214,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_track_time_interval( - self.hass, self._async_control_heating, timedelta(seconds=30) + self.hass, self._async_control_heating_loop, timedelta(seconds=30) ) ) @@ -222,11 +243,12 @@ async def async_added_to_hass(self) -> None: ) ) - # Check If we have an old state - if (old_state := await self.async_get_last_state()) is not None: - # If we have no initial temperature, restore + async def _restore_previous_state_or_set_defaults(self): + """Restore previous state if available, or set default values.""" + old_state = await self.async_get_last_state() + + if old_state is not None: if self._target_temperature is None: - # If we have a previously saved temperature if old_state.attributes.get(ATTR_TEMPERATURE) is None: self._pid.setpoint = self.min_temp self._target_temperature = self.min_temp @@ -244,41 +266,30 @@ async def async_added_to_hass(self) -> None: if old_state.attributes.get(ATTR_ROOMS): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: - await self._update_room_with_target_temperature() + await self._async_update_rooms_from_climates() else: - # No previous state, try and restore defaults if self._rooms is None: - await self._update_room_with_target_temperature() + await self._async_update_rooms_from_climates() if self._target_temperature is None: self._pid.setpoint = self.min_temp self._target_temperature = self.min_temp _LOGGER.warning("No previously saved temperature, setting to %s", self._target_temperature) - # Set default state to off if not self._hvac_mode: self._hvac_mode = HVACMode.OFF - if self.current_outside_temperature is not None: - self._heating_curve.update( - target_temperature=self.target_temperature, - outside_temperature=self.current_outside_temperature - ) - - self.async_write_ha_state() - await self._async_control_heating() + async def _register_services(self): + async def reset_integral(_call: ServiceCall): + """Service to reset the integral part of the PID controller.""" + self._pid.reset() async def set_overshoot_protection_value(_call: ServiceCall): """Service to set the overshoot protection value.""" self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, _call.data.get("value")) - self.hass.services.async_register(DOMAIN, "overshoot_protection_value", set_overshoot_protection_value) - - async def reset_integral(_call: ServiceCall): - """Service to reset the integral part of the PID controller.""" - self._pid.reset() - - self.hass.services.async_register(DOMAIN, "reset_integral", reset_integral) + self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) + self.hass.services.async_register(DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION, set_overshoot_protection_value) async def track_sensor_temperature(self, entity_id): """ @@ -332,7 +343,7 @@ def extra_state_attributes(self): "heating_curve": self._heating_curve.value, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self._heating_curve.optimal_coefficient, - "pulse_width_modulation_enabled": self._pulse_width_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, "overshoot_protection_calculating": self._overshoot_protection_calculate, @@ -472,7 +483,7 @@ def valves_open(self) -> bool: return False @property - def _pulse_width_modulation_enabled(self) -> bool: + def pulse_width_modulation_enabled(self) -> bool: """Return True if pulse width modulation is enabled, False otherwise. If an overshoot protection value is not set, pulse width modulation is disabled. @@ -495,6 +506,9 @@ def _pulse_width_modulation_enabled(self) -> bool: return False + def _get_requested_setpoint(self): + return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) + def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self._heating_curve.value is None: @@ -510,9 +524,6 @@ def _calculate_control_setpoint(self) -> float: # Ensure setpoint is limited to our max return min(requested_setpoint, self._coordinator.maximum_setpoint) - def _get_requested_setpoint(self): - return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) - async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" new_state = event.data.get("new_state") @@ -524,14 +535,14 @@ async def _async_inside_sensor_changed(self, event: Event) -> None: self.async_write_ha_state() await self._async_control_pid() - await self._async_control_heating() + await self._async_control_heating_loop() async def _async_outside_entity_changed(self, event: Event) -> None: """Handle changes to the outside entity.""" if event.data.get("new_state") is None: return - await self._async_control_heating() + await self._async_control_heating_loop() async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" @@ -542,7 +553,7 @@ async def _async_main_climate_changed(self, event: Event) -> None: if old_state is None or new_state.state != old_state.state: _LOGGER.debug(f"Main Climate State Changed ({new_state.entity_id}).") - await self._async_control_heating() + await self._async_control_heating_loop() async def _async_climate_changed(self, event: Event) -> None: """Handle changes to the climate entity. @@ -588,7 +599,7 @@ async def _async_climate_changed(self, event: Event) -> None: self._rooms[new_state.entity_id] = float(target_temperature) # Update the heating control - await self._async_control_heating() + await self._async_control_heating_loop() async def _async_temperature_change(self, event: Event) -> None: """Handle changes to the climate sensor entity. @@ -601,9 +612,9 @@ async def _async_temperature_change(self, event: Event) -> None: _LOGGER.debug(f"Climate Sensor Changed ({new_state.entity_id}).") await self._async_control_pid(False) - await self._async_control_heating() + await self._async_control_heating_loop() - async def _async_control_heating(self, _time=None) -> None: + async def _async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" # If overshoot protection is active, we are not doing anything since we already have task running in async if self._overshoot_protection_calculate: @@ -614,7 +625,7 @@ async def _async_control_heating(self, _time=None) -> None: return # Control the heating through the coordinator - await self._coordinator.async_control_heating(self) + await self._coordinator.async_control_heating_loop(self) # Pulse Width Modulation if self._overshoot_protection or self._force_pulse_width_modulation: @@ -697,7 +708,7 @@ async def _async_control_pid(self, reset: bool = False): async def _async_control_setpoint(self, pwm_state: PWMState): """Control the setpoint of the heating system.""" if self.hvac_mode == HVACMode.HEAT: - if self._pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: + if self.pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: self._setpoint = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) if pwm_state == pwm_state.ON else MINIMUM_SETPOINT _LOGGER.info(f"Running pulse width modulation cycle: {pwm_state}") else: @@ -710,6 +721,25 @@ async def _async_control_setpoint(self, pwm_state: PWMState): await self._coordinator.async_control_setpoint(self._setpoint) + async def _async_update_rooms_from_climates(self): + """Update the temperature setpoint for each room based on their associated climate entity.""" + self._rooms = {} + + # Iterate through each climate entity + for entity_id in self._climates: + state = self.hass.states.get(entity_id) + + # Skip any entities that are unavailable or have an unknown state + if not state or state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + continue + + # Retrieve the target temperature from the climate entity's attributes + target_temperature = state.attributes.get("temperature") + + # If the target temperature exists, store it in the _rooms dictionary with the climate entity as the key + if target_temperature is not None: + self._rooms[entity_id] = float(target_temperature) + async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -766,7 +796,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: continue target_temperature = self._presets[preset_mode] - if preset_mode in [PRESET_HOME, PRESET_COMFORT]: + if preset_mode == PRESET_HOME or preset_mode == PRESET_COMFORT: target_temperature = self._rooms[entity_id] data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: target_temperature} @@ -792,7 +822,7 @@ async def async_set_target_temperature(self, temperature: float): self.async_write_ha_state() # Control the heating based on the new temperature setpoint - await self._async_control_heating() + await self._async_control_heating_loop() async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the heating/cooling mode for the devices and update the state.""" @@ -820,14 +850,4 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: # Update the state and control the heating self.async_write_ha_state() - await self._async_control_heating() - - async def _update_room_with_target_temperature(self): - self._rooms = {} - for climate in self._climates: - state = self.hass.states.get(climate) - if state is None or state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: - continue - - if target_temperature := state.attributes.get("temperature"): - self._rooms[climate] = float(target_temperature) + await self._async_control_heating_loop() diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 17c778bb..23f554c2 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -119,6 +119,11 @@ # Storage STORAGE_OVERSHOOT_PROTECTION_VALUE = "overshoot_protection_value" +# Services +SERVICE_RESET_INTEGRAL = "reset_integral" +SERVICE_SET_OVERSHOOT_PROTECTION = "overshoot_protection_value" +SERVICE_OVERSHOOT_PROTECTION_CALCULATION = "overshoot_protection_calculation" + # Config steps STEP_SETUP_GATEWAY = "gateway" STEP_SETUP_SENSORS = "sensors" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index e436bd63..248c9ac0 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -4,6 +4,7 @@ import typing from enum import Enum +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -33,18 +34,17 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: @property def store(self): + """Return the configuration store for the integration.""" return self._store - @property - def supports_setpoint_management(self): - return False - @property def device_state(self): + """Return the current state of the device.""" return self._device_state @property def maximum_setpoint(self) -> float: + """Return the maximum setpoint temperature that the device can support.""" if self._heating_system == HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES: return 75.0 @@ -57,16 +57,39 @@ def maximum_setpoint(self) -> float: if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: return 50.0 + @property + def supports_setpoint_management(self): + """Returns whether the device supports setting a setpoint. + + This property is used to determine whether the coordinator can send a setpoint to the device. + If a device doesn't support setpoint management, the coordinator won't be able to control + the temperature or other properties of the device. + + Returns: + A boolean indicating whether the device supports setpoint management. True indicates + that the device supports it, while False indicates that it does not. + """ + return False + async def async_added_to_hass(self, climate: SatClimate) -> None: + """Perform setup when the integration is added to Home Assistant.""" pass - async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: + """Control the heating loop for the device.""" pass async def async_control_setpoint(self, value: float) -> None: + """Control the setpoint temperature for the device.""" if self.supports_setpoint_management: self.logger.info("Set control setpoint to %d", value) 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) + + async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): + """Send a notification to the user.""" + data = {"title": title, "message": message} + await self.hass.services.async_call(NOTIFY_DOMAIN, service, data) diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index 81a5c455..fcbf1d61 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -7,5 +7,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + """ + Add sensors for the OpenTherm protocol if the integration is set to use it. + """ + + # Check if integration is set to use the OpenTherm protocol if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + # Call function to set up OpenTherm numbers await opentherm_number.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py index 0690949d..e74b833f 100644 --- a/custom_components/sat/opentherm/coordinator.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -1,15 +1,15 @@ from __future__ import annotations import typing +from functools import partial from typing import Optional, Any -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed from pyotgw import OpenThermGateway +from .services import start_overshoot_protection_calculation from ..config_store import SatConfigStore from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -31,18 +31,6 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenTherm self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - @property - def supports_setpoint_management(self): - return True - - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. - - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None - async def async_added_to_hass(self, climate: SatClimate) -> None: """Run when entity about to be added.""" await self._async_control_max_setpoint() @@ -50,85 +38,37 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: self._overshoot_protection = False - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled overshoot protection because no overshoot value has been found." - }) + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled overshoot protection because no overshoot value has been found." + ) if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: self._force_pulse_width_modulation = False - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Smart Autotune Thermostat", - "message": "Disabled forced pulse width modulation because no overshoot value has been found." - }) - - async def start_overshoot_protection_calculation(_call: ServiceCall): - """Service to start the overshoot protection calculation process. - - This process will activate overshoot protection by turning on the heater and setting the control setpoint to - a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data - points. If the difference between the current return water temperature and the mean is small, it will - deactivate overshoot protection and store the calculated value. - """ - if self._overshoot_protection_calculate: - self.logger.warning("[Overshoot Protection] Calculation already in progress.") - return - - self._device_state = DeviceState.ON - self._overshoot_protection_calculate = True - - saved_hvac_mode = climate.hvac_mode - saved_target_temperature = climate.target_temperature - - saved_target_temperatures = {} - for entity_id in self._store.options.get(CONF_CLIMATES): - if state := self.hass.states.get(entity_id): - saved_target_temperatures[entity_id] = float(state.attributes.get("temperature")) + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled forced pulse width modulation because no overshoot value has been found." + ) - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + self.hass.services.async_register( + DOMAIN, + SERVICE_OVERSHOOT_PROTECTION_CALCULATION, + partial(start_overshoot_protection_calculation, self, climate) + ) - await climate.async_set_target_temperature(30) - await climate.async_set_hvac_mode(HVACMode.HEAT) - - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." - }) - - from .overshoot_protection import OvershootProtection - overshoot_protection_value = await OvershootProtection(self).calculate(_call.data.get("solution")) - self._overshoot_protection_calculate = False - - await climate.async_set_hvac_mode(saved_hvac_mode) - - await self._async_control_max_setpoint() - await climate.async_set_target_temperature(saved_target_temperature) - - for entity_id in self._store.options.get(CONF_CLIMATES): - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - if overshoot_protection_value is None: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Timed out waiting for stable temperature" - }) - else: - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" - }) - - # Turn the overshoot protection settings back on - self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + @property + def supports_setpoint_management(self): + """Control the setpoint temperature for the device.""" + return True - # Store the new value - self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. - self.hass.services.async_register(DOMAIN, "start_overshoot_protection_calculation", start_overshoot_protection_calculation) + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None async def cleanup(self) -> None: """Cleanup and disconnect.""" @@ -148,9 +88,9 @@ async def _async_update_data(self): async def _async_coroutine(self, data): self.async_set_updated_data(data) - async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: """Control the max relative mod of the heating system.""" - await super().async_control_heating(climate) + await super().async_control_heating_loop(climate) if climate.hvac_mode == HVACMode.OFF and bool(self.get(gw_vars.DATA_MASTER_CH_ENABLED)): await self.async_set_heater_state(DeviceState.OFF) @@ -168,6 +108,7 @@ async def _async_control_max_relative_mod(self, climate: SatClimate, _time=None) self.logger.info("Set max relative mod to %d", max_relative_mod) async def _async_control_max_setpoint(self) -> None: + """Set a maximum temperature limit on the boiler.""" if not self._simulation: await self.api.set_max_ch_setpoint(self.maximum_setpoint) diff --git a/custom_components/sat/opentherm/services.py b/custom_components/sat/opentherm/services.py new file mode 100644 index 00000000..a0774332 --- /dev/null +++ b/custom_components/sat/opentherm/services.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import typing + +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE +from homeassistant.core import ServiceCall + +from ..const import * +from ..coordinator import DeviceState + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + + +async def start_overshoot_protection_calculation(self, climate: SatClimate, call: ServiceCall): + """Service to start the overshoot protection calculation process. + + This process will activate overshoot protection by turning on the heater and setting the control setpoint to + a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data + points. If the difference between the current return water temperature and the mean is small, it will + deactivate overshoot protection and store the calculated value. + """ + if self._overshoot_protection_calculate: + self.logger.warning("[Overshoot Protection] Calculation already in progress.") + return + + self._device_state = DeviceState.ON + self._overshoot_protection_calculate = True + + saved_hvac_mode = climate.hvac_mode + saved_target_temperature = climate.target_temperature + + saved_target_temperatures = {} + for entity_id in self._store.options.get(CONF_CLIMATES): + if state := self.hass.states.get(entity_id): + saved_target_temperatures[entity_id] = float(state.attributes.get(ATTR_TEMPERATURE)) + + data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} + await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + + await climate.async_set_target_temperature(30) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { + "title": "Overshoot Protection Calculation", + "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." + }) + + from .overshoot_protection import OvershootProtection + overshoot_protection_value = await OvershootProtection(self).calculate(call.data.get("solution")) + self._overshoot_protection_calculate = False + + await climate.async_set_hvac_mode(saved_hvac_mode) + + await self._async_control_max_setpoint() + await climate.async_set_target_temperature(saved_target_temperature) + + for entity_id in self._store.options.get(CONF_CLIMATES): + data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} + await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + + if overshoot_protection_value is None: + await self.async_send_notification( + title="Overshoot Protection Calculation", + message=f"Timed out waiting for stable temperature" + ) + + return + + await self.async_send_notification( + title="Overshoot Protection Calculation", + message=f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" + ) + + # Turn the overshoot protection settings back on + self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) + self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + + # Store the new value + self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index de95b7f3..c5cf32cf 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -7,5 +7,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): + """ + Add sensors for the OpenTherm protocol if the integration is set to use it. + """ + + # Check if integration is set to use the OpenTherm protocol if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + # Call function to set up OpenTherm sensors await opentherm_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/switch/coordinator.py b/custom_components/sat/switch/coordinator.py index fe759d92..74997a44 100644 --- a/custom_components/sat/switch/coordinator.py +++ b/custom_components/sat/switch/coordinator.py @@ -23,9 +23,9 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: super().__init__(hass, store) self._entity_id = self._store.options.get(CONF_SWITCH) - async def async_control_heating(self, climate: SatClimate, _time=None) -> None: + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: """Control the max relative mod of the heating system.""" - await super().async_control_heating(climate) + await super().async_control_heating_loop(climate) if climate.hvac_mode == HVACMode.OFF and self.hass.states.get(self._entity_id).state != "OFF": await self.async_set_heater_state(DeviceState.OFF) From 43832f55e4c8324401b85e260121063c294aba78 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:38:00 +0200 Subject: [PATCH 008/237] Fixed the service naming --- custom_components/sat/climate.py | 2 +- custom_components/sat/const.py | 2 +- custom_components/sat/services.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 617dcf2e..d0c99f9b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -289,7 +289,7 @@ async def set_overshoot_protection_value(_call: ServiceCall): self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, _call.data.get("value")) self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) - self.hass.services.async_register(DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION, set_overshoot_protection_value) + self.hass.services.async_register(DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, set_overshoot_protection_value) async def track_sensor_temperature(self, entity_id): """ diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 23f554c2..8bf5e456 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -121,7 +121,7 @@ # Services SERVICE_RESET_INTEGRAL = "reset_integral" -SERVICE_SET_OVERSHOOT_PROTECTION = "overshoot_protection_value" +SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" SERVICE_OVERSHOOT_PROTECTION_CALCULATION = "overshoot_protection_calculation" # Config steps diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index 54e4af74..9affd0be 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -17,7 +17,7 @@ start_overshoot_protection_calculation: - label: "With Zero Modulation" value: "with_zero_modulation" -overshoot_protection_value: +set_overshoot_protection_value: name: Overshoot Protection Value description: "Override the stored overshoot protection value without doing a calculation." fields: From 452df2c657a382c301bca1ee8222ad39951a1827 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:41:21 +0200 Subject: [PATCH 009/237] Moved the "set_overshoot_protection_value" to OpenTherm, since Switch uses the "Setpoint" config --- custom_components/sat/climate.py | 5 ----- custom_components/sat/opentherm/coordinator.py | 8 +++++++- custom_components/sat/opentherm/services.py | 5 +++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index d0c99f9b..5f661394 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -284,12 +284,7 @@ async def reset_integral(_call: ServiceCall): """Service to reset the integral part of the PID controller.""" self._pid.reset() - async def set_overshoot_protection_value(_call: ServiceCall): - """Service to set the overshoot protection value.""" - self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, _call.data.get("value")) - self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) - self.hass.services.async_register(DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, set_overshoot_protection_value) async def track_sensor_temperature(self, entity_id): """ diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py index e74b833f..2a090d07 100644 --- a/custom_components/sat/opentherm/coordinator.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -9,7 +9,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from pyotgw import OpenThermGateway -from .services import start_overshoot_protection_calculation +from .services import start_overshoot_protection_calculation, set_overshoot_protection_value from ..config_store import SatConfigStore from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -57,6 +57,12 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: partial(start_overshoot_protection_calculation, self, climate) ) + self.hass.services.async_register( + DOMAIN, + SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, + partial(set_overshoot_protection_value, self) + ) + @property def supports_setpoint_management(self): """Control the setpoint temperature for the device.""" diff --git a/custom_components/sat/opentherm/services.py b/custom_components/sat/opentherm/services.py index a0774332..5a162dc2 100644 --- a/custom_components/sat/opentherm/services.py +++ b/custom_components/sat/opentherm/services.py @@ -14,6 +14,11 @@ from ..climate import SatClimate +async def set_overshoot_protection_value(self, call: ServiceCall): + """Service to set the overshoot protection value.""" + self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) + + async def start_overshoot_protection_calculation(self, climate: SatClimate, call: ServiceCall): """Service to start the overshoot protection calculation process. From 2756126f35913a2e01ad257d006e987e6227dd77 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:42:51 +0200 Subject: [PATCH 010/237] Naming --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 729d4c25..f4be1d3f 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -5,7 +5,7 @@ "title": "Smart Autotune Thermostat (SAT)", "description": "SAT is a smart thermostat that is capable of auto-tuning itself to optimize temperature control. Select the appropriate mode that matches your heating system.", "menu_options": { - "opentherm": "OpenTherm gateway", + "opentherm": "OpenTherm Gateway ( OTGW )", "switch": "PID Thermostat with PWM ( ON/OFF )" } }, From da54e8cebad7f8f6faf3b7bf443caa2f4c268607 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:43:08 +0200 Subject: [PATCH 011/237] Naming --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index f4be1d3f..2cee1ded 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -10,7 +10,7 @@ } }, "opentherm": { - "title": "OpenTherm gateway", + "title": "OpenTherm Gateway ( OTGW )", "description": "Please fill in the following details to set up the OpenTherm gateway:", "data": { "name": "Name", From 1ecfab3f82405185b9c44866f1dfd51827d2e729 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:43:28 +0200 Subject: [PATCH 012/237] Typo? --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 2cee1ded..380c20be 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -11,7 +11,7 @@ }, "opentherm": { "title": "OpenTherm Gateway ( OTGW )", - "description": "Please fill in the following details to set up the OpenTherm gateway:", + "description": "Please fill in the following details to set up the OpenTherm Gateway:", "data": { "name": "Name", "device": "Path or URL" From 0e6ddea254082fe885e8b1678c3bddf9294b11df Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:45:04 +0200 Subject: [PATCH 013/237] Cleanup --- custom_components/sat/opentherm/services.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/opentherm/services.py b/custom_components/sat/opentherm/services.py index 5a162dc2..f8a97ff3 100644 --- a/custom_components/sat/opentherm/services.py +++ b/custom_components/sat/opentherm/services.py @@ -3,7 +3,6 @@ import typing from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import ServiceCall @@ -48,10 +47,10 @@ async def start_overshoot_protection_calculation(self, climate: SatClimate, call await climate.async_set_target_temperature(30) await climate.async_set_hvac_mode(HVACMode.HEAT) - await self.hass.services.async_call(NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION, { - "title": "Overshoot Protection Calculation", - "message": "Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." - }) + await self.async_send_notification( + title="Overshoot Protection Calculation", + message="Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." + ) from .overshoot_protection import OvershootProtection overshoot_protection_value = await OvershootProtection(self).calculate(call.data.get("solution")) From 2cc9f14fab4326a5e786f680e65e9f397890b4a9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:46:53 +0200 Subject: [PATCH 014/237] Fixed the determination of that the overshoot protection service is running --- custom_components/sat/climate.py | 10 +++++----- custom_components/sat/opentherm/services.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5f661394..04f4f227 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -111,6 +111,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn super().__init__(coordinator, config_entry) self._store = coordinator.store + self.overshoot_protection_calculate = False # Create dictionary mapping preset keys to temperature options conf_presets = {p: f"{p}_temperature" for p in (PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} @@ -154,7 +155,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._hvac_mode = None self._target_temperature = None self._saved_target_temperature = None - self._overshoot_protection_calculate = False self._climates = self._store.options.get(CONF_CLIMATES) self._main_climates = self._store.options.get(CONF_MAIN_CLIMATES) @@ -612,7 +612,7 @@ async def _async_temperature_change(self, event: Event) -> None: async def _async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" # If overshoot protection is active, we are not doing anything since we already have task running in async - if self._overshoot_protection_calculate: + if self.overshoot_protection_calculate: return # If the current, target or outside temperature is not available, do nothing @@ -741,7 +741,7 @@ async def async_set_temperature(self, **kwargs) -> None: return # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: + if self.overshoot_protection_calculate: return # Automatically select the preset @@ -759,7 +759,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: raise ValueError(f"Got unsupported preset_mode {preset_mode}. Must be one of {self.preset_modes}") # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: + if self.overshoot_protection_calculate: return # Return if the given preset mode is already set @@ -822,7 +822,7 @@ async def async_set_target_temperature(self, temperature: float): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the heating/cooling mode for the devices and update the state.""" # Ignore the request when we are in calculation mode - if self._overshoot_protection_calculate: + if self.overshoot_protection_calculate: return # Only allow the hvac mode to be set to heat or off diff --git a/custom_components/sat/opentherm/services.py b/custom_components/sat/opentherm/services.py index f8a97ff3..49a7c42e 100644 --- a/custom_components/sat/opentherm/services.py +++ b/custom_components/sat/opentherm/services.py @@ -26,7 +26,7 @@ async def start_overshoot_protection_calculation(self, climate: SatClimate, call points. If the difference between the current return water temperature and the mean is small, it will deactivate overshoot protection and store the calculated value. """ - if self._overshoot_protection_calculate: + if climate.overshoot_protection_calculate: self.logger.warning("[Overshoot Protection] Calculation already in progress.") return @@ -54,7 +54,7 @@ async def start_overshoot_protection_calculation(self, climate: SatClimate, call from .overshoot_protection import OvershootProtection overshoot_protection_value = await OvershootProtection(self).calculate(call.data.get("solution")) - self._overshoot_protection_calculate = False + climate.overshoot_protection_calculate = False await climate.async_set_hvac_mode(saved_hvac_mode) From 499ccce3dbd550b753faea262690a9d64a0aa98e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:49:04 +0200 Subject: [PATCH 015/237] Revert so we keep our typehint --- custom_components/sat/opentherm/number.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sat/opentherm/number.py b/custom_components/sat/opentherm/number.py index c1c95434..6facb5bf 100644 --- a/custom_components/sat/opentherm/number.py +++ b/custom_components/sat/opentherm/number.py @@ -16,6 +16,8 @@ class SatHotWaterSetpointEntity(SatEntity, NumberEntity): def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): super().__init__(coordinator, config_entry) + self._coordinator = coordinator + @property def name(self) -> str | None: return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" From 169741f55f7a14d198db96827a9f2ce82b494e91 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:49:26 +0200 Subject: [PATCH 016/237] 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 04f4f227..8e4b1840 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -341,7 +341,7 @@ def extra_state_attributes(self): "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, - "overshoot_protection_calculating": self._overshoot_protection_calculate, + "overshoot_protection_calculating": self.overshoot_protection_calculate, "overshoot_protection_value": self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE), } From ac0cecc2a9ec09cf9defda76b1bd638ae7448b97 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 6 May 2023 21:51:37 +0200 Subject: [PATCH 017/237] Make sure we unload all platforms --- custom_components/sat/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 956d893e..42b78472 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -63,7 +63,7 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: unloaded = all( await asyncio.gather( _hass.config_entries.async_forward_entry_unload(_entry, _entry.data.get(CONF_MODE)), - _hass.config_entries.async_forward_entry_unload(_entry, CLIMATE), + _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR, NUMBER, BINARY_SENSOR]), ) ) From a49d038b00d4afd3dc24e6022ab335ce06e3b68c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 7 May 2023 15:35:32 +0200 Subject: [PATCH 018/237] Added support for a minimum setpoint --- custom_components/sat/climate.py | 22 +++++++++---------- custom_components/sat/config_store.py | 8 +++---- custom_components/sat/coordinator.py | 5 +++++ .../sat/opentherm/coordinator.py | 4 ++++ custom_components/sat/pwm.py | 18 ++++++--------- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 8e4b1840..31fea76c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -34,7 +34,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt -from .config_store import SatConfigStore from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity @@ -86,7 +85,7 @@ def create_heating_curve_controller(options) -> HeatingCurve: return HeatingCurve(heating_system=heating_system, coefficient=coefficient) -def create_pwm_controller(heating_curve: HeatingCurve, store: SatConfigStore, options) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options force = bool(options.get(CONF_MODE) == MODE_SWITCH) @@ -94,7 +93,7 @@ def create_pwm_controller(heating_curve: HeatingCurve, store: SatConfigStore, op max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) # Return a new PWM controller instance with the given configuration options - return PWM(store=store, heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) + return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_devices: AddEntitiesCallback): @@ -144,7 +143,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._heating_curve = create_heating_curve_controller(self._store.options) # Create PWM controller with given configuration options - self._pwm = create_pwm_controller(self._heating_curve, self._store, self._store.options) + self._pwm = create_pwm_controller(self._heating_curve, self._store.options) self._sensors = [] self._rooms = None @@ -336,13 +335,13 @@ def extra_state_attributes(self): "warming_up": self._warming_up, "valves_open": self.valves_open, "heating_curve": self._heating_curve.value, + "minimum_setpoint": self._coordinator.minimum_setpoint, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self._heating_curve.optimal_coefficient, "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, "overshoot_protection_calculating": self.overshoot_protection_calculate, - "overshoot_protection_value": self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE), } @property @@ -481,17 +480,17 @@ def valves_open(self) -> bool: def pulse_width_modulation_enabled(self) -> bool: """Return True if pulse width modulation is enabled, False otherwise. - If an overshoot protection value is not set, pulse width modulation is disabled. If we are a coordinator that doesn't support it, it is enabled. + If an overshoot protection value is not set, pulse width modulation is disabled. If pulse width modulation is forced on, it is enabled. If overshoot protection is enabled, and we are below the overshoot protection value. """ - if (overshoot_protection_value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is None: - return False - if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True + if (overshoot_protection_value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is None: + return False + if self._overshoot_protection: if self.max_error <= 0.1: return True @@ -623,8 +622,7 @@ async def _async_control_heating_loop(self, _time=None) -> None: await self._coordinator.async_control_heating_loop(self) # Pulse Width Modulation - if self._overshoot_protection or self._force_pulse_width_modulation: - await self._pwm.update(self._get_requested_setpoint()) + await self._pwm.update(self._get_requested_setpoint(), self._coordinator.minimum_setpoint) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self._pwm.state) @@ -704,7 +702,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState): """Control the setpoint of the heating system.""" if self.hvac_mode == HVACMode.HEAT: if self.pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: - self._setpoint = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) if pwm_state == pwm_state.ON else MINIMUM_SETPOINT + self._setpoint = self._coordinator.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT _LOGGER.info(f"Running pulse width modulation cycle: {pwm_state}") else: self._outputs.append(self._calculate_control_setpoint()) diff --git a/custom_components/sat/config_store.py b/custom_components/sat/config_store.py index d3b206ca..f409b144 100644 --- a/custom_components/sat/config_store.py +++ b/custom_components/sat/config_store.py @@ -10,23 +10,23 @@ class SatConfigStore: _STORAGE_KEY = DOMAIN def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + self._data = {} self._hass = hass - self._data = None self._options = None self._config_entry = config_entry self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) async def async_initialize(self): if (data := await self._store.async_load()) is None: - data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: self._config_entry.data.get(CONF_SETPOINT)} + data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: None} self._data = data self._options = OPTIONS_DEFAULTS.copy() self._options.update(self._config_entry.data) self._options.update(self._config_entry.options) - def get(self, key: str): - return self._data[key] + def get(self, key: str, default=None): + return self._data.get(key, default) def update(self, key: str, value: float): self._data[key] = value diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 248c9ac0..adf7c847 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -27,6 +27,7 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" self._store = store self._device_state = DeviceState.OFF + self._setpoint = float(self._store.options.get(CONF_SETPOINT)) self._simulation = bool(self._store.options.get(CONF_SIMULATION)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) @@ -57,6 +58,10 @@ def maximum_setpoint(self) -> float: if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: return 50.0 + @property + def minimum_setpoint(self) -> float: + return self._setpoint + @property def supports_setpoint_management(self): """Returns whether the device supports setting a setpoint. diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py index 2a090d07..130759cf 100644 --- a/custom_components/sat/opentherm/coordinator.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -63,6 +63,10 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: partial(set_overshoot_protection_value, self) ) + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._setpoint) + @property def supports_setpoint_management(self): """Control the setpoint temperature for the device.""" diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index af65fdbe..b56df71a 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,8 +3,6 @@ from time import monotonic from typing import Optional, Tuple -from .config_store import SatConfigStore -from .const import * from .heating_curve import HeatingCurve _LOGGER = logging.getLogger(__name__) @@ -27,10 +25,9 @@ class PWMState(Enum): class PWM: """A class for implementing Pulse Width Modulation (PWM) control.""" - def __init__(self, store: SatConfigStore, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): + def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._force = force - self._store = store self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time self._automatic_duty_cycle = automatic_duty_cycle @@ -43,7 +40,7 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, setpoint: float) -> None: + async def update(self, requested_setpoint: float, minimum_setpoint: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE @@ -51,14 +48,14 @@ async def update(self, setpoint: float) -> None: _LOGGER.warning("Invalid heating curve value") return - if not self._force and (setpoint is None or setpoint > self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)): + if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): self._state = PWMState.IDLE self._last_update = monotonic() _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value") return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(setpoint) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, minimum_setpoint) if self._duty_cycle is None: self._state = PWMState.IDLE @@ -83,16 +80,15 @@ async def update(self, setpoint: float) -> None: _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, setpoint: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset - overshoot_protection = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - duty_cycle_percentage = (setpoint - base_offset) / (overshoot_protection - base_offset) + duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) duty_cycle_percentage = min(duty_cycle_percentage, 1) duty_cycle_percentage = max(duty_cycle_percentage, 0) - _LOGGER.debug("Requested setpoint %.1f", setpoint) + _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) _LOGGER.debug("Calculated duty cycle %.0f%%", duty_cycle_percentage * 100) if not self._automatic_duty_cycle: From 2bf4bb7183a5d25009132bd5b08b152c88cf4424 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 7 May 2023 18:03:55 +0200 Subject: [PATCH 019/237] Added support for contact sensors --- configuration.yaml | 7 ++- custom_components/sat/climate.py | 57 ++++++++++++++++++++-- custom_components/sat/config_flow.py | 21 +++++++- custom_components/sat/const.py | 6 +++ custom_components/sat/translations/en.json | 17 +++++-- 5 files changed, 97 insertions(+), 11 deletions(-) diff --git a/configuration.yaml b/configuration.yaml index 9cce5856..874310f8 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -25,6 +25,10 @@ switch: entity_id: input_boolean.heater template: + binary_sensor: + name: "Window" + device_class: "window" + state: "{{ is_state('input_boolean.window_sensor', 'on') }}" sensor: - unit_of_measurement: °C name: Heater Temperature @@ -62,4 +66,5 @@ input_number: input_boolean: heater: name: Heater - icon: mdi:heater + window_sensor: + name: Window Sensor diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 31fea76c..3972a379 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -1,6 +1,7 @@ """Climate platform for SAT.""" from __future__ import annotations +import asyncio import logging from collections import deque from datetime import timedelta @@ -13,6 +14,7 @@ ClimateEntityFeature, HVACAction, HVACMode, + PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_NONE, @@ -27,7 +29,7 @@ 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 +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.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval @@ -113,7 +115,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self.overshoot_protection_calculate = False # Create dictionary mapping preset keys to temperature options - conf_presets = {p: f"{p}_temperature" for p in (PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} + conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} # Create dictionary mapping preset keys to temperature values self._presets = {key: self._store.options[value] for key, value in conf_presets.items() if value in self._store.options} @@ -153,10 +155,13 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._hvac_mode = None self._target_temperature = None - self._saved_target_temperature = None + self._window_sensor_handle = None + self._saved_target_temperature_before_custom = None + self._saved_target_temperature_before_activity = None self._climates = self._store.options.get(CONF_CLIMATES) self._main_climates = self._store.options.get(CONF_MAIN_CLIMATES) + self._window_sensor_id = self._store.options.get(CONF_WINDOW_SENSOR) self._simulation = bool(self._store.options.get(CONF_SIMULATION)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) @@ -166,6 +171,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sync_climates_with_preset = bool(self._store.options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) self._sensor_max_value_age = convert_time_str_to_seconds(self._store.options.get(CONF_SENSOR_MAX_VALUE_AGE)) + self._window_minimum_open_time = convert_time_str_to_seconds(self._store.options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() @@ -242,6 +248,13 @@ async def _register_event_listeners(self): ) ) + if self._window_sensor_id is not None: + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._window_sensor_id], self._async_window_sensor_changed + ) + ) + async def _restore_previous_state_or_set_defaults(self): """Restore previous state if available, or set default values.""" old_state = await self.async_get_last_state() @@ -608,6 +621,40 @@ async def _async_temperature_change(self, event: Event) -> None: await self._async_control_pid(False) await self._async_control_heating_loop() + async def _async_window_sensor_changed(self, event: Event) -> None: + """Handle changes to the contact sensor entity.""" + new_state = event.data.get("new_state") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + _LOGGER.debug(f"Window Sensor Changed to {new_state.state} ({new_state.entity_id}).") + + if new_state.state == STATE_ON: + if self.preset_mode == PRESET_ACTIVITY: + return + + try: + self._window_sensor_handle = asyncio.create_task(asyncio.sleep(self._window_minimum_open_time)) + self._saved_target_temperature_before_activity = self.target_temperature + + await self._window_sensor_handle + await self.async_set_preset_mode(PRESET_ACTIVITY) + except asyncio.CancelledError: + _LOGGER.debug("Window closed before minimum open time.") + + return + + if new_state.state == STATE_OFF: + if self._window_sensor_handle is not None: + self._window_sensor_handle.cancel() + self._window_sensor_handle = None + + if self.preset_mode == PRESET_ACTIVITY: + _LOGGER.debug(f"Restoring original target temperature.") + await self.async_set_temperature(temperature=self._saved_target_temperature_before_activity) + + return + async def _async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" # If overshoot protection is active, we are not doing anything since we already have task running in async @@ -767,7 +814,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Reset the preset mode if `PRESET_NONE` is given if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE - await self.async_set_target_temperature(self._saved_target_temperature) + await self.async_set_target_temperature(self._saved_target_temperature_before_custom) else: # Set the HVAC mode to `HEAT` if it is currently `OFF` if self.hvac_mode == HVACMode.OFF: @@ -775,7 +822,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Save the current target temperature if the preset mode is being set for the first time if self._attr_preset_mode == PRESET_NONE: - self._saved_target_temperature = self._target_temperature + self._saved_target_temperature_before_custom = self._target_temperature # Set the preset mode and target temperature self._attr_preset_mode = preset_mode diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 8d2d5cb8..331fb26b 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -139,7 +140,7 @@ async def async_step_init(self, _user_input=None): return await self.async_step_user(_user_input) async def async_step_user(self, _user_input=None) -> FlowResult: - menu_options = ["general", "presets", "climates"] + menu_options = ["general", "presets", "climates", "contact_sensors"] if self.show_advanced_options: menu_options.append("advanced") @@ -197,6 +198,9 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: return self.async_show_form( step_id="presets", data_schema=vol.Schema({ + vol.Required(CONF_ACTIVITY_TEMPERATURE, default=defaults[CONF_ACTIVITY_TEMPERATURE]): selector.NumberSelector( + selector.NumberSelectorConfig(min=5, max=35, step=0.5) + ), vol.Required(CONF_AWAY_TEMPERATURE, default=defaults[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5) ), @@ -236,6 +240,20 @@ async def async_step_climates(self, _user_input=None) -> FlowResult: }) ) + async def async_step_contact_sensors(self, _user_input=None) -> FlowResult: + if _user_input is not None: + return await self.update_options(_user_input) + + defaults = await self.get_options() + return self.async_show_form( + step_id="contact_sensors", + data_schema=vol.Schema({ + vol.Optional(CONF_WINDOW_SENSOR, default=defaults[CONF_WINDOW_SENSOR]): selector.EntitySelector( + selector.EntitySelectorConfig(domain=BINARY_SENSOR_DOMAIN, device_class=BinarySensorDeviceClass.WINDOW) + ), + }) + ) + async def async_step_advanced(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) @@ -254,6 +272,7 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: schema[vol.Required(CONF_SAMPLE_TIME, default=defaults.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=defaults.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() + schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=defaults.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=defaults[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( selector.NumberSelectorConfig(min=-1, max=1, step=0.1) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 8bf5e456..9dca02ee 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -54,6 +54,8 @@ CONF_SETPOINT = "setpoint" CONF_CLIMATES = "climates" CONF_MAIN_CLIMATES = "main_climates" +CONF_WINDOW_SENSOR = "window_sensor" +CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" CONF_SIMULATION = "simulation" CONF_INTEGRAL = "integral" CONF_DERIVATIVE = "derivative" @@ -78,6 +80,7 @@ CONF_HOME_TEMPERATURE = "home_temperature" CONF_SLEEP_TEMPERATURE = "sleep_temperature" CONF_COMFORT_TEMPERATURE = "comfort_temperature" +CONF_ACTIVITY_TEMPERATURE = "activity_temperature" HEATING_SYSTEM_UNDERFLOOR = "underfloor" HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES = "radiator_low_temperatures" @@ -93,6 +96,7 @@ CONF_CLIMATES: [], CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, + CONF_WINDOW_SENSOR: None, CONF_AUTOMATIC_GAINS: False, CONF_AUTOMATIC_DUTY_CYCLE: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, @@ -106,7 +110,9 @@ CONF_CLIMATE_VALVE_OFFSET: 0, CONF_TARGET_TEMPERATURE_STEP: 0.5, CONF_SENSOR_MAX_VALUE_AGE: "06:00:00", + CONF_WINDOW_MINIMUM_OPEN_TIME: "00:00:15", + CONF_ACTIVITY_TEMPERATURE: 10, CONF_AWAY_TEMPERATURE: 10, CONF_HOME_TEMPERATURE: 18, CONF_SLEEP_TEMPERATURE: 15, diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 380c20be..29812f1d 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -47,6 +47,7 @@ "general": "General", "presets": "Presets", "climates": "Climates", + "contact_sensors": "Contact Sensors", "advanced": "Advanced" } }, @@ -70,6 +71,7 @@ "home_temperature": "Home Temperature", "sleep_temperature": "Sleep Temperature", "comfort_temperature": "Comfort Temperature", + "activity_temperature": "Activity Temperature", "sync_climates_with_preset": "Synchronize climates with preset (sleep / away)" } }, @@ -80,18 +82,25 @@ "main_climates": "Climates (main)" } }, + "contact_sensors": { + "title": "Contact Sensors", + "data": { + "window_sensor": "Window" + } + }, "advanced": { "title": "Advanced", "data": { "simulation": "Simulation", - "sample_time": "Sample Time", - "climate_valve_offset": "Climate valve offset", - "sensor_max_value_age": "Sensor max. value age", "automatic_gains": "Automatic gains", + "climate_valve_offset": "Climate valve offset", "overshoot_protection": "Overshoot Protection (with PWM)", "automatic_duty_cycle": "Automatic duty cycle (experimental)", "force_pulse_width_modulation": "Force Pulse Width Modulation", - "min_num_updates": "Minimum number of updates required for auto-tuning" + "min_num_updates": "Minimum number of updates required for auto-tuning", + "sample_time": "Minimum time interval between updates to the PID controller", + "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)", + "contact_sensor_minimum_open_time": "Minimum time for contact sensors to be open before reacting" } } } From 1f7525654641edc23bc5eea58ee529f02c65559c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 7 May 2023 18:08:45 +0200 Subject: [PATCH 020/237] Make sure we also include ACTIVITY --- custom_components/sat/translations/en.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 29812f1d..e7d2d659 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -72,7 +72,7 @@ "sleep_temperature": "Sleep Temperature", "comfort_temperature": "Comfort Temperature", "activity_temperature": "Activity Temperature", - "sync_climates_with_preset": "Synchronize climates with preset (sleep / away)" + "sync_climates_with_preset": "Synchronize climates with preset (sleep / away / activity)" } }, "climates": { @@ -99,8 +99,8 @@ "force_pulse_width_modulation": "Force Pulse Width Modulation", "min_num_updates": "Minimum number of updates required for auto-tuning", "sample_time": "Minimum time interval between updates to the PID controller", - "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)", - "contact_sensor_minimum_open_time": "Minimum time for contact sensors to be open before reacting" + "window_minimum_open_time": "Minimum time for window to be open before reacting", + "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)" } } } From 8dc5519684605c9f0e479d69fc2c2d4e730de5a0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 7 May 2023 18:28:37 +0200 Subject: [PATCH 021/237] Add the open window detection to the feature list --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cc589a9c..3ba1ce4c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant tha - Overshoot protection to prevent the boiler from overshooting the setpoint - Climate valve offset to adjust the temperature reading for your climate valve - Sample time for PID control to fine-tune your system's response time +- Open Window detection - Control DHW setpoint ## Installation From 4d28315dff0696c5f19ce2f6c069c13818dace7b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 8 May 2023 18:20:11 +0200 Subject: [PATCH 022/237] Fixed an issue with setting the correct maximum relative modulation value --- custom_components/sat/opentherm/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py index 130759cf..1fbf4fa2 100644 --- a/custom_components/sat/opentherm/coordinator.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -155,7 +155,7 @@ def _calculate_max_relative_mod(self, climate: SatClimate) -> int: """ setpoint = float(self.get(gw_vars.DATA_CONTROL_SETPOINT)) - if climate.hvac_mode == HVACMode.HEAT or bool(self.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: + if climate.hvac_mode != HVACMode.HEAT or bool(self.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: return MAXIMUM_RELATIVE_MOD if self._overshoot_protection and not self._force_pulse_width_modulation: From c643e4f656b31fac541f2215b1e8cb07a03d4636 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 9 May 2023 19:13:11 +0200 Subject: [PATCH 023/237] Cleanup, add DOOR and GARAGE to contact sensors and fixed mode detection --- custom_components/sat/__init__.py | 6 +- custom_components/sat/config_flow.py | 6 +- custom_components/sat/config_store.py | 2 +- custom_components/sat/const.py | 542 +----------------- .../sat/opentherm/binary_sensor.py | 75 ++- .../sat/opentherm/coordinator.py | 10 +- custom_components/sat/opentherm/number.py | 8 +- .../sat/opentherm/overshoot_protection.py | 6 +- custom_components/sat/opentherm/sensor.py | 96 +++- 9 files changed, 165 insertions(+), 586 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 42b78472..7b047bd2 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -36,8 +36,12 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() + # Retrieve the defaults and override it with the user options + options = OPTIONS_DEFAULTS.copy() + options.update(_entry.data) + # Get the module name from the config entry data and import it dynamically - module = getattr(sys.modules[__name__], _entry.data.get(CONF_MODE)) + module = getattr(sys.modules[__name__], options.get(CONF_MODE)) # Call the async_setup_entry function of the module await _hass.async_add_job(module.async_setup_entry, _hass, _entry) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 331fb26b..e17b9b78 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -4,7 +4,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -245,11 +245,13 @@ async def async_step_contact_sensors(self, _user_input=None) -> FlowResult: return await self.update_options(_user_input) defaults = await self.get_options() + device_class = [BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.GARAGE_DOOR] + return self.async_show_form( step_id="contact_sensors", data_schema=vol.Schema({ vol.Optional(CONF_WINDOW_SENSOR, default=defaults[CONF_WINDOW_SENSOR]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=BINARY_SENSOR_DOMAIN, device_class=BinarySensorDeviceClass.WINDOW) + selector.EntitySelectorConfig(domain=BINARY_SENSOR_DOMAIN, device_class=device_class) ), }) ) diff --git a/custom_components/sat/config_store.py b/custom_components/sat/config_store.py index f409b144..1217f251 100644 --- a/custom_components/sat/config_store.py +++ b/custom_components/sat/config_store.py @@ -26,7 +26,7 @@ async def async_initialize(self): self._options.update(self._config_entry.options) def get(self, key: str, default=None): - return self._data.get(key, default) + return self._data.get(key) or default def update(self, key: str, value: float): self._data[key] = value diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 9dca02ee..ccd10c75 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,29 +1,15 @@ -import pyotgw.vars as gw_vars -from homeassistant.backports.enum import StrEnum -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import ( - UnitOfTemperature, - UnitOfPressure, - UnitOfVolume, - UnitOfPower, - TIME_MINUTES, - PERCENTAGE -) +from pyotgw.vars import * # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "0.0.1" +VERSION = "2.0.2" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" MODE_SWITCH = "switch" MODE_OPENTHERM = "opentherm" -UNIT_KW = "kW" -UNIT_L_MIN = f"L/{TIME_MINUTES}" - HOT_TOLERANCE = 0.3 COLD_TOLERANCE = 0.1 MINIMUM_SETPOINT = 10 @@ -139,525 +125,7 @@ # Sensors TRANSLATE_SOURCE = { - gw_vars.OTGW: None, - gw_vars.BOILER: "Boiler", - gw_vars.THERMOSTAT: "Thermostat", -} - - -# Time units -class UnitOfTime(StrEnum): - """Time units.""" - - MICROSECONDS = "μs" - MILLISECONDS = "ms" - SECONDS = "s" - MINUTES = "min" - HOURS = "h" - DAYS = "d" - WEEKS = "w" - MONTHS = "m" - YEARS = "y" - - -BINARY_SENSOR_INFO: dict[str, list] = { - # [device_class, friendly_name format, [status source, ...]] - gw_vars.DATA_MASTER_CH_ENABLED: [ - None, - "Thermostat Central Heating {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_DHW_ENABLED: [ - None, - "Thermostat Hot Water {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_COOLING_ENABLED: [ - None, - "Thermostat Cooling {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_OTC_ENABLED: [ - None, - "Thermostat Outside Temperature Correction {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_CH2_ENABLED: [ - None, - "Thermostat Central Heating 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_FAULT_IND: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Central Heating {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Hot Water {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_FLAME_ON: [ - BinarySensorDeviceClass.HEAT, - "Boiler Flame {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_COOLING_ACTIVE: [ - BinarySensorDeviceClass.COLD, - "Boiler Cooling {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH2_ACTIVE: [ - BinarySensorDeviceClass.HEAT, - "Boiler Central Heating 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DIAG_IND: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Diagnostics {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_PRESENT: [ - None, - "Boiler Hot Water Present {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CONTROL_TYPE: [ - None, - "Boiler Control Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_COOLING_SUPPORTED: [ - None, - "Boiler Cooling Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_CONFIG: [ - None, - "Boiler Hot Water Configuration {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: [ - None, - "Boiler Pump Commands Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH2_PRESENT: [ - None, - "Boiler Central Heating 2 Present {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_SERVICE_REQ: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Service Required {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_REMOTE_RESET: [ - None, - "Boiler Remote Reset Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_LOW_WATER_PRESS: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Low Water Pressure {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_GAS_FAULT: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Gas Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Air Pressure Fault {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_WATER_OVERTEMP: [ - BinarySensorDeviceClass.PROBLEM, - "Boiler Water Over-temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_TRANSFER_DHW: [ - None, - "Remote Hot Water Setpoint Transfer Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: [ - None, - "Remote Maximum Central Heating Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_RW_DHW: [ - None, - "Remote Hot Water Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REMOTE_RW_MAX_CH: [ - None, - "Remote Central Heating Setpoint Write Support {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROVRD_MAN_PRIO: [ - None, - "Remote Override Manual Change Priority {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROVRD_AUTO_PRIO: [ - None, - "Remote Override Program Change Priority {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.OTGW_GPIO_A_STATE: [ - None, - "Gateway GPIO A {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_GPIO_B_STATE: [ - None, - "Gateway GPIO B {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_IGNORE_TRANSITIONS: [ - None, - "Gateway Ignore Transitions {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_OVRD_HB: [ - None, - "Gateway Override High Byte {}", - [gw_vars.OTGW] - ], -} - -SENSOR_INFO: dict[str, list] = { - # [device_class, unit, friendly_name, [status source, ...]] - gw_vars.DATA_CONTROL_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Control Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_MEMBERID: [ - None, - None, - "Thermostat Member ID {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MEMBERID: [ - None, - None, - "Boiler Member ID {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_OEM_FAULT: [ - None, - None, - "Boiler OEM Fault Code {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_COOLING_CONTROL: [ - None, - PERCENTAGE, - "Cooling Control Signal {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CONTROL_SETPOINT_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Control Setpoint 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT_OVRD: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint Override {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ - None, - PERCENTAGE, - "Boiler Maximum Relative Modulation {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MAX_CAPACITY: [ - SensorDeviceClass.POWER, - UnitOfPower.KILO_WATT, - "Boiler Maximum Capacity {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ - None, - PERCENTAGE, - "Boiler Minimum Modulation Level {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_REL_MOD_LEVEL: [ - None, - PERCENTAGE, - "Relative Modulation Level {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_PRESS: [ - SensorDeviceClass.PRESSURE, - UnitOfPressure.BAR, - "Central Heating Water Pressure {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_FLOW_RATE: [ - None, - f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", - "Hot Water Flow Rate {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_SETPOINT_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Setpoint 2 {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_ROOM_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Room Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Central Heating Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_OUTSIDE_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Outside Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_RETURN_WATER_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Return Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SOLAR_STORAGE_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Solar Storage Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SOLAR_COLL_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Solar Collector Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_WATER_TEMP_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Central Heating 2 Water Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_TEMP_2: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water 2 Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_EXHAUST_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Exhaust Temperature {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_MAX_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Maximum Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_DHW_MIN_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Hot Water Minimum Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_MAX_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Boiler Maximum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_CH_MIN_SETP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Boiler Minimum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MAX_CH_SETPOINT: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Maximum Central Heating Setpoint {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_OEM_DIAG: [ - None, - None, - "OEM Diagnostic Code {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_TOTAL_BURNER_STARTS: [ - None, - None, - "Total Burner Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_PUMP_STARTS: [ - None, - None, - "Central Heating Pump Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_PUMP_STARTS: [ - None, - None, - "Hot Water Pump Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_BURNER_STARTS: [ - None, - None, - "Hot Water Burner Starts {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_TOTAL_BURNER_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Total Burner Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_CH_PUMP_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Central Heating Pump Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_PUMP_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Hot Water Pump Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_DHW_BURNER_HOURS: [ - SensorDeviceClass.DURATION, - UnitOfTime.HOURS, - "Hot Water Burner Hours {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_OT_VERSION: [ - None, - None, - "Thermostat OpenTherm Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_OT_VERSION: [ - None, - None, - "Boiler OpenTherm Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_PRODUCT_TYPE: [ - None, - None, - "Thermostat Product Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_MASTER_PRODUCT_VERSION: [ - None, - None, - "Thermostat Product Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_PRODUCT_TYPE: [ - None, - None, - "Boiler Product Type {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.DATA_SLAVE_PRODUCT_VERSION: [ - None, - None, - "Boiler Product Version {}", - [gw_vars.BOILER, gw_vars.THERMOSTAT], - ], - gw_vars.OTGW_MODE: [ - None, - None, - "Gateway/Monitor Mode {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_DHW_OVRD: [ - None, - None, - "Gateway Hot Water Override Mode {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_ABOUT: [ - None, - None, - "Gateway Firmware Version {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_BUILD: [ - None, - None, - "Gateway Firmware Build {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_SB_TEMP: [ - SensorDeviceClass.TEMPERATURE, - UnitOfTemperature.CELSIUS, - "Gateway Setback Temperature {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_SETP_OVRD_MODE: [ - None, - None, - "Gateway Room Setpoint Override Mode {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_SMART_PWR: [ - None, - None, - "Gateway Smart Power Mode {}", - [gw_vars.OTGW] - ], - gw_vars.OTGW_THRM_DETECT: [ - None, - None, - "Gateway Thermostat Detection {}", - [gw_vars.OTGW], - ], - gw_vars.OTGW_VREF: [ - None, - None, - "Gateway Reference Voltage Setting {}", - [gw_vars.OTGW], - ], + OTGW: None, + BOILER: "Boiler", + THERMOSTAT: "Thermostat", } diff --git a/custom_components/sat/opentherm/binary_sensor.py b/custom_components/sat/opentherm/binary_sensor.py index 6c1db0f0..71d23a18 100644 --- a/custom_components/sat/opentherm/binary_sensor.py +++ b/custom_components/sat/opentherm/binary_sensor.py @@ -4,16 +4,14 @@ import logging import typing -import pyotgw.vars as gw_vars -from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass, ENTITY_ID_FORMAT from homeassistant.components.climate import HVACAction from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id from .coordinator import SatOpenThermCoordinator -from ..const import DOMAIN, COORDINATOR, CLIMATE, TRANSLATE_SOURCE, CONF_NAME, BINARY_SENSOR_INFO +from ..const import * from ..entity import SatEntity if typing.TYPE_CHECKING: @@ -26,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn """Setup sensor platform.""" climate = hass.data[DOMAIN][config_entry.entry_id][CLIMATE] coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" + has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" # Create list of devices to be added sensors = [ @@ -36,17 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn # Iterate through sensor information for key, info in BINARY_SENSOR_INFO.items(): - device_class = info[0] - status_sources = info[2] - friendly_name_format = info[1] - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: + for source in info.status_sources: + if source == THERMOSTAT and has_thermostat is False: continue if coordinator.data[source].get(key) is not None: - sensors.append(SatBinarySensor(coordinator, config_entry, key, source, device_class, friendly_name_format)) + sensors.append(SatBinarySensor(coordinator, config_entry, key, source, info.device_class, info.friendly_name_format)) # Add all devices async_add_entities(sensors) @@ -128,7 +122,7 @@ def available(self): if self._climate is None: return False - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: + if self._coordinator.data is None or self._coordinator.data[BOILER] is None: return False return True @@ -136,7 +130,7 @@ def available(self): @property def is_on(self): """Return the state of the sensor.""" - boiler_setpoint = float(self._coordinator.data[gw_vars.BOILER].get(gw_vars.DATA_CONTROL_SETPOINT) or 0) + boiler_setpoint = float(self._coordinator.data[BOILER].get(DATA_CONTROL_SETPOINT) or 0) climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or boiler_setpoint) return not ( @@ -172,7 +166,7 @@ def available(self) -> bool: if self._climate is None: return False - if self._coordinator.data is None or self._coordinator.data[gw_vars.BOILER] is None: + if self._coordinator.data is None or self._coordinator.data[BOILER] is None: return False return True @@ -180,8 +174,8 @@ def available(self) -> bool: @property def is_on(self) -> bool: """Return the state of the sensor.""" - boiler = self._coordinator.data[gw_vars.BOILER] - boiler_central_heating = bool(boiler.get(gw_vars.DATA_MASTER_CH_ENABLED)) + boiler = self._coordinator.data[BOILER] + boiler_central_heating = bool(boiler.get(DATA_MASTER_CH_ENABLED)) climate_hvac_action = self._climate.state_attributes.get("hvac_action") return not ( @@ -194,3 +188,50 @@ def is_on(self) -> bool: def unique_id(self) -> str: """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" + + +class SatBinarySensorInfo: + def __init__(self, device_class: typing.Optional[str], friendly_name_format: str, status_sources: typing.List[str]): + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +BINARY_SENSOR_INFO: dict[str, SatBinarySensorInfo] = { + DATA_MASTER_CH_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating {}", [BOILER, THERMOSTAT]), + DATA_MASTER_DHW_ENABLED: SatBinarySensorInfo(None, "Thermostat Hot Water {}", [BOILER, THERMOSTAT]), + DATA_MASTER_COOLING_ENABLED: SatBinarySensorInfo(None, "Thermostat Cooling {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OTC_ENABLED: SatBinarySensorInfo(None, "Thermostat Outside Temperature Correction {}", [BOILER, THERMOSTAT]), + DATA_MASTER_CH2_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating 2 {}", [BOILER, THERMOSTAT]), + + DATA_SLAVE_FAULT_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Hot Water {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_FLAME_ON: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Flame {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.COLD, "Boiler Cooling {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating 2 {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DIAG_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Diagnostics {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_PRESENT: SatBinarySensorInfo(None, "Boiler Hot Water Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CONTROL_TYPE: SatBinarySensorInfo(None, "Boiler Control Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_SUPPORTED: SatBinarySensorInfo(None, "Boiler Cooling Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_CONFIG: SatBinarySensorInfo(None, "Boiler Hot Water Configuration {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MASTER_LOW_OFF_PUMP: SatBinarySensorInfo(None, "Boiler Pump Commands Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_PRESENT: SatBinarySensorInfo(None, "Boiler Central Heating 2 Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_SERVICE_REQ: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Service Required {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_REMOTE_RESET: SatBinarySensorInfo(None, "Boiler Remote Reset Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_LOW_WATER_PRESS: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Low Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_GAS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Gas Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_AIR_PRESS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Air Pressure Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_WATER_OVERTEMP: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Water Over-temperature {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Transfer Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_MAX_CH: SatBinarySensorInfo(None, "Remote Maximum Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_MAX_CH: SatBinarySensorInfo(None, "Remote Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_MAN_PRIO: SatBinarySensorInfo(None, "Remote Override Manual Change Priority {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_AUTO_PRIO: SatBinarySensorInfo(None, "Remote Override Program Change Priority {}", [BOILER, THERMOSTAT]), + + OTGW_GPIO_A_STATE: SatBinarySensorInfo(None, "Gateway GPIO A {}", [OTGW]), + OTGW_GPIO_B_STATE: SatBinarySensorInfo(None, "Gateway GPIO B {}", [OTGW]), + OTGW_IGNORE_TRANSITIONS: SatBinarySensorInfo(None, "Gateway Ignore Transitions {}", [OTGW]), + OTGW_OVRD_HB: SatBinarySensorInfo(None, "Gateway Override High Byte {}", [OTGW]), +} diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py index 1fbf4fa2..8565a55c 100644 --- a/custom_components/sat/opentherm/coordinator.py +++ b/custom_components/sat/opentherm/coordinator.py @@ -78,7 +78,7 @@ def get(self, key: str) -> Optional[Any]: :param key: Key of the value to retrieve from the boiler data. :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. """ - return self.data[gw_vars.BOILER].get(key) if self.data[gw_vars.BOILER] else None + return self.data[BOILER].get(key) if self.data[BOILER] else None async def cleanup(self) -> None: """Cleanup and disconnect.""" @@ -102,14 +102,14 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N """Control the max relative mod of the heating system.""" await super().async_control_heating_loop(climate) - if climate.hvac_mode == HVACMode.OFF and bool(self.get(gw_vars.DATA_MASTER_CH_ENABLED)): + if climate.hvac_mode == HVACMode.OFF and bool(self.get(DATA_MASTER_CH_ENABLED)): await self.async_set_heater_state(DeviceState.OFF) await self._async_control_max_relative_mod(climate) async def _async_control_max_relative_mod(self, climate: SatClimate, _time=None) -> None: max_relative_mod = self._calculate_max_relative_mod(climate) - if float(self.get(gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD)) == max_relative_mod: + if float(self.get(DATA_SLAVE_MAX_RELATIVE_MOD)) == max_relative_mod: return if not self._simulation: @@ -153,9 +153,9 @@ def _calculate_max_relative_mod(self, climate: SatClimate) -> int: Returns: An integer representing the maximum relative modulation for the heating system. """ - setpoint = float(self.get(gw_vars.DATA_CONTROL_SETPOINT)) + setpoint = float(self.get(DATA_CONTROL_SETPOINT)) - if climate.hvac_mode != HVACMode.HEAT or bool(self.get(gw_vars.DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: + if climate.hvac_mode != HVACMode.HEAT or bool(self.get(DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: return MAXIMUM_RELATIVE_MOD if self._overshoot_protection and not self._force_pulse_width_modulation: diff --git a/custom_components/sat/opentherm/number.py b/custom_components/sat/opentherm/number.py index 6facb5bf..2faa8eb7 100644 --- a/custom_components/sat/opentherm/number.py +++ b/custom_components/sat/opentherm/number.py @@ -39,7 +39,7 @@ def icon(self) -> str | None: @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + return self._coordinator.data is not None and self._coordinator.data[BOILER] is not None @property def native_unit_of_measurement(self): @@ -49,17 +49,17 @@ def native_unit_of_measurement(self): @property def native_value(self): """Return the state of the device in native units.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_DHW_SETPOINT] + return self._coordinator.data[BOILER][DATA_DHW_SETPOINT] @property def native_min_value(self) -> float: """Return the minimum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MIN_SETP] + return self._coordinator.data[BOILER][DATA_SLAVE_DHW_MIN_SETP] @property def native_max_value(self) -> float: """Return the maximum accepted temperature.""" - return self._coordinator.data[gw_vars.BOILER][gw_vars.DATA_SLAVE_DHW_MAX_SETP] + return self._coordinator.data[BOILER][DATA_SLAVE_DHW_MAX_SETP] async def async_set_native_value(self, value: float) -> None: """Update the setpoint.""" diff --git a/custom_components/sat/opentherm/overshoot_protection.py b/custom_components/sat/opentherm/overshoot_protection.py index d753fbcb..8450a4e3 100644 --- a/custom_components/sat/opentherm/overshoot_protection.py +++ b/custom_components/sat/opentherm/overshoot_protection.py @@ -38,7 +38,7 @@ async def calculate(self, solution: str) -> float | None: await asyncio.sleep(OVERSHOOT_PROTECTION_INITIAL_WAIT) # Check if relative modulation is still zero - if float(self._coordinator.get(gw_vars.DATA_REL_MOD_LEVEL)) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: + if float(self._coordinator.get(DATA_REL_MOD_LEVEL)) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: return await start_with_zero_modulation_task else: start_with_zero_modulation_task.cancel() @@ -77,7 +77,7 @@ async def _calculate_with_modulation(self) -> float: async def _wait_for_flame(self): while True: - if bool(self._coordinator.get(gw_vars.DATA_SLAVE_FLAME_ON)): + if bool(self._coordinator.get(DATA_SLAVE_FLAME_ON)): _LOGGER.info("Heating system has started to run") break @@ -89,7 +89,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: previous_average_temp = None while True: - actual_temp = float(self._coordinator.get(gw_vars.DATA_CH_WATER_TEMP)) + actual_temp = float(self._coordinator.get(DATA_CH_WATER_TEMP)) temps.append(actual_temp) average_temp = sum(temps) / 50 diff --git a/custom_components/sat/opentherm/sensor.py b/custom_components/sat/opentherm/sensor.py index a573775b..5bb047e0 100644 --- a/custom_components/sat/opentherm/sensor.py +++ b/custom_components/sat/opentherm/sensor.py @@ -1,8 +1,10 @@ """Sensor platform for SAT.""" import logging +from typing import Optional, List -from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT +from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT, SensorDeviceClass from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower, UnitOfTemperature, PERCENTAGE, UnitOfPressure, UnitOfVolume, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id @@ -16,25 +18,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): """Setup sensor platform.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - has_thermostat = coordinator.data[gw_vars.OTGW].get(gw_vars.OTGW_THRM_DETECT) != "D" + has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" # Create list of devices to be added devices = [SatCurrentPowerSensor(coordinator, config_entry)] # Iterate through sensor information for key, info in SENSOR_INFO.items(): - unit = info[1] - device_class = info[0] - status_sources = info[3] - friendly_name_format = info[2] - # Check if the sensor should be added based on its availability and thermostat presence - for source in status_sources: - if source == gw_vars.THERMOSTAT and has_thermostat is False: + for source in info.status_sources: + if source == THERMOSTAT and has_thermostat is False: continue if coordinator.data[source].get(key) is not None: - devices.append(SatSensor(coordinator, config_entry, key, source, device_class, unit, friendly_name_format)) + devices.append(SatSensor(coordinator, config_entry, key, source, info.device_class, info.unit, info.friendly_name_format)) # Add all devices async_add_entities(devices) @@ -116,7 +113,7 @@ def native_unit_of_measurement(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[gw_vars.BOILER] is not None + return self._coordinator.data is not None and self._coordinator.data[BOILER] is not None @property def native_value(self) -> float: @@ -125,21 +122,21 @@ def native_value(self) -> float: In this case, the state represents the current capacity of the boiler in kW. """ # Get the data of the boiler from the coordinator - boiler = self._coordinator.data[gw_vars.BOILER] + boiler = self._coordinator.data[BOILER] # If the flame is off, return 0 kW - if bool(boiler.get(gw_vars.DATA_SLAVE_FLAME_ON)) is False: + if bool(boiler.get(DATA_SLAVE_FLAME_ON)) is False: return 0 # Get the relative modulation level from the data - relative_modulation = float(boiler.get(gw_vars.DATA_REL_MOD_LEVEL) or 0) + relative_modulation = float(boiler.get(DATA_REL_MOD_LEVEL) or 0) # Get the maximum capacity from the data - if (maximum_capacity := float(boiler.get(gw_vars.DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: + if (maximum_capacity := float(boiler.get(DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: return 0 # Get and calculate the minimum capacity from the data - minimum_capacity = maximum_capacity / (100 / float(boiler.get(gw_vars.DATA_SLAVE_MIN_MOD_LEVEL))) + minimum_capacity = maximum_capacity / (100 / float(boiler.get(DATA_SLAVE_MIN_MOD_LEVEL))) # Calculate and return the current capacity in kW return minimum_capacity + (((maximum_capacity - minimum_capacity) / 100) * relative_modulation) @@ -148,3 +145,70 @@ def native_value(self) -> float: def unique_id(self) -> str: """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" + + +class SatSensorInfo: + def __init__(self, device_class: Optional[str], unit: Optional[str], friendly_name_format: str, status_sources: List[str]): + self.unit = unit + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +SENSOR_INFO: dict[str, SatSensorInfo] = { + DATA_CONTROL_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MASTER_MEMBERID: SatSensorInfo(None, None, "Thermostat Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MEMBERID: SatSensorInfo(None, None, "Boiler Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OEM_FAULT: SatSensorInfo(None, None, "Boiler OEM Fault Code {}", [BOILER, THERMOSTAT]), + DATA_COOLING_CONTROL: SatSensorInfo(None, PERCENTAGE, "Cooling Control Signal {}", [BOILER, THERMOSTAT]), + DATA_CONTROL_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_OVRD: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_RELATIVE_MOD: SatSensorInfo(None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_CAPACITY: SatSensorInfo(SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MIN_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Boiler Minimum Modulation Level {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", [BOILER, THERMOSTAT]), + DATA_REL_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Relative Modulation Level {}", [BOILER, THERMOSTAT], ), + DATA_CH_WATER_PRESS: SatSensorInfo(SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_DHW_FLOW_RATE: SatSensorInfo(None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_OUTSIDE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", [BOILER, THERMOSTAT]), + DATA_RETURN_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_STORAGE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_COLL_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", [BOILER, THERMOSTAT]), + DATA_EXHAUST_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MAX_CH_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_OEM_DIAG: SatSensorInfo(None, None, "OEM Diagnostic Code {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_STARTS: SatSensorInfo(None, None, "Total Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_STARTS: SatSensorInfo(None, None, "Central Heating Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_STARTS: SatSensorInfo(None, None, "Hot Water Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_STARTS: SatSensorInfo(None, None, "Hot Water Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OT_VERSION: SatSensorInfo(None, None, "Thermostat OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OT_VERSION: SatSensorInfo(None, None, "Boiler OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_TYPE: SatSensorInfo(None, None, "Thermostat Product Type {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_VERSION: SatSensorInfo(None, None, "Thermostat Product Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_TYPE: SatSensorInfo(None, None, "Boiler Product Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_VERSION: SatSensorInfo(None, None, "Boiler Product Version {}", [BOILER, THERMOSTAT]), + + OTGW_MODE: SatSensorInfo(None, None, "Gateway/Monitor Mode {}", [OTGW]), + OTGW_DHW_OVRD: SatSensorInfo(None, None, "Gateway Hot Water Override Mode {}", [OTGW]), + OTGW_ABOUT: SatSensorInfo(None, None, "Gateway Firmware Version {}", [OTGW]), + OTGW_BUILD: SatSensorInfo(None, None, "Gateway Firmware Build {}", [OTGW]), + OTGW_SB_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", [OTGW]), + OTGW_SETP_OVRD_MODE: SatSensorInfo(None, None, "Gateway Room Setpoint Override Mode {}", [OTGW]), + OTGW_SMART_PWR: SatSensorInfo(None, None, "Gateway Smart Power Mode {}", [OTGW]), + OTGW_THRM_DETECT: SatSensorInfo(None, None, "Gateway Thermostat Detection {}", [OTGW]), + OTGW_VREF: SatSensorInfo(None, None, "Gateway Reference Voltage Setting {}", [OTGW]), +} From 2ea8fd070ee4c08cf43bcc69af471d460d7f4bdb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 9 May 2023 19:42:03 +0200 Subject: [PATCH 024/237] Fixed mode detection, again --- custom_components/sat/__init__.py | 6 +++- custom_components/sat/binary_sensor.py | 8 +++-- custom_components/sat/config_flow.py | 47 +++++++++++++------------- custom_components/sat/const.py | 2 +- custom_components/sat/number.py | 8 +++-- custom_components/sat/sensor.py | 8 +++-- 6 files changed, 47 insertions(+), 32 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 7b047bd2..660906d0 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -63,10 +63,14 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: This function is called by Home Assistant when the integration is being removed. """ + # Retrieve the defaults and override it with the user options + options = OPTIONS_DEFAULTS.copy() + options.update(_entry.data) + # Unload the entry and its dependent components unloaded = all( await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, _entry.data.get(CONF_MODE)), + _hass.config_entries.async_forward_entry_unload(_entry, options.get(CONF_MODE)), _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR, NUMBER, BINARY_SENSOR]), ) ) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 47209a4a..8719d5be 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_OPENTHERM +from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS from .opentherm import binary_sensor as opentherm_binary_sensor @@ -11,7 +11,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a Add binary sensors for the OpenTherm protocol if the integration is set to use it. """ + # Retrieve the defaults and override it with the user options + options = OPTIONS_DEFAULTS.copy() + options.update(_config_entry.data) + # Check if integration is set to use the OpenTherm protocol - if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + if options.get(CONF_MODE) == MODE_OPENTHERM: # Call function to set up OpenTherm binary sensors await opentherm_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index e17b9b78..b7c0a491 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -134,7 +134,6 @@ class SatOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: ConfigEntry): self._config_entry = config_entry self._options = dict(config_entry.options) - self._mode = config_entry.data.get(CONF_MODE) async def async_step_init(self, _user_input=None): return await self.async_step_user(_user_input) @@ -154,19 +153,19 @@ async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() schema = { - vol.Required(CONF_HEATING_CURVE_COEFFICIENT, default=defaults[CONF_HEATING_CURVE_COEFFICIENT]): selector.NumberSelector( + vol.Required(CONF_HEATING_CURVE_COEFFICIENT, default=options[CONF_HEATING_CURVE_COEFFICIENT]): selector.NumberSelector( selector.NumberSelectorConfig(min=0.1, max=12, step=0.1) ), - vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=defaults[CONF_TARGET_TEMPERATURE_STEP]): selector.NumberSelector( + vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP]): selector.NumberSelector( selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) ), } - if self._mode == MODE_OPENTHERM: - schema[vol.Required(CONF_HEATING_SYSTEM, default=defaults[CONF_HEATING_SYSTEM])] = selector.SelectSelector( + if options.get(CONF_MODE) == MODE_OPENTHERM: + schema[vol.Required(CONF_HEATING_SYSTEM, default=options[CONF_HEATING_SYSTEM])] = selector.SelectSelector( selector.SelectSelectorConfig(options=[ {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, {"value": HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES, "label": "Radiators ( Medium Temperatures )"}, @@ -175,18 +174,18 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ]) ) - if self._mode == MODE_SWITCH: + if options.get(CONF_MODE) == MODE_SWITCH: schema[vol.Required(CONF_SETPOINT, default=50)] = selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=100, step=1) ) - if not defaults.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=defaults.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=defaults.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=defaults.get(CONF_DERIVATIVE))] = str + if not options.get(CONF_AUTOMATIC_GAINS): + schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str + schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str + schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str - if not defaults.get(CONF_AUTOMATIC_DUTY_CYCLE): - schema[vol.Required(CONF_DUTY_CYCLE, default=defaults.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): + schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() return self.async_show_form(step_id="general", data_schema=vol.Schema(schema)) @@ -260,23 +259,23 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() schema = { - vol.Required(CONF_SIMULATION, default=defaults[CONF_SIMULATION]): bool, - vol.Required(CONF_AUTOMATIC_GAINS, default=defaults.get(CONF_AUTOMATIC_GAINS)): bool, - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=defaults.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, + vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, + vol.Required(CONF_AUTOMATIC_GAINS, default=options.get(CONF_AUTOMATIC_GAINS)): bool, + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, } - if self._mode == MODE_OPENTHERM: - schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=defaults[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool - schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=defaults[CONF_OVERSHOOT_PROTECTION])] = bool + if options.get(CONF_MODE) == MODE_OPENTHERM: + schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool + schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool - schema[vol.Required(CONF_SAMPLE_TIME, default=defaults.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() - schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=defaults.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() - schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=defaults.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() + schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() - schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=defaults[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( + schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( selector.NumberSelectorConfig(min=-1, max=1, step=0.1) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index ccd10c75..e5e01066 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -3,7 +3,7 @@ # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "2.0.2" +VERSION = "2.0.3" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index fcbf1d61..55187674 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_OPENTHERM +from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS from .opentherm import number as opentherm_number @@ -11,7 +11,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a Add sensors for the OpenTherm protocol if the integration is set to use it. """ + # Retrieve the defaults and override it with the user options + options = OPTIONS_DEFAULTS.copy() + options.update(_config_entry.data) + # Check if integration is set to use the OpenTherm protocol - if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + if options.get(CONF_MODE) == MODE_OPENTHERM: # Call function to set up OpenTherm numbers await opentherm_number.async_setup_entry(_hass, _config_entry, _async_add_entities) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index c5cf32cf..5f993501 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -2,7 +2,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_OPENTHERM +from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS from .opentherm import sensor as opentherm_sensor @@ -11,7 +11,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a Add sensors for the OpenTherm protocol if the integration is set to use it. """ + # Retrieve the defaults and override it with the user options + options = OPTIONS_DEFAULTS.copy() + options.update(_config_entry.data) + # Check if integration is set to use the OpenTherm protocol - if _config_entry.data.get(CONF_MODE) == MODE_OPENTHERM: + if options.get(CONF_MODE) == MODE_OPENTHERM: # Call function to set up OpenTherm sensors await opentherm_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) From baa6ee98a7d731dbc8d073d35433a48198ab3914 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 20:04:22 +0200 Subject: [PATCH 025/237] Initial support for MQTT --- custom_components/sat/__init__.py | 3 +- custom_components/sat/binary_sensor.py | 109 ++++++++- custom_components/sat/climate.py | 4 +- custom_components/sat/config_flow.py | 70 ++++-- custom_components/sat/const.py | 21 +- custom_components/sat/coordinator.py | 167 ++++++++++++-- custom_components/sat/manifest.json | 5 +- custom_components/sat/mqtt/__init__.py | 17 ++ custom_components/sat/mqtt/coordinator.py | 212 +++++++++++++++++ custom_components/sat/number.py | 74 ++++-- .../sat/opentherm/coordinator.py | 167 -------------- custom_components/sat/opentherm/number.py | 66 ------ .../{opentherm => }/overshoot_protection.py | 25 +- custom_components/sat/sensor.py | 186 ++++++++++++++- .../sat/{opentherm => serial}/__init__.py | 13 +- .../{opentherm => serial}/binary_sensor.py | 218 +++++------------- custom_components/sat/serial/coordinator.py | 154 +++++++++++++ .../sat/{opentherm => serial}/sensor.py | 209 +++++++---------- .../sat/{opentherm => }/services.py | 6 +- custom_components/sat/switch/coordinator.py | 18 +- custom_components/sat/translations/en.json | 22 +- 21 files changed, 1127 insertions(+), 639 deletions(-) create mode 100644 custom_components/sat/mqtt/__init__.py create mode 100644 custom_components/sat/mqtt/coordinator.py delete mode 100644 custom_components/sat/opentherm/coordinator.py delete mode 100644 custom_components/sat/opentherm/number.py rename custom_components/sat/{opentherm => }/overshoot_protection.py (80%) rename custom_components/sat/{opentherm => serial}/__init__.py (70%) rename custom_components/sat/{opentherm => serial}/binary_sensor.py (62%) create mode 100644 custom_components/sat/serial/coordinator.py rename custom_components/sat/{opentherm => serial}/sensor.py (79%) rename custom_components/sat/{opentherm => }/services.py (97%) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 660906d0..6f90e30f 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant -from . import opentherm, switch +from . import mqtt, serial, switch from .config_store import SatConfigStore from .const import * @@ -70,7 +70,6 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: # Unload the entry and its dependent components unloaded = all( await asyncio.gather( - _hass.config_entries.async_forward_entry_unload(_entry, options.get(CONF_MODE)), _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR, NUMBER, BINARY_SENSOR]), ) ) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 8719d5be..d34b04ed 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,21 +1,118 @@ +from __future__ import annotations + +import logging +import typing + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.components.climate import HVACAction from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS -from .opentherm import binary_sensor as opentherm_binary_sensor +from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .entity import SatEntity +from .serial import binary_sensor as serial_binary_sensor + +if typing.TYPE_CHECKING: + from .climate import SatClimate + +_LOGGER: logging.Logger = logging.getLogger(__name__) async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): """ - Add binary sensors for the OpenTherm protocol if the integration is set to use it. + Add binary sensors for the serial protocol if the integration is set to use it. """ + climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] # Retrieve the defaults and override it with the user options options = OPTIONS_DEFAULTS.copy() options.update(_config_entry.data) - # Check if integration is set to use the OpenTherm protocol - if options.get(CONF_MODE) == MODE_OPENTHERM: + # Check if integration is set to use the serial protocol + if options.get(CONF_MODE) == MODE_SERIAL: # Call function to set up OpenTherm binary sensors - await opentherm_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + await serial_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + + _async_add_entities([ + SatControlSetpointSynchroSensor(coordinator, climate, _config_entry), + SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry), + ]) + + +class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): + + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + self._climate = climate + + @property + def name(self): + """Return the friendly name of the sensor.""" + return "Control Setpoint Synchro" + + @property + def device_class(self): + """Return the device class.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate is not None and self._coordinator.setpoint is not None + + @property + def is_on(self): + """Return the state of the sensor.""" + coordinator_setpoint = self._coordinator.setpoint + climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or coordinator_setpoint) + + return not ( + self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or + round(climate_setpoint, 1) == round(coordinator_setpoint, 1) + ) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" + + +class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: + """Initialize the Central Heating Synchro sensor.""" + super().__init__(coordinator, config_entry) + self._climate = climate + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return "Central Heating Synchro" + + @property + def device_class(self) -> str: + """Return the device class.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def available(self) -> bool: + """Return availability of the sensor.""" + return self._climate is not None + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + device_active = self._coordinator.device_active + climate_hvac_action = self._climate.state_attributes.get("hvac_action") + + return not ( + (climate_hvac_action == HVACAction.OFF and not device_active) or + (climate_hvac_action == HVACAction.IDLE and not device_active) or + (climate_hvac_action == HVACAction.HEATING and device_active) + ) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3972a379..ac1f4602 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -90,9 +90,9 @@ def create_heating_curve_controller(options) -> HeatingCurve: def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options - force = bool(options.get(CONF_MODE) == MODE_SWITCH) automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) + force = bool(options.get(CONF_MODE) == MODE_SWITCH) or bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) # 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, force=force) @@ -759,7 +759,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState): self._outputs.clear() self._setpoint = MINIMUM_SETPOINT - await self._coordinator.async_control_setpoint(self._setpoint) + await self._coordinator.async_set_control_setpoint(self._setpoint) async def _async_update_rooms_from_climates(self): """Update the temperature setpoint for each room based on their associated climate entity.""" diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index b7c0a491..c316df48 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -3,9 +3,10 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import dhcp +from homeassistant.components import mqtt from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -17,6 +18,8 @@ from .const import * +DEFAULT_NAME = "Living Room" + _LOGGER = logging.getLogger(__name__) @@ -33,30 +36,31 @@ async def async_step_user(self, _user_input=None) -> FlowResult: """Handle user flow.""" return self.async_show_menu( step_id="user", - menu_options=["opentherm", "switch"] + menu_options=["mqtt", "serial", "switch"] ) - async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - _LOGGER.debug("Discovered OTGW at [%s]", discovery_info.ip) + _LOGGER.debug("Discovered OTGW at [%s]", discovery_info.hostname) + self._data[CONF_DEVICE] = f"socket://{discovery_info.hostname}:25238" # abort if we already have exactly this gateway id/host # reload the integration if the host got updated - await self.async_set_unique_id(discovery_info.ip) - self._abort_if_unique_id_configured(updates={CONF_DEVICE: discovery_info.ip}, reload_on_update=True) + await self.async_set_unique_id(discovery_info.hostname) + self._abort_if_unique_id_configured(updates=self._data, reload_on_update=True) - return await self.async_step_opentherm() + return await self.async_step_serial() - async def async_step_opentherm(self, _user_input=None): + async def async_step_mqtt(self, _user_input=None): self._errors = {} if _user_input is not None: self._data.update(_user_input) - self._data[CONF_MODE] = MODE_OPENTHERM + self._data[CONF_MODE] = MODE_MQTT - if not await self._test_gateway_connection(): - self._errors["base"] = "auth" - return await self.async_step_opentherm() + if not await mqtt.async_wait_for_mqtt_client(self.hass): + self._errors["base"] = "mqtt_component" + return await self.async_step_serial() await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() @@ -64,11 +68,39 @@ async def async_step_opentherm(self, _user_input=None): return await self.async_step_sensors_setup() return self.async_show_form( - step_id="opentherm", + step_id="mqtt", last_step=False, errors=self._errors, data_schema=vol.Schema({ - vol.Required(CONF_NAME, default="Living Room"): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_DEVICE): selector.DeviceSelector( + selector.DeviceSelectorConfig(model="otgw-nodo") + ), + }), + ) + + async def async_step_serial(self, _user_input=None): + self._errors = {} + + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SERIAL + + if not await OpenThermGateway().connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5): + self._errors["base"] = "connection" + return await self.async_step_serial() + + await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) + self._abort_if_unique_id_configured() + + return await self.async_step_sensors_setup() + + return self.async_show_form( + step_id="serial", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_DEVICE, default="socket://otgw.local:25238"): str, }), ) @@ -89,7 +121,7 @@ async def async_step_switch(self, _user_input=None): last_step=False, errors=self._errors, data_schema=vol.Schema({ - vol.Required(CONF_NAME, default="Living Room"): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_SWITCH): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN]) ) @@ -118,10 +150,6 @@ async def async_step_sensors_setup(self): }), ) - async def _test_gateway_connection(self): - """Return true if credentials is valid.""" - return await OpenThermGateway().connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5) - @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry): @@ -164,7 +192,7 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ), } - if options.get(CONF_MODE) == MODE_OPENTHERM: + if options.get(CONF_MODE) == MODE_SERIAL: schema[vol.Required(CONF_HEATING_SYSTEM, default=options[CONF_HEATING_SYSTEM])] = selector.SelectSelector( selector.SelectSelectorConfig(options=[ {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, @@ -267,7 +295,7 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, } - if options.get(CONF_MODE) == MODE_OPENTHERM: + if options.get(CONF_MODE) == MODE_SERIAL: schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index e5e01066..ba44abbf 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,17 +1,14 @@ -from pyotgw.vars import * - # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "2.0.3" +VERSION = "2.1.0" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" +MODE_MQTT = "mqtt" MODE_SWITCH = "switch" -MODE_OPENTHERM = "opentherm" +MODE_SERIAL = "serial" -HOT_TOLERANCE = 0.3 -COLD_TOLERANCE = 0.1 MINIMUM_SETPOINT = 10 MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 @@ -74,7 +71,7 @@ HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES = "radiator_high_temperatures" OPTIONS_DEFAULTS = { - CONF_MODE: MODE_OPENTHERM, + CONF_MODE: MODE_SERIAL, CONF_PROPORTIONAL: "45", CONF_INTEGRAL: "0", CONF_DERIVATIVE: "6000", @@ -119,13 +116,3 @@ # Config steps STEP_SETUP_GATEWAY = "gateway" STEP_SETUP_SENSORS = "sensors" - -# Defaults -DEFAULT_NAME = DOMAIN - -# Sensors -TRANSLATE_SOURCE = { - OTGW: None, - BOILER: "Boiler", - THERMOSTAT: "Thermostat", -} diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index adf7c847..a0991423 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -2,14 +2,18 @@ import logging import typing +from abc import abstractmethod from enum import Enum +from functools import partial +from homeassistant.components.climate import HVACMode from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .config_store import SatConfigStore from .const import * +from .services import start_overshoot_protection_calculation, set_overshoot_protection_value if typing.TYPE_CHECKING: from .climate import SatClimate @@ -26,10 +30,13 @@ class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" self._store = store + self._requested_setpoint = None self._device_state = DeviceState.OFF - self._setpoint = float(self._store.options.get(CONF_SETPOINT)) self._simulation = bool(self._store.options.get(CONF_SIMULATION)) + self._minimum_setpoint = float(self._store.options.get(CONF_SETPOINT)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) + self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) + self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) super().__init__(hass, _LOGGER, name=DOMAIN) @@ -58,42 +65,174 @@ def maximum_setpoint(self) -> float: if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: return 50.0 + @property + @abstractmethod + def setpoint(self) -> float | None: + pass + + @property + @abstractmethod + def device_active(self) -> bool: + pass + + @property + def flame_active(self) -> bool: + return True + + @property + def hot_water_active(self) -> bool: + return False + + @property + def hot_water_setpoint(self) -> float | None: + return None + + @property + def boiler_temperature(self) -> float | None: + return None + + @property + def minimum_hot_water_setpoint(self) -> float: + return 30 + + @property + def maximum_hot_water_setpoint(self) -> float: + return 60 + + @property + def relative_modulation_value(self) -> float | None: + return None + + @property + def boiler_capacity(self) -> float | None: + return None + + @property + def minimum_relative_modulation_value(self) -> float | None: + return None + @property def minimum_setpoint(self) -> float: - return self._setpoint + return self._minimum_setpoint @property def supports_setpoint_management(self): - """Returns whether the device supports setting a setpoint. + """Returns whether the device supports setting a boiler setpoint. This property is used to determine whether the coordinator can send a setpoint to the device. - If a device doesn't support setpoint management, the coordinator won't be able to control - the temperature or other properties of the device. + If a device doesn't support setpoint management, the coordinator won't be able to control the temperature. + """ + return False + + @property + def supports_hot_water_setpoint_management(self): + """Returns whether the device supports setting a hot water setpoint. - Returns: - A boolean indicating whether the device supports setpoint management. True indicates - that the device supports it, while False indicates that it does not. + This property is used to determine whether the coordinator can send a setpoint to the device. + If a device doesn't support setpoint management, the coordinator won't be able to control the temperature. + """ + return False + + @property + def support_relative_modulation_management(self): + """Returns whether the device supports setting a relative modulation value. + + 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 + + @property + def supports_maximum_setpoint_management(self): + """Returns whether the device supports setting a maximum setpoint. + + This property is used to determine whether the coordinator can send a maximum setpoint to the device. + If a device doesn't support maximum setpoint management, the coordinator won't be able to control the value. """ return False async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" - pass + await self.async_set_control_max_setpoint(self.maximum_setpoint) + + if self.supports_setpoint_management: + if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._overshoot_protection = False + + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled overshoot protection because no overshoot value has been found." + ) + + if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._force_pulse_width_modulation = False + + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled forced pulse width modulation because no overshoot value has been found." + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_OVERSHOOT_PROTECTION_CALCULATION, + partial(start_overshoot_protection_calculation, self, climate) + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, + partial(set_overshoot_protection_value, self) + ) async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: """Control the heating loop for the device.""" - pass + if climate.hvac_mode == HVACMode.OFF and self.device_active: + # Send out a new command to turn off the device + await self.async_set_heater_state(DeviceState.OFF) - async def async_control_setpoint(self, value: float) -> None: - """Control the setpoint temperature for the device.""" - if self.supports_setpoint_management: - self.logger.info("Set control setpoint to %d", value) + if self.support_relative_modulation_management: + # Check if the climate control is not in heating mode + not_in_heating_mode = climate.hvac_mode != HVACMode.HEAT + + # Check if the setpoint is below the minimum allowed value (or basically off) + is_below_min_setpoint = self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT + + # Check if the setpoint is close to or above the overshoot protection value + is_overshooting = self.setpoint is not None and abs(climate.max_error) > 0.1 and self.setpoint >= (self.minimum_setpoint - 2) + + # Determine whether to enable maximum or minimum relative modulation value based on the conditions + relative_modulation_enabled = not_in_heating_mode or self.hot_water_active or is_below_min_setpoint or is_overshooting + + # Control the relative modulation value based on the conditions + await self.async_set_control_max_relative_modulation( + MAXIMUM_RELATIVE_MOD if relative_modulation_enabled else MINIMUM_RELATIVE_MOD + ) 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) + 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) + + 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) + + async def async_set_control_max_setpoint(self, value: float): + """Control the maximum setpoint temperature for the device.""" + if self.supports_maximum_setpoint_management: + self.logger.info("Set maximum setpoint to %d", value) + + async def async_set_control_max_relative_modulation(self, value: float) -> None: + """Control the maximum relative modulation for the device.""" + if self.support_relative_modulation_management: + self.logger.info("Set maximum relative modulation to %d", value) + async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): """Send a notification to the user.""" data = {"title": title, "message": message} diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index 035ecfd8..32476b67 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -5,6 +5,9 @@ "@Alexwijn" ], "config_flow": true, + "dependencies": [ + "mqtt" + ], "dhcp": [ { "hostname": "otgw" @@ -16,5 +19,5 @@ "requirements": [ "pyotgw==2.1.3" ], - "version": "2.0.0" + "version": "2.1.0" } diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py new file mode 100644 index 00000000..063848c4 --- /dev/null +++ b/custom_components/sat/mqtt/__init__.py @@ -0,0 +1,17 @@ +import logging + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .coordinator import SatMqttCoordinator +from ..const import * + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): + store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatMqttCoordinator(_hass, store, _entry.data.get(CONF_DEVICE)) + + await mqtt.async_wait_for_mqtt_client(_hass) diff --git a/custom_components/sat/mqtt/coordinator.py b/custom_components/sat/mqtt/coordinator.py new file mode 100644 index 00000000..53574356 --- /dev/null +++ b/custom_components/sat/mqtt/coordinator.py @@ -0,0 +1,212 @@ +from __future__ import annotations, annotations + +import logging +import typing + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, Event, State +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event + +from ..config_store import SatConfigStore +from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +DATA_FLAME_ACTIVE = "flame" +DATA_DHW_SETPOINT = "TdhwSet" +DATA_CONTROL_SETPOINT = "TSet" +DATA_DHW_ENABLE = "dhw_enable" +DATA_REL_MOD_LEVEL = "RelModLevel" +DATA_BOILER_TEMPERATURE = "Tboiler" +DATA_CENTRAL_HEATING = "centralheating" +DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" +DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" +DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" +DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): + entities = entity_registry.async_get(hass) + entity = entities.async_get(entity_id) + + if entity.unique_id: + return entity.unique_id[len(node_id) + 1:] + + return None + + +class SatMqttCoordinator(SatDataUpdateCoordinator): + """Class to manage fetching data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: + super().__init__(hass, store) + + self.data = {} + + self._device = device_registry.async_get(hass).async_get(device_id) + self._node_id = list(self._device.identifiers)[0][1] + + self.entity_registry = entity_registry.async_get(hass) + self._entities = entity_registry.async_entries_for_device(self.entity_registry, self._device.id) + + @property + def supports_setpoint_management(self): + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + def supports_maximum_setpoint_management(self): + return True + + @property + def support_relative_modulation_management(self): + return True + + @property + def device_active(self) -> bool: + return bool(self.data.get(DATA_CENTRAL_HEATING)) + + @property + def flame_active(self) -> bool: + return bool(self.data.get(DATA_FLAME_ACTIVE)) + + @property + def hot_water_active(self) -> bool: + return bool(self.data.get(DATA_DHW_ENABLE)) + + @property + def setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return None + + @property + def boiler_capacity(self) -> float | None: + if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + async def async_added_to_hass(self, climate: SatClimate) -> None: + await self._send_command("PM=48") + + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(DATA_FLAME_ACTIVE), + self._get_entity_id(DATA_DHW_SETPOINT), + self._get_entity_id(DATA_CONTROL_SETPOINT), + self._get_entity_id(DATA_DHW_ENABLE), + self._get_entity_id(DATA_REL_MOD_LEVEL), + self._get_entity_id(DATA_CENTRAL_HEATING), + self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), + ])) + + for entity_id in entities: + if state := self.hass.states.get(entity_id): + await self._on_state_change(entity_id, state) + + async def on_state_change(event: Event): + await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) + + async_track_state_change_event(self.hass, entities, on_state_change) + + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: + await super().async_control_heating_loop(climate, _time) + + async def async_set_control_setpoint(self, value: float) -> None: + await self._send_command(f"CS={value}") + + await super().async_set_control_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") + + await super().async_set_heater_state(state) + + async def async_control_max_relative_mod(self, value: float) -> None: + await self._send_command("MM={value}") + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + await self._send_command(f"SH={value}") + + await super().async_set_control_max_setpoint(value) + + def _get_entity_id(self, key: str): + return self.entity_registry.async_get_entity_id(SENSOR, MQTT_DOMAIN, f"{self._node_id}-{key}") + + async def _on_state_change(self, entity_id: str, state: State): + key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) + if key is None: + return + + if state.state is not None and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self.data[key] = state.state + else: + self.data[key] = None + + if self._listeners: + self._schedule_refresh() + + self.async_update_listeners() + + async def _send_command(self, command: str): + if not self._simulation: + await mqtt.async_publish(self.hass, f"OTGW/set/{self._node_id}/command", command) diff --git a/custom_components/sat/number.py b/custom_components/sat/number.py index 55187674..00bf5518 100644 --- a/custom_components/sat/number.py +++ b/custom_components/sat/number.py @@ -1,21 +1,67 @@ +from homeassistant.components.number import NumberEntity, NumberDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS -from .opentherm import number as opentherm_number +from .const import * +from .coordinator import SatDataUpdateCoordinator +from .entity import SatEntity -async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): - """ - Add sensors for the OpenTherm protocol if the integration is set to use it. - """ +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_config_entry.data) + if coordinator.supports_hot_water_setpoint_management: + async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) - # Check if integration is set to use the OpenTherm protocol - if options.get(CONF_MODE) == MODE_OPENTHERM: - # Call function to set up OpenTherm numbers - await opentherm_number.async_setup_entry(_hass, _config_entry, _async_add_entities) + +class SatHotWaterSetpointEntity(SatEntity, NumberEntity): + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + self._name = self._config_entry.data.get(CONF_NAME) + + @property + def name(self) -> str | None: + return f"Hot Water Setpoint {self._name} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return NumberDeviceClass.TEMPERATURE + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._name.lower()}-boiler-dhw-setpoint" + + @property + def icon(self) -> str | None: + return "mdi:thermometer" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.hot_water_setpoint is not None + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def native_value(self): + """Return the state of the device in native units.""" + return self._coordinator.hot_water_setpoint + + @property + def native_min_value(self) -> float: + """Return the minimum accepted temperature.""" + return self._coordinator.minimum_hot_water_setpoint + + @property + def native_max_value(self) -> float: + """Return the maximum accepted temperature.""" + return self._coordinator.maximum_hot_water_setpoint + + async def async_set_native_value(self, value: float) -> None: + """Update the setpoint.""" + await self._coordinator.async_set_control_hot_water_setpoint(value) diff --git a/custom_components/sat/opentherm/coordinator.py b/custom_components/sat/opentherm/coordinator.py deleted file mode 100644 index 8565a55c..00000000 --- a/custom_components/sat/opentherm/coordinator.py +++ /dev/null @@ -1,167 +0,0 @@ -from __future__ import annotations - -import typing -from functools import partial -from typing import Optional, Any - -from homeassistant.components.climate import HVACMode -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import UpdateFailed -from pyotgw import OpenThermGateway - -from .services import start_overshoot_protection_calculation, set_overshoot_protection_value -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -if typing.TYPE_CHECKING: - from ..climate import SatClimate - - -class SatOpenThermCoordinator(SatDataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenThermGateway) -> None: - """Initialize.""" - super().__init__(hass, store) - - self.api = client - self.api.subscribe(self._async_coroutine) - - self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - - async def async_added_to_hass(self, climate: SatClimate) -> None: - """Run when entity about to be added.""" - await self._async_control_max_setpoint() - - if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._overshoot_protection = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled overshoot protection because no overshoot value has been found." - ) - - if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._force_pulse_width_modulation = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled forced pulse width modulation because no overshoot value has been found." - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self, climate) - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self) - ) - - @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._setpoint) - - @property - def supports_setpoint_management(self): - """Control the setpoint temperature for the device.""" - return True - - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. - - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[BOILER].get(key) if self.data[BOILER] else None - - async def cleanup(self) -> None: - """Cleanup and disconnect.""" - self.api.unsubscribe(self._async_coroutine) - - await self.api.set_control_setpoint(0) - await self.api.set_max_relative_mod("-") - await self.api.disconnect() - - async def _async_update_data(self): - """Update data via library.""" - try: - return await self.api.get_status() - except Exception as exception: - raise UpdateFailed() from exception - - async def _async_coroutine(self, data): - self.async_set_updated_data(data) - - async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: - """Control the max relative mod of the heating system.""" - await super().async_control_heating_loop(climate) - - if climate.hvac_mode == HVACMode.OFF and bool(self.get(DATA_MASTER_CH_ENABLED)): - await self.async_set_heater_state(DeviceState.OFF) - - await self._async_control_max_relative_mod(climate) - - async def _async_control_max_relative_mod(self, climate: SatClimate, _time=None) -> None: - max_relative_mod = self._calculate_max_relative_mod(climate) - if float(self.get(DATA_SLAVE_MAX_RELATIVE_MOD)) == max_relative_mod: - return - - if not self._simulation: - await self.api.set_max_relative_mod(max_relative_mod) - - self.logger.info("Set max relative mod to %d", max_relative_mod) - - async def _async_control_max_setpoint(self) -> None: - """Set a maximum temperature limit on the boiler.""" - if not self._simulation: - await self.api.set_max_ch_setpoint(self.maximum_setpoint) - - self.logger.info(f"Set max setpoint to {self.maximum_setpoint}") - - async def async_control_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_control_setpoint(value) - - await super().async_control_setpoint(value) - - async def async_set_heater_state(self, state: DeviceState) -> None: - """Control the state of the central heating.""" - if not self._simulation: - await self.api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) - - await super().async_set_heater_state(state) - - def _calculate_max_relative_mod(self, climate: SatClimate) -> int: - """Calculate the maximum relative modulation for the heating system. - - This method determines the maximum relative modulation that should be used for the heating system, based on the current - climate conditions and system configuration. If the heating system is currently in heat mode, or if domestic hot water - is active, or if the setpoint is below a certain minimum value, the maximum relative modulation is returned as a constant value. - - Otherwise, if overshoot protection is enabled and certain conditions are met, the maximum relative modulation is also set - to a constant value. Otherwise, we return the minimum relative modulation. - - Args: - climate: A `SatClimate` object representing the current climate conditions. - - Returns: - An integer representing the maximum relative modulation for the heating system. - """ - setpoint = float(self.get(DATA_CONTROL_SETPOINT)) - - if climate.hvac_mode != HVACMode.HEAT or bool(self.get(DATA_SLAVE_DHW_ACTIVE)) or setpoint <= MINIMUM_SETPOINT: - return MAXIMUM_RELATIVE_MOD - - if self._overshoot_protection and not self._force_pulse_width_modulation: - overshoot_protection_value = self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - - if overshoot_protection_value is None or (abs(climate.max_error) > 0.1 and setpoint >= (overshoot_protection_value - 2)): - return MAXIMUM_RELATIVE_MOD - - return MINIMUM_RELATIVE_MOD diff --git a/custom_components/sat/opentherm/number.py b/custom_components/sat/opentherm/number.py deleted file mode 100644 index 2faa8eb7..00000000 --- a/custom_components/sat/opentherm/number.py +++ /dev/null @@ -1,66 +0,0 @@ -from homeassistant.components.number import NumberEntity, NumberDeviceClass -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .coordinator import SatOpenThermCoordinator -from ..const import * -from ..entity import SatEntity - - -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] - async_add_entities([SatHotWaterSetpointEntity(coordinator, config_entry)]) - - -class SatHotWaterSetpointEntity(SatEntity, NumberEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - - self._coordinator = coordinator - - @property - def name(self) -> str | None: - return f"Hot Water Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" - - @property - def device_class(self): - """Return the device class.""" - return NumberDeviceClass.TEMPERATURE - - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-dhw-setpoint" - - @property - def icon(self) -> str | None: - return "mdi:thermometer" - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[BOILER] is not None - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement in native units.""" - return "°C" - - @property - def native_value(self): - """Return the state of the device in native units.""" - return self._coordinator.data[BOILER][DATA_DHW_SETPOINT] - - @property - def native_min_value(self) -> float: - """Return the minimum accepted temperature.""" - return self._coordinator.data[BOILER][DATA_SLAVE_DHW_MIN_SETP] - - @property - def native_max_value(self) -> float: - """Return the maximum accepted temperature.""" - return self._coordinator.data[BOILER][DATA_SLAVE_DHW_MAX_SETP] - - async def async_set_native_value(self, value: float) -> None: - """Update the setpoint.""" - await self._coordinator.api.set_dhw_setpoint(value) diff --git a/custom_components/sat/opentherm/overshoot_protection.py b/custom_components/sat/overshoot_protection.py similarity index 80% rename from custom_components/sat/opentherm/overshoot_protection.py rename to custom_components/sat/overshoot_protection.py index 8450a4e3..3f29409a 100644 --- a/custom_components/sat/opentherm/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -2,8 +2,8 @@ import logging from collections import deque -from .coordinator import SatOpenThermCoordinator -from ..const import * +from custom_components.sat.const import * +from custom_components.sat.coordinator import DeviceState, SatDataUpdateCoordinator SOLUTION_AUTOMATIC = "auto" SOLUTION_WITH_MODULATION = "with_modulation" @@ -19,14 +19,15 @@ class OvershootProtection: - def __init__(self, coordinator: SatOpenThermCoordinator): + def __init__(self, coordinator: SatDataUpdateCoordinator): self._coordinator = coordinator async def calculate(self, solution: str) -> float | None: _LOGGER.info("Starting calculation") - await self._coordinator.api.set_ch_enable_bit(1) - await self._coordinator.api.set_max_ch_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - await self._coordinator.api.set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + + await self._coordinator.async_set_heater_state(DeviceState.ON) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + await self._coordinator.async_set_control_max_relative_modulation(OVERSHOOT_PROTECTION_SETPOINT) try: # First wait for a flame @@ -38,7 +39,7 @@ async def calculate(self, solution: str) -> float | None: await asyncio.sleep(OVERSHOOT_PROTECTION_INITIAL_WAIT) # Check if relative modulation is still zero - if float(self._coordinator.get(DATA_REL_MOD_LEVEL)) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: + if float(self._coordinator.relative_modulation_value) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: return await start_with_zero_modulation_task else: start_with_zero_modulation_task.cancel() @@ -54,7 +55,9 @@ async def calculate(self, solution: str) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.api.set_max_relative_mod(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD) + await self._coordinator.async_set_control_max_relative_modulation( + OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD + ) try: return await asyncio.wait_for( @@ -77,7 +80,7 @@ async def _calculate_with_modulation(self) -> float: async def _wait_for_flame(self): while True: - if bool(self._coordinator.get(DATA_SLAVE_FLAME_ON)): + if bool(self._coordinator.flame_active): _LOGGER.info("Heating system has started to run") break @@ -89,7 +92,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: previous_average_temp = None while True: - actual_temp = float(self._coordinator.get(DATA_CH_WATER_TEMP)) + actual_temp = float(self._coordinator.boiler_temperature) temps.append(actual_temp) average_temp = sum(temps) / 50 @@ -100,7 +103,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: return actual_temp if max_modulation != OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: - await self._coordinator.api.set_control_setpoint(actual_temp) + await self._coordinator.async_set_control_setpoint(actual_temp) previous_average_temp = average_temp await asyncio.sleep(3) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 5f993501..9b61624a 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,21 +1,193 @@ +from __future__ import annotations + +import typing + +from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import UnitOfPower, UnitOfTemperature +from homeassistant.core import HomeAssistant, Event from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event + +from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .coordinator import SatDataUpdateCoordinator +from .entity import SatEntity +from .serial import sensor as serial_sensor -from .const import CONF_MODE, MODE_OPENTHERM, OPTIONS_DEFAULTS -from .opentherm import sensor as opentherm_sensor +if typing.TYPE_CHECKING: + from .climate import SatClimate async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): """ - Add sensors for the OpenTherm protocol if the integration is set to use it. + Add sensors for the serial protocol if the integration is set to use it. """ + climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] + coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] # Retrieve the defaults and override it with the user options options = OPTIONS_DEFAULTS.copy() options.update(_config_entry.data) - # Check if integration is set to use the OpenTherm protocol - if options.get(CONF_MODE) == MODE_OPENTHERM: + # Check if integration is set to use the serial protocol + if options.get(CONF_MODE) == MODE_SERIAL: # Call function to set up OpenTherm sensors - await opentherm_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + await serial_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + + _async_add_entities([ + SatErrorValueSensor(coordinator, _config_entry, climate), + SatHeatingCurveSensor(coordinator, _config_entry, climate), + ]) + + if coordinator.support_relative_modulation_management: + _async_add_entities([SatCurrentPowerSensor(coordinator, _config_entry)]) + + +class SatCurrentPowerSensor(SatEntity, SensorEntity): + + @property + def name(self) -> str: + return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.POWER + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfPower.KILO_WATT + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.relative_modulation_value is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current capacity of the boiler in kW. + """ + # If the flame is off, return 0 kW + if self._coordinator.flame_active is False: + return 0 + + # Get the relative modulation level from the data + relative_modulation = self._coordinator.relative_modulation_value + + # Get the boiler capacity from the data + if (boiler_capacity := self._coordinator.boiler_capacity) == 0: + return 0 + + # Get and calculate the minimum capacity from the data + minimum_capacity = boiler_capacity / (100 / self._coordinator.minimum_relative_modulation_value) + + # Calculate and return the current capacity in kW + return minimum_capacity + (((boiler_capacity - minimum_capacity) / 100) * relative_modulation) + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" + + +class SatHeatingCurveSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, climate: SatClimate): + super().__init__(coordinator, config_entry) + + self._climate = climate + + async def async_added_to_hass(self) -> None: + async def on_state_change(_event: Event): + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate.entity_id], on_state_change + ) + ) + + @property + def name(self) -> str: + return f"Heating Curve {self._config_entry.data.get(CONF_NAME)}" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfTemperature.CELSIUS + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate.extra_state_attributes.get("heating_curve") is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current heating curve value. + """ + return self._climate.extra_state_attributes.get("heating_curve") + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-heating-curve" + + +class SatErrorValueSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, climate: SatClimate): + super().__init__(coordinator, config_entry) + + self._climate = climate + + async def async_added_to_hass(self) -> None: + async def on_state_change(_event: Event): + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate.entity_id], on_state_change + ) + ) + + @property + def name(self) -> str: + return f"Error Value {self._config_entry.data.get(CONF_NAME)}" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfTemperature.CELSIUS + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate.extra_state_attributes.get("error") is not None + + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current error value. + """ + return self._climate.extra_state_attributes.get("error") + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-error-value" diff --git a/custom_components/sat/opentherm/__init__.py b/custom_components/sat/serial/__init__.py similarity index 70% rename from custom_components/sat/opentherm/__init__.py rename to custom_components/sat/serial/__init__.py index d32b9037..810fa822 100644 --- a/custom_components/sat/opentherm/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -4,14 +4,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from pyotgw import OpenThermGateway +from pyotgw import OpenThermGateway, vars as gw_vars from serial import SerialException -from .coordinator import SatOpenThermCoordinator +from .coordinator import SatSerialCoordinator from ..const import * _LOGGER: logging.Logger = logging.getLogger(__name__) +# Sensors +TRANSLATE_SOURCE = { + gw_vars.OTGW: None, + gw_vars.BOILER: "Boiler", + gw_vars.THERMOSTAT: "Thermostat", +} + async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): try: @@ -21,4 +28,4 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {exception}") from exception store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatOpenThermCoordinator(_hass, store, client) + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatSerialCoordinator(_hass, store, client) diff --git a/custom_components/sat/opentherm/binary_sensor.py b/custom_components/sat/serial/binary_sensor.py similarity index 62% rename from custom_components/sat/opentherm/binary_sensor.py rename to custom_components/sat/serial/binary_sensor.py index 71d23a18..2a495ad7 100644 --- a/custom_components/sat/opentherm/binary_sensor.py +++ b/custom_components/sat/serial/binary_sensor.py @@ -5,32 +5,76 @@ import typing from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass, ENTITY_ID_FORMAT -from homeassistant.components.climate import HVACAction from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id +from pyotgw.vars import * -from .coordinator import SatOpenThermCoordinator +from . import TRANSLATE_SOURCE +from .coordinator import SatSerialCoordinator from ..const import * from ..entity import SatEntity if typing.TYPE_CHECKING: - from ..climate import SatClimate + pass _LOGGER = logging.getLogger(__name__) +class SatBinarySensorInfo: + def __init__(self, device_class: typing.Optional[str], friendly_name_format: str, status_sources: typing.List[str]): + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +BINARY_SENSOR_INFO: dict[str, SatBinarySensorInfo] = { + DATA_MASTER_CH_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating {}", [BOILER, THERMOSTAT]), + DATA_MASTER_DHW_ENABLED: SatBinarySensorInfo(None, "Thermostat Hot Water {}", [BOILER, THERMOSTAT]), + DATA_MASTER_COOLING_ENABLED: SatBinarySensorInfo(None, "Thermostat Cooling {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OTC_ENABLED: SatBinarySensorInfo(None, "Thermostat Outside Temperature Correction {}", [BOILER, THERMOSTAT]), + DATA_MASTER_CH2_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating 2 {}", [BOILER, THERMOSTAT]), + + DATA_SLAVE_FAULT_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Hot Water {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_FLAME_ON: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Flame {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.COLD, "Boiler Cooling {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating 2 {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DIAG_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Diagnostics {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_PRESENT: SatBinarySensorInfo(None, "Boiler Hot Water Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CONTROL_TYPE: SatBinarySensorInfo(None, "Boiler Control Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_COOLING_SUPPORTED: SatBinarySensorInfo(None, "Boiler Cooling Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_CONFIG: SatBinarySensorInfo(None, "Boiler Hot Water Configuration {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MASTER_LOW_OFF_PUMP: SatBinarySensorInfo(None, "Boiler Pump Commands Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH2_PRESENT: SatBinarySensorInfo(None, "Boiler Central Heating 2 Present {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_SERVICE_REQ: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Service Required {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_REMOTE_RESET: SatBinarySensorInfo(None, "Boiler Remote Reset Support {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_LOW_WATER_PRESS: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Low Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_GAS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Gas Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_AIR_PRESS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Air Pressure Fault {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_WATER_OVERTEMP: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Water Over-temperature {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Transfer Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_TRANSFER_MAX_CH: SatBinarySensorInfo(None, "Remote Maximum Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_REMOTE_RW_MAX_CH: SatBinarySensorInfo(None, "Remote Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_MAN_PRIO: SatBinarySensorInfo(None, "Remote Override Manual Change Priority {}", [BOILER, THERMOSTAT]), + DATA_ROVRD_AUTO_PRIO: SatBinarySensorInfo(None, "Remote Override Program Change Priority {}", [BOILER, THERMOSTAT]), + + OTGW_GPIO_A_STATE: SatBinarySensorInfo(None, "Gateway GPIO A {}", [OTGW]), + OTGW_GPIO_B_STATE: SatBinarySensorInfo(None, "Gateway GPIO B {}", [OTGW]), + OTGW_IGNORE_TRANSITIONS: SatBinarySensorInfo(None, "Gateway Ignore Transitions {}", [OTGW]), + OTGW_OVRD_HB: SatBinarySensorInfo(None, "Gateway Override High Byte {}", [OTGW]), +} + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): """Setup sensor platform.""" - climate = hass.data[DOMAIN][config_entry.entry_id][CLIMATE] coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of devices to be added - sensors = [ - SatControlSetpointSynchroSensor(coordinator, climate, config_entry), - SatCentralHeatingSynchroSensor(coordinator, climate, config_entry), - ] + # Create list of entities to be added + entities = [] # Iterate through sensor information for key, info in BINARY_SENSOR_INFO.items(): @@ -40,24 +84,16 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn continue if coordinator.data[source].get(key) is not None: - sensors.append(SatBinarySensor(coordinator, config_entry, key, source, info.device_class, info.friendly_name_format)) + entities.append(SatBinarySensor(coordinator, config_entry, info, key, source)) # Add all devices - async_add_entities(sensors) + async_add_entities(entities) class SatBinarySensor(SatEntity, BinarySensorEntity): _attr_should_poll = False - def __init__( - self, - coordinator: SatOpenThermCoordinator, - config_entry: ConfigEntry, - key: str, - source: str, - device_class: str, - friendly_name_format: str - ): + def __init__(self, coordinator: SatSerialCoordinator, config_entry: ConfigEntry, info: SatBinarySensorInfo, key: str, source: str): super().__init__(coordinator, config_entry) self.entity_id = async_generate_entity_id( @@ -66,11 +102,12 @@ def __init__( self._key = key self._source = source - self._device_class = device_class self._config_entry = config_entry + self._device_class = info.device_class + friendly_name_format = info.friendly_name_format if TRANSLATE_SOURCE[source] is not None: - friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" + friendly_name_format = f"{info.friendly_name_format} ({TRANSLATE_SOURCE[source]})" self._friendly_name = friendly_name_format.format(config_entry.data.get(CONF_NAME)) @@ -98,140 +135,3 @@ def is_on(self): def unique_id(self): """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME.lower())}-{self._source}-{self._key}" - - -class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - self._climate = climate - - @property - def name(self): - """Return the friendly name of the sensor.""" - return "Control Setpoint Synchro" - - @property - def device_class(self): - """Return the device class.""" - return BinarySensorDeviceClass.PROBLEM - - @property - def available(self): - """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[BOILER] is None: - return False - - return True - - @property - def is_on(self): - """Return the state of the sensor.""" - boiler_setpoint = float(self._coordinator.data[BOILER].get(DATA_CONTROL_SETPOINT) or 0) - climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or boiler_setpoint) - - return not ( - self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or - round(climate_setpoint, 1) == round(boiler_setpoint, 1) - ) - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" - - -class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: - """Initialize the Central Heating Synchro sensor.""" - super().__init__(coordinator, config_entry) - self._climate = climate - - @property - def name(self) -> str: - """Return the friendly name of the sensor.""" - return "Central Heating Synchro" - - @property - def device_class(self) -> str: - """Return the device class.""" - return BinarySensorDeviceClass.PROBLEM - - @property - def available(self) -> bool: - """Return availability of the sensor.""" - if self._climate is None: - return False - - if self._coordinator.data is None or self._coordinator.data[BOILER] is None: - return False - - return True - - @property - def is_on(self) -> bool: - """Return the state of the sensor.""" - boiler = self._coordinator.data[BOILER] - boiler_central_heating = bool(boiler.get(DATA_MASTER_CH_ENABLED)) - climate_hvac_action = self._climate.state_attributes.get("hvac_action") - - return not ( - (climate_hvac_action == HVACAction.OFF and not boiler_central_heating) or - (climate_hvac_action == HVACAction.IDLE and not boiler_central_heating) or - (climate_hvac_action == HVACAction.HEATING and boiler_central_heating) - ) - - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" - - -class SatBinarySensorInfo: - def __init__(self, device_class: typing.Optional[str], friendly_name_format: str, status_sources: typing.List[str]): - self.device_class = device_class - self.status_sources = status_sources - self.friendly_name_format = friendly_name_format - - -BINARY_SENSOR_INFO: dict[str, SatBinarySensorInfo] = { - DATA_MASTER_CH_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating {}", [BOILER, THERMOSTAT]), - DATA_MASTER_DHW_ENABLED: SatBinarySensorInfo(None, "Thermostat Hot Water {}", [BOILER, THERMOSTAT]), - DATA_MASTER_COOLING_ENABLED: SatBinarySensorInfo(None, "Thermostat Cooling {}", [BOILER, THERMOSTAT]), - DATA_MASTER_OTC_ENABLED: SatBinarySensorInfo(None, "Thermostat Outside Temperature Correction {}", [BOILER, THERMOSTAT]), - DATA_MASTER_CH2_ENABLED: SatBinarySensorInfo(None, "Thermostat Central Heating 2 {}", [BOILER, THERMOSTAT]), - - DATA_SLAVE_FAULT_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Fault {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CH_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DHW_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Hot Water {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_FLAME_ON: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Flame {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_COOLING_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.COLD, "Boiler Cooling {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CH2_ACTIVE: SatBinarySensorInfo(BinarySensorDeviceClass.HEAT, "Boiler Central Heating 2 {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DIAG_IND: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Diagnostics {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DHW_PRESENT: SatBinarySensorInfo(None, "Boiler Hot Water Present {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CONTROL_TYPE: SatBinarySensorInfo(None, "Boiler Control Type {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_COOLING_SUPPORTED: SatBinarySensorInfo(None, "Boiler Cooling Support {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DHW_CONFIG: SatBinarySensorInfo(None, "Boiler Hot Water Configuration {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_MASTER_LOW_OFF_PUMP: SatBinarySensorInfo(None, "Boiler Pump Commands Support {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CH2_PRESENT: SatBinarySensorInfo(None, "Boiler Central Heating 2 Present {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_SERVICE_REQ: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Service Required {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_REMOTE_RESET: SatBinarySensorInfo(None, "Boiler Remote Reset Support {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_LOW_WATER_PRESS: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Low Water Pressure {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_GAS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Gas Fault {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_AIR_PRESS_FAULT: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Air Pressure Fault {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_WATER_OVERTEMP: SatBinarySensorInfo(BinarySensorDeviceClass.PROBLEM, "Boiler Water Over-temperature {}", [BOILER, THERMOSTAT]), - DATA_REMOTE_TRANSFER_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Transfer Support {}", [BOILER, THERMOSTAT]), - DATA_REMOTE_TRANSFER_MAX_CH: SatBinarySensorInfo(None, "Remote Maximum Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), - DATA_REMOTE_RW_DHW: SatBinarySensorInfo(None, "Remote Hot Water Setpoint Write Support {}", [BOILER, THERMOSTAT]), - DATA_REMOTE_RW_MAX_CH: SatBinarySensorInfo(None, "Remote Central Heating Setpoint Write Support {}", [BOILER, THERMOSTAT]), - DATA_ROVRD_MAN_PRIO: SatBinarySensorInfo(None, "Remote Override Manual Change Priority {}", [BOILER, THERMOSTAT]), - DATA_ROVRD_AUTO_PRIO: SatBinarySensorInfo(None, "Remote Override Program Change Priority {}", [BOILER, THERMOSTAT]), - - OTGW_GPIO_A_STATE: SatBinarySensorInfo(None, "Gateway GPIO A {}", [OTGW]), - OTGW_GPIO_B_STATE: SatBinarySensorInfo(None, "Gateway GPIO B {}", [OTGW]), - OTGW_IGNORE_TRANSITIONS: SatBinarySensorInfo(None, "Gateway Ignore Transitions {}", [OTGW]), - OTGW_OVRD_HB: SatBinarySensorInfo(None, "Gateway Override High Byte {}", [OTGW]), -} diff --git a/custom_components/sat/serial/coordinator.py b/custom_components/sat/serial/coordinator.py new file mode 100644 index 00000000..b04a3f75 --- /dev/null +++ b/custom_components/sat/serial/coordinator.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import logging +from typing import Optional, Any + +from homeassistant.core import HomeAssistant +from pyotgw import OpenThermGateway +from pyotgw.vars import * + +from ..config_store import SatConfigStore +from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SatSerialCoordinator(SatDataUpdateCoordinator): + """Class to manage fetching data from the OTGW Gateway using pyotgw.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenThermGateway) -> None: + """Initialize.""" + super().__init__(hass, store) + + self.api = client + self.api.subscribe(self.async_set_updated_data) + + @property + def device_active(self) -> bool: + return bool(self.get(DATA_MASTER_CH_ENABLED) or False) + + @property + def hot_water_active(self) -> bool: + return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + @property + def supports_maximum_setpoint_management(self) -> bool: + return True + + @property + def support_relative_modulation_management(self) -> bool: + return self._overshoot_protection or not self._force_pulse_width_modulation + + @property + def setpoint(self) -> float | None: + if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return super().hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.get(DATA_CH_WATER_TEMP)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return super().relative_modulation_value + + @property + def boiler_capacity(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().minimum_relative_modulation_value + + @property + def flame_active(self) -> bool: + return bool(self.get(DATA_SLAVE_FLAME_ON)) + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. + + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[BOILER].get(key) if self.data[BOILER] else None + + async def async_cleanup(self) -> None: + self.api.unsubscribe(self.async_set_updated_data) + + await self.api.set_control_setpoint(0) + await self.api.set_max_relative_mod("-") + await self.api.disconnect() + + async def async_set_control_setpoint(self, value: float) -> None: + if not self._simulation: + await self.api.set_control_setpoint(value) + + await super().async_set_control_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + """Control the state of the central heating.""" + if not self._simulation: + await self.api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) + + await super().async_set_heater_state(state) + + async def async_set_control_max_relative_modulation(self, value: float) -> None: + if not self._simulation: + await self.api.set_max_relative_mod(value) + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + """Set a maximum temperature limit on the boiler.""" + if not self._simulation: + await self.api.set_max_ch_setpoint(value) + + await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/opentherm/sensor.py b/custom_components/sat/serial/sensor.py similarity index 79% rename from custom_components/sat/opentherm/sensor.py rename to custom_components/sat/serial/sensor.py index 5bb047e0..583d1be6 100644 --- a/custom_components/sat/opentherm/sensor.py +++ b/custom_components/sat/serial/sensor.py @@ -7,21 +7,90 @@ from homeassistant.const import UnitOfPower, UnitOfTemperature, PERCENTAGE, UnitOfPressure, UnitOfVolume, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import async_generate_entity_id +from pyotgw.vars import * -from .coordinator import SatOpenThermCoordinator +from . import TRANSLATE_SOURCE +from .coordinator import SatSerialCoordinator from ..const import * from ..entity import SatEntity _LOGGER = logging.getLogger(__name__) +class SatSensorInfo: + def __init__(self, device_class: Optional[str], unit: Optional[str], friendly_name_format: str, status_sources: List[str]): + self.unit = unit + self.device_class = device_class + self.status_sources = status_sources + self.friendly_name_format = friendly_name_format + + +SENSOR_INFO: dict[str, SatSensorInfo] = { + DATA_CONTROL_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MASTER_MEMBERID: SatSensorInfo(None, None, "Thermostat Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MEMBERID: SatSensorInfo(None, None, "Boiler Member ID {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OEM_FAULT: SatSensorInfo(None, None, "Boiler OEM Fault Code {}", [BOILER, THERMOSTAT]), + DATA_COOLING_CONTROL: SatSensorInfo(None, PERCENTAGE, "Cooling Control Signal {}", [BOILER, THERMOSTAT]), + DATA_CONTROL_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_OVRD: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_RELATIVE_MOD: SatSensorInfo(None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MAX_CAPACITY: SatSensorInfo(SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_MIN_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Boiler Minimum Modulation Level {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", [BOILER, THERMOSTAT]), + DATA_REL_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Relative Modulation Level {}", [BOILER, THERMOSTAT], ), + DATA_CH_WATER_PRESS: SatSensorInfo(SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", [BOILER, THERMOSTAT]), + DATA_DHW_FLOW_RATE: SatSensorInfo(None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", [BOILER, THERMOSTAT]), + DATA_ROOM_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", [BOILER, THERMOSTAT]), + DATA_ROOM_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_OUTSIDE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", [BOILER, THERMOSTAT]), + DATA_RETURN_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_STORAGE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", [BOILER, THERMOSTAT]), + DATA_SOLAR_COLL_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", [BOILER, THERMOSTAT]), + DATA_CH_WATER_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", [BOILER, THERMOSTAT]), + DATA_DHW_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", [BOILER, THERMOSTAT]), + DATA_EXHAUST_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_DHW_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_CH_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_MAX_CH_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), + DATA_OEM_DIAG: SatSensorInfo(None, None, "OEM Diagnostic Code {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_STARTS: SatSensorInfo(None, None, "Total Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_STARTS: SatSensorInfo(None, None, "Central Heating Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_STARTS: SatSensorInfo(None, None, "Hot Water Pump Starts {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_STARTS: SatSensorInfo(None, None, "Hot Water Burner Starts {}", [BOILER, THERMOSTAT]), + DATA_TOTAL_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_CH_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", [BOILER, THERMOSTAT]), + DATA_DHW_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", [BOILER, THERMOSTAT]), + DATA_MASTER_OT_VERSION: SatSensorInfo(None, None, "Thermostat OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_OT_VERSION: SatSensorInfo(None, None, "Boiler OpenTherm Version {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_TYPE: SatSensorInfo(None, None, "Thermostat Product Type {}", [BOILER, THERMOSTAT]), + DATA_MASTER_PRODUCT_VERSION: SatSensorInfo(None, None, "Thermostat Product Version {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_TYPE: SatSensorInfo(None, None, "Boiler Product Type {}", [BOILER, THERMOSTAT]), + DATA_SLAVE_PRODUCT_VERSION: SatSensorInfo(None, None, "Boiler Product Version {}", [BOILER, THERMOSTAT]), + + OTGW_MODE: SatSensorInfo(None, None, "Gateway/Monitor Mode {}", [OTGW]), + OTGW_DHW_OVRD: SatSensorInfo(None, None, "Gateway Hot Water Override Mode {}", [OTGW]), + OTGW_ABOUT: SatSensorInfo(None, None, "Gateway Firmware Version {}", [OTGW]), + OTGW_BUILD: SatSensorInfo(None, None, "Gateway Firmware Build {}", [OTGW]), + OTGW_SB_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", [OTGW]), + OTGW_SETP_OVRD_MODE: SatSensorInfo(None, None, "Gateway Room Setpoint Override Mode {}", [OTGW]), + OTGW_SMART_PWR: SatSensorInfo(None, None, "Gateway Smart Power Mode {}", [OTGW]), + OTGW_THRM_DETECT: SatSensorInfo(None, None, "Gateway Thermostat Detection {}", [OTGW]), + OTGW_VREF: SatSensorInfo(None, None, "Gateway Reference Voltage Setting {}", [OTGW]), +} + + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): """Setup sensor platform.""" coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of devices to be added - devices = [SatCurrentPowerSensor(coordinator, config_entry)] + # Create list of entities to be added + entities = [] # Iterate through sensor information for key, info in SENSOR_INFO.items(): @@ -31,14 +100,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn continue if coordinator.data[source].get(key) is not None: - devices.append(SatSensor(coordinator, config_entry, key, source, info.device_class, info.unit, info.friendly_name_format)) + entities.append(SatSensor(coordinator, config_entry, info, key, source)) # Add all devices - async_add_entities(devices) + async_add_entities(entities) class SatSensor(SatEntity, SensorEntity): - def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry, key: str, source: str, device_class: str, unit: str, friendly_name_format: str): + def __init__(self, coordinator: SatSerialCoordinator, config_entry: ConfigEntry, info: SatSensorInfo, key: str, source: str): super().__init__(coordinator, config_entry) self.entity_id = async_generate_entity_id( @@ -46,11 +115,12 @@ def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEnt ) self._key = key - self._unit = unit + self._unit = info.unit self._source = source - self._device_class = device_class + self._device_class = info.device_class self._config_entry = config_entry + friendly_name_format = info.friendly_name_format if TRANSLATE_SOURCE[source] is not None: friendly_name_format = f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" @@ -89,126 +159,3 @@ def native_value(self): def unique_id(self): """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME).lower()}-{self._source}-{self._key}" - - -class SatCurrentPowerSensor(SatEntity, SensorEntity): - - def __init__(self, coordinator: SatOpenThermCoordinator, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - - @property - def name(self) -> str | None: - return f"Boiler Current Power {self._config_entry.data.get(CONF_NAME)} (Boiler)" - - @property - def device_class(self): - """Return the device class.""" - return SensorDeviceClass.POWER - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return UnitOfPower.KILO_WATT - - @property - def available(self): - """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[BOILER] is not None - - @property - def native_value(self) -> float: - """Return the state of the device in native units. - - In this case, the state represents the current capacity of the boiler in kW. - """ - # Get the data of the boiler from the coordinator - boiler = self._coordinator.data[BOILER] - - # If the flame is off, return 0 kW - if bool(boiler.get(DATA_SLAVE_FLAME_ON)) is False: - return 0 - - # Get the relative modulation level from the data - relative_modulation = float(boiler.get(DATA_REL_MOD_LEVEL) or 0) - - # Get the maximum capacity from the data - if (maximum_capacity := float(boiler.get(DATA_SLAVE_MAX_CAPACITY) or 0)) == 0: - return 0 - - # Get and calculate the minimum capacity from the data - minimum_capacity = maximum_capacity / (100 / float(boiler.get(DATA_SLAVE_MIN_MOD_LEVEL))) - - # Calculate and return the current capacity in kW - return minimum_capacity + (((maximum_capacity - minimum_capacity) / 100) * relative_modulation) - - @property - def unique_id(self) -> str: - """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" - - -class SatSensorInfo: - def __init__(self, device_class: Optional[str], unit: Optional[str], friendly_name_format: str, status_sources: List[str]): - self.unit = unit - self.device_class = device_class - self.status_sources = status_sources - self.friendly_name_format = friendly_name_format - - -SENSOR_INFO: dict[str, SatSensorInfo] = { - DATA_CONTROL_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint {}", [BOILER, THERMOSTAT]), - DATA_MASTER_MEMBERID: SatSensorInfo(None, None, "Thermostat Member ID {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_MEMBERID: SatSensorInfo(None, None, "Boiler Member ID {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_OEM_FAULT: SatSensorInfo(None, None, "Boiler OEM Fault Code {}", [BOILER, THERMOSTAT]), - DATA_COOLING_CONTROL: SatSensorInfo(None, PERCENTAGE, "Cooling Control Signal {}", [BOILER, THERMOSTAT]), - DATA_CONTROL_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Control Setpoint 2 {}", [BOILER, THERMOSTAT]), - DATA_ROOM_SETPOINT_OVRD: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint Override {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_MAX_RELATIVE_MOD: SatSensorInfo(None, PERCENTAGE, "Boiler Maximum Relative Modulation {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_MAX_CAPACITY: SatSensorInfo(SensorDeviceClass.POWER, UnitOfPower.KILO_WATT, "Boiler Maximum Capacity {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_MIN_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Boiler Minimum Modulation Level {}", [BOILER, THERMOSTAT]), - DATA_ROOM_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint {}", [BOILER, THERMOSTAT]), - DATA_REL_MOD_LEVEL: SatSensorInfo(None, PERCENTAGE, "Relative Modulation Level {}", [BOILER, THERMOSTAT], ), - DATA_CH_WATER_PRESS: SatSensorInfo(SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, "Central Heating Water Pressure {}", [BOILER, THERMOSTAT]), - DATA_DHW_FLOW_RATE: SatSensorInfo(None, f"{UnitOfVolume.LITERS}/{UnitOfTime.MINUTES}", "Hot Water Flow Rate {}", [BOILER, THERMOSTAT]), - DATA_ROOM_SETPOINT_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Setpoint 2 {}", [BOILER, THERMOSTAT]), - DATA_ROOM_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Room Temperature {}", [BOILER, THERMOSTAT]), - DATA_CH_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating Water Temperature {}", [BOILER, THERMOSTAT]), - DATA_DHW_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Temperature {}", [BOILER, THERMOSTAT]), - DATA_OUTSIDE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Outside Temperature {}", [BOILER, THERMOSTAT]), - DATA_RETURN_WATER_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Return Water Temperature {}", [BOILER, THERMOSTAT]), - DATA_SOLAR_STORAGE_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Storage Temperature {}", [BOILER, THERMOSTAT]), - DATA_SOLAR_COLL_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Solar Collector Temperature {}", [BOILER, THERMOSTAT]), - DATA_CH_WATER_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Central Heating 2 Water Temperature {}", [BOILER, THERMOSTAT]), - DATA_DHW_TEMP_2: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water 2 Temperature {}", [BOILER, THERMOSTAT]), - DATA_EXHAUST_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Exhaust Temperature {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DHW_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Maximum Setpoint {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_DHW_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Hot Water Minimum Setpoint {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CH_MAX_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_CH_MIN_SETP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Boiler Minimum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), - DATA_MAX_CH_SETPOINT: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Maximum Central Heating Setpoint {}", [BOILER, THERMOSTAT]), - DATA_OEM_DIAG: SatSensorInfo(None, None, "OEM Diagnostic Code {}", [BOILER, THERMOSTAT]), - DATA_TOTAL_BURNER_STARTS: SatSensorInfo(None, None, "Total Burner Starts {}", [BOILER, THERMOSTAT]), - DATA_CH_PUMP_STARTS: SatSensorInfo(None, None, "Central Heating Pump Starts {}", [BOILER, THERMOSTAT]), - DATA_DHW_PUMP_STARTS: SatSensorInfo(None, None, "Hot Water Pump Starts {}", [BOILER, THERMOSTAT]), - DATA_DHW_BURNER_STARTS: SatSensorInfo(None, None, "Hot Water Burner Starts {}", [BOILER, THERMOSTAT]), - DATA_TOTAL_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Total Burner Hours {}", [BOILER, THERMOSTAT]), - DATA_CH_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Central Heating Pump Hours {}", [BOILER, THERMOSTAT]), - DATA_DHW_PUMP_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Pump Hours {}", [BOILER, THERMOSTAT]), - DATA_DHW_BURNER_HOURS: SatSensorInfo(SensorDeviceClass.DURATION, UnitOfTime.HOURS, "Hot Water Burner Hours {}", [BOILER, THERMOSTAT]), - DATA_MASTER_OT_VERSION: SatSensorInfo(None, None, "Thermostat OpenTherm Version {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_OT_VERSION: SatSensorInfo(None, None, "Boiler OpenTherm Version {}", [BOILER, THERMOSTAT]), - DATA_MASTER_PRODUCT_TYPE: SatSensorInfo(None, None, "Thermostat Product Type {}", [BOILER, THERMOSTAT]), - DATA_MASTER_PRODUCT_VERSION: SatSensorInfo(None, None, "Thermostat Product Version {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_PRODUCT_TYPE: SatSensorInfo(None, None, "Boiler Product Type {}", [BOILER, THERMOSTAT]), - DATA_SLAVE_PRODUCT_VERSION: SatSensorInfo(None, None, "Boiler Product Version {}", [BOILER, THERMOSTAT]), - - OTGW_MODE: SatSensorInfo(None, None, "Gateway/Monitor Mode {}", [OTGW]), - OTGW_DHW_OVRD: SatSensorInfo(None, None, "Gateway Hot Water Override Mode {}", [OTGW]), - OTGW_ABOUT: SatSensorInfo(None, None, "Gateway Firmware Version {}", [OTGW]), - OTGW_BUILD: SatSensorInfo(None, None, "Gateway Firmware Build {}", [OTGW]), - OTGW_SB_TEMP: SatSensorInfo(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, "Gateway Setback Temperature {}", [OTGW]), - OTGW_SETP_OVRD_MODE: SatSensorInfo(None, None, "Gateway Room Setpoint Override Mode {}", [OTGW]), - OTGW_SMART_PWR: SatSensorInfo(None, None, "Gateway Smart Power Mode {}", [OTGW]), - OTGW_THRM_DETECT: SatSensorInfo(None, None, "Gateway Thermostat Detection {}", [OTGW]), - OTGW_VREF: SatSensorInfo(None, None, "Gateway Reference Voltage Setting {}", [OTGW]), -} diff --git a/custom_components/sat/opentherm/services.py b/custom_components/sat/services.py similarity index 97% rename from custom_components/sat/opentherm/services.py rename to custom_components/sat/services.py index 49a7c42e..686182c6 100644 --- a/custom_components/sat/opentherm/services.py +++ b/custom_components/sat/services.py @@ -6,11 +6,10 @@ from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import ServiceCall -from ..const import * -from ..coordinator import DeviceState +from .const import * if typing.TYPE_CHECKING: - from ..climate import SatClimate + from .climate import SatClimate async def set_overshoot_protection_value(self, call: ServiceCall): @@ -30,6 +29,7 @@ async def start_overshoot_protection_calculation(self, climate: SatClimate, call self.logger.warning("[Overshoot Protection] Calculation already in progress.") return + from .coordinator import DeviceState self._device_state = DeviceState.ON self._overshoot_protection_calculate = True diff --git a/custom_components/sat/switch/coordinator.py b/custom_components/sat/switch/coordinator.py index 74997a44..1a37539b 100644 --- a/custom_components/sat/switch/coordinator.py +++ b/custom_components/sat/switch/coordinator.py @@ -2,7 +2,6 @@ import typing -from homeassistant.components.climate import HVACMode from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -12,7 +11,7 @@ from ..coordinator import DeviceState, SatDataUpdateCoordinator if typing.TYPE_CHECKING: - from ..climate import SatClimate + pass class SatSwitchCoordinator(SatDataUpdateCoordinator): @@ -23,13 +22,6 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: super().__init__(hass, store) self._entity_id = self._store.options.get(CONF_SWITCH) - async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: - """Control the max relative mod of the heating system.""" - await super().async_control_heating_loop(climate) - - if climate.hvac_mode == HVACMode.OFF and self.hass.states.get(self._entity_id).state != "OFF": - await self.async_set_heater_state(DeviceState.OFF) - async def async_set_heater_state(self, state: DeviceState) -> None: """Control the state of the central heating.""" if not self._simulation: @@ -37,3 +29,11 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) await super().async_set_heater_state(state) + + @property + def setpoint(self) -> float: + return self.minimum_setpoint + + @property + def device_active(self) -> bool: + return self.hass.states.get(self._entity_id).state != "OFF" diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index e7d2d659..3c1a90ac 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -5,16 +5,25 @@ "title": "Smart Autotune Thermostat (SAT)", "description": "SAT is a smart thermostat that is capable of auto-tuning itself to optimize temperature control. Select the appropriate mode that matches your heating system.", "menu_options": { - "opentherm": "OpenTherm Gateway ( OTGW )", + "mqtt": "OpenTherm Gateway ( MQTT )", + "serial": "OpenTherm Gateway ( SERIAL )", "switch": "PID Thermostat with PWM ( ON/OFF )" } }, - "opentherm": { - "title": "OpenTherm Gateway ( OTGW )", - "description": "Please fill in the following details to set up the OpenTherm Gateway:", + "mqtt": { + "title": "OpenTherm Gateway ( MQTT )", + "description": "Please fill in the following details to set up the OpenTherm Gateway.", "data": { "name": "Name", - "device": "Path or URL" + "device": "Device" + } + }, + "serial": { + "title": "OpenTherm Gateway ( SERIAL )", + "description": "Please fill in the following details to set up the OpenTherm Gateway.", + "data": { + "name": "Name", + "device": "URL" } }, "switch": { @@ -34,7 +43,8 @@ } }, "error": { - "auth": "Unable to connect." + "connection": "Unable to connect.", + "mqtt_component": "MQTT component is not enabled." }, "abort": { "already_configured": "Gateway is already configured." From e46bd53a42447cb76d254f43d41dc03a394504ae Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 20:07:34 +0200 Subject: [PATCH 026/237] Removed obsolete variable --- custom_components/sat/coordinator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index a0991423..33174c7b 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -30,7 +30,6 @@ class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" self._store = store - self._requested_setpoint = None self._device_state = DeviceState.OFF self._simulation = bool(self._store.options.get(CONF_SIMULATION)) self._minimum_setpoint = float(self._store.options.get(CONF_SETPOINT)) From 294699322c6873f691382b85c7942cca50431234 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 20:09:35 +0200 Subject: [PATCH 027/237] Added missing fields --- custom_components/sat/mqtt/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/mqtt/coordinator.py b/custom_components/sat/mqtt/coordinator.py index 53574356..60a6e3a8 100644 --- a/custom_components/sat/mqtt/coordinator.py +++ b/custom_components/sat/mqtt/coordinator.py @@ -152,7 +152,10 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: self._get_entity_id(DATA_CONTROL_SETPOINT), self._get_entity_id(DATA_DHW_ENABLE), self._get_entity_id(DATA_REL_MOD_LEVEL), + self._get_entity_id(DATA_BOILER_TEMPERATURE), self._get_entity_id(DATA_CENTRAL_HEATING), + self._get_entity_id(DATA_BOILER_CAPACITY), + self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), ])) From 8470f5e20f2ba42e69d71012b984d8e148e4ffe6 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 22:34:26 +0200 Subject: [PATCH 028/237] Add missing methods and made the MQTT topic customizable --- custom_components/sat/climate.py | 7 ++++++- custom_components/sat/config_flow.py | 6 ++++-- custom_components/sat/const.py | 4 ++++ custom_components/sat/coordinator.py | 6 +++++- custom_components/sat/mqtt/coordinator.py | 19 +++++++++++++++---- custom_components/sat/serial/coordinator.py | 12 ++++++++++++ custom_components/sat/translations/en.json | 3 ++- 7 files changed, 48 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index ac1f4602..7408249d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -165,6 +165,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._simulation = bool(self._store.options.get(CONF_SIMULATION)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) + self._sync_with_thermostat = bool(self._store.options.get(CONF_SYNC_WITH_THERMOSTAT)) self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) self._climate_valve_offset = float(self._store.options.get(CONF_CLIMATE_VALVE_OFFSET)) self._target_temperature_step = float(self._store.options.get(CONF_TARGET_TEMPERATURE_STEP)) @@ -853,11 +854,15 @@ async def async_set_target_temperature(self, temperature: float): # Reset the PID controller await self._async_control_pid(True) - # Set the temperature for each main climate + # Set the target temperature for each main climate for entity_id in self._main_climates: 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: + # Set the target temperature for the connected boiler + await self._coordinator.async_set_control_thermostat_setpoint(temperature) + # Write the state to Home Assistant self.async_write_ha_state() diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index c316df48..9e6ed256 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -73,6 +73,7 @@ async def async_step_mqtt(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_MQTT_TOPIC, default=OPTIONS_DEFAULTS[CONF_MQTT_TOPIC]): str, vol.Required(CONF_DEVICE): selector.DeviceSelector( selector.DeviceSelectorConfig(model="otgw-nodo") ), @@ -192,7 +193,7 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ), } - if options.get(CONF_MODE) == MODE_SERIAL: + if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: schema[vol.Required(CONF_HEATING_SYSTEM, default=options[CONF_HEATING_SYSTEM])] = selector.SelectSelector( selector.SelectSelectorConfig(options=[ {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, @@ -264,6 +265,7 @@ async def async_step_climates(self, _user_input=None) -> FlowResult: vol.Optional(CONF_CLIMATES, default=defaults[CONF_CLIMATES]): selector.EntitySelector( selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) ), + vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=defaults[CONF_SYNC_WITH_THERMOSTAT]): bool, }) ) @@ -295,7 +297,7 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, } - if options.get(CONF_MODE) == MODE_SERIAL: + if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index ba44abbf..6393ee8d 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -36,8 +36,10 @@ CONF_SWITCH = "switch" CONF_SETPOINT = "setpoint" CONF_CLIMATES = "climates" +CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" CONF_WINDOW_SENSOR = "window_sensor" +CONF_SYNC_WITH_THERMOSTAT = "sync_with_thermostat" CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" CONF_SIMULATION = "simulation" CONF_INTEGRAL = "integral" @@ -82,12 +84,14 @@ CONF_WINDOW_SENSOR: None, CONF_AUTOMATIC_GAINS: False, CONF_AUTOMATIC_DUTY_CYCLE: False, + CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, CONF_SETPOINT: 80, CONF_OVERSHOOT_PROTECTION: False, CONF_FORCE_PULSE_WIDTH_MODULATION: False, + CONF_MQTT_TOPIC: "OTGW", CONF_DUTY_CYCLE: "00:13:00", CONF_SAMPLE_TIME: "00:01:00", CONF_CLIMATE_VALVE_OFFSET: 0, diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 33174c7b..1a68f590 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -222,7 +222,11 @@ async def async_set_control_hot_water_setpoint(self, value: float) -> None: if self.supports_hot_water_setpoint_management: self.logger.info("Set control hot water setpoint to %d", value) - async def async_set_control_max_setpoint(self, value: float): + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + """Control the setpoint temperature for the thermostat.""" + pass + + 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) diff --git a/custom_components/sat/mqtt/coordinator.py b/custom_components/sat/mqtt/coordinator.py index 60a6e3a8..a27f59f7 100644 --- a/custom_components/sat/mqtt/coordinator.py +++ b/custom_components/sat/mqtt/coordinator.py @@ -52,9 +52,10 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) - self._device = device_registry.async_get(hass).async_get(device_id) self._node_id = list(self._device.identifiers)[0][1] + self._topic = store.options.get(CONF_MQTT_TOPIC) - 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 = entity_registry.async_get(hass) + self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) @property def supports_setpoint_management(self): @@ -177,6 +178,16 @@ async def async_set_control_setpoint(self, value: float) -> None: await super().async_set_control_setpoint(value) + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + await self._send_command(f"SW={value}") + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + await self._send_command(f"TC={value}") + + await super().async_set_control_thermostat_setpoint(value) + async def async_set_heater_state(self, state: DeviceState) -> None: await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") @@ -193,7 +204,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) def _get_entity_id(self, key: str): - return self.entity_registry.async_get_entity_id(SENSOR, MQTT_DOMAIN, f"{self._node_id}-{key}") + return self._entity_registry.async_get_entity_id(SENSOR, MQTT_DOMAIN, f"{self._node_id}-{key}") async def _on_state_change(self, entity_id: str, state: State): key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) @@ -212,4 +223,4 @@ async def _on_state_change(self, entity_id: str, state: State): async def _send_command(self, command: str): if not self._simulation: - await mqtt.async_publish(self.hass, f"OTGW/set/{self._node_id}/command", command) + await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", command) diff --git a/custom_components/sat/serial/coordinator.py b/custom_components/sat/serial/coordinator.py index b04a3f75..73159250 100644 --- a/custom_components/sat/serial/coordinator.py +++ b/custom_components/sat/serial/coordinator.py @@ -133,6 +133,18 @@ async def async_set_control_setpoint(self, value: float) -> None: await super().async_set_control_setpoint(value) + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + if not self._simulation: + await self.api.set_dhw_setpoint(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + if not self._simulation: + await self.api.set_target_temp(value) + + await super().async_set_control_thermostat_setpoint(value) + async def async_set_heater_state(self, state: DeviceState) -> None: """Control the state of the central heating.""" if not self._simulation: diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 3c1a90ac..df574551 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -89,7 +89,8 @@ "title": "Climates (multi-room)", "data": { "climates": "Climates (rooms)", - "main_climates": "Climates (main)" + "main_climates": "Climates (main)", + "sync_with_thermostat": "Synchronize setpoint with thermostat" } }, "contact_sensors": { From dfd0c109c9dde70615741851871e627b147454da Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 22:43:25 +0200 Subject: [PATCH 029/237] Add missing translation --- custom_components/sat/translations/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index df574551..3359597f 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -15,7 +15,8 @@ "description": "Please fill in the following details to set up the OpenTherm Gateway.", "data": { "name": "Name", - "device": "Device" + "device": "Device", + "mqtt_topic": "Top Topic" } }, "serial": { From 26d6ef1afe9239ea6d201b0d7cc22303718673fe Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 10 May 2023 22:43:52 +0200 Subject: [PATCH 030/237] Making sure that our own climate can't be added as a room --- custom_components/sat/config_flow.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 9e6ed256..11070dd1 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector +from homeassistant.helpers import selector, entity_registry from pyotgw import OpenThermGateway from .const import * @@ -255,17 +255,21 @@ async def async_step_climates(self, _user_input=None) -> FlowResult: return await self.update_options(_user_input) - defaults = await self.get_options() + entities = entity_registry.async_get(self.hass) + device_name = self._config_entry.data.get(CONF_NAME) + climate_id = entities.async_get_entity_id(CLIMATE_DOMAIN, DOMAIN, str(device_name).lower()) + + entity_selector = selector.EntitySelector(selector.EntitySelectorConfig( + exclude_entities=[climate_id], domain=CLIMATE_DOMAIN, multiple=True + )) + + options = await self.get_options() return self.async_show_form( step_id="climates", data_schema=vol.Schema({ - vol.Optional(CONF_MAIN_CLIMATES, default=defaults[CONF_MAIN_CLIMATES]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) - ), - vol.Optional(CONF_CLIMATES, default=defaults[CONF_CLIMATES]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN, multiple=True) - ), - vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=defaults[CONF_SYNC_WITH_THERMOSTAT]): bool, + vol.Optional(CONF_MAIN_CLIMATES, default=options[CONF_MAIN_CLIMATES]): entity_selector, + vol.Optional(CONF_CLIMATES, default=options[CONF_CLIMATES]): entity_selector, + vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=options[CONF_SYNC_WITH_THERMOSTAT]): bool, }) ) From 41779e440d1fec5e3e4fb68c7120c05f029d3405 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 19:58:41 +0200 Subject: [PATCH 031/237] More cleaning --- custom_components/sat/__init__.py | 44 ++-- custom_components/sat/binary_sensor.py | 43 +--- custom_components/sat/climate.py | 19 +- custom_components/sat/config_flow.py | 4 +- custom_components/sat/const.py | 14 +- custom_components/sat/coordinator.py | 24 +- custom_components/sat/entity.py | 16 +- custom_components/sat/mqtt/__init__.py | 225 ++++++++++++++++- custom_components/sat/mqtt/coordinator.py | 226 ------------------ custom_components/sat/pid.py | 4 +- custom_components/sat/sensor.py | 12 +- custom_components/sat/serial/__init__.py | 183 +++++++++++++- custom_components/sat/serial/binary_sensor.py | 10 +- custom_components/sat/serial/coordinator.py | 166 ------------- custom_components/sat/serial/sensor.py | 7 +- custom_components/sat/switch/__init__.py | 39 ++- custom_components/sat/switch/coordinator.py | 39 --- custom_components/sat/translations/en.json | 2 +- 18 files changed, 515 insertions(+), 562 deletions(-) delete mode 100644 custom_components/sat/mqtt/coordinator.py delete mode 100644 custom_components/sat/serial/coordinator.py delete mode 100644 custom_components/sat/switch/coordinator.py diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 6f90e30f..959624c0 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -1,13 +1,17 @@ import asyncio import logging -import sys +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant from . import mqtt, serial, switch from .config_store import SatConfigStore from .const import * +from .coordinator import SatDataUpdateCoordinatorFactory _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -23,32 +27,25 @@ async def async_setup(_hass: HomeAssistant, __config: Config): async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): """ - Set up this integration using UI. + Set up this integration using the UI. - This function is called by Home Assistant when the integration is set up using the UI. + This function is called by Home Assistant when the integration is set up with the UI. """ - - # Create a new dictionary for this entry if it doesn't exist - if _hass.data.get(DOMAIN) is None: - _hass.data.setdefault(DOMAIN, {}) + # Create a new dictionary + _hass.data[DOMAIN] = {_entry.entry_id: {}} # Create a new config store for this entry and initialize it - _hass.data[DOMAIN][_entry.entry_id] = {CONFIG_STORE: SatConfigStore(_hass, _entry)} + _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] = store = SatConfigStore(_hass, _entry) await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_entry.data) - - # Get the module name from the config entry data and import it dynamically - module = getattr(sys.modules[__name__], options.get(CONF_MODE)) - - # Call the async_setup_entry function of the module - await _hass.async_add_job(module.async_setup_entry, _hass, _entry) + # Resolve the coordinator by using the factory according to the mode + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve( + hass=_hass, store=store, mode=store.options.get(CONF_MODE), device=store.options.get(CONF_DEVICE) + ) # Forward entry setup for climate and other platforms - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE)) - await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR, NUMBER, BINARY_SENSOR])) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN)) + await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN])) # Add an update listener for this entry _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) @@ -62,15 +59,11 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: This function is called by Home Assistant when the integration is being removed. """ - - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_entry.data) - # Unload the entry and its dependent components unloaded = all( await asyncio.gather( - _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR, NUMBER, BINARY_SENSOR]), + _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(_hass.data[DOMAIN][_entry.entry_id][CLIMATE]), + _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]), ) ) @@ -87,7 +80,6 @@ async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: This function is called by Home Assistant when the integration configuration is updated. """ - # Unload the entry and its dependent components await async_unload_entry(_hass, _entry) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index d34b04ed..ddba0e5a 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import typing from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.components.climate import HVACAction @@ -9,13 +8,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE -from .entity import SatEntity +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .entity import SatClimateEntity from .serial import binary_sensor as serial_binary_sensor -if typing.TYPE_CHECKING: - from .climate import SatClimate - _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -26,26 +22,17 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_config_entry.data) - # Check if integration is set to use the serial protocol - if options.get(CONF_MODE) == MODE_SERIAL: - # Call function to set up OpenTherm binary sensors + if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: await serial_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) - _async_add_entities([ - SatControlSetpointSynchroSensor(coordinator, climate, _config_entry), - SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry), - ]) + if coordinator.supports_setpoint_management: + _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) + _async_add_entities([SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry)]) -class SatControlSetpointSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): - super().__init__(coordinator, config_entry) - self._climate = climate +class SatControlSetpointSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self): @@ -60,18 +47,12 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._climate is not None and self._coordinator.setpoint is not None + return self._climate.setpoint is not None and self._coordinator.setpoint is not None @property def is_on(self): """Return the state of the sensor.""" - coordinator_setpoint = self._coordinator.setpoint - climate_setpoint = float(self._climate.extra_state_attributes.get("setpoint") or coordinator_setpoint) - - return not ( - self._climate.state_attributes.get("hvac_action") != HVACAction.HEATING or - round(climate_setpoint, 1) == round(coordinator_setpoint, 1) - ) + return round(self._climate.setpoint, 1) != round(self._coordinator.setpoint, 1) @property def unique_id(self): @@ -79,11 +60,7 @@ def unique_id(self): return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" -class SatCentralHeatingSynchroSensor(SatEntity, BinarySensorEntity): - def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry) -> None: - """Initialize the Central Heating Synchro sensor.""" - super().__init__(coordinator, config_entry) - self._climate = climate +class SatCentralHeatingSynchroSensor(SatClimateEntity, BinarySensorEntity): @property def name(self) -> str: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 7408249d..c7619708 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -197,7 +197,7 @@ async def async_added_to_hass(self) -> None: # Restore previous state if available, or set default values await self._restore_previous_state_or_set_defaults() - # Update heating curve if outside temperature is available + # Update a heating curve if outside temperature is available if self.current_outside_temperature is not None: self._heating_curve.update(self.target_temperature, self.current_outside_temperature) @@ -426,6 +426,10 @@ def hvac_action(self): def max_error(self) -> float: return max([self.error] + self.climate_errors) + @property + def setpoint(self) -> float | None: + return self._setpoint + @property def climate_errors(self) -> List[float]: """Calculate the temperature difference between the current temperature and target temperature for all connected climates.""" @@ -515,6 +519,9 @@ def pulse_width_modulation_enabled(self) -> bool: return False def _get_requested_setpoint(self): + if self._heating_curve.value is None: + return MINIMUM_SETPOINT + return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) def _calculate_control_setpoint(self) -> float: @@ -525,7 +532,7 @@ def _calculate_control_setpoint(self) -> float: # Combine the heating curve value and the calculated output from the pid controller requested_setpoint = self._get_requested_setpoint() - # Make sure we are above the base setpoint when we are below target temperature + # Make sure we are above the base setpoint when we are below the target temperature if self.max_error > 0: requested_setpoint = max(requested_setpoint, self._heating_curve.value) @@ -598,7 +605,7 @@ async def _async_climate_changed(self, event: Event) -> None: elif new_attrs.get("temperature") != old_attrs.get("temperature"): await self._async_control_pid(True) - # If current temperature has changed, update the PID controller + # If the current temperature has changed, update the PID controller elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): await self._async_control_pid(False) @@ -658,7 +665,7 @@ async def _async_window_sensor_changed(self, event: Event) -> None: async def _async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" - # If overshoot protection is active, we are not doing anything since we already have task running in async + # If overshoot protection is active, we are not doing anything since we already have a task running in async if self.overshoot_protection_calculate: return @@ -717,7 +724,7 @@ async def _async_control_pid(self, reset: bool = False): if not reset: _LOGGER.info(f"Updating error value to {max_error} (Reset: False)") - # Calculate optimal heating curve when we are in the deadband + # Calculate an optimal heating curve when we are in the deadband if -0.1 <= max_error <= 0.1: self._heating_curve.autotune( setpoint=self._get_requested_setpoint(), @@ -725,7 +732,7 @@ async def _async_control_pid(self, reset: bool = False): outside_temperature=self.current_outside_temperature ) - # Since we are in the deadband we can safely assume we are not warming up anymore + # Since we are in the deadband, we can safely assume we are not warming up anymore if self._warming_up and max_error <= 0.1: self._warming_up = False _LOGGER.info("Reached deadband, turning off warming up.") diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 11070dd1..c16ad3ba 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -111,7 +111,7 @@ async def async_step_switch(self, _user_input=None): self._data.update(_user_input) self._data[CONF_MODE] = MODE_SWITCH - await self.async_set_unique_id(self._data[CONF_SWITCH], raise_on_progress=False) + await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() @@ -123,7 +123,7 @@ async def async_step_switch(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_SWITCH): selector.EntitySelector( + vol.Required(CONF_DEVICE): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN]) ) }), diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 6393ee8d..159b9bae 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -2,6 +2,7 @@ NAME = "Smart Autotune Thermostat" DOMAIN = "sat" VERSION = "2.1.0" +CLIMATE = "climate" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" @@ -17,23 +18,10 @@ OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 -# Icons -ICON = "mdi:format-quote-close" - -# Device classes -BINARY_SENSOR_DEVICE_CLASS = "connectivity" - -# Platforms -SENSOR = "sensor" -NUMBER = "number" -CLIMATE = "climate" -BINARY_SENSOR = "binary_sensor" - # Configuration and options CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" -CONF_SWITCH = "switch" CONF_SETPOINT = "setpoint" CONF_CLIMATES = "climates" CONF_MQTT_TOPIC = "mqtt_topic" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 1a68f590..76395b73 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -26,6 +26,24 @@ class DeviceState(Enum): OFF = "off" +class SatDataUpdateCoordinatorFactory: + @staticmethod + async def resolve(hass: HomeAssistant, store: SatConfigStore, mode: str, device: str) -> DataUpdateCoordinator: + if mode == MODE_MQTT: + from .mqtt import SatMqttCoordinator + return SatMqttCoordinator(hass, store, device) + + if mode == MODE_SERIAL: + from .serial import SatSerialCoordinator + return await SatSerialCoordinator(hass, store, device).async_connect() + + if mode == MODE_SWITCH: + from .switch import SatSwitchCoordinator + return SatSwitchCoordinator(hass, store, device) + + raise Exception(f'Invalid mode[{mode}]') + + class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: """Initialize.""" @@ -76,7 +94,7 @@ def device_active(self) -> bool: @property def flame_active(self) -> bool: - return True + return self.device_active @property def hot_water_active(self) -> bool: @@ -183,6 +201,10 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: partial(set_overshoot_protection_value, self) ) + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + """Run when entity will be removed from hass.""" + pass + async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: """Control the heating loop for the device.""" if climate.hvac_mode == HVACMode.OFF and self.device_active: diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index d5e2550e..b4cea2f1 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -1,14 +1,19 @@ -"""SatEntity class""" +from __future__ import annotations + import logging +import typing from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, NAME, VERSION, CONF_NAME -from .coordinator import SatDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + from .climate import SatClimate + from .coordinator import SatDataUpdateCoordinator + class SatEntity(CoordinatorEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): @@ -25,3 +30,10 @@ def device_info(self): "manufacturer": NAME, "identifiers": {(DOMAIN, self._config_entry.data.get(CONF_NAME))}, } + + +class SatClimateEntity(SatEntity): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._climate = climate diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 063848c4..97455386 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -1,17 +1,228 @@ +from __future__ import annotations, annotations + import logging +import typing from homeassistant.components import mqtt -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, Event, State +from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event -from .coordinator import SatMqttCoordinator +from ..config_store import SatConfigStore from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +DATA_FLAME_ACTIVE = "flame" +DATA_DHW_SETPOINT = "TdhwSet" +DATA_CONTROL_SETPOINT = "TSet" +DATA_DHW_ENABLE = "dhw_enable" +DATA_REL_MOD_LEVEL = "RelModLevel" +DATA_BOILER_TEMPERATURE = "Tboiler" +DATA_CENTRAL_HEATING = "centralheating" +DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" +DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" +DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" +DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" + +if typing.TYPE_CHECKING: + from ..climate import SatClimate _LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatMqttCoordinator(_hass, store, _entry.data.get(CONF_DEVICE)) +def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): + entities = entity_registry.async_get(hass) + entity = entities.async_get(entity_id) + + if entity.unique_id: + return entity.unique_id[len(node_id) + 1:] + + return None + + +class SatMqttCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: + super().__init__(hass, store) + + self.data = {} + + self._device = device_registry.async_get(hass).async_get(device_id) + self._node_id = list(self._device.identifiers)[0][1] + self._topic = store.options.get(CONF_MQTT_TOPIC) + + self._entity_registry = entity_registry.async_get(hass) + self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) + + @property + def supports_setpoint_management(self): + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + def supports_maximum_setpoint_management(self): + return True + + @property + def support_relative_modulation_management(self): + return True + + @property + def device_active(self) -> bool: + return bool(self.data.get(DATA_CENTRAL_HEATING)) + + @property + def flame_active(self) -> bool: + return bool(self.data.get(DATA_FLAME_ACTIVE)) + + @property + def hot_water_active(self) -> bool: + return bool(self.data.get(DATA_DHW_ENABLE)) + + @property + def setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return None + + @property + def boiler_capacity(self) -> float | None: + if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + async def async_added_to_hass(self, climate: SatClimate) -> None: + await mqtt.async_wait_for_mqtt_client(self.hass) + + await self._send_command("PM=48") + + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(DATA_FLAME_ACTIVE), + self._get_entity_id(DATA_DHW_SETPOINT), + self._get_entity_id(DATA_CONTROL_SETPOINT), + self._get_entity_id(DATA_DHW_ENABLE), + self._get_entity_id(DATA_REL_MOD_LEVEL), + self._get_entity_id(DATA_BOILER_TEMPERATURE), + self._get_entity_id(DATA_CENTRAL_HEATING), + self._get_entity_id(DATA_BOILER_CAPACITY), + self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), + ])) + + for entity_id in entities: + if state := self.hass.states.get(entity_id): + await self._on_state_change(entity_id, state) + + async def async_coroutine(event: Event): + await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) + + async_track_state_change_event(self.hass, entities, async_coroutine) + + async def async_set_control_setpoint(self, value: float) -> None: + await self._send_command(f"CS={value}") + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + await self._send_command(f"SW={value}") + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + await self._send_command(f"TC={value}") + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") + + await super().async_set_heater_state(state) + + async def async_control_max_relative_mod(self, value: float) -> None: + await self._send_command("MM={value}") + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + await self._send_command(f"SH={value}") + + await super().async_set_control_max_setpoint(value) + + def _get_entity_id(self, key: str): + return self._entity_registry.async_get_entity_id(SENSOR_DOMAIN, MQTT_DOMAIN, f"{self._node_id}-{key}") + + async def _on_state_change(self, entity_id: str, state: State): + key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) + if key is None: + return + + if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + self.data[key] = None + else: + self.data[key] = state.state + + if self._listeners: + self._schedule_refresh() + + self.async_update_listeners() + + async def _send_command(self, payload: str): + if not self._simulation: + await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", payload) - await mqtt.async_wait_for_mqtt_client(_hass) + _LOGGER.debug(f"Publishing '{payload}' to MQTT.") diff --git a/custom_components/sat/mqtt/coordinator.py b/custom_components/sat/mqtt/coordinator.py deleted file mode 100644 index a27f59f7..00000000 --- a/custom_components/sat/mqtt/coordinator.py +++ /dev/null @@ -1,226 +0,0 @@ -from __future__ import annotations, annotations - -import logging -import typing - -from homeassistant.components import mqtt -from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, Event, State -from homeassistant.helpers import device_registry, entity_registry -from homeassistant.helpers.event import async_track_state_change_event - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -DATA_FLAME_ACTIVE = "flame" -DATA_DHW_SETPOINT = "TdhwSet" -DATA_CONTROL_SETPOINT = "TSet" -DATA_DHW_ENABLE = "dhw_enable" -DATA_REL_MOD_LEVEL = "RelModLevel" -DATA_BOILER_TEMPERATURE = "Tboiler" -DATA_CENTRAL_HEATING = "centralheating" -DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" -DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" -DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" -DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" - -if typing.TYPE_CHECKING: - from ..climate import SatClimate - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): - entities = entity_registry.async_get(hass) - entity = entities.async_get(entity_id) - - if entity.unique_id: - return entity.unique_id[len(node_id) + 1:] - - return None - - -class SatMqttCoordinator(SatDataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway using mqtt.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: - super().__init__(hass, store) - - self.data = {} - - self._device = device_registry.async_get(hass).async_get(device_id) - self._node_id = list(self._device.identifiers)[0][1] - self._topic = store.options.get(CONF_MQTT_TOPIC) - - self._entity_registry = entity_registry.async_get(hass) - self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) - - @property - def supports_setpoint_management(self): - return True - - @property - def supports_hot_water_setpoint_management(self): - return True - - def supports_maximum_setpoint_management(self): - return True - - @property - def support_relative_modulation_management(self): - return True - - @property - def device_active(self) -> bool: - return bool(self.data.get(DATA_CENTRAL_HEATING)) - - @property - def flame_active(self) -> bool: - return bool(self.data.get(DATA_FLAME_ACTIVE)) - - @property - def hot_water_active(self) -> bool: - return bool(self.data.get(DATA_DHW_ENABLE)) - - @property - def setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: - return float(setpoint) - - return super().minimum_hot_water_setpoint - - @property - def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: - return float(setpoint) - - return super().maximum_hot_water_setpoint - - @property - def boiler_temperature(self) -> float | None: - if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: - return float(value) - - return super().boiler_temperature - - @property - def relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: - return float(value) - - return None - - @property - def boiler_capacity(self) -> float | None: - if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) - - async def async_added_to_hass(self, climate: SatClimate) -> None: - await self._send_command("PM=48") - - entities = list(filter(lambda entity: entity is not None, [ - self._get_entity_id(DATA_FLAME_ACTIVE), - self._get_entity_id(DATA_DHW_SETPOINT), - self._get_entity_id(DATA_CONTROL_SETPOINT), - self._get_entity_id(DATA_DHW_ENABLE), - self._get_entity_id(DATA_REL_MOD_LEVEL), - self._get_entity_id(DATA_BOILER_TEMPERATURE), - self._get_entity_id(DATA_CENTRAL_HEATING), - self._get_entity_id(DATA_BOILER_CAPACITY), - self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), - self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), - self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), - ])) - - for entity_id in entities: - if state := self.hass.states.get(entity_id): - await self._on_state_change(entity_id, state) - - async def on_state_change(event: Event): - await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) - - async_track_state_change_event(self.hass, entities, on_state_change) - - async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: - await super().async_control_heating_loop(climate, _time) - - async def async_set_control_setpoint(self, value: float) -> None: - await self._send_command(f"CS={value}") - - await super().async_set_control_setpoint(value) - - async def async_set_control_hot_water_setpoint(self, value: float) -> None: - await self._send_command(f"SW={value}") - - await super().async_set_control_hot_water_setpoint(value) - - async def async_set_control_thermostat_setpoint(self, value: float) -> None: - await self._send_command(f"TC={value}") - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_heater_state(self, state: DeviceState) -> None: - await self._send_command(f"CH={1 if state == DeviceState.ON else 0}") - - await super().async_set_heater_state(state) - - async def async_control_max_relative_mod(self, value: float) -> None: - await self._send_command("MM={value}") - - await super().async_set_control_max_relative_modulation(value) - - async def async_set_control_max_setpoint(self, value: float) -> None: - await self._send_command(f"SH={value}") - - await super().async_set_control_max_setpoint(value) - - def _get_entity_id(self, key: str): - return self._entity_registry.async_get_entity_id(SENSOR, MQTT_DOMAIN, f"{self._node_id}-{key}") - - async def _on_state_change(self, entity_id: str, state: State): - key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) - if key is None: - return - - if state.state is not None and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self.data[key] = state.state - else: - self.data[key] = None - - if self._listeners: - self._schedule_refresh() - - self.async_update_listeners() - - async def _send_command(self, command: str): - if not self._simulation: - await mqtt.async_publish(self.hass, f"{self._topic}/set/{self._node_id}/command", command) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 6a133627..9a28b1f7 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -176,7 +176,7 @@ def update_derivative(self, error: float, alpha1: float = 0.8, alpha2: float = 0 def update_history_size(self, alpha: float = 0.8): """ - Update the size of the history of errors and times. + Update the history of errors and times. The size of the history is updated based on the frequency of updates to the sensor value. If the frequency of updates is high, the history size is increased, and if the frequency of updates is low, @@ -202,7 +202,7 @@ def update_history_size(self, alpha: float = 0.8): history_size = max(2, history_size) history_size = min(history_size, 100) - # Calculate a weighted average of the rate of updates and the previous history size + # Calculate an average weighted rate of updates and the previous history size self._history_size = alpha * history_size + (1 - alpha) * self._history_size # Update our lists with the new size diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 9b61624a..b1f0cfe0 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import typing from homeassistant.components.sensor import SensorEntity, SensorDeviceClass @@ -9,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_MODE, MODE_SERIAL, OPTIONS_DEFAULTS, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE from .coordinator import SatDataUpdateCoordinator from .entity import SatEntity from .serial import sensor as serial_sensor @@ -17,6 +18,8 @@ if typing.TYPE_CHECKING: from .climate import SatClimate +_LOGGER: logging.Logger = logging.getLogger(__name__) + async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_entities: AddEntitiesCallback): """ @@ -25,13 +28,8 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a climate = _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] - # Retrieve the defaults and override it with the user options - options = OPTIONS_DEFAULTS.copy() - options.update(_config_entry.data) - # Check if integration is set to use the serial protocol - if options.get(CONF_MODE) == MODE_SERIAL: - # Call function to set up OpenTherm sensors + if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: await serial_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) _async_add_entities([ diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 810fa822..96940b55 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -1,14 +1,22 @@ +from __future__ import annotations + import asyncio import logging +import typing +from typing import Optional, Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from pyotgw import OpenThermGateway, vars as gw_vars +from pyotgw import vars as gw_vars, OpenThermGateway +from pyotgw.vars import * from serial import SerialException -from .coordinator import SatSerialCoordinator +from ..config_store import SatConfigStore from ..const import * +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +if typing.TYPE_CHECKING: + from ..climate import SatClimate _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -20,12 +28,165 @@ } -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - try: - client = OpenThermGateway() - await client.connect(port=_entry.data.get(CONF_DEVICE), timeout=5) - except (asyncio.TimeoutError, ConnectionError, SerialException) as exception: - raise ConfigEntryNotReady(f"Could not connect to gateway at {_entry.data.get(CONF_DEVICE)}: {exception}") from exception +class SatSerialCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using pyotgw.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore, port: str) -> None: + """Initialize.""" + super().__init__(hass, store) + + self.data = DEFAULT_STATUS + + async def async_coroutine(data): + self.async_set_updated_data(data) + + self._port = port + self._api = OpenThermGateway() + self._api.subscribe(async_coroutine) + + @property + def device_active(self) -> bool: + return bool(self.get(DATA_MASTER_CH_ENABLED) or False) + + @property + def hot_water_active(self) -> bool: + return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def supports_hot_water_setpoint_management(self): + return True + + @property + def supports_maximum_setpoint_management(self) -> bool: + return True + + @property + def support_relative_modulation_management(self) -> bool: + return self._overshoot_protection or not self._force_pulse_width_modulation + + @property + def setpoint(self) -> float | None: + if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: + return float(setpoint) + + return None + + @property + def hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: + return float(setpoint) + + return super().hot_water_setpoint + + @property + def boiler_temperature(self) -> float | None: + if (value := self.get(DATA_CH_WATER_TEMP)) is not None: + return float(value) + + return super().boiler_temperature + + @property + def minimum_hot_water_setpoint(self) -> float: + if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: + return float(setpoint) + + return super().minimum_hot_water_setpoint + + @property + def maximum_hot_water_setpoint(self) -> float | None: + if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: + return float(setpoint) + + return super().maximum_hot_water_setpoint + + @property + def relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: + return float(value) + + return super().relative_modulation_value + + @property + def boiler_capacity(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: + return float(value) + + return super().boiler_capacity + + @property + def minimum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: + return float(value) + + return super().minimum_relative_modulation_value + + @property + def flame_active(self) -> bool: + return bool(self.get(DATA_SLAVE_FLAME_ON)) + + @property + def minimum_setpoint(self): + return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + + def get(self, key: str) -> Optional[Any]: + """Get the value for the given `key` from the boiler data. + + :param key: Key of the value to retrieve from the boiler data. + :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. + """ + return self.data[BOILER].get(key) + + async def async_connect(self) -> SatSerialCoordinator: + try: + await self._api.connect(port=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 + + return self + + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: + self._api.unsubscribe(self.async_set_updated_data) + + await self._api.set_control_setpoint(0) + await self._api.set_max_relative_mod("-") + await self._api.disconnect() + + async def async_set_control_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_control_setpoint(value) + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_dhw_setpoint(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_target_temp(value) + + await super().async_set_control_thermostat_setpoint(value) + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + await self._api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) + + await super().async_set_heater_state(state) + + async def async_set_control_max_relative_modulation(self, value: float) -> None: + if not self._simulation: + await self._api.set_max_relative_mod(value) + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + if not self._simulation: + await self._api.set_max_ch_setpoint(value) - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = SatSerialCoordinator(_hass, store, client) + await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/serial/binary_sensor.py b/custom_components/sat/serial/binary_sensor.py index 2a495ad7..6a7f54ae 100644 --- a/custom_components/sat/serial/binary_sensor.py +++ b/custom_components/sat/serial/binary_sensor.py @@ -10,14 +10,10 @@ from homeassistant.helpers.entity import async_generate_entity_id from pyotgw.vars import * -from . import TRANSLATE_SOURCE -from .coordinator import SatSerialCoordinator +from . import TRANSLATE_SOURCE, SatSerialCoordinator from ..const import * from ..entity import SatEntity -if typing.TYPE_CHECKING: - pass - _LOGGER = logging.getLogger(__name__) @@ -73,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of entities to be added + # Create a list of entities to be added entities = [] # Iterate through sensor information @@ -124,7 +120,7 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._coordinator.data[self._source].get(self._key) is not None @property def is_on(self): diff --git a/custom_components/sat/serial/coordinator.py b/custom_components/sat/serial/coordinator.py deleted file mode 100644 index 73159250..00000000 --- a/custom_components/sat/serial/coordinator.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -import logging -from typing import Optional, Any - -from homeassistant.core import HomeAssistant -from pyotgw import OpenThermGateway -from pyotgw.vars import * - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class SatSerialCoordinator(SatDataUpdateCoordinator): - """Class to manage fetching data from the OTGW Gateway using pyotgw.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore, client: OpenThermGateway) -> None: - """Initialize.""" - super().__init__(hass, store) - - self.api = client - self.api.subscribe(self.async_set_updated_data) - - @property - def device_active(self) -> bool: - return bool(self.get(DATA_MASTER_CH_ENABLED) or False) - - @property - def hot_water_active(self) -> bool: - return bool(self.get(DATA_SLAVE_DHW_ACTIVE) or False) - - @property - def supports_setpoint_management(self) -> bool: - return True - - @property - def supports_hot_water_setpoint_management(self): - return True - - @property - def supports_maximum_setpoint_management(self) -> bool: - return True - - @property - def support_relative_modulation_management(self) -> bool: - return self._overshoot_protection or not self._force_pulse_width_modulation - - @property - def setpoint(self) -> float | None: - if (setpoint := self.get(DATA_CONTROL_SETPOINT)) is not None: - return float(setpoint) - - return None - - @property - def hot_water_setpoint(self) -> float | None: - if (setpoint := self.get(DATA_DHW_SETPOINT)) is not None: - return float(setpoint) - - return super().hot_water_setpoint - - @property - def boiler_temperature(self) -> float | None: - if (value := self.get(DATA_CH_WATER_TEMP)) is not None: - return float(value) - - return super().boiler_temperature - - @property - def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.get(DATA_SLAVE_DHW_MIN_SETP)) is not None: - return float(setpoint) - - return super().minimum_hot_water_setpoint - - @property - def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.get(DATA_SLAVE_DHW_MAX_SETP)) is not None: - return float(setpoint) - - return super().maximum_hot_water_setpoint - - @property - def relative_modulation_value(self) -> float | None: - if (value := self.get(DATA_REL_MOD_LEVEL)) is not None: - return float(value) - - return super().relative_modulation_value - - @property - def boiler_capacity(self) -> float | None: - if (value := self.get(DATA_SLAVE_MAX_CAPACITY)) is not None: - return float(value) - - return super().boiler_capacity - - @property - def minimum_relative_modulation_value(self) -> float | None: - if (value := self.get(DATA_SLAVE_MIN_MOD_LEVEL)) is not None: - return float(value) - - return super().minimum_relative_modulation_value - - @property - def flame_active(self) -> bool: - return bool(self.get(DATA_SLAVE_FLAME_ON)) - - @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) - - def get(self, key: str) -> Optional[Any]: - """Get the value for the given `key` from the boiler data. - - :param key: Key of the value to retrieve from the boiler data. - :return: Value for the given key from the boiler data, or None if the boiler data or the value are not available. - """ - return self.data[BOILER].get(key) if self.data[BOILER] else None - - async def async_cleanup(self) -> None: - self.api.unsubscribe(self.async_set_updated_data) - - await self.api.set_control_setpoint(0) - await self.api.set_max_relative_mod("-") - await self.api.disconnect() - - async def async_set_control_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_control_setpoint(value) - - await super().async_set_control_setpoint(value) - - async def async_set_control_hot_water_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_dhw_setpoint(value) - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_control_thermostat_setpoint(self, value: float) -> None: - if not self._simulation: - await self.api.set_target_temp(value) - - await super().async_set_control_thermostat_setpoint(value) - - async def async_set_heater_state(self, state: DeviceState) -> None: - """Control the state of the central heating.""" - if not self._simulation: - await self.api.set_ch_enable_bit(1 if state == DeviceState.ON else 0) - - await super().async_set_heater_state(state) - - async def async_set_control_max_relative_modulation(self, value: float) -> None: - if not self._simulation: - await self.api.set_max_relative_mod(value) - - await super().async_set_control_max_relative_modulation(value) - - async def async_set_control_max_setpoint(self, value: float) -> None: - """Set a maximum temperature limit on the boiler.""" - if not self._simulation: - await self.api.set_max_ch_setpoint(value) - - await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/serial/sensor.py b/custom_components/sat/serial/sensor.py index 583d1be6..a190ba3d 100644 --- a/custom_components/sat/serial/sensor.py +++ b/custom_components/sat/serial/sensor.py @@ -9,8 +9,7 @@ from homeassistant.helpers.entity import async_generate_entity_id from pyotgw.vars import * -from . import TRANSLATE_SOURCE -from .coordinator import SatSerialCoordinator +from . import TRANSLATE_SOURCE, SatSerialCoordinator from ..const import * from ..entity import SatEntity @@ -89,7 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] has_thermostat = coordinator.data[OTGW].get(OTGW_THRM_DETECT) != "D" - # Create list of entities to be added + # Create a list of entities to be added entities = [] # Iterate through sensor information @@ -144,7 +143,7 @@ def native_unit_of_measurement(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.data is not None and self._coordinator.data[self._source] is not None + return self._coordinator.data[self._source].get(self._key) is not None @property def native_value(self): diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index f02977d6..af9c98d8 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -1,16 +1,37 @@ -import logging +from __future__ import annotations -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant -from .coordinator import SatSwitchCoordinator -from ..const import * +from ..config_store import SatConfigStore +from ..coordinator import DeviceState, SatDataUpdateCoordinator -_LOGGER: logging.Logger = logging.getLogger(__name__) +class SatSwitchCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" -async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): - _LOGGER.debug("Setting up Switch integration") + def __init__(self, hass: HomeAssistant, store: SatConfigStore, entity_id: str) -> None: + """Initialize.""" + super().__init__(hass, store) - store = _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] - _hass.data[DOMAIN][_entry.entry_id] = {COORDINATOR: SatSwitchCoordinator(_hass, store)} + self._entity_id = entity_id + + @property + def setpoint(self) -> float: + return self.minimum_setpoint + + @property + def maximum_setpoint(self) -> float: + return self.minimum_setpoint + + @property + def device_active(self) -> bool: + return self.hass.states.get(self._entity_id).state == STATE_ON + + async def async_set_heater_state(self, state: DeviceState) -> None: + if not self._simulation: + service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF + await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) + + await super().async_set_heater_state(state) diff --git a/custom_components/sat/switch/coordinator.py b/custom_components/sat/switch/coordinator.py deleted file mode 100644 index 1a37539b..00000000 --- a/custom_components/sat/switch/coordinator.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import typing - -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.const import SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant - -from ..config_store import SatConfigStore -from ..const import * -from ..coordinator import DeviceState, SatDataUpdateCoordinator - -if typing.TYPE_CHECKING: - pass - - -class SatSwitchCoordinator(SatDataUpdateCoordinator): - """Class to manage the Switch.""" - - def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: - """Initialize.""" - super().__init__(hass, store) - self._entity_id = self._store.options.get(CONF_SWITCH) - - async def async_set_heater_state(self, state: DeviceState) -> None: - """Control the state of the central heating.""" - if not self._simulation: - service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF - await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) - - await super().async_set_heater_state(state) - - @property - def setpoint(self) -> float: - return self.minimum_setpoint - - @property - def device_active(self) -> bool: - return self.hass.states.get(self._entity_id).state != "OFF" diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 3359597f..c366c704 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -32,7 +32,7 @@ "description": "Please fill in the following details to setup the switch:", "data": { "name": "Name", - "switch": "Switch" + "device": "Switch" } }, "sensors": { From d910610454612b7c561e49ebdd219390859d61d4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:11:31 +0200 Subject: [PATCH 032/237] Typo --- 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 159b9bae..012aa2a9 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -103,7 +103,7 @@ # Services SERVICE_RESET_INTEGRAL = "reset_integral" SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" -SERVICE_OVERSHOOT_PROTECTION_CALCULATION = "overshoot_protection_calculation" +SERVICE_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" # Config steps STEP_SETUP_GATEWAY = "gateway" From f32fd05930cc4d3f693d302d9cc47405f5b2b6a2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:13:24 +0200 Subject: [PATCH 033/237] Some renaming --- custom_components/sat/const.py | 2 +- custom_components/sat/coordinator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 012aa2a9..dbec5649 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -103,7 +103,7 @@ # Services SERVICE_RESET_INTEGRAL = "reset_integral" SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" -SERVICE_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" +SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" # Config steps STEP_SETUP_GATEWAY = "gateway" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 76395b73..9121dc42 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -191,7 +191,7 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: self.hass.services.async_register( DOMAIN, - SERVICE_OVERSHOOT_PROTECTION_CALCULATION, + SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, partial(start_overshoot_protection_calculation, self, climate) ) From 613e5aa796be6b31d09c1398a8dfe10fba57431c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:16:06 +0200 Subject: [PATCH 034/237] Making sure the services are always available --- custom_components/sat/coordinator.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 9121dc42..6feb23e2 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -172,6 +172,18 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) + self.hass.services.async_register( + DOMAIN, + SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, + partial(start_overshoot_protection_calculation, self, climate) + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, + partial(set_overshoot_protection_value, self) + ) + if self.supports_setpoint_management: if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: self._overshoot_protection = False @@ -189,18 +201,6 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: message="Disabled forced pulse width modulation because no overshoot value has been found." ) - self.hass.services.async_register( - DOMAIN, - SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self, climate) - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self) - ) - async def async_will_remove_from_hass(self, climate: SatClimate) -> None: """Run when entity will be removed from hass.""" pass From 40501344ffcaaf984c1c8795776cddc77c90d14b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:24:42 +0200 Subject: [PATCH 035/237] Revert and also make sure the parent method is executed --- custom_components/sat/coordinator.py | 24 ++++++++++++------------ custom_components/sat/mqtt/__init__.py | 2 ++ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 6feb23e2..9121dc42 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -172,18 +172,6 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) - self.hass.services.async_register( - DOMAIN, - SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self, climate) - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self) - ) - if self.supports_setpoint_management: if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: self._overshoot_protection = False @@ -201,6 +189,18 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: message="Disabled forced pulse width modulation because no overshoot value has been found." ) + self.hass.services.async_register( + DOMAIN, + SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, + partial(start_overshoot_protection_calculation, self, climate) + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, + partial(set_overshoot_protection_value, self) + ) + async def async_will_remove_from_hass(self, climate: SatClimate) -> None: """Run when entity will be removed from hass.""" pass diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 97455386..fc5eb524 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -173,6 +173,8 @@ async def async_coroutine(event: Event): async_track_state_change_event(self.hass, entities, async_coroutine) + await super().async_added_to_hass(climate) + async def async_set_control_setpoint(self, value: float) -> None: await self._send_command(f"CS={value}") From 6918099bc141a117b7bed9aa421cc44b7a4a5eb5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:25:44 +0200 Subject: [PATCH 036/237] Typo? --- custom_components/sat/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index c16ad3ba..a90813af 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -60,7 +60,7 @@ async def async_step_mqtt(self, _user_input=None): if not await mqtt.async_wait_for_mqtt_client(self.hass): self._errors["base"] = "mqtt_component" - return await self.async_step_serial() + return await self.async_step_mqtt() await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() From 79ec5632d1d56d4368961f7ae04fcef116aa2594 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 21:28:11 +0200 Subject: [PATCH 037/237] Some translation improvements --- custom_components/sat/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index c366c704..d1987d7e 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -44,8 +44,8 @@ } }, "error": { - "connection": "Unable to connect.", - "mqtt_component": "MQTT component is not enabled." + "connection": "Unable to connect to the gateway.", + "mqtt_component": "The MQTT component is unavailable." }, "abort": { "already_configured": "Gateway is already configured." From 1eacc14dbf9e698871222ac19baa195d0bf2f566 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 11 May 2023 22:02:45 +0200 Subject: [PATCH 038/237] Making sure we make it apparent that you need at least version 2023.5 --- hacs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hacs.json b/hacs.json index 3d319078..22022c11 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "hacs": "1.6.0", "render_readme": true, - "homeassistant": "2023.1.0", + "homeassistant": "2023.5.0", "name": "Smart Autotune Thermostat" } From 916958bf354eddef2d26f43edf7daef3d1b57a77 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 12 May 2023 23:51:24 +0200 Subject: [PATCH 039/237] Fixed max relative mod adjusting with MQTT --- 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 fc5eb524..65567825 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -196,7 +196,7 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await super().async_set_heater_state(state) async def async_control_max_relative_mod(self, value: float) -> None: - await self._send_command("MM={value}") + await self._send_command(f"MM={value}") await super().async_set_control_max_relative_modulation(value) From b7574732ec482011f8762704e7cc10c8b0562a41 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 13 May 2023 21:06:22 +0200 Subject: [PATCH 040/237] Fixed some issues for determining when to enable or disable relation modulation --- custom_components/sat/coordinator.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 9121dc42..47901f30 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -212,17 +212,17 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N await self.async_set_heater_state(DeviceState.OFF) if self.support_relative_modulation_management: - # Check if the climate control is not in heating mode - not_in_heating_mode = climate.hvac_mode != HVACMode.HEAT + # Determine whether to enable maximum relative modulation value based on the conditions + relative_modulation_enabled = ( + # If the climate control is not in heating mode or hot water is requested + climate.hvac_mode != HVACMode.HEAT or self.hot_water_active - # Check if the setpoint is below the minimum allowed value (or basically off) - is_below_min_setpoint = self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT + # If the setpoint is below the minimum allowed value (or basically off) + or (self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT) - # Check if the setpoint is close to or above the overshoot protection value - is_overshooting = self.setpoint is not None and abs(climate.max_error) > 0.1 and self.setpoint >= (self.minimum_setpoint - 2) - - # Determine whether to enable maximum or minimum relative modulation value based on the conditions - relative_modulation_enabled = not_in_heating_mode or self.hot_water_active or is_below_min_setpoint or is_overshooting + # If the pulse width modulation is enabled (in deadband or warming up, and we are above minimum setpoint) + and climate.pulse_width_modulation_enabled + ) # Control the relative modulation value based on the conditions await self.async_set_control_max_relative_modulation( From 21d2ae354ce64175fc1592bce0ad523f980705cf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 14 May 2023 00:18:49 +0200 Subject: [PATCH 041/237] Re-worked the config flow to be more user-friendly --- custom_components/sat/config_flow.py | 60 ++++++++++++++++------------ custom_components/sat/const.py | 2 +- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index a90813af..8f5d9e27 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector, entity_registry @@ -180,18 +181,13 @@ async def async_step_user(self, _user_input=None) -> FlowResult: async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: + _user_input[CONF_AUTOMATIC_GAINS] = _user_input[CONF_AUTOMATIC_GAINS] == STATE_ON + return await self.update_options(_user_input) + schema = {} options = await self.get_options() - - 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) - ), - vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP]): selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) - ), - } + options[CONF_AUTOMATIC_GAINS] = STATE_ON if options[CONF_AUTOMATIC_GAINS] else STATE_OFF if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: schema[vol.Required(CONF_HEATING_SYSTEM, default=options[CONF_HEATING_SYSTEM])] = selector.SelectSelector( @@ -203,18 +199,25 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ]) ) - if options.get(CONF_MODE) == MODE_SWITCH: - schema[vol.Required(CONF_SETPOINT, default=50)] = selector.NumberSelector( - selector.NumberSelectorConfig(min=0, max=100, step=1) - ) + schema[vol.Required(CONF_AUTOMATIC_GAINS, default=options.get(CONF_AUTOMATIC_GAINS))] = selector.SelectSelector( + selector.SelectSelectorConfig(options=[ + {"value": STATE_OFF, "label": "Manual"}, + {"value": STATE_ON, "label": "Automatic"}, + ]) + ) - if not options.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str + schema[vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) + ) - if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): - schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + 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) + ) + + if options.get(CONF_MODE) in [MODE_SWITCH]: + schema[vol.Required(CONF_SETPOINT, default=50)] = selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ) return self.async_show_form(step_id="general", data_schema=vol.Schema(schema)) @@ -297,22 +300,29 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: schema = { vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, - vol.Required(CONF_AUTOMATIC_GAINS, default=options.get(CONF_AUTOMATIC_GAINS)): bool, vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, } if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: - schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool - - schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() - schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() - schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( selector.NumberSelectorConfig(min=-1, max=1, step=0.1) ) + if not options.get(CONF_AUTOMATIC_GAINS): + schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str + schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str + schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str + + if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): + schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + + schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() + schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() + return self.async_show_form( step_id="advanced", data_schema=vol.Schema(schema) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index dbec5649..656dd4f7 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -70,7 +70,7 @@ CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, CONF_WINDOW_SENSOR: None, - CONF_AUTOMATIC_GAINS: False, + CONF_AUTOMATIC_GAINS: True, CONF_AUTOMATIC_DUTY_CYCLE: False, CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, From 1c34b2622030d4f359ebcf902d46b39b752f21dd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 14 May 2023 00:19:02 +0200 Subject: [PATCH 042/237] Re-worked the config flow to be more user-friendly --- custom_components/sat/translations/en.json | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index d1987d7e..64c96340 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -37,6 +37,7 @@ }, "sensors": { "title": "Configure sensors", + "description": "Please select the sensors that will be used to track the temperature.", "data": { "inside_sensor_entity_id": "Inside Sensor Entity", "outside_sensor_entity_id": "Outside Sensor Entity" @@ -64,12 +65,10 @@ }, "general": { "title": "General", + "description": "General settings and configurations.", "data": { "setpoint": "Setpoint", - "integral": "Integral", - "derivative": "Derivative", - "proportional": "Proportional", - "duty_cycle": "Duty Cycle", + "automatic_gains": "Tuning", "heating_system": "Heating System", "target_temperature_step": "Target Temperature Step", "heating_curve_coefficient": "Heating Curve Coefficient" @@ -77,6 +76,7 @@ }, "presets": { "title": "Presets", + "description": "Predefined temperature settings for different scenarios or activities.", "data": { "away_temperature": "Away Temperature", "home_temperature": "Home Temperature", @@ -88,6 +88,7 @@ }, "climates": { "title": "Climates (multi-room)", + "description": "Settings related to multi-room climates and temperature control.", "data": { "climates": "Climates (rooms)", "main_climates": "Climates (main)", @@ -96,20 +97,24 @@ }, "contact_sensors": { "title": "Contact Sensors", + "description": "Settings for contact sensors, particularly window sensors.", "data": { "window_sensor": "Window" } }, "advanced": { "title": "Advanced", + "description": "For fine-tuning and customization.", "data": { "simulation": "Simulation", - "automatic_gains": "Automatic gains", + "integral": "Integral (kI)", + "derivative": "Derivative (kD)", + "proportional": "Proportional (kP)", "climate_valve_offset": "Climate valve offset", + "automatic_duty_cycle": "Automatic duty cycle", "overshoot_protection": "Overshoot Protection (with PWM)", - "automatic_duty_cycle": "Automatic duty cycle (experimental)", + "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", "force_pulse_width_modulation": "Force Pulse Width Modulation", - "min_num_updates": "Minimum number of updates required for auto-tuning", "sample_time": "Minimum time interval between updates to the PID controller", "window_minimum_open_time": "Minimum time for window to be open before reacting", "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)" From 5794308f92d11ce2c8832d37fb0bfef9902fd75c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 14 May 2023 00:19:16 +0200 Subject: [PATCH 043/237] Fixed pushing the modulation value through MQTT --- 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 65567825..5a470d60 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -195,7 +195,7 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await super().async_set_heater_state(state) - async def async_control_max_relative_mod(self, value: float) -> None: + async def async_set_control_max_relative_modulation(self, value: float) -> None: await self._send_command(f"MM={value}") await super().async_set_control_max_relative_modulation(value) From ec6553e085151874e4e7aaafa93fdb07fd98c408 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 14 May 2023 00:19:35 +0200 Subject: [PATCH 044/237] Add support to determine how much time it takes to heat up the house --- custom_components/sat/climate.py | 52 +++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c7619708..456e532e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -44,6 +44,8 @@ from .pwm import PWM, PWMState ATTR_ROOMS = "rooms" +ATTR_WARMING_UP = "warming_up" +ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" _LOGGER = logging.getLogger(__name__) @@ -63,6 +65,24 @@ def convert_time_str_to_seconds(time_str: str) -> float: 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. + + Args: + temperature_error (float): The temperature error or difference. + time_taken_seconds (float): The time taken in seconds. + + Returns: + float: The derivative per hour. + + """ + # 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 create_pid_controller(options) -> PID: """Create and return a PID controller instance with the given configuration options.""" # Extract the configuration options @@ -107,6 +127,12 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a _hass.data[DOMAIN][_config_entry.entry_id][CLIMATE] = climate +class SatWarmingUp: + def __init__(self, error: float, started: int = None): + self.error = error + self.started = started if started is not None else monotonic() + + class SatClimate(SatEntity, ClimateEntity, RestoreEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) @@ -150,9 +176,11 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensors = [] self._rooms = None self._setpoint = None - self._warming_up = False self._outputs = deque(maxlen=50) + self._warming_up = None + self._warming_up_derivative = None + self._hvac_mode = None self._target_temperature = None self._window_sensor_handle = None @@ -276,6 +304,12 @@ 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 = SatWarmingUp(warming_up["error"], 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_ROOMS): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: @@ -292,6 +326,8 @@ async def _restore_previous_state_or_set_defaults(self): if not self._hvac_mode: self._hvac_mode = HVACMode.OFF + 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.""" @@ -346,7 +382,8 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, - "warming_up": self._warming_up, + "warming_up_derivative": self._warming_up_derivative, + "warming_up": vars(self._warming_up) if self._warming_up is not None else None, "valves_open": self.valves_open, "heating_curve": self._heating_curve.value, "minimum_setpoint": self._coordinator.minimum_setpoint, @@ -513,7 +550,7 @@ def pulse_width_modulation_enabled(self) -> bool: if self.max_error <= 0.1: return True - if self._warming_up and self._setpoint < (overshoot_protection_value - 2): + if self._warming_up is not None and self._setpoint < (overshoot_protection_value - 2): return True return False @@ -734,8 +771,13 @@ async def _async_control_pid(self, reset: bool = False): # Since we are in the deadband, we can safely assume we are not warming up anymore if self._warming_up and max_error <= 0.1: - self._warming_up = False + # Calculate the derivative per hour + time_taken_seconds = monotonic() - self._warming_up.started + self._warming_up_derivative = calculate_derivative_per_hour(self._warming_up.error, time_taken_seconds) + + # Notify that we are not warming anymore _LOGGER.info("Reached deadband, turning off warming up.") + self._warming_up = None # Update the pid controller self._pid.update(error=max_error, heating_curve_value=self._heating_curve.value) @@ -748,7 +790,7 @@ async def _async_control_pid(self, reset: bool = False): # Determine if we are warming up if self.max_error > 0.1: - self._warming_up = True + self._warming_up = SatWarmingUp(self.max_error) _LOGGER.info("Outside of deadband, we are warming up") self.async_write_ha_state() From 61f815257a8a75a8298c00de541d9bf78a1c309e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 14 May 2023 11:58:46 +0200 Subject: [PATCH 045/237] Fixed a typo in the relative modulation value --- 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 47901f30..59b128bf 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -221,7 +221,7 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N or (self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT) # If the pulse width modulation is enabled (in deadband or warming up, and we are above minimum setpoint) - and climate.pulse_width_modulation_enabled + or climate.pulse_width_modulation_enabled ) # Control the relative modulation value based on the conditions From 3096797165347306d6275ed46bb3deb71a7166af Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 17:08:30 +0200 Subject: [PATCH 046/237] Enable modulation when not using PWM --- 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 59b128bf..f0785a05 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -220,8 +220,8 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N # If the setpoint is below the minimum allowed value (or basically off) or (self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT) - # If the pulse width modulation is enabled (in deadband or warming up, and we are above minimum setpoint) - or climate.pulse_width_modulation_enabled + # If the pulse width modulation is disabled (not in deadband or warming up, and we are above minimum setpoint) + or not climate.pulse_width_modulation_enabled ) # Control the relative modulation value based on the conditions From b0778b810026aba6c897cca9dd7b7093411b622f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 17:12:03 +0200 Subject: [PATCH 047/237] Update some docs --- 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 f0785a05..48e4e5da 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -220,7 +220,7 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N # If the setpoint is below the minimum allowed value (or basically off) or (self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT) - # If the pulse width modulation is disabled (not in deadband or warming up, and we are above minimum setpoint) + # If the system is not in pulse width modulation mode or not climate.pulse_width_modulation_enabled ) From cb9d73b66d0fe8248933146dc24ce9692cd497d0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 19:07:14 +0200 Subject: [PATCH 048/237] Add back support for 2023.1, although without MQTT --- custom_components/sat/config_flow.py | 16 +++++++++++----- hacs.json | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 8f5d9e27..d72627bb 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.const import STATE_ON, STATE_OFF, MAJOR_VERSION, MINOR_VERSION from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector, entity_registry @@ -35,10 +35,16 @@ def __init__(self): async def async_step_user(self, _user_input=None) -> FlowResult: """Handle user flow.""" - return self.async_show_menu( - step_id="user", - menu_options=["mqtt", "serial", "switch"] - ) + menu_options = [] + + # Since we rely on the availability logic in 2023.5, we do not support it below it. + if MAJOR_VERSION >= 2023 and MINOR_VERSION >= 5: + menu_options.append("mqtt") + + menu_options.append("serial") + menu_options.append("switch") + + return self.async_show_menu(step_id="user", menu_options=menu_options) async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" diff --git a/hacs.json b/hacs.json index 22022c11..3d319078 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "hacs": "1.6.0", "render_readme": true, - "homeassistant": "2023.5.0", + "homeassistant": "2023.1.0", "name": "Smart Autotune Thermostat" } From a681498d6aa12264463b9f1be880a5792d488c6e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 22:05:06 +0200 Subject: [PATCH 049/237] Cleaning up code and optimized the pulse width modulation and relative modulation --- custom_components/sat/climate.py | 103 +++++++++++++++-------- custom_components/sat/const.py | 2 +- custom_components/sat/coordinator.py | 63 +------------- custom_components/sat/mqtt/__init__.py | 7 +- custom_components/sat/pwm.py | 2 +- custom_components/sat/serial/__init__.py | 9 +- custom_components/sat/services.py | 51 ++++++----- 7 files changed, 109 insertions(+), 128 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 456e532e..339fbdb2 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -5,6 +5,7 @@ import logging from collections import deque from datetime import timedelta +from functools import partial from statistics import mean from time import monotonic from typing import List @@ -26,6 +27,7 @@ SERVICE_SET_TEMPERATURE, DOMAIN as CLIMATE_DOMAIN, ) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -42,9 +44,10 @@ from .heating_curve import HeatingCurve from .pid import PID from .pwm import PWM, PWMState +from .services import start_overshoot_protection_calculation, set_overshoot_protection_value ATTR_ROOMS = "rooms" -ATTR_WARMING_UP = "warming_up" +ATTR_WARMING_UP = "warming_up_data" ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -67,16 +70,7 @@ def convert_time_str_to_seconds(time_str: str) -> float: 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. - - Args: - temperature_error (float): The temperature error or difference. - time_taken_seconds (float): The time taken in seconds. - - Returns: - float: The derivative per hour. - - """ + 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 @@ -178,7 +172,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._setpoint = None self._outputs = deque(maxlen=50) - self._warming_up = None + self._warming_up_data = None self._warming_up_derivative = None self._hvac_mode = None @@ -305,7 +299,7 @@ async def _restore_previous_state_or_set_defaults(self): self._attr_preset_mode = old_state.attributes.get(ATTR_PRESET_MODE) if warming_up := old_state.attributes.get(ATTR_WARMING_UP): - self._warming_up = SatWarmingUp(warming_up["error"], warming_up["started"]) + self._warming_up_data = SatWarmingUp(warming_up["error"], warming_up["started"]) if old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE): self._warming_up_derivative = old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE) @@ -335,6 +329,35 @@ async def reset_integral(_call: ServiceCall): self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) + if self._coordinator.supports_setpoint_management: + if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._overshoot_protection = False + + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled overshoot protection because no overshoot value has been found." + ) + + if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: + self._force_pulse_width_modulation = False + + await self.async_send_notification( + title="Smart Autotune Thermostat", + message="Disabled forced pulse width modulation because no overshoot value has been found." + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, + partial(start_overshoot_protection_calculation, self) + ) + + self.hass.services.async_register( + DOMAIN, + SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, + partial(set_overshoot_protection_value, self) + ) + async def track_sensor_temperature(self, entity_id): """ Track the temperature of the sensor specified by the given entity_id. @@ -382,13 +405,14 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, + "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, - "warming_up": vars(self._warming_up) if self._warming_up is not None else None, "valves_open": self.valves_open, "heating_curve": self._heating_curve.value, "minimum_setpoint": self._coordinator.minimum_setpoint, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self._heating_curve.optimal_coefficient, + "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, @@ -533,29 +557,25 @@ def valves_open(self) -> bool: @property def pulse_width_modulation_enabled(self) -> bool: - """Return True if pulse width modulation is enabled, False otherwise. - - If we are a coordinator that doesn't support it, it is enabled. - If an overshoot protection value is not set, pulse width modulation is disabled. - If pulse width modulation is forced on, it is enabled. - If overshoot protection is enabled, and we are below the overshoot protection value. - """ + """Return True if pulse width modulation is enabled, False otherwise.""" if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True - if (overshoot_protection_value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is None: - return False + return self._overshoot_protection and self._setpoint < (self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - 2) - if self._overshoot_protection: - if self.max_error <= 0.1: - return True + @property + def relative_modulation_enabled(self): + """Return True if relative modulation is enabled, False otherwise.""" + if not self._coordinator.support_relative_modulation_management: + return False - if self._warming_up is not None and self._setpoint < (overshoot_protection_value - 2): - return True + if self._coordinator.hot_water_active: + return True - return False + return self.hvac_mode == HVACMode.HEAT and not self.pulse_width_modulation_enabled def _get_requested_setpoint(self): + """Get the requested setpoint based on the heating curve and PID output.""" if self._heating_curve.value is None: return MINIMUM_SETPOINT @@ -716,6 +736,9 @@ async def _async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation await self._pwm.update(self._get_requested_setpoint(), self._coordinator.minimum_setpoint) + # Set the relative modulation value, if supported + await self._async_control_relative_modulation() + # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self._pwm.state) @@ -770,14 +793,14 @@ async def _async_control_pid(self, reset: bool = False): ) # Since we are in the deadband, we can safely assume we are not warming up anymore - if self._warming_up and max_error <= 0.1: + if self._warming_up_data and max_error <= 0.1: # Calculate the derivative per hour - time_taken_seconds = monotonic() - self._warming_up.started - self._warming_up_derivative = calculate_derivative_per_hour(self._warming_up.error, time_taken_seconds) + time_taken_seconds = monotonic() - self._warming_up_data.started + self._warming_up_derivative = calculate_derivative_per_hour(self._warming_up_data.error, time_taken_seconds) # Notify that we are not warming anymore _LOGGER.info("Reached deadband, turning off warming up.") - self._warming_up = None + self._warming_up_data = None # Update the pid controller self._pid.update(error=max_error, heating_curve_value=self._heating_curve.value) @@ -790,7 +813,7 @@ async def _async_control_pid(self, reset: bool = False): # Determine if we are warming up if self.max_error > 0.1: - self._warming_up = SatWarmingUp(self.max_error) + self._warming_up_data = SatWarmingUp(self.max_error) _LOGGER.info("Outside of deadband, we are warming up") self.async_write_ha_state() @@ -811,6 +834,13 @@ async def _async_control_setpoint(self, pwm_state: PWMState): await self._coordinator.async_set_control_setpoint(self._setpoint) + async def _async_control_relative_modulation(self): + """Control the relative modulation value based on the conditions""" + if self._coordinator.support_relative_modulation_management: + await self._coordinator.async_set_control_max_relative_modulation( + MAXIMUM_RELATIVE_MOD if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD + ) + async def _async_update_rooms_from_climates(self): """Update the temperature setpoint for each room based on their associated climate entity.""" self._rooms = {} @@ -918,6 +948,11 @@ async def async_set_target_temperature(self, temperature: float): # Control the heating based on the new temperature setpoint await self._async_control_heating_loop() + async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): + """Send a notification to the user.""" + data = {"title": title, "message": message} + await self.hass.services.async_call(NOTIFY_DOMAIN, service, data) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the heating/cooling mode for the devices and update the state.""" # Ignore the request when we are in calculation mode diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 656dd4f7..1dfbc974 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,7 +1,7 @@ # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "2.1.0" +VERSION = "2.3.0" CLIMATE = "climate" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 48e4e5da..d51a1a11 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -4,16 +4,13 @@ import typing from abc import abstractmethod from enum import Enum -from functools import partial from homeassistant.components.climate import HVACMode -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN, SERVICE_PERSISTENT_NOTIFICATION from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .config_store import SatConfigStore from .const import * -from .services import start_overshoot_protection_calculation, set_overshoot_protection_value if typing.TYPE_CHECKING: from .climate import SatClimate @@ -52,8 +49,6 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: self._simulation = bool(self._store.options.get(CONF_SIMULATION)) self._minimum_setpoint = float(self._store.options.get(CONF_SETPOINT)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) - self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) super().__init__(hass, _LOGGER, name=DOMAIN) @@ -172,35 +167,6 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) - if self.supports_setpoint_management: - if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._overshoot_protection = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled overshoot protection because no overshoot value has been found." - ) - - if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._force_pulse_width_modulation = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled forced pulse width modulation because no overshoot value has been found." - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self, climate) - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self) - ) - async def async_will_remove_from_hass(self, climate: SatClimate) -> None: """Run when entity will be removed from hass.""" pass @@ -211,24 +177,6 @@ async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> N # Send out a new command to turn off the device await self.async_set_heater_state(DeviceState.OFF) - if self.support_relative_modulation_management: - # Determine whether to enable maximum relative modulation value based on the conditions - relative_modulation_enabled = ( - # If the climate control is not in heating mode or hot water is requested - climate.hvac_mode != HVACMode.HEAT or self.hot_water_active - - # If the setpoint is below the minimum allowed value (or basically off) - or (self.setpoint is not None and self.setpoint <= MINIMUM_SETPOINT) - - # If the system is not in pulse width modulation mode - or not climate.pulse_width_modulation_enabled - ) - - # Control the relative modulation value based on the conditions - await self.async_set_control_max_relative_modulation( - MAXIMUM_RELATIVE_MOD if relative_modulation_enabled else MINIMUM_RELATIVE_MOD - ) - async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" self._device_state = state @@ -244,10 +192,6 @@ async def async_set_control_hot_water_setpoint(self, value: float) -> None: if self.supports_hot_water_setpoint_management: self.logger.info("Set control hot water setpoint to %d", value) - async def async_set_control_thermostat_setpoint(self, value: float) -> None: - """Control the setpoint temperature for the thermostat.""" - pass - 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: @@ -258,7 +202,6 @@ async def async_set_control_max_relative_modulation(self, value: float) -> None: if self.support_relative_modulation_management: self.logger.info("Set maximum relative modulation to %d", value) - async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): - """Send a notification to the user.""" - data = {"title": title, "message": message} - await self.hass.services.async_call(NOTIFY_DOMAIN, service, data) + async def async_set_control_thermostat_setpoint(self, value: float) -> None: + """Control the setpoint temperature for the thermostat.""" + pass diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 5a470d60..b135ae7a 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -142,8 +142,11 @@ def minimum_relative_modulation_value(self) -> float | None: return super().boiler_capacity @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + def minimum_setpoint(self) -> float: + if (value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is not None: + return float(value) + + return super().minimum_setpoint async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index b56df71a..9d40b1ff 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -51,7 +51,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float) -> No if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): self._state = PWMState.IDLE self._last_update = monotonic() - _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value") + _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") return elapsed = monotonic() - self._last_update diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 96940b55..5471306e 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -66,7 +66,7 @@ def supports_maximum_setpoint_management(self) -> bool: @property def support_relative_modulation_management(self) -> bool: - return self._overshoot_protection or not self._force_pulse_width_modulation + return True @property def setpoint(self) -> float | None: @@ -129,8 +129,11 @@ def flame_active(self) -> bool: return bool(self.get(DATA_SLAVE_FLAME_ON)) @property - def minimum_setpoint(self): - return self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE, self._minimum_setpoint) + def minimum_setpoint(self) -> float: + if (value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is not None: + return float(value) + + return super().minimum_setpoint def get(self, key: str) -> Optional[Any]: """Get the value for the given `key` from the boiler data. diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py index 686182c6..322da9bf 100644 --- a/custom_components/sat/services.py +++ b/custom_components/sat/services.py @@ -1,23 +1,21 @@ from __future__ import annotations -import typing - from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import ServiceCall +from . import async_reload_entry +from .climate import SatClimate from .const import * - -if typing.TYPE_CHECKING: - from .climate import SatClimate +from .coordinator import SatDataUpdateCoordinator -async def set_overshoot_protection_value(self, call: ServiceCall): +async def set_overshoot_protection_value(coordinator: SatDataUpdateCoordinator, call: ServiceCall): """Service to set the overshoot protection value.""" - self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) + coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) -async def start_overshoot_protection_calculation(self, climate: SatClimate, call: ServiceCall): +async def start_overshoot_protection_calculation(climate: SatClimate, coordinator: SatDataUpdateCoordinator, call: ServiceCall): """Service to start the overshoot protection calculation process. This process will activate overshoot protection by turning on the heater and setting the control setpoint to @@ -26,61 +24,60 @@ async def start_overshoot_protection_calculation(self, climate: SatClimate, call deactivate overshoot protection and store the calculated value. """ if climate.overshoot_protection_calculate: - self.logger.warning("[Overshoot Protection] Calculation already in progress.") + coordinator.logger.warning("[Overshoot Protection] Calculation already in progress.") return from .coordinator import DeviceState - self._device_state = DeviceState.ON - self._overshoot_protection_calculate = True + climate._device_state = DeviceState.ON + climate._overshoot_protection_calculate = True saved_hvac_mode = climate.hvac_mode saved_target_temperature = climate.target_temperature saved_target_temperatures = {} - for entity_id in self._store.options.get(CONF_CLIMATES): - if state := self.hass.states.get(entity_id): + for entity_id in coordinator.store.options.get(CONF_CLIMATES): + if state := climate.hass.states.get(entity_id): saved_target_temperatures[entity_id] = float(state.attributes.get(ATTR_TEMPERATURE)) data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + await climate.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) await climate.async_set_target_temperature(30) await climate.async_set_hvac_mode(HVACMode.HEAT) - await self.async_send_notification( + await climate.async_send_notification( title="Overshoot Protection Calculation", message="Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." ) from .overshoot_protection import OvershootProtection - overshoot_protection_value = await OvershootProtection(self).calculate(call.data.get("solution")) + overshoot_protection_value = await OvershootProtection(coordinator).calculate(call.data.get("solution")) climate.overshoot_protection_calculate = False await climate.async_set_hvac_mode(saved_hvac_mode) - - await self._async_control_max_setpoint() await climate.async_set_target_temperature(saved_target_temperature) - for entity_id in self._store.options.get(CONF_CLIMATES): + await coordinator.async_set_control_max_setpoint(coordinator.maximum_setpoint) + + for entity_id in coordinator.store.options.get(CONF_CLIMATES): data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} - await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) + await climate.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) if overshoot_protection_value is None: - await self.async_send_notification( + await climate.async_send_notification( title="Overshoot Protection Calculation", message=f"Timed out waiting for stable temperature" ) return - await self.async_send_notification( + await climate.async_send_notification( title="Overshoot Protection Calculation", message=f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" ) - # Turn the overshoot protection settings back on - self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) - self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - # Store the new value - self._store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) + coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) + + # Reload the system + await async_reload_entry(coordinator.hass, coordinator.config_entry) From 669e4f735efa617a22dcb2c981a036f581882924 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 22:14:28 +0200 Subject: [PATCH 050/237] More cleaning --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/overshoot_protection.py | 2 ++ custom_components/sat/services.py | 13 +++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 339fbdb2..027ca883 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -349,13 +349,13 @@ async def reset_integral(_call: ServiceCall): self.hass.services.async_register( DOMAIN, SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self) + partial(start_overshoot_protection_calculation, self._coordinator, self) ) self.hass.services.async_register( DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self) + partial(set_overshoot_protection_value, self._coordinator) ) async def track_sensor_temperature(self, entity_id): diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 3f29409a..ea65e859 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -104,6 +104,8 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: if max_modulation != OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: await self._coordinator.async_set_control_setpoint(actual_temp) + else: + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) previous_average_temp = average_temp await asyncio.sleep(3) diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py index 322da9bf..fc68d500 100644 --- a/custom_components/sat/services.py +++ b/custom_components/sat/services.py @@ -1,21 +1,25 @@ from __future__ import annotations +import typing + from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE from homeassistant.core import ServiceCall from . import async_reload_entry -from .climate import SatClimate from .const import * from .coordinator import SatDataUpdateCoordinator +if typing.TYPE_CHECKING: + from .climate import SatClimate + async def set_overshoot_protection_value(coordinator: SatDataUpdateCoordinator, call: ServiceCall): """Service to set the overshoot protection value.""" coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) -async def start_overshoot_protection_calculation(climate: SatClimate, coordinator: SatDataUpdateCoordinator, call: ServiceCall): +async def start_overshoot_protection_calculation(coordinator: SatDataUpdateCoordinator, climate: SatClimate, call: ServiceCall): """Service to start the overshoot protection calculation process. This process will activate overshoot protection by turning on the heater and setting the control setpoint to @@ -27,9 +31,10 @@ async def start_overshoot_protection_calculation(climate: SatClimate, coordinato coordinator.logger.warning("[Overshoot Protection] Calculation already in progress.") return + climate.overshoot_protection_calculate = True + from .coordinator import DeviceState - climate._device_state = DeviceState.ON - climate._overshoot_protection_calculate = True + await coordinator.async_set_heater_state(DeviceState.ON) saved_hvac_mode = climate.hvac_mode saved_target_temperature = climate.target_temperature From 1a1bc4306a3e5d95b5bb94b3412662dd3e430cef Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 15 May 2023 22:26:23 +0200 Subject: [PATCH 051/237] Fixed booting when we do not have a setpoint --- custom_components/sat/climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 027ca883..0c54b6f1 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -558,6 +558,9 @@ def valves_open(self) -> bool: @property def pulse_width_modulation_enabled(self) -> bool: """Return True if pulse width modulation is enabled, False otherwise.""" + if self._setpoint is None: + return False + if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True @@ -566,7 +569,7 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_enabled(self): """Return True if relative modulation is enabled, False otherwise.""" - if not self._coordinator.support_relative_modulation_management: + if self._setpoint is None or not self._coordinator.support_relative_modulation_management: return False if self._coordinator.hot_water_active: From e177ae63e8f0eda445eca885e857df5ba76d958a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 16 May 2023 15:44:13 +0200 Subject: [PATCH 052/237] Fixed an issue with determining if we need to run a normal cycle or not: - Make sure we use the calculated setpoint to determine if we are overshooting or not and not the actual boiler setpoint --- custom_components/sat/climate.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 0c54b6f1..112d6934 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -558,13 +558,10 @@ def valves_open(self) -> bool: @property def pulse_width_modulation_enabled(self) -> bool: """Return True if pulse width modulation is enabled, False otherwise.""" - if self._setpoint is None: - return False - if not self._coordinator.supports_setpoint_management or self._force_pulse_width_modulation: return True - return self._overshoot_protection and self._setpoint < (self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - 2) + return self._overshoot_protection and self._calculate_control_setpoint() < (self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - 2) @property def relative_modulation_enabled(self): @@ -577,7 +574,7 @@ def relative_modulation_enabled(self): return self.hvac_mode == HVACMode.HEAT and not self.pulse_width_modulation_enabled - def _get_requested_setpoint(self): + def _get_requested_setpoint(self) -> float: """Get the requested setpoint based on the heating curve and PID output.""" if self._heating_curve.value is None: return MINIMUM_SETPOINT From ac0675dc4b52bffeea922298f5cbbe5267fe082f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 16 May 2023 19:04:25 +0200 Subject: [PATCH 053/237] Cleaning up and added some sanity checks --- custom_components/sat/__init__.py | 16 +++++++++------- custom_components/sat/climate.py | 19 ++++++++++--------- custom_components/sat/const.py | 2 +- custom_components/sat/coordinator.py | 2 +- custom_components/sat/pwm.py | 2 +- custom_components/sat/services.py | 3 ++- custom_components/sat/switch/__init__.py | 5 ++++- 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 959624c0..baeabfb4 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -31,8 +31,11 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): This function is called by Home Assistant when the integration is set up with the UI. """ + if _hass.data.get(DOMAIN) is None: + _hass.data.setdefault(DOMAIN, {}) + # Create a new dictionary - _hass.data[DOMAIN] = {_entry.entry_id: {}} + _hass.data[DOMAIN][_entry.entry_id] = {} # Create a new config store for this entry and initialize it _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] = store = SatConfigStore(_hass, _entry) @@ -47,9 +50,6 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN)) await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN])) - # Add an update listener for this entry - _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) - return True @@ -59,11 +59,13 @@ async def async_unload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: This function is called by Home Assistant when the integration is being removed. """ - # Unload the entry and its dependent components + + climate = _hass.data[DOMAIN][_entry.entry_id][CLIMATE] + await _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(climate) + unloaded = all( await asyncio.gather( - _hass.data[DOMAIN][_entry.entry_id][COORDINATOR].async_will_remove_from_hass(_hass.data[DOMAIN][_entry.entry_id][CLIMATE]), - _hass.config_entries.async_unload_platforms(_entry, [CLIMATE, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]), + _hass.config_entries.async_unload_platforms(_entry, [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN]), ) ) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 112d6934..a463d78a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -355,7 +355,7 @@ async def reset_integral(_call: ServiceCall): self.hass.services.async_register( DOMAIN, SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self._coordinator) + partial(set_overshoot_protection_value, self._coordinator, self) ) async def track_sensor_temperature(self, entity_id): @@ -561,12 +561,12 @@ 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._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) - 2) + return self._overshoot_protection and self._calculate_control_setpoint() < (self._coordinator.minimum_setpoint - 2) @property def relative_modulation_enabled(self): """Return True if relative modulation is enabled, False otherwise.""" - if self._setpoint is None or not self._coordinator.support_relative_modulation_management: + if not self._coordinator.support_relative_modulation_management: return False if self._coordinator.hot_water_active: @@ -821,13 +821,14 @@ async def _async_control_pid(self, reset: bool = False): async def _async_control_setpoint(self, pwm_state: PWMState): """Control the setpoint of the heating system.""" if self.hvac_mode == HVACMode.HEAT: - if self.pulse_width_modulation_enabled and pwm_state != pwm_state.IDLE: - self._setpoint = self._coordinator.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT - _LOGGER.info(f"Running pulse width modulation cycle: {pwm_state}") - else: - self._outputs.append(self._calculate_control_setpoint()) + 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") self._setpoint = mean(list(self._outputs)[-5:]) - _LOGGER.info("Running normal cycle") + else: + _LOGGER.info(f"Running PWM cycle: {pwm_state}") + self._setpoint = self._coordinator.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT else: self._outputs.clear() self._setpoint = MINIMUM_SETPOINT diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 1dfbc974..6e928850 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,7 +1,7 @@ # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "2.3.0" +VERSION = "2.3.x" CLIMATE = "climate" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index d51a1a11..dda68cf0 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -18,7 +18,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) -class DeviceState(Enum): +class DeviceState(str, Enum): ON = "on" OFF = "off" diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 9d40b1ff..e206a697 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -16,7 +16,7 @@ ON_TIME_80_PERCENT = 900 -class PWMState(Enum): +class PWMState(str, Enum): ON = "on" OFF = "off" IDLE = "idle" diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py index fc68d500..39f7b267 100644 --- a/custom_components/sat/services.py +++ b/custom_components/sat/services.py @@ -14,9 +14,10 @@ from .climate import SatClimate -async def set_overshoot_protection_value(coordinator: SatDataUpdateCoordinator, call: ServiceCall): +async def set_overshoot_protection_value(coordinator: SatDataUpdateCoordinator, climate: SatClimate, call: ServiceCall): """Service to set the overshoot protection value.""" coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) + climate.async_write_ha_state() async def start_overshoot_protection_calculation(coordinator: SatDataUpdateCoordinator, climate: SatClimate, call: ServiceCall): diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index af9c98d8..65bc6de1 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -27,7 +27,10 @@ def maximum_setpoint(self) -> float: @property def device_active(self) -> bool: - return self.hass.states.get(self._entity_id).state == STATE_ON + if (state := self.hass.states.get(self._entity_id)) is None: + return False + + return state.state == STATE_ON async def async_set_heater_state(self, state: DeviceState) -> None: if not self._simulation: From 9a4d822d107dcece0802b183ee5fdba2ec20a85e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 17 May 2023 01:28:47 +0200 Subject: [PATCH 054/237] Optimize the relative modulation when the boiler is basically off --- 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 a463d78a..423a3324 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -566,10 +566,10 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_enabled(self): """Return True if relative modulation is enabled, False otherwise.""" - if not self._coordinator.support_relative_modulation_management: + if not self._coordinator.support_relative_modulation_management or self._setpoint is None: return False - if self._coordinator.hot_water_active: + if self._coordinator.hot_water_active or self._setpoint <= MINIMUM_SETPOINT: return True return self.hvac_mode == HVACMode.HEAT and not self.pulse_width_modulation_enabled @@ -736,12 +736,12 @@ async def _async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation await self._pwm.update(self._get_requested_setpoint(), self._coordinator.minimum_setpoint) - # Set the relative modulation value, if supported - await self._async_control_relative_modulation() - # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self._pwm.state) + # Set the relative modulation value, if supported + 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) From c4bad84bc39352554326f02ea310ff2a40bd1178 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:42:41 +0200 Subject: [PATCH 055/237] Added support pytest --- .github/workflows/pytest.yml | 29 ++ custom_components/sat/__init__.py | 9 +- custom_components/sat/climate.py | 377 +++++++++++-------------- custom_components/sat/config_flow.py | 2 +- custom_components/sat/const.py | 5 +- custom_components/sat/coordinator.py | 6 +- custom_components/sat/fake/__init__.py | 94 ++++++ custom_components/sat/pwm.py | 34 ++- custom_components/sat/util.py | 64 +++++ requirements.test.txt | 8 + setup.cfg | 3 + tests/__init__.py | 0 tests/bandit.yaml | 17 ++ tests/conftest.py | 47 +++ tests/const.py | 13 + tests/test_climate.py | 128 +++++++++ tests/test_init.py | 26 ++ 17 files changed, 623 insertions(+), 239 deletions(-) create mode 100644 .github/workflows/pytest.yml create mode 100644 custom_components/sat/fake/__init__.py create mode 100644 custom_components/sat/util.py create mode 100644 requirements.test.txt create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/bandit.yaml create mode 100644 tests/conftest.py create mode 100644 tests/const.py create mode 100644 tests/test_climate.py create mode 100644 tests/test_init.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..46d04261 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,29 @@ +name: Run PyTest Unit Tests + +# yamllint disable-line rule:truthy +on: + push: + pull_request: + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10" ] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements_test.txt ]; then pip install -r requirements_test.txt; fi + - name: Test with pytest + run: | + pytest \ No newline at end of file diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index baeabfb4..49d6ecbd 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -31,10 +31,10 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): This function is called by Home Assistant when the integration is set up with the UI. """ - if _hass.data.get(DOMAIN) is None: - _hass.data.setdefault(DOMAIN, {}) + # Make sure we have our default domain property + _hass.data.setdefault(DOMAIN, {}) - # Create a new dictionary + # Create a new dictionary for this entry _hass.data[DOMAIN][_entry.entry_id] = {} # Create a new config store for this entry and initialize it @@ -50,6 +50,9 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): await _hass.async_add_job(_hass.config_entries.async_forward_entry_setup(_entry, CLIMATE_DOMAIN)) await _hass.async_add_job(_hass.config_entries.async_forward_entry_setups(_entry, [SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN])) + # Add an update listener for this entry + _entry.async_on_unload(_entry.add_update_listener(async_reload_entry)) + return True diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 423a3324..4df35d1c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -36,15 +36,13 @@ 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 homeassistant.util import dt from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity -from .heating_curve import HeatingCurve -from .pid import PID -from .pwm import PWM, PWMState +from .pwm import PWMState from .services import start_overshoot_protection_calculation, set_overshoot_protection_value +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, calculate_derivative_per_hour ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -54,64 +52,6 @@ _LOGGER = logging.getLogger(__name__) -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 create_pid_controller(options) -> PID: - """Create and return a PID controller instance with the given configuration options.""" - # Extract the configuration options - kp = float(options.get(CONF_PROPORTIONAL)) - ki = float(options.get(CONF_INTEGRAL)) - kd = float(options.get(CONF_DERIVATIVE)) - heating_system = options.get(CONF_HEATING_SYSTEM) - automatic_gains = bool(options.get(CONF_AUTOMATIC_GAINS)) - sample_time_limit = convert_time_str_to_seconds(options.get(CONF_SAMPLE_TIME)) - - # Return a new PID controller instance with the given configuration options - return PID(kp=kp, ki=ki, kd=kd, heating_system=heating_system, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) - - -def create_heating_curve_controller(options) -> HeatingCurve: - """Create and return a PID controller instance with the given configuration options.""" - # Extract the configuration options - heating_system = options.get(CONF_HEATING_SYSTEM) - coefficient = float(options.get(CONF_HEATING_CURVE_COEFFICIENT)) - - # Return a new heating Curve controller instance with the given configuration options - return HeatingCurve(heating_system=heating_system, coefficient=coefficient) - - -def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: - """Create and return a PWM controller instance with the given configuration options.""" - # Extract the configuration options - automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) - max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) - force = bool(options.get(CONF_MODE) == MODE_SWITCH) or bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - - # 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, force=force) - - async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _async_add_devices: AddEntitiesCallback): """Set up the SatClimate device.""" coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] @@ -140,9 +80,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn # Create dictionary mapping preset keys to temperature values self._presets = {key: self._store.options[value] for key, value in conf_presets.items() if value in self._store.options} - # Create PID controller with given configuration options - self._pid = create_pid_controller(self._store.options) - # Get inside sensor entity ID self.inside_sensor_entity_id = self._store.options.get(CONF_INSIDE_SENSOR_ENTITY_ID) @@ -161,11 +98,14 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn if isinstance(self.outside_sensor_entities, str): self.outside_sensor_entities = [self.outside_sensor_entities] + # Create PID controller with given configuration options + self.pid = create_pid_controller(self._store.options) + # Create Heating Curve controller with given configuration options - self._heating_curve = create_heating_curve_controller(self._store.options) + self.heating_curve = create_heating_curve_controller(self._store.options) # Create PWM controller with given configuration options - self._pwm = create_pwm_controller(self._heating_curve, self._store.options) + self.pwm = create_pwm_controller(self.heating_curve, self._store.options) self._sensors = [] self._rooms = None @@ -221,10 +161,10 @@ async def async_added_to_hass(self) -> None: # Update a heating curve if outside temperature is available if self.current_outside_temperature is not None: - self._heating_curve.update(self.target_temperature, self.current_outside_temperature) + self.heating_curve.update(self.target_temperature, self.current_outside_temperature) # Start control loop - await self._async_control_heating_loop() + await self.async_control_heating_loop() # Register services await self._register_services() @@ -242,7 +182,7 @@ async def _register_event_listeners(self): self.async_on_remove( async_track_time_interval( - self.hass, self._async_control_heating_loop, timedelta(seconds=30) + self.hass, self.async_control_heating_loop, timedelta(seconds=30) ) ) @@ -263,7 +203,7 @@ async def _register_event_listeners(self): for climate_id in self._climates: state = self.hass.states.get(climate_id) if state is not None and (sensor_temperature_id := state.attributes.get(SENSOR_TEMPERATURE_ID)): - await self.track_sensor_temperature(sensor_temperature_id) + await self.async_track_sensor_temperature(sensor_temperature_id) self.async_on_remove( async_track_state_change_event( @@ -285,11 +225,11 @@ async def _restore_previous_state_or_set_defaults(self): if old_state is not None: if self._target_temperature is None: if old_state.attributes.get(ATTR_TEMPERATURE) is None: - self._pid.setpoint = self.min_temp + 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.pid.restore(old_state) self._target_temperature = float(old_state.attributes[ATTR_TEMPERATURE]) if old_state.state: @@ -313,7 +253,7 @@ async def _restore_previous_state_or_set_defaults(self): await self._async_update_rooms_from_climates() if self._target_temperature is None: - self._pid.setpoint = self.min_temp + self.pid.setpoint = self.min_temp self._target_temperature = self.min_temp _LOGGER.warning("No previously saved temperature, setting to %s", self._target_temperature) @@ -325,7 +265,7 @@ async def _restore_previous_state_or_set_defaults(self): 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.pid.reset() self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) @@ -358,72 +298,51 @@ async def reset_integral(_call: ServiceCall): partial(set_overshoot_protection_value, self._coordinator, self) ) - async def track_sensor_temperature(self, entity_id): - """ - Track the temperature of the sensor specified by the given entity_id. - - Parameters: - entity_id (str): The entity id of the sensor to track. - - If the sensor is already being tracked, the method will return without doing anything. - Otherwise, it will register a callback for state changes on the specified sensor and start tracking its temperature. - """ - if entity_id in self._sensors: - return - - self.async_on_remove( - async_track_state_change_event( - self.hass, [entity_id], self._async_temperature_change - ) - ) - - self._sensors.append(entity_id) - @property def name(self): """Return the friendly name of the sensor.""" return self._attr_name + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return self._attr_id + @property def extra_state_attributes(self): """Return device state attributes.""" return { - "error": self._pid.last_error, - "integral": self._pid.integral, - "derivative": self._pid.derivative, - "proportional": self._pid.proportional, - "history_size": self._pid.history_size, - "collected_errors": self._pid.num_errors, - "integral_enabled": self._pid.integral_enabled, + "error": self.pid.last_error, + "integral": self.pid.integral, + "derivative": self.pid.derivative, + "proportional": self.pid.proportional, + "history_size": self.pid.history_size, + "collected_errors": self.pid.num_errors, + "integral_enabled": self.pid.integral_enabled, - "derivative_enabled": self._pid.derivative_enabled, - "derivative_raw": self._pid.raw_derivative, + "derivative_enabled": self.pid.derivative_enabled, + "derivative_raw": self.pid.raw_derivative, - "current_kp": self._pid.kp, - "current_ki": self._pid.ki, - "current_kd": self._pid.kd, + "current_kp": self.pid.kp, + "current_ki": self.pid.ki, + "current_kd": self.pid.kd, "rooms": self._rooms, "setpoint": self._setpoint, "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, + "heating_curve": self.heating_curve.value, "minimum_setpoint": self._coordinator.minimum_setpoint, "outside_temperature": self.current_outside_temperature, - "optimal_coefficient": self._heating_curve.optimal_coefficient, + "optimal_coefficient": self.heating_curve.optimal_coefficient, "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, + "pulse_width_modulation_state": self.pwm.state, + "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle, "overshoot_protection_calculating": self.overshoot_protection_calculate, } - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return self._attr_id - @property def current_temperature(self): """Return the sensor temperature.""" @@ -491,6 +410,14 @@ def max_error(self) -> float: def setpoint(self) -> float | None: return self._setpoint + @property + def requested_setpoint(self) -> float: + """Get the requested setpoint based on the heating curve and PID output.""" + if self.heating_curve.value is None: + return MINIMUM_SETPOINT + + return max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT) + @property def climate_errors(self) -> List[float]: """Calculate the temperature difference between the current temperature and target temperature for all connected climates.""" @@ -564,7 +491,7 @@ def pulse_width_modulation_enabled(self) -> bool: return self._overshoot_protection and self._calculate_control_setpoint() < (self._coordinator.minimum_setpoint - 2) @property - def relative_modulation_enabled(self): + def relative_modulation_enabled(self) -> bool: """Return True if relative modulation is enabled, False otherwise.""" if not self._coordinator.support_relative_modulation_management or self._setpoint is None: return False @@ -574,24 +501,17 @@ def relative_modulation_enabled(self): return self.hvac_mode == HVACMode.HEAT and not self.pulse_width_modulation_enabled - def _get_requested_setpoint(self) -> float: - """Get the requested setpoint based on the heating curve and PID output.""" - if self._heating_curve.value is None: - return MINIMUM_SETPOINT - - return max(self._heating_curve.value + self._pid.output, MINIMUM_SETPOINT) - def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" - if self._heating_curve.value is None: + if self.heating_curve.value is None: return MINIMUM_SETPOINT # Combine the heating curve value and the calculated output from the pid controller - requested_setpoint = self._get_requested_setpoint() + requested_setpoint = self.requested_setpoint # Make sure we are above the base setpoint when we are below the target temperature if self.max_error > 0: - requested_setpoint = max(requested_setpoint, self._heating_curve.value) + requested_setpoint = max(requested_setpoint, self.heating_curve.value) # Ensure setpoint is limited to our max return min(requested_setpoint, self._coordinator.maximum_setpoint) @@ -607,14 +527,14 @@ async def _async_inside_sensor_changed(self, event: Event) -> None: self.async_write_ha_state() await self._async_control_pid() - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def _async_outside_entity_changed(self, event: Event) -> None: """Handle changes to the outside entity.""" if event.data.get("new_state") is None: return - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" @@ -625,7 +545,7 @@ async def _async_main_climate_changed(self, event: Event) -> None: if old_state is None or new_state.state != old_state.state: _LOGGER.debug(f"Main Climate State Changed ({new_state.entity_id}).") - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def _async_climate_changed(self, event: Event) -> None: """Handle changes to the climate entity. @@ -652,7 +572,7 @@ async def _async_climate_changed(self, event: Event) -> None: # Check if the last state is None, so we can track the attached sensor if needed if old_state is None and (sensor_temperature_id := new_attrs.get(SENSOR_TEMPERATURE_ID)): - await self.track_sensor_temperature(sensor_temperature_id) + await self.async_track_sensor_temperature(sensor_temperature_id) # 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: @@ -671,7 +591,7 @@ async def _async_climate_changed(self, event: Event) -> None: self._rooms[new_state.entity_id] = float(target_temperature) # Update the heating control - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def _async_temperature_change(self, event: Event) -> None: """Handle changes to the climate sensor entity. @@ -684,7 +604,7 @@ async def _async_temperature_change(self, event: Event) -> None: _LOGGER.debug(f"Climate Sensor Changed ({new_state.entity_id}).") await self._async_control_pid(False) - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def _async_window_sensor_changed(self, event: Event) -> None: """Handle changes to the contact sensor entity.""" @@ -720,62 +640,21 @@ async def _async_window_sensor_changed(self, event: Event) -> None: return - async def _async_control_heating_loop(self, _time=None) -> None: - """Control the heating based on current temperature, target temperature, and outside temperature.""" - # If overshoot protection is active, we are not doing anything since we already have a task running in async - if self.overshoot_protection_calculate: - return - - # 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: - return - - # Control the heating through the coordinator - await self._coordinator.async_control_heating_loop(self) - - # Pulse Width Modulation - await self._pwm.update(self._get_requested_setpoint(), self._coordinator.minimum_setpoint) - - # Set the control setpoint to make sure we always stay in control - await self._async_control_setpoint(self._pwm.state) - - # Set the relative modulation value, if supported - 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._coordinator.device_state == DeviceState.ON: - # If the setpoint is too low or the valves are closed or HVAC is off, turn off the heater - if self._setpoint <= MINIMUM_SETPOINT or not self.valves_open or self.hvac_mode == HVACMode.OFF: - await self._coordinator.async_set_heater_state(DeviceState.OFF) - else: - await self._coordinator.async_set_heater_state(DeviceState.ON) - - if self._coordinator.device_state == DeviceState.OFF: - # If the setpoint is high and the valves are open and the HVAC is not off, turn on the heater - if self._setpoint > MINIMUM_SETPOINT and self.valves_open and self.hvac_mode != HVACMode.OFF: - await self._coordinator.async_set_heater_state(DeviceState.ON) - else: - await self._coordinator.async_set_heater_state(DeviceState.OFF) - - self.async_write_ha_state() - - async def _async_control_pid(self, reset: bool = False): + async def _async_control_pid(self, reset: bool = False) -> None: """Control the PID controller.""" # We can't continue if we don't have a valid outside temperature if self.current_outside_temperature is None: return # Reset the PID controller if the sensor data is too old - if self._sensor_max_value_age != 0 and monotonic() - self._pid.last_updated > self._sensor_max_value_age: - self._pid.reset() + if self._sensor_max_value_age != 0 and monotonic() - self.pid.last_updated > self._sensor_max_value_age: + self.pid.reset() # Calculate the maximum error between the current temperature and the target temperature of all climates max_error = self.max_error # Make sure we use the latest heating curve value - self._heating_curve.update( + self.heating_curve.update( target_temperature=self.target_temperature, outside_temperature=self.current_outside_temperature, ) @@ -786,8 +665,8 @@ async def _async_control_pid(self, reset: bool = False): # Calculate an optimal heating curve when we are in the deadband if -0.1 <= max_error <= 0.1: - self._heating_curve.autotune( - setpoint=self._get_requested_setpoint(), + self.heating_curve.autotune( + setpoint=self.requested_setpoint, target_temperature=self.target_temperature, outside_temperature=self.current_outside_temperature ) @@ -803,13 +682,13 @@ async def _async_control_pid(self, reset: bool = False): self._warming_up_data = None # Update the pid controller - self._pid.update(error=max_error, heating_curve_value=self._heating_curve.value) - elif max_error != self._pid.last_error: + self.pid.update(error=max_error, heating_curve_value=self.heating_curve.value) + elif max_error != self.pid.last_error: _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.pid.update_reset(error=max_error, heating_curve_value=self.heating_curve.value) self._outputs.clear() - self._pwm.reset() + self.pwm.reset() # Determine if we are warming up if self.max_error > 0.1: @@ -818,7 +697,7 @@ async def _async_control_pid(self, reset: bool = False): self.async_write_ha_state() - async def _async_control_setpoint(self, pwm_state: PWMState): + 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()) @@ -835,14 +714,14 @@ async def _async_control_setpoint(self, pwm_state: PWMState): await self._coordinator.async_set_control_setpoint(self._setpoint) - async def _async_control_relative_modulation(self): + async def _async_control_relative_modulation(self) -> None: """Control the relative modulation value based on the conditions""" if self._coordinator.support_relative_modulation_management: await self._coordinator.async_set_control_max_relative_modulation( MAXIMUM_RELATIVE_MOD if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD ) - async def _async_update_rooms_from_climates(self): + async def _async_update_rooms_from_climates(self) -> None: """Update the temperature setpoint for each room based on their associated climate entity.""" self._rooms = {} @@ -861,6 +740,68 @@ async def _async_update_rooms_from_climates(self): if target_temperature is not None: self._rooms[entity_id] = float(target_temperature) + async def async_track_sensor_temperature(self, entity_id): + """ + Track the temperature of the sensor specified by the given entity_id. + + Parameters: + entity_id (str): The entity id of the sensor to track. + + If the sensor is already being tracked, the method will return without doing anything. + Otherwise, it will register a callback for state changes on the specified sensor and start tracking its temperature. + """ + if entity_id in self._sensors: + return + + self.async_on_remove( + async_track_state_change_event( + self.hass, [entity_id], self._async_temperature_change + ) + ) + + self._sensors.append(entity_id) + + async def async_control_heating_loop(self, _time=None) -> None: + """Control the heating based on current temperature, target temperature, and outside temperature.""" + # If overshoot protection is active, we are not doing anything since we already have a task running in async + if self.overshoot_protection_calculate: + return + + # 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: + return + + # Control the heating through the coordinator + await self._coordinator.async_control_heating_loop(self) + + # Pulse Width Modulation + await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint) + + # Set the control setpoint to make sure we always stay in control + await self._async_control_setpoint(self.pwm.state) + + # Set the relative modulation value, if supported + 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._coordinator.device_state == DeviceState.ON: + # If the setpoint is too low or the valves are closed or HVAC is off, turn off the heater + if self._setpoint <= MINIMUM_SETPOINT or not self.valves_open or self.hvac_mode == HVACMode.OFF: + await self._coordinator.async_set_heater_state(DeviceState.OFF) + else: + await self._coordinator.async_set_heater_state(DeviceState.ON) + + if self._coordinator.device_state == DeviceState.OFF: + # If the setpoint is high and the valves are open and the HVAC is not off, turn on the heater + if self._setpoint > MINIMUM_SETPOINT and self.valves_open and self.hvac_mode != HVACMode.OFF: + await self._coordinator.async_set_heater_state(DeviceState.ON) + else: + await self._coordinator.async_set_heater_state(DeviceState.OFF) + + self.async_write_ha_state() + async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: @@ -878,6 +819,34 @@ async def async_set_temperature(self, **kwargs) -> None: self._attr_preset_mode = PRESET_NONE await self.async_set_target_temperature(temperature) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the heating/cooling mode for the devices and update the state.""" + # Ignore the request when we are in calculation mode + if self.overshoot_protection_calculate: + return + + # Only allow the hvac mode to be set to heat or off + if hvac_mode == HVACMode.HEAT: + self._hvac_mode = HVACMode.HEAT + elif hvac_mode == HVACMode.OFF: + self._hvac_mode = HVACMode.OFF + else: + # If an unsupported mode is passed, log an error message + _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) + return + + # Reset the PID controller + await self._async_control_pid(True) + + # Set the hvac mode for all climate devices + for entity_id in (self._climates + self._main_climates): + 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) + + # Update the state and control the heating + self.async_write_ha_state() + await self.async_control_heating_loop() + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode for the thermostat.""" # Check if the given preset mode is valid @@ -923,7 +892,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: target_temperature} await self.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - async def async_set_target_temperature(self, temperature: float): + async def async_set_target_temperature(self, temperature: float) -> None: """Set the temperature setpoint for all main climates.""" if self._target_temperature == temperature: return @@ -947,37 +916,9 @@ async def async_set_target_temperature(self, temperature: float): self.async_write_ha_state() # Control the heating based on the new temperature setpoint - await self._async_control_heating_loop() + await self.async_control_heating_loop() async def async_send_notification(self, title: str, message: str, service: str = SERVICE_PERSISTENT_NOTIFICATION): """Send a notification to the user.""" data = {"title": title, "message": message} await self.hass.services.async_call(NOTIFY_DOMAIN, service, data) - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the heating/cooling mode for the devices and update the state.""" - # Ignore the request when we are in calculation mode - if self.overshoot_protection_calculate: - return - - # Only allow the hvac mode to be set to heat or off - if hvac_mode == HVACMode.HEAT: - self._hvac_mode = HVACMode.HEAT - elif hvac_mode == HVACMode.OFF: - self._hvac_mode = HVACMode.OFF - else: - # If an unsupported mode is passed, log an error message - _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) - return - - # Reset the PID controller - await self._async_control_pid(True) - - # Set the hvac mode for all climate devices - for entity_id in (self._climates + self._main_climates): - 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) - - # Update the state and control the heating - self.async_write_ha_state() - await self._async_control_heating_loop() diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index d72627bb..fe87b99f 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -221,7 +221,7 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ) if options.get(CONF_MODE) in [MODE_SWITCH]: - schema[vol.Required(CONF_SETPOINT, default=50)] = selector.NumberSelector( + schema[vol.Required(CONF_MINIMUM_SETPOINT, default=50)] = selector.NumberSelector( selector.NumberSelectorConfig(min=1, max=100, step=1) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 6e928850..f726b85e 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -6,6 +6,7 @@ COORDINATOR = "coordinator" CONFIG_STORE = "config_store" +MODE_FAKE = "fake" MODE_MQTT = "mqtt" MODE_SWITCH = "switch" MODE_SERIAL = "serial" @@ -22,7 +23,7 @@ CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" -CONF_SETPOINT = "setpoint" +CONF_MINIMUM_SETPOINT = "setpoint" CONF_CLIMATES = "climates" CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" @@ -75,7 +76,7 @@ CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, - CONF_SETPOINT: 80, + CONF_MINIMUM_SETPOINT: 80, CONF_OVERSHOOT_PROTECTION: False, CONF_FORCE_PULSE_WIDTH_MODULATION: False, diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index dda68cf0..3055a0e3 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -26,6 +26,10 @@ class DeviceState(str, Enum): class SatDataUpdateCoordinatorFactory: @staticmethod async def resolve(hass: HomeAssistant, store: SatConfigStore, mode: str, device: str) -> DataUpdateCoordinator: + if mode == MODE_FAKE: + from .fake import SatFakeCoordinator + return SatFakeCoordinator(hass, store) + if mode == MODE_MQTT: from .mqtt import SatMqttCoordinator return SatMqttCoordinator(hass, store, device) @@ -47,7 +51,7 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: self._store = store self._device_state = DeviceState.OFF self._simulation = bool(self._store.options.get(CONF_SIMULATION)) - self._minimum_setpoint = float(self._store.options.get(CONF_SETPOINT)) + self._minimum_setpoint = float(self._store.options.get(CONF_MINIMUM_SETPOINT)) self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) super().__init__(hass, _LOGGER, name=DOMAIN) diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py new file mode 100644 index 00000000..821e7bdb --- /dev/null +++ b/custom_components/sat/fake/__init__.py @@ -0,0 +1,94 @@ +from __future__ import annotations, annotations + +import logging + +from homeassistant.core import HomeAssistant + +from ..config_store import SatConfigStore +from ..coordinator import DeviceState, SatDataUpdateCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SatFakeConfig: + def __init__( + self, + supports_setpoint_management: bool = False, + supports_maximum_setpoint_management: bool = False, + supports_hot_water_setpoint_management: bool = False, + support_relative_modulation_management: bool = False + ): + self.supports_setpoint_management = supports_setpoint_management + self.supports_maximum_setpoint_management = supports_maximum_setpoint_management + self.supports_hot_water_setpoint_management = supports_hot_water_setpoint_management + self.support_relative_modulation_management = support_relative_modulation_management + + +class SatFakeCoordinator(SatDataUpdateCoordinator): + """Class to manage to fetch data from the OTGW Gateway using mqtt.""" + + def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: + self.data = {} + self.config = None + + self._setpoint = None + self._maximum_setpoint = None + self._hot_water_setpoint = None + self._relative_modulation_value = 100 + + super().__init__(hass, store) + + @property + def setpoint(self) -> float | None: + return self._setpoint + + @property + def device_active(self) -> bool: + return self._device_state == DeviceState.ON + + @property + def supports_setpoint_management(self): + if self.config is None: + return super().supports_setpoint_management + + return self.config.supports_maximum_setpoint_management + + @property + def supports_hot_water_setpoint_management(self): + if self.config is None: + return super().supports_hot_water_setpoint_management + + return self.config.supports_hot_water_setpoint_management + + def supports_maximum_setpoint_management(self): + if self.config is None: + return super().supports_maximum_setpoint_management + + return self.config.supports_maximum_setpoint_management + + @property + def support_relative_modulation_management(self): + if self.config is None: + return super().support_relative_modulation_management + + return self.config.support_relative_modulation_management + + async def async_set_control_setpoint(self, value: float) -> None: + self._setpoint = value + + await super().async_set_control_setpoint(value) + + async def async_set_control_hot_water_setpoint(self, value: float) -> None: + self._hot_water_setpoint = value + + await super().async_set_control_hot_water_setpoint(value) + + async def async_set_control_max_relative_modulation(self, value: float) -> None: + self._relative_modulation_value = value + + await super().async_set_control_max_relative_modulation(value) + + async def async_set_control_max_setpoint(self, value: float) -> None: + self._maximum_setpoint = value + + await super().async_set_control_max_setpoint(value) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index e206a697..7f25ef33 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -28,6 +28,8 @@ class PWM: def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._force = force + self._last_duty_cycle_percentage = None + self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time self._automatic_duty_cycle = automatic_duty_cycle @@ -84,38 +86,38 @@ def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: flo """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset - duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) - duty_cycle_percentage = min(duty_cycle_percentage, 1) - duty_cycle_percentage = max(duty_cycle_percentage, 0) + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) + self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) + self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) - _LOGGER.debug("Calculated duty cycle %.0f%%", duty_cycle_percentage * 100) + _LOGGER.debug("Calculated duty cycle %.2f%%", self._last_duty_cycle_percentage * 100) if not self._automatic_duty_cycle: - return int(duty_cycle_percentage * self._max_cycle_time), int((1 - duty_cycle_percentage) * self._max_cycle_time) + return int(self._last_duty_cycle_percentage * self._max_cycle_time), int((1 - self._last_duty_cycle_percentage) * self._max_cycle_time) - if duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: + if self._last_duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: return 0, 0 - if duty_cycle_percentage <= DUTY_CYCLE_20_PERCENT: + if self._last_duty_cycle_percentage <= DUTY_CYCLE_20_PERCENT: on_time = ON_TIME_20_PERCENT - off_time = (ON_TIME_20_PERCENT / duty_cycle_percentage) - ON_TIME_20_PERCENT + off_time = (ON_TIME_20_PERCENT / self._last_duty_cycle_percentage) - ON_TIME_20_PERCENT return int(on_time), int(off_time) - if duty_cycle_percentage <= DUTY_CYCLE_80_PERCENT: - on_time = ON_TIME_80_PERCENT * duty_cycle_percentage - off_time = ON_TIME_80_PERCENT * (1 - duty_cycle_percentage) + if self._last_duty_cycle_percentage <= DUTY_CYCLE_80_PERCENT: + on_time = ON_TIME_80_PERCENT * self._last_duty_cycle_percentage + off_time = ON_TIME_80_PERCENT * (1 - self._last_duty_cycle_percentage) return int(on_time), int(off_time) - if duty_cycle_percentage <= MAX_DUTY_CYCLE_PERCENTAGE: - on_time = ON_TIME_20_PERCENT / (1 - duty_cycle_percentage) - ON_TIME_20_PERCENT + if self._last_duty_cycle_percentage <= MAX_DUTY_CYCLE_PERCENTAGE: + on_time = ON_TIME_20_PERCENT / (1 - self._last_duty_cycle_percentage) - ON_TIME_20_PERCENT off_time = ON_TIME_20_PERCENT return int(on_time), int(off_time) - if duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: + if self._last_duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: return None @property @@ -132,3 +134,7 @@ def duty_cycle(self) -> None | tuple[int, int]: Otherwise, a tuple is returned with the on and off times of the duty cycle in seconds. """ return self._duty_cycle + + @property + def last_duty_cycle_percentage(self): + return round(self._last_duty_cycle_percentage * 100, 2) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py new file mode 100644 index 00000000..874beb50 --- /dev/null +++ b/custom_components/sat/util.py @@ -0,0 +1,64 @@ +from homeassistant.util import dt + +from custom_components.sat.const import * +from custom_components.sat.heating_curve import HeatingCurve +from custom_components.sat.pid import PID +from custom_components.sat.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 create_pid_controller(options) -> PID: + """Create and return a PID controller instance with the given configuration options.""" + # Extract the configuration options + kp = float(options.get(CONF_PROPORTIONAL)) + ki = float(options.get(CONF_INTEGRAL)) + kd = float(options.get(CONF_DERIVATIVE)) + heating_system = options.get(CONF_HEATING_SYSTEM) + automatic_gains = bool(options.get(CONF_AUTOMATIC_GAINS)) + sample_time_limit = convert_time_str_to_seconds(options.get(CONF_SAMPLE_TIME)) + + # Return a new PID controller instance with the given configuration options + return PID(kp=kp, ki=ki, kd=kd, heating_system=heating_system, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) + + +def create_heating_curve_controller(options) -> HeatingCurve: + """Create and return a PID controller instance with the given configuration options.""" + # Extract the configuration options + heating_system = options.get(CONF_HEATING_SYSTEM) + coefficient = float(options.get(CONF_HEATING_CURVE_COEFFICIENT)) + + # Return a new heating Curve controller instance with the given configuration options + return HeatingCurve(heating_system=heating_system, coefficient=coefficient) + + +def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: + """Create and return a PWM controller instance with the given configuration options.""" + # Extract the configuration options + automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) + max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) + force = bool(options.get(CONF_MODE) == MODE_SWITCH) or bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + + # 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, force=force) diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 00000000..cad70e1d --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,8 @@ +pytest +pytest-cov +pytest-asyncio +pytest-homeassistant-custom-component +homeassistant==2023.5.3 +aiodiscover +freezegun +scapy \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..c3b29162 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[tool:pytest] +testpaths = tests +asyncio_mode = auto \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/bandit.yaml b/tests/bandit.yaml new file mode 100644 index 00000000..ebd284ea --- /dev/null +++ b/tests/bandit.yaml @@ -0,0 +1,17 @@ +# https://bandit.readthedocs.io/en/latest/config.html + +tests: + - B108 + - B306 + - B307 + - B313 + - B314 + - B315 + - B316 + - B317 + - B318 + - B319 + - B320 + - B325 + - B602 + - B604 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..618f56ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +"""Fixtures for testing.""" +import pytest +from _pytest.logging import LogCaptureFixture +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from pytest_homeassistant_custom_component.common import assert_setup_component, MockConfigEntry + +from custom_components.sat import DOMAIN, CLIMATE, COORDINATOR +from custom_components.sat.climate import SatClimate +from custom_components.sat.fake import SatFakeCoordinator +from tests.const import DEFAULT_USER_DATA + + +@pytest.fixture(autouse=True) +def auto_enable_custom_integrations(enable_custom_integrations): + yield + + +@pytest.fixture +async def entry(hass: HomeAssistant, domains: list, test_data: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: + """Setup any given integration.""" + for domain, count in domains: + with assert_setup_component(count, domain): + assert await async_setup_component(hass, domain, config) + + await hass.async_block_till_done() + + await hass.async_start() + await hass.async_block_till_done() + + user_data = DEFAULT_USER_DATA.copy() + user_data.update(test_data) + + config_entry = MockConfigEntry(domain=DOMAIN, data=user_data) + await hass.config_entries.async_add(config_entry) + + return config_entry + + +@pytest.fixture +async def climate(hass, entry: MockConfigEntry) -> SatClimate: + return hass.data[DOMAIN][entry.entry_id][CLIMATE] + + +@pytest.fixture +async def coordinator(hass, entry: MockConfigEntry) -> SatFakeCoordinator: + return hass.data[DOMAIN][entry.entry_id][COORDINATOR] diff --git a/tests/const.py b/tests/const.py new file mode 100644 index 00000000..891884de --- /dev/null +++ b/tests/const.py @@ -0,0 +1,13 @@ +from custom_components.sat import CONF_NAME, CONF_DEVICE, CONF_INSIDE_SENSOR_ENTITY_ID, CONF_OUTSIDE_SENSOR_ENTITY_ID, CONF_MODE, MODE_FAKE, CONF_AUTOMATIC_GAINS, \ + CONF_AUTOMATIC_DUTY_CYCLE, CONF_OVERSHOOT_PROTECTION + +DEFAULT_USER_DATA = { + CONF_MODE: MODE_FAKE, + CONF_NAME: "Test", + CONF_DEVICE: None, + CONF_AUTOMATIC_GAINS: True, + CONF_AUTOMATIC_DUTY_CYCLE: True, + CONF_OVERSHOOT_PROTECTION: True, + CONF_INSIDE_SENSOR_ENTITY_ID: "sensor.test_inside_sensor", + CONF_OUTSIDE_SENSOR_ENTITY_ID: "sensor.test_outside_sensor", +} diff --git a/tests/test_climate.py b/tests/test_climate.py new file mode 100644 index 00000000..ec617358 --- /dev/null +++ b/tests/test_climate.py @@ -0,0 +1,128 @@ +"""The tests for the climate component.""" + +import pytest +from homeassistant.components.climate import HVACMode +from homeassistant.components.command_line.const import DOMAIN as COMMAND_LINE_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.sat import CONF_HEATING_CURVE_COEFFICIENT, CONF_MINIMUM_SETPOINT +from custom_components.sat.climate import SatClimate +from custom_components.sat.fake import SatFakeCoordinator + + +@pytest.mark.parametrize(*[ + "domains, test_data, config", + [( + [(SENSOR_DOMAIN, 2)], + { + CONF_MINIMUM_SETPOINT: 57, + CONF_HEATING_CURVE_COEFFICIENT: 1.8 + }, + { + SENSOR_DOMAIN: [ + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_inside_sensor", + "value_template": "{{ 20.9 | float }}", + }, + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_outside_sensor", + "value_template": "{{ 9.9 | float }}", + }, + ], + }, + )], +]) +async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await climate.async_set_target_temperature(21.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 57 + assert climate.heating_curve.value == 32.6 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 36.17 + assert climate.pwm.duty_cycle == (325, 574) + + +@pytest.mark.parametrize(*[ + "domains, test_data, config", + [( + [(SENSOR_DOMAIN, 2)], + { + CONF_MINIMUM_SETPOINT: 58, + CONF_HEATING_CURVE_COEFFICIENT: 1.3 + }, + { + SENSOR_DOMAIN: [ + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_inside_sensor", + "value_template": "{{ 18.99 | float }}", + }, + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_outside_sensor", + "value_template": "{{ 11.1 | float }}", + }, + ], + }, + )], +]) +async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await climate.async_set_target_temperature(19.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 58 + assert climate.heating_curve.value == 30.1 + assert climate.requested_setpoint == 30.597 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 11.03 + assert climate.pwm.duty_cycle == (180, 1452) + + +@pytest.mark.parametrize(*[ + "domains, test_data, config", + [( + [(SENSOR_DOMAIN, 2)], + { + CONF_MINIMUM_SETPOINT: 41, + CONF_HEATING_CURVE_COEFFICIENT: 0.9 + }, + { + SENSOR_DOMAIN: [ + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_inside_sensor", + "value_template": "{{ 19.9 | float }}", + }, + { + "platform": COMMAND_LINE_DOMAIN, + "command": "echo 0", + "name": "test_outside_sensor", + "value_template": "{{ -2.2 | float }}", + }, + ], + }, + )], +]) +async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await climate.async_set_target_temperature(20.0) + await climate.async_set_hvac_mode(HVACMode.HEAT) + + assert climate.setpoint == 41 + assert climate.heating_curve.value == 32.1 + assert climate.requested_setpoint == 37.397 + + assert climate.pulse_width_modulation_enabled + assert climate.pwm.last_duty_cycle_percentage == 73.89 + assert climate.pwm.duty_cycle == (665, 234) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 00000000..f9c2b25c --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,26 @@ +"""Test setup process.""" + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.sat import async_reload_entry +from custom_components.sat.const import DOMAIN, CONF_SIMULATION +from tests.const import DEFAULT_USER_DATA + + +async def test_setup_update_unload_entry(hass): + """Test entry setup and unload.""" + # Create the switch entry + switch_entry = MockConfigEntry(domain=SWITCH_DOMAIN, entry_id="switch.test") + await hass.config_entries.async_add(switch_entry) + + # Create our entity + sat_entry = MockConfigEntry(domain=DOMAIN, data=DEFAULT_USER_DATA, options={CONF_SIMULATION: True}) + await hass.config_entries.async_add(sat_entry) + + # Wait till there are no tasks and see if we have been configured + await hass.async_block_till_done() + assert DOMAIN in hass.data and sat_entry.entry_id in hass.data[DOMAIN] + + # Reload the entry without errors + assert await async_reload_entry(hass, sat_entry) is None From a7c8129e4c03f972ddbf8e5e2d673ede0a0b5bc9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:44:11 +0200 Subject: [PATCH 056/237] Filename typo --- requirements.test.txt => requirements_test.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename requirements.test.txt => requirements_test.txt (100%) diff --git a/requirements.test.txt b/requirements_test.txt similarity index 100% rename from requirements.test.txt rename to requirements_test.txt From b7db1975ea71a12066310d19cfdc9b73b373bd05 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:47:56 +0200 Subject: [PATCH 057/237] As missing pyotgw --- requirements_test.txt | 3 ++- tests/test_init.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index cad70e1d..5f4ad3b0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -5,4 +5,5 @@ pytest-homeassistant-custom-component homeassistant==2023.5.3 aiodiscover freezegun -scapy \ No newline at end of file +pyotgw +scapy diff --git a/tests/test_init.py b/tests/test_init.py index f9c2b25c..ebf5ec36 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,7 +4,7 @@ from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.sat import async_reload_entry -from custom_components.sat.const import DOMAIN, CONF_SIMULATION +from custom_components.sat.const import DOMAIN from tests.const import DEFAULT_USER_DATA @@ -15,7 +15,7 @@ async def test_setup_update_unload_entry(hass): await hass.config_entries.async_add(switch_entry) # Create our entity - sat_entry = MockConfigEntry(domain=DOMAIN, data=DEFAULT_USER_DATA, options={CONF_SIMULATION: True}) + sat_entry = MockConfigEntry(domain=DOMAIN, data=DEFAULT_USER_DATA) await hass.config_entries.async_add(sat_entry) # Wait till there are no tasks and see if we have been configured From 0fb0c840355859954072557aad0cb0fae006c9ba Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:51:19 +0200 Subject: [PATCH 058/237] Making sure HASS is loaded --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 618f56ba..0f62ffca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,9 +39,11 @@ async def entry(hass: HomeAssistant, domains: list, test_data: dict, config: dic @pytest.fixture async def climate(hass, entry: MockConfigEntry) -> SatClimate: + await hass.async_block_till_done() return hass.data[DOMAIN][entry.entry_id][CLIMATE] @pytest.fixture async def coordinator(hass, entry: MockConfigEntry) -> SatFakeCoordinator: + await hass.async_block_till_done() return hass.data[DOMAIN][entry.entry_id][COORDINATOR] From 2281bc7876dcf4936f741c2e2112352efcb46372 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:52:07 +0200 Subject: [PATCH 059/237] As missing aiohttp_cors --- requirements_test.txt | 1 + tests/conftest.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5f4ad3b0..b55ca818 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,6 +3,7 @@ pytest-cov pytest-asyncio pytest-homeassistant-custom-component homeassistant==2023.5.3 +aiohttp_cors aiodiscover freezegun pyotgw diff --git a/tests/conftest.py b/tests/conftest.py index 0f62ffca..618f56ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -39,11 +39,9 @@ async def entry(hass: HomeAssistant, domains: list, test_data: dict, config: dic @pytest.fixture async def climate(hass, entry: MockConfigEntry) -> SatClimate: - await hass.async_block_till_done() return hass.data[DOMAIN][entry.entry_id][CLIMATE] @pytest.fixture async def coordinator(hass, entry: MockConfigEntry) -> SatFakeCoordinator: - await hass.async_block_till_done() return hass.data[DOMAIN][entry.entry_id][COORDINATOR] From 914d099da8284ee6034a8a2ec350947feaa6829c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 22:54:05 +0200 Subject: [PATCH 060/237] Add missing janus --- requirements_test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements_test.txt b/requirements_test.txt index b55ca818..61b52bad 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,3 +8,4 @@ aiodiscover freezegun pyotgw scapy +janus \ No newline at end of file From 11d6320aad9edb3e02cc5afaf7a848b762e245fc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 23:07:00 +0200 Subject: [PATCH 061/237] Cleaning up tests --- tests/test_climate.py | 74 +++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index ec617358..0dd7741f 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -2,8 +2,8 @@ import pytest from homeassistant.components.climate import HVACMode -from homeassistant.components.command_line.const import DOMAIN as COMMAND_LINE_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.template import DOMAIN as TEMPLATE_DOMAIN from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -15,24 +15,24 @@ @pytest.mark.parametrize(*[ "domains, test_data, config", [( - [(SENSOR_DOMAIN, 2)], + [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 57, CONF_HEATING_CURVE_COEFFICIENT: 1.8 }, { - SENSOR_DOMAIN: [ + TEMPLATE_DOMAIN: [ { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_inside_sensor", - "value_template": "{{ 20.9 | float }}", - }, - { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_outside_sensor", - "value_template": "{{ 9.9 | float }}", + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 20.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 9.9 | float }}", + } + ] }, ], }, @@ -53,24 +53,24 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ "domains, test_data, config", [( - [(SENSOR_DOMAIN, 2)], + [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 58, CONF_HEATING_CURVE_COEFFICIENT: 1.3 }, { - SENSOR_DOMAIN: [ - { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_inside_sensor", - "value_template": "{{ 18.99 | float }}", - }, + TEMPLATE_DOMAIN: [ { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_outside_sensor", - "value_template": "{{ 11.1 | float }}", + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 18.99 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ 11.1 | float }}", + } + ] }, ], }, @@ -92,24 +92,24 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ "domains, test_data, config", [( - [(SENSOR_DOMAIN, 2)], + [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 41, CONF_HEATING_CURVE_COEFFICIENT: 0.9 }, { - SENSOR_DOMAIN: [ - { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_inside_sensor", - "value_template": "{{ 19.9 | float }}", - }, + TEMPLATE_DOMAIN: [ { - "platform": COMMAND_LINE_DOMAIN, - "command": "echo 0", - "name": "test_outside_sensor", - "value_template": "{{ -2.2 | float }}", + SENSOR_DOMAIN: [ + { + "name": "test_inside_sensor", + "state": "{{ 19.9 | float }}", + }, + { + "name": "test_outside_sensor", + "state": "{{ -2.2 | float }}", + } + ] }, ], }, From aee7a655e1955131990ee0c9e0a595c9e96eb1f2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 18 May 2023 23:09:49 +0200 Subject: [PATCH 062/237] Renaming test_data --- tests/conftest.py | 4 ++-- tests/test_climate.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 618f56ba..93afe0f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture -async def entry(hass: HomeAssistant, domains: list, test_data: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: +async def entry(hass: HomeAssistant, domains: list, data: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: """Setup any given integration.""" for domain, count in domains: with assert_setup_component(count, domain): @@ -29,7 +29,7 @@ async def entry(hass: HomeAssistant, domains: list, test_data: dict, config: dic await hass.async_block_till_done() user_data = DEFAULT_USER_DATA.copy() - user_data.update(test_data) + user_data.update(data) config_entry = MockConfigEntry(domain=DOMAIN, data=user_data) await hass.config_entries.async_add(config_entry) diff --git a/tests/test_climate.py b/tests/test_climate.py index 0dd7741f..0c2f8e2d 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -13,7 +13,7 @@ @pytest.mark.parametrize(*[ - "domains, test_data, config", + "domains, data, config", [( [(TEMPLATE_DOMAIN, 1)], { @@ -51,7 +51,7 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ - "domains, test_data, config", + "domains, data, config", [( [(TEMPLATE_DOMAIN, 1)], { @@ -90,7 +90,7 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ - "domains, test_data, config", + "domains, data, config", [( [(TEMPLATE_DOMAIN, 1)], { From 47fdf85c1d4445497973d53299aca3ede9950e64 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 21 May 2023 19:02:44 +0200 Subject: [PATCH 063/237] Small improvements: - Re-worked the UI - Add MQTT discovery - Disable automatic gains by default - Add support for restoring the optimal coefficient --- custom_components/sat/climate.py | 6 +- custom_components/sat/config_flow.py | 106 ++++++++++++++------- custom_components/sat/const.py | 2 +- custom_components/sat/heating_curve.py | 21 ++-- custom_components/sat/manifest.json | 5 +- custom_components/sat/translations/en.json | 49 +++++----- 6 files changed, 118 insertions(+), 71 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 4df35d1c..3baa9678 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -46,6 +46,7 @@ ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" +ATTR_OPTIMAL_COEFFICIENT = "optimal_coefficient" ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -244,6 +245,9 @@ async def _restore_previous_state_or_set_defaults(self): 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_OPTIMAL_COEFFICIENT): + self.heating_curve.restore_autotune(old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT)) + if old_state.attributes.get(ATTR_ROOMS): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: @@ -397,7 +401,7 @@ def hvac_action(self): if self._hvac_mode == HVACMode.OFF: return HVACAction.OFF - if not self._coordinator.device_state == DeviceState.ON: + if self._coordinator.device_state == DeviceState.OFF: return HVACAction.IDLE return HVACAction.HEATING diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index fe87b99f..40e0d254 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -14,7 +15,8 @@ from homeassistant.const import STATE_ON, STATE_OFF, MAJOR_VERSION, MINOR_VERSION from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import selector, entity_registry +from homeassistant.helpers import selector, entity_registry, device_registry +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from pyotgw import OpenThermGateway from .const import * @@ -39,7 +41,7 @@ async def async_step_user(self, _user_input=None) -> FlowResult: # Since we rely on the availability logic in 2023.5, we do not support it below it. if MAJOR_VERSION >= 2023 and MINOR_VERSION >= 5: - menu_options.append("mqtt") + menu_options.append("mosquitto") menu_options.append("serial") menu_options.append("switch") @@ -48,7 +50,7 @@ async def async_step_user(self, _user_input=None) -> FlowResult: async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - _LOGGER.debug("Discovered OTGW at [%s]", discovery_info.hostname) + _LOGGER.debug("Discovered OTGW at [socket://%s]", discovery_info.hostname) self._data[CONF_DEVICE] = f"socket://{discovery_info.hostname}:25238" # abort if we already have exactly this gateway id/host @@ -58,7 +60,23 @@ async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: return await self.async_step_serial() - async def async_step_mqtt(self, _user_input=None): + async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: + """Handle dhcp discovery.""" + device = device_registry.async_get(self.hass).async_get_device( + {(MQTT_DOMAIN, discovery_info.topic[11:])} + ) + + _LOGGER.debug("Discovered OTGW at [mqtt://%s]", discovery_info.topic) + self._data[CONF_DEVICE] = device.id + + # abort if we already have exactly this gateway id/host + # reload the integration if the host got updated + await self.async_set_unique_id(device.id) + self._abort_if_unique_id_configured(updates=self._data, reload_on_update=True) + + return await self.async_step_mosquitto() + + async def async_step_mosquitto(self, _user_input=None): self._errors = {} if _user_input is not None: @@ -67,7 +85,7 @@ async def async_step_mqtt(self, _user_input=None): if not await mqtt.async_wait_for_mqtt_client(self.hass): self._errors["base"] = "mqtt_component" - return await self.async_step_mqtt() + return await self.async_step_mosquitto() await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) self._abort_if_unique_id_configured() @@ -75,13 +93,13 @@ async def async_step_mqtt(self, _user_input=None): return await self.async_step_sensors_setup() return self.async_show_form( - step_id="mqtt", + step_id="mosquitto", last_step=False, errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_MQTT_TOPIC, default=OPTIONS_DEFAULTS[CONF_MQTT_TOPIC]): str, - vol.Required(CONF_DEVICE): selector.DeviceSelector( + vol.Required(CONF_DEVICE, default=self._data.get(CONF_DEVICE)): selector.DeviceSelector( selector.DeviceSelectorConfig(model="otgw-nodo") ), }), @@ -94,7 +112,9 @@ async def async_step_serial(self, _user_input=None): self._data.update(_user_input) self._data[CONF_MODE] = MODE_SERIAL - if not await OpenThermGateway().connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5): + gateway = OpenThermGateway() + if not await gateway.connect(port=self._data[CONF_DEVICE], skip_init=True, timeout=5): + await gateway.disconnect() self._errors["base"] = "connection" return await self.async_step_serial() @@ -109,7 +129,7 @@ async def async_step_serial(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - vol.Required(CONF_DEVICE, default="socket://otgw.local:25238"): str, + vol.Required(CONF_DEVICE, default=self._data.get(CONF_DEVICE, "socket://otgw.local:25238")): str, }), ) @@ -175,7 +195,7 @@ async def async_step_init(self, _user_input=None): return await self.async_step_user(_user_input) async def async_step_user(self, _user_input=None) -> FlowResult: - menu_options = ["general", "presets", "climates", "contact_sensors"] + menu_options = ["general", "areas", "presets", "system_configuration"] if self.show_advanced_options: menu_options.append("advanced") @@ -212,10 +232,6 @@ async def async_step_general(self, _user_input=None) -> FlowResult: ]) ) - schema[vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP])] = selector.NumberSelector( - selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) - ) - 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) ) @@ -254,7 +270,7 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: }) ) - async def async_step_climates(self, _user_input=None) -> FlowResult: + async def async_step_areas(self, _user_input=None) -> FlowResult: if _user_input is not None: if _user_input.get(CONF_MAIN_CLIMATES) is None: self._options[CONF_MAIN_CLIMATES] = [] @@ -262,40 +278,63 @@ async def async_step_climates(self, _user_input=None) -> FlowResult: if _user_input.get(CONF_CLIMATES) is None: self._options[CONF_CLIMATES] = [] + if _user_input.get(CONF_WINDOW_SENSOR) is None: + self._options[CONF_WINDOW_SENSOR] = [] + return await self.update_options(_user_input) entities = entity_registry.async_get(self.hass) device_name = self._config_entry.data.get(CONF_NAME) climate_id = entities.async_get_entity_id(CLIMATE_DOMAIN, DOMAIN, str(device_name).lower()) - entity_selector = selector.EntitySelector(selector.EntitySelectorConfig( + climate_selector = selector.EntitySelector(selector.EntitySelectorConfig( exclude_entities=[climate_id], domain=CLIMATE_DOMAIN, multiple=True )) + contact_sensor_selector = selector.EntitySelector(selector.EntitySelectorConfig( + domain=BINARY_SENSOR_DOMAIN, device_class=[ + BinarySensorDeviceClass.DOOR, + BinarySensorDeviceClass.WINDOW, + BinarySensorDeviceClass.GARAGE_DOOR + ]) + ) + options = await self.get_options() + return self.async_show_form( - step_id="climates", + step_id="areas", data_schema=vol.Schema({ - vol.Optional(CONF_MAIN_CLIMATES, default=options[CONF_MAIN_CLIMATES]): entity_selector, - vol.Optional(CONF_CLIMATES, default=options[CONF_CLIMATES]): entity_selector, + vol.Optional(CONF_MAIN_CLIMATES, default=options[CONF_MAIN_CLIMATES]): climate_selector, + vol.Optional(CONF_CLIMATES, default=options[CONF_CLIMATES]): climate_selector, + vol.Optional(CONF_WINDOW_SENSOR, default=options[CONF_WINDOW_SENSOR]): contact_sensor_selector, vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=options[CONF_SYNC_WITH_THERMOSTAT]): bool, }) ) - async def async_step_contact_sensors(self, _user_input=None) -> FlowResult: + async def async_step_system_configuration(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() - device_class = [BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.GARAGE_DOOR] + options = await self.get_options() + + schema = { + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, + } + + if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: + schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool + + if not options.get(CONF_AUTOMATIC_GAINS): + schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str + schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str + schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str + + schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() + schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() return self.async_show_form( - step_id="contact_sensors", - data_schema=vol.Schema({ - vol.Optional(CONF_WINDOW_SENSOR, default=defaults[CONF_WINDOW_SENSOR]): selector.EntitySelector( - selector.EntitySelectorConfig(domain=BINARY_SENSOR_DOMAIN, device_class=device_class) - ), - }) + step_id="system_configuration", + data_schema=vol.Schema(schema) ) async def async_step_advanced(self, _user_input=None) -> FlowResult: @@ -306,28 +345,23 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: schema = { vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, } if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: - schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( selector.NumberSelectorConfig(min=-1, max=1, step=0.1) ) - if not options.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str + schema[vol.Required(CONF_TARGET_TEMPERATURE_STEP, default=options[CONF_TARGET_TEMPERATURE_STEP])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) + ) if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() - schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() - schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() return self.async_show_form( step_id="advanced", diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index f726b85e..9082fd58 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -71,7 +71,7 @@ CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, CONF_WINDOW_SENSOR: None, - CONF_AUTOMATIC_GAINS: True, + CONF_AUTOMATIC_GAINS: False, CONF_AUTOMATIC_DUTY_CYCLE: False, CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index 0c7f07d7..a202eaa7 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -52,18 +52,18 @@ def autotune(self, setpoint: float, target_temperature: float, outside_temperatu self._optimal_coefficient = coefficient + def restore_autotune(self, coefficient: float): + if coefficient <= 0: + return + + self._optimal_coefficient = coefficient + self._optimal_coefficients = deque([coefficient] * 5, maxlen=5) + @staticmethod def _get_heating_curve_value(target_temperature: float, outside_temperature: float) -> float: """Calculate the heating curve value based on the current outside temperature""" return target_temperature - (0.01 * outside_temperature ** 2) - (0.8 * outside_temperature) - @property - def optimal_coefficient(self): - if len(self._optimal_coefficients) == 0: - return None - - return round(mean(self._optimal_coefficients), 1) - @property def base_offset(self) -> float: """Determine the base offset for the heating system.""" @@ -72,3 +72,10 @@ def base_offset(self) -> float: @property def value(self): return self._last_heating_curve_value + + @property + def optimal_coefficient(self): + if len(self._optimal_coefficients) == 0: + return None + + return round(mean(self._optimal_coefficients), 1) diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index 32476b67..6a8b67b6 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -13,8 +13,11 @@ "hostname": "otgw" } ], + "mqtt": [ + "OTGW/value/+" + ], "documentation": "https://github.com/Alexwijn/SAT", - "iot_class": "local_polling", + "iot_class": "local_push", "issue_tracker": "https://github.com/Alexwijn/SAT/issues", "requirements": [ "pyotgw==2.1.3" diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 64c96340..1e0526ee 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -5,7 +5,7 @@ "title": "Smart Autotune Thermostat (SAT)", "description": "SAT is a smart thermostat that is capable of auto-tuning itself to optimize temperature control. Select the appropriate mode that matches your heating system.", "menu_options": { - "mqtt": "OpenTherm Gateway ( MQTT )", + "mosquitto": "OpenTherm Gateway ( MQTT )", "serial": "OpenTherm Gateway ( SERIAL )", "switch": "PID Thermostat with PWM ( ON/OFF )" } @@ -58,19 +58,18 @@ "menu_options": { "general": "General", "presets": "Presets", - "climates": "Climates", - "contact_sensors": "Contact Sensors", - "advanced": "Advanced" + "areas": "Areas", + "advanced": "Advanced Options", + "system_configuration": "System Configuration" } }, "general": { "title": "General", "description": "General settings and configurations.", "data": { - "setpoint": "Setpoint", "automatic_gains": "Tuning", "heating_system": "Heating System", - "target_temperature_step": "Target Temperature Step", + "minimum_setpoint": "Minimum Setpoint", "heating_curve_coefficient": "Heating Curve Coefficient" } }, @@ -86,39 +85,39 @@ "sync_climates_with_preset": "Synchronize climates with preset (sleep / away / activity)" } }, - "climates": { - "title": "Climates (multi-room)", - "description": "Settings related to multi-room climates and temperature control.", + "areas": { + "title": "Areas", + "description": "Settings related to climates, multi-room and temperature control.", "data": { - "climates": "Climates (rooms)", - "main_climates": "Climates (main)", + "climates": "Rooms", + "main_climates": "Climates", + "window_sensor": "Contact Sensor or group", "sync_with_thermostat": "Synchronize setpoint with thermostat" } }, - "contact_sensors": { - "title": "Contact Sensors", - "description": "Settings for contact sensors, particularly window sensors.", - "data": { - "window_sensor": "Window" - } - }, - "advanced": { - "title": "Advanced", + "system_configuration": { + "title": "System Configuration", "description": "For fine-tuning and customization.", "data": { - "simulation": "Simulation", "integral": "Integral (kI)", "derivative": "Derivative (kD)", "proportional": "Proportional (kP)", - "climate_valve_offset": "Climate valve offset", "automatic_duty_cycle": "Automatic duty cycle", "overshoot_protection": "Overshoot Protection (with PWM)", - "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", - "force_pulse_width_modulation": "Force Pulse Width Modulation", - "sample_time": "Minimum time interval between updates to the PID controller", "window_minimum_open_time": "Minimum time for window to be open before reacting", "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)" } + }, + "advanced": { + "title": "Advanced", + "data": { + "simulation": "Simulation ", + "climate_valve_offset": "Climate valve offset", + "target_temperature_step": "Target Temperature Step", + "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", + "force_pulse_width_modulation": "Force Pulse Width Modulation", + "sample_time": "Minimum time interval between updates to the PID controller" + } } } } From 98a4415deae3d8b61e533121945d163a8b716c09 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 21 May 2023 19:07:02 +0200 Subject: [PATCH 064/237] Fixed sorting --- custom_components/sat/manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/manifest.json b/custom_components/sat/manifest.json index 6a8b67b6..6135a9c8 100644 --- a/custom_components/sat/manifest.json +++ b/custom_components/sat/manifest.json @@ -13,12 +13,12 @@ "hostname": "otgw" } ], - "mqtt": [ - "OTGW/value/+" - ], "documentation": "https://github.com/Alexwijn/SAT", "iot_class": "local_push", "issue_tracker": "https://github.com/Alexwijn/SAT/issues", + "mqtt": [ + "OTGW/value/+" + ], "requirements": [ "pyotgw==2.1.3" ], From 3686cdd3641532b8bcd30f9ffaa1c02f89329aac Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 22 May 2023 21:17:01 +0200 Subject: [PATCH 065/237] Improved Heating Curve auto tuning --- custom_components/sat/climate.py | 7 ++- custom_components/sat/heating_curve.py | 67 ++++++++++++++------------ 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3baa9678..922ab935 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -47,6 +47,7 @@ ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" ATTR_OPTIMAL_COEFFICIENT = "optimal_coefficient" +ATTR_COEFFICIENT_DERIVATIVE = "coefficient_derivative" ATTR_WARMING_UP_DERIVATIVE = "warming_up_derivative" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -246,7 +247,10 @@ async def _restore_previous_state_or_set_defaults(self): self._warming_up_derivative = old_state.attributes.get(ATTR_WARMING_UP_DERIVATIVE) if old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT): - self.heating_curve.restore_autotune(old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT)) + self.heating_curve.restore_autotune( + old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT), + old_state.attributes.get(ATTR_COEFFICIENT_DERIVATIVE) + ) if old_state.attributes.get(ATTR_ROOMS): self._rooms = old_state.attributes.get(ATTR_ROOMS) @@ -340,6 +344,7 @@ def extra_state_attributes(self): "minimum_setpoint": self._coordinator.minimum_setpoint, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, + "coefficient_derivative": self.heating_curve.coefficient_derivative, "relative_modulation_enabled": self.relative_modulation_enabled, "pulse_width_modulation_enabled": self.pulse_width_modulation_enabled, "pulse_width_modulation_state": self.pwm.state, diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index a202eaa7..cf7a9b3b 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -16,47 +16,53 @@ def __init__(self, heating_system: str, coefficient: float): def reset(self): self._optimal_coefficient = None + self._coefficient_derivative = None self._last_heating_curve_value = None self._optimal_coefficients = deque(maxlen=5) def update(self, target_temperature: float, outside_temperature: float) -> None: """Calculate the heating curve based on the outside temperature.""" - heating_curve_value = self._get_heating_curve_value( - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - + heating_curve_value = self._get_heating_curve_value(target_temperature, outside_temperature) self._last_heating_curve_value = round(self.base_offset + ((self._coefficient / 4) * heating_curve_value), 1) def calculate_coefficient(self, setpoint: float, target_temperature: float, outside_temperature: float) -> float: """Convert a setpoint to a coefficient value""" - heating_curve_value = self._get_heating_curve_value( - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - + heating_curve_value = self._get_heating_curve_value(target_temperature, outside_temperature) return round(4 * (setpoint - self.base_offset) / heating_curve_value, 1) def autotune(self, setpoint: float, target_temperature: float, outside_temperature: float): + """Calculate an optimal coefficient value.""" if setpoint <= MINIMUM_SETPOINT: return - coefficient = self.calculate_coefficient( - setpoint=setpoint, - target_temperature=target_temperature, - outside_temperature=outside_temperature - ) - - if coefficient != self._optimal_coefficient: - self._optimal_coefficients.append(coefficient) - + coefficient = self.calculate_coefficient(setpoint, target_temperature, outside_temperature) + self._coefficient_derivative = round(coefficient - self._optimal_coefficient, 1) + + # Fuzzy logic for when the derivative is positive + if self._coefficient_derivative > 1: + coefficient += 0.3 + elif self._coefficient_derivative < 0.5: + coefficient += 0.1 + elif self._coefficient_derivative < 1: + coefficient += 0.2 + + # Fuzzy logic for when the derivative is negative + if self._coefficient_derivative < -1: + coefficient -= 0.3 + elif self._coefficient_derivative > -0.5: + coefficient -= 0.1 + elif self._coefficient_derivative > -1: + coefficient -= 0.2 + + # Store the results + self._optimal_coefficients.append(coefficient) + self._optimal_coefficient = round(mean(self._optimal_coefficients), 1) + + def restore_autotune(self, coefficient: float, derivative: float): + """Restore a previous optimal coefficient value.""" self._optimal_coefficient = coefficient + self._coefficient_derivative = derivative - def restore_autotune(self, coefficient: float): - if coefficient <= 0: - return - - self._optimal_coefficient = coefficient self._optimal_coefficients = deque([coefficient] * 5, maxlen=5) @staticmethod @@ -70,12 +76,13 @@ def base_offset(self) -> float: return 20 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 27.2 @property - def value(self): - return self._last_heating_curve_value + def optimal_coefficient(self): + return self._optimal_coefficient @property - def optimal_coefficient(self): - if len(self._optimal_coefficients) == 0: - return None + def coefficient_derivative(self): + return self._coefficient_derivative - return round(mean(self._optimal_coefficients), 1) + @property + def value(self): + return self._last_heating_curve_value From c16e9aed95e50a5d373bcf7fb9514bf9884be658 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 23 May 2023 00:43:35 +0200 Subject: [PATCH 066/237] Typo? --- custom_components/sat/heating_curve.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index cf7a9b3b..d9450580 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -40,19 +40,19 @@ def autotune(self, setpoint: float, target_temperature: float, outside_temperatu # Fuzzy logic for when the derivative is positive if self._coefficient_derivative > 1: - coefficient += 0.3 + coefficient -= 0.3 elif self._coefficient_derivative < 0.5: - coefficient += 0.1 + coefficient -= 0.1 elif self._coefficient_derivative < 1: - coefficient += 0.2 + coefficient -= 0.2 # Fuzzy logic for when the derivative is negative if self._coefficient_derivative < -1: - coefficient -= 0.3 + coefficient += 0.3 elif self._coefficient_derivative > -0.5: - coefficient -= 0.1 + coefficient += 0.1 elif self._coefficient_derivative > -1: - coefficient -= 0.2 + coefficient += 0.2 # Store the results self._optimal_coefficients.append(coefficient) From 3f0a1d24eac2ae7a02f782bf491a907677292bed Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 30 May 2023 20:53:28 +0200 Subject: [PATCH 067/237] Improved the logic of when to use relative_modulation and re-factored some variables --- compose.yml | 1 - custom_components/sat/__init__.py | 11 +-- custom_components/sat/climate.py | 70 +++++++++++-------- custom_components/sat/const.py | 3 + custom_components/sat/coordinator.py | 4 +- custom_components/sat/fake/__init__.py | 10 +-- custom_components/sat/mqtt/__init__.py | 2 +- custom_components/sat/overshoot_protection.py | 2 +- custom_components/sat/pid.py | 2 +- custom_components/sat/pwm.py | 5 +- custom_components/sat/sensor.py | 2 +- custom_components/sat/serial/__init__.py | 2 +- 12 files changed, 59 insertions(+), 55 deletions(-) diff --git a/compose.yml b/compose.yml index cfd80767..43ce7a2f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,6 @@ version: "3.9" services: homeassistant: - container_name: homeassistant image: "ghcr.io/home-assistant/home-assistant:stable" volumes: - homeassistant:/config diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 49d6ecbd..c84b19bb 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -6,7 +6,7 @@ from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant +from homeassistant.core import HomeAssistant from . import mqtt, serial, switch from .config_store import SatConfigStore @@ -16,15 +16,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) -async def async_setup(_hass: HomeAssistant, __config: Config): - """ - Set up this integration using YAML is not supported. - - This function is not needed for this integration, but it is required by the Home Assistant framework. - """ - return True - - async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): """ Set up this integration using the UI. diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 922ab935..538797ea 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -68,6 +68,10 @@ def __init__(self, error: float, started: int = None): self.error = error self.started = started if started is not None else monotonic() + @property + def elapsed(self): + return monotonic() - self.started + class SatClimate(SatEntity, ClimateEntity, RestoreEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): @@ -176,6 +180,12 @@ async def async_added_to_hass(self) -> None: async def _register_event_listeners(self): """Register event listeners.""" + self.async_on_remove( + async_track_time_interval( + self.hass, self.async_control_heating_loop, timedelta(seconds=30) + ) + ) + self.async_on_remove( async_track_state_change_event( self.hass, [self.inside_sensor_entity_id], self._async_inside_sensor_changed @@ -183,22 +193,27 @@ async def _register_event_listeners(self): ) self.async_on_remove( - async_track_time_interval( - self.hass, self.async_control_heating_loop, timedelta(seconds=30) + async_track_state_change_event( + self.hass, self.outside_sensor_entities, self._async_outside_entity_changed ) ) - for entity_id in self.outside_sensor_entities: - self.async_on_remove( - async_track_state_change_event( - self.hass, [entity_id], self._async_outside_entity_changed - ) + self.async_on_remove( + async_track_state_change_event( + self.hass, self._main_climates, self._async_main_climate_changed + ) + ) + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._climates, self._async_climate_changed ) + ) - for climate_id in self._main_climates: + if self._window_sensor_id is not None: self.async_on_remove( async_track_state_change_event( - self.hass, [climate_id], self._async_main_climate_changed + self.hass, [self._window_sensor_id], self._async_window_sensor_changed ) ) @@ -207,19 +222,6 @@ async def _register_event_listeners(self): if state is not None and (sensor_temperature_id := state.attributes.get(SENSOR_TEMPERATURE_ID)): await self.async_track_sensor_temperature(sensor_temperature_id) - self.async_on_remove( - async_track_state_change_event( - self.hass, [climate_id], self._async_climate_changed - ) - ) - - if self._window_sensor_id is not None: - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._window_sensor_id], self._async_window_sensor_changed - ) - ) - async def _restore_previous_state_or_set_defaults(self): """Restore previous state if available, or set default values.""" old_state = await self.async_get_last_state() @@ -502,13 +504,19 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_enabled(self) -> bool: """Return True if relative modulation is enabled, False otherwise.""" - if not self._coordinator.support_relative_modulation_management or self._setpoint is None: + if not self._coordinator.supports_relative_modulation_management: return False + if self.hvac_mode == HVACMode.OFF or self._setpoint is None: + return True + if self._coordinator.hot_water_active or self._setpoint <= MINIMUM_SETPOINT: return True - return self.hvac_mode == HVACMode.HEAT and not self.pulse_width_modulation_enabled + if self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME: + return False + + return self.max_error > DEADBAND or not self.pulse_width_modulation_enabled def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" @@ -673,7 +681,7 @@ async def _async_control_pid(self, reset: bool = False) -> None: _LOGGER.info(f"Updating error value to {max_error} (Reset: False)") # Calculate an optimal heating curve when we are in the deadband - if -0.1 <= max_error <= 0.1: + if -DEADBAND <= max_error <= DEADBAND: self.heating_curve.autotune( setpoint=self.requested_setpoint, target_temperature=self.target_temperature, @@ -681,10 +689,12 @@ async def _async_control_pid(self, reset: bool = False) -> None: ) # Since we are in the deadband, we can safely assume we are not warming up anymore - if self._warming_up_data and max_error <= 0.1: + if self._warming_up_data and max_error <= DEADBAND: # Calculate the derivative per hour - time_taken_seconds = monotonic() - self._warming_up_data.started - self._warming_up_derivative = calculate_derivative_per_hour(self._warming_up_data.error, time_taken_seconds) + 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.") @@ -700,7 +710,7 @@ async def _async_control_pid(self, reset: bool = False) -> None: self.pwm.reset() # Determine if we are warming up - if self.max_error > 0.1: + if self.max_error > DEADBAND: self._warming_up_data = SatWarmingUp(self.max_error) _LOGGER.info("Outside of deadband, we are warming up") @@ -725,7 +735,7 @@ 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 self._coordinator.support_relative_modulation_management: + if self._coordinator.supports_relative_modulation_management: await self._coordinator.async_set_control_max_relative_modulation( MAXIMUM_RELATIVE_MOD if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 9082fd58..47e29c09 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -11,6 +11,9 @@ MODE_SWITCH = "switch" MODE_SERIAL = "serial" +DEADBAND = 0.1 +HEATER_STARTUP_TIMEFRAME = 180 + MINIMUM_SETPOINT = 10 MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 3055a0e3..e847831a 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -150,7 +150,7 @@ def supports_hot_water_setpoint_management(self): return False @property - def support_relative_modulation_management(self): + def supports_relative_modulation_management(self): """Returns whether the device supports setting a relative modulation value. This property is used to determine whether the coordinator can send a relative modulation value to the device. @@ -203,7 +203,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: async def async_set_control_max_relative_modulation(self, value: float) -> None: """Control the maximum relative modulation for the device.""" - if self.support_relative_modulation_management: + if self.supports_relative_modulation_management: self.logger.info("Set maximum relative modulation to %d", value) async def async_set_control_thermostat_setpoint(self, value: float) -> None: diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index 821e7bdb..79114bce 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -16,12 +16,12 @@ def __init__( supports_setpoint_management: bool = False, supports_maximum_setpoint_management: bool = False, supports_hot_water_setpoint_management: bool = False, - support_relative_modulation_management: bool = False + supports_relative_modulation_management: bool = False ): self.supports_setpoint_management = supports_setpoint_management self.supports_maximum_setpoint_management = supports_maximum_setpoint_management self.supports_hot_water_setpoint_management = supports_hot_water_setpoint_management - self.support_relative_modulation_management = support_relative_modulation_management + self.supports_relative_modulation_management = supports_relative_modulation_management class SatFakeCoordinator(SatDataUpdateCoordinator): @@ -67,11 +67,11 @@ def supports_maximum_setpoint_management(self): return self.config.supports_maximum_setpoint_management @property - def support_relative_modulation_management(self): + def supports_relative_modulation_management(self): if self.config is None: - return super().support_relative_modulation_management + return super().supports_relative_modulation_management - return self.config.support_relative_modulation_management + return self.config.supports_relative_modulation_management async def async_set_control_setpoint(self, value: float) -> None: self._setpoint = value diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index b135ae7a..7f554515 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -70,7 +70,7 @@ def supports_maximum_setpoint_management(self): return True @property - def support_relative_modulation_management(self): + def supports_relative_modulation_management(self): return True @property diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index ea65e859..597d119b 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -98,7 +98,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: average_temp = sum(temps) / 50 if previous_average_temp is not None: - if abs(actual_temp - previous_average_temp) <= 0.1: + if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 9a28b1f7..3b3afb5d 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -12,7 +12,7 @@ class PID: def __init__(self, kp: float, ki: float, kd: float, max_history: int = 2, - deadband: float = 0.1, + deadband: float = DEADBAND, automatic_gains: bool = False, integral_time_limit: float = 300, sample_time_limit: Optional[float] = 10, diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 7f25ef33..afdf3ec7 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -3,6 +3,7 @@ from time import monotonic from typing import Optional, Tuple +from .const import HEATER_STARTUP_TIMEFRAME from .heating_curve import HeatingCurve _LOGGER = logging.getLogger(__name__) @@ -68,13 +69,13 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float) -> No _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) - if self._state != PWMState.ON and self._duty_cycle[0] >= 180 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): self._state = PWMState.ON self._last_update = monotonic() _LOGGER.debug("Starting duty cycle.") return - if self._state != PWMState.OFF and (self._duty_cycle[0] < 180 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.debug("Finished duty cycle.") diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index b1f0cfe0..cdf3f580 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -37,7 +37,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a SatHeatingCurveSensor(coordinator, _config_entry, climate), ]) - if coordinator.support_relative_modulation_management: + if coordinator.supports_relative_modulation_management: _async_add_entities([SatCurrentPowerSensor(coordinator, _config_entry)]) diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 5471306e..70d81339 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -65,7 +65,7 @@ def supports_maximum_setpoint_management(self) -> bool: return True @property - def support_relative_modulation_management(self) -> bool: + def supports_relative_modulation_management(self) -> bool: return True @property From 09ab6a34824df9f3e3d1bec5527b2b4bb68dca0f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 7 Jun 2023 21:12:54 +0200 Subject: [PATCH 068/237] Initial commit with new configuration wizard, simulator coordinator and the removal of the config store --- custom_components/sat/__init__.py | 44 ++- custom_components/sat/binary_sensor.py | 2 +- custom_components/sat/climate.py | 95 ++---- custom_components/sat/config_flow.py | 300 +++++++++++++----- custom_components/sat/config_store.py | 37 --- custom_components/sat/const.py | 28 +- custom_components/sat/coordinator.py | 55 ++-- custom_components/sat/fake/__init__.py | 6 +- custom_components/sat/mqtt/__init__.py | 15 +- custom_components/sat/overshoot_protection.py | 19 +- custom_components/sat/pid.py | 8 +- custom_components/sat/sensor.py | 9 +- custom_components/sat/serial/__init__.py | 14 +- custom_components/sat/serial/sensor.py | 4 +- custom_components/sat/services.py | 89 ------ custom_components/sat/services.yaml | 33 -- custom_components/sat/simulator/__init__.py | 96 ++++++ custom_components/sat/simulator/sensor.py | 99 ++++++ custom_components/sat/switch/__init__.py | 19 +- custom_components/sat/translations/en.json | 80 ++++- custom_components/sat/util.py | 10 + 21 files changed, 650 insertions(+), 412 deletions(-) delete mode 100644 custom_components/sat/config_store.py delete mode 100644 custom_components/sat/services.py create mode 100644 custom_components/sat/simulator/__init__.py create mode 100644 custom_components/sat/simulator/sensor.py diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index c84b19bb..60476202 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -7,9 +7,9 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store from . import mqtt, serial, switch -from .config_store import SatConfigStore from .const import * from .coordinator import SatDataUpdateCoordinatorFactory @@ -28,13 +28,9 @@ async def async_setup_entry(_hass: HomeAssistant, _entry: ConfigEntry): # Create a new dictionary for this entry _hass.data[DOMAIN][_entry.entry_id] = {} - # Create a new config store for this entry and initialize it - _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE] = store = SatConfigStore(_hass, _entry) - await _hass.data[DOMAIN][_entry.entry_id][CONFIG_STORE].async_initialize() - # Resolve the coordinator by using the factory according to the mode _hass.data[DOMAIN][_entry.entry_id][COORDINATOR] = await SatDataUpdateCoordinatorFactory().resolve( - hass=_hass, store=store, mode=store.options.get(CONF_MODE), device=store.options.get(CONF_DEVICE) + hass=_hass, config_entry=_entry, mode=_entry.data.get(CONF_MODE), device=_entry.data.get(CONF_DEVICE) ) # Forward entry setup for climate and other platforms @@ -81,3 +77,39 @@ async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: # Set up the entry again await async_setup_entry(_hass, _entry) + + +async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", _entry.version) + + if _entry.version == 1: + # Legacy Store + store = Store(_hass, 1, DOMAIN) + data = await store.async_load() + + new = {**_entry.data} + + if overshoot_protection_value := data.get("overshoot_protection_value"): + new[CONF_MINIMUM_SETPOINT] = overshoot_protection_value + else: + new[CONF_MINIMUM_SETPOINT] = 10 + + if _entry.options[CONF_HEATING_SYSTEM] == "underfloor": + new[CONF_MAXIMUM_SETPOINT] = 50 + + if _entry.options[CONF_HEATING_SYSTEM] == "radiator_low_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 55 + + if _entry.options[CONF_HEATING_SYSTEM] == "radiator_medium_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 65 + + if _entry.options[CONF_HEATING_SYSTEM] == "radiator_high_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 75 + + _entry.version = 2 + _hass.config_entries.async_update_entry(_entry, data=new) + + _LOGGER.info("Migration to version %s successful", _entry.version) + + return True diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index ddba0e5a..7de02a28 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -23,7 +23,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] # Check if integration is set to use the serial protocol - if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: + if _config_entry.data.get(CONF_MODE) == MODE_SERIAL: await serial_binary_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) if coordinator.supports_setpoint_management: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 538797ea..b32b5b8d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -5,7 +5,6 @@ import logging from collections import deque from datetime import timedelta -from functools import partial from statistics import mean from time import monotonic from typing import List @@ -41,7 +40,6 @@ from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity from .pwm import PWMState -from .services import start_overshoot_protection_calculation, set_overshoot_protection_value from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, calculate_derivative_per_hour ATTR_ROOMS = "rooms" @@ -77,17 +75,14 @@ class SatClimate(SatEntity, ClimateEntity, RestoreEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) - self._store = coordinator.store - self.overshoot_protection_calculate = False - # Create dictionary mapping preset keys to temperature options conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} # Create dictionary mapping preset keys to temperature values - self._presets = {key: self._store.options[value] for key, value in conf_presets.items() if value in self._store.options} + self._presets = {key: config_entry.data[value] for key, value in conf_presets.items() if value in config_entry.data} # Get inside sensor entity ID - self.inside_sensor_entity_id = self._store.options.get(CONF_INSIDE_SENSOR_ENTITY_ID) + self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID) # Get inside sensor entity state inside_sensor_entity = coordinator.hass.states.get(self.inside_sensor_entity_id) @@ -104,14 +99,18 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn if isinstance(self.outside_sensor_entities, str): self.outside_sensor_entities = [self.outside_sensor_entities] + # Create config data dictionary with defaults + config_data = OPTIONS_DEFAULTS.copy() + config_data.update(config_entry.data) + # Create PID controller with given configuration options - self.pid = create_pid_controller(self._store.options) + self.pid = create_pid_controller(config_data) # Create Heating Curve controller with given configuration options - self.heating_curve = create_heating_curve_controller(self._store.options) + self.heating_curve = create_heating_curve_controller(config_data) # Create PWM controller with given configuration options - self.pwm = create_pwm_controller(self.heating_curve, self._store.options) + self.pwm = create_pwm_controller(self.heating_curve, config_data) self._sensors = [] self._rooms = None @@ -127,20 +126,20 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._saved_target_temperature_before_custom = None self._saved_target_temperature_before_activity = None - self._climates = self._store.options.get(CONF_CLIMATES) - self._main_climates = self._store.options.get(CONF_MAIN_CLIMATES) - self._window_sensor_id = self._store.options.get(CONF_WINDOW_SENSOR) - - self._simulation = bool(self._store.options.get(CONF_SIMULATION)) - self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) - self._sync_with_thermostat = bool(self._store.options.get(CONF_SYNC_WITH_THERMOSTAT)) - self._overshoot_protection = bool(self._store.options.get(CONF_OVERSHOOT_PROTECTION)) - self._climate_valve_offset = float(self._store.options.get(CONF_CLIMATE_VALVE_OFFSET)) - self._target_temperature_step = float(self._store.options.get(CONF_TARGET_TEMPERATURE_STEP)) - self._sync_climates_with_preset = bool(self._store.options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) - self._force_pulse_width_modulation = bool(self._store.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - self._sensor_max_value_age = convert_time_str_to_seconds(self._store.options.get(CONF_SENSOR_MAX_VALUE_AGE)) - self._window_minimum_open_time = convert_time_str_to_seconds(self._store.options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) + self._climates = config_data.get(CONF_CLIMATES) + self._main_climates = config_data.get(CONF_MAIN_CLIMATES) + self._window_sensor_id = config_data.get(CONF_WINDOW_SENSOR) + + self._simulation = bool(config_data.get(CONF_SIMULATION)) + self._heating_system = str(config_data.get(CONF_HEATING_SYSTEM)) + self._sync_with_thermostat = bool(config_data.get(CONF_SYNC_WITH_THERMOSTAT)) + self._overshoot_protection = bool(config_data.get(CONF_OVERSHOOT_PROTECTION)) + self._climate_valve_offset = float(config_data.get(CONF_CLIMATE_VALVE_OFFSET)) + self._target_temperature_step = float(config_data.get(CONF_TARGET_TEMPERATURE_STEP)) + self._sync_climates_with_preset = bool(config_data.get(CONF_SYNC_CLIMATES_WITH_PRESET)) + self._force_pulse_width_modulation = bool(config_data.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + self._sensor_max_value_age = convert_time_str_to_seconds(config_data.get(CONF_SENSOR_MAX_VALUE_AGE)) + self._window_minimum_open_time = convert_time_str_to_seconds(config_data.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() @@ -279,35 +278,6 @@ async def reset_integral(_call: ServiceCall): self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) - if self._coordinator.supports_setpoint_management: - if self._overshoot_protection and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._overshoot_protection = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled overshoot protection because no overshoot value has been found." - ) - - if self._force_pulse_width_modulation and self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE) is None: - self._force_pulse_width_modulation = False - - await self.async_send_notification( - title="Smart Autotune Thermostat", - message="Disabled forced pulse width modulation because no overshoot value has been found." - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION, - partial(start_overshoot_protection_calculation, self._coordinator, self) - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_SET_OVERSHOOT_PROTECTION_VALUE, - partial(set_overshoot_protection_value, self._coordinator, self) - ) - @property def name(self): """Return the friendly name of the sensor.""" @@ -350,8 +320,7 @@ def extra_state_attributes(self): "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, - "overshoot_protection_calculating": self.overshoot_protection_calculate, + "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle } @property @@ -782,10 +751,6 @@ async def async_track_sensor_temperature(self, entity_id): async def async_control_heating_loop(self, _time=None) -> None: """Control the heating based on current temperature, target temperature, and outside temperature.""" - # If overshoot protection is active, we are not doing anything since we already have a task running in async - if self.overshoot_protection_calculate: - return - # 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: return @@ -826,10 +791,6 @@ async def async_set_temperature(self, **kwargs) -> None: if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - # Ignore the request when we are in calculation mode - if self.overshoot_protection_calculate: - return - # Automatically select the preset for preset in self._presets: if float(self._presets[preset]) == float(temperature): @@ -840,10 +801,6 @@ async def async_set_temperature(self, **kwargs) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the heating/cooling mode for the devices and update the state.""" - # Ignore the request when we are in calculation mode - if self.overshoot_protection_calculate: - return - # Only allow the hvac mode to be set to heat or off if hvac_mode == HVACMode.HEAT: self._hvac_mode = HVACMode.HEAT @@ -872,10 +829,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_mode not in self.preset_modes: raise ValueError(f"Got unsupported preset_mode {preset_mode}. Must be one of {self.preset_modes}") - # Ignore the request when we are in calculation mode - if self.overshoot_protection_calculate: - return - # Return if the given preset mode is already set if preset_mode == self._attr_preset_mode: return diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 40e0d254..f680b956 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -1,4 +1,5 @@ """Adds config flow for SAT.""" +import asyncio import logging import voluptuous as vol @@ -7,19 +8,24 @@ from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, STATE_OFF, MAJOR_VERSION, MINOR_VERSION +from homeassistant.config_entries import ConfigEntry, SOURCE_USER +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, entity_registry, device_registry from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from pyotgw import OpenThermGateway +from . import SatDataUpdateCoordinatorFactory from .const import * +from .coordinator import SatDataUpdateCoordinator +from .overshoot_protection import OvershootProtection +from .util import calculate_default_maximum_setpoint DEFAULT_NAME = "Living Room" @@ -28,24 +34,39 @@ class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SAT.""" - VERSION = 1 + VERSION = 2 + calibration = None + overshoot_protection_value = None def __init__(self): """Initialize.""" self._data = {} self._errors = {} + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry): + return SatOptionsFlowHandler(config_entry) + + @callback + def async_remove(self) -> None: + if self.calibration is not None: + self.calibration.cancel() + async def async_step_user(self, _user_input=None) -> FlowResult: """Handle user flow.""" menu_options = [] - # Since we rely on the availability logic in 2023.5, we do not support it below it. + # Since we rely on the availability logic in 2023.5, we do not support below it. if MAJOR_VERSION >= 2023 and MINOR_VERSION >= 5: menu_options.append("mosquitto") menu_options.append("serial") menu_options.append("switch") + if self.show_advanced_options: + menu_options.append("simulator") + return self.async_show_menu(step_id="user", menu_options=menu_options) async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: @@ -60,7 +81,7 @@ async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: return await self.async_step_serial() - async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: + async def async_step_mqtt(self, discovery_info: MqttServiceInfo): """Handle dhcp discovery.""" device = device_registry.async_get(self.hass).async_get_device( {(MQTT_DOMAIN, discovery_info.topic[11:])} @@ -87,10 +108,7 @@ async def async_step_mosquitto(self, _user_input=None): self._errors["base"] = "mqtt_component" return await self.async_step_mosquitto() - await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) - self._abort_if_unique_id_configured() - - return await self.async_step_sensors_setup() + return await self.async_step_sensors() return self.async_show_form( step_id="mosquitto", @@ -118,10 +136,7 @@ async def async_step_serial(self, _user_input=None): self._errors["base"] = "connection" return await self.async_step_serial() - await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) - self._abort_if_unique_id_configured() - - return await self.async_step_sensors_setup() + return await self.async_step_sensors() return self.async_show_form( step_id="serial", @@ -138,11 +153,7 @@ async def async_step_switch(self, _user_input=None): self._data.update(_user_input) self._data[CONF_MODE] = MODE_SWITCH - await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) - - self._abort_if_unique_id_configured() - - return await self.async_step_sensors_setup() + return await self.async_step_sensors() return self.async_show_form( step_id="switch", @@ -150,22 +161,54 @@ async def async_step_switch(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + [vol.Required(CONF_MINIMUM_SETPOINT, default=50)]: selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1) + ), vol.Required(CONF_DEVICE): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN]) + selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]) ) }), ) + async def async_step_simulator(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + self._data[CONF_MODE] = MODE_SIMULATOR + self._data[CONF_DEVICE] = MODE_SIMULATOR + + return await self.async_step_sensors() + + return self.async_show_form( + step_id="simulator", + last_step=False, + errors=self._errors, + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Required(CONF_SIMULATED_HEATING, default=OPTIONS_DEFAULTS[CONF_SIMULATED_HEATING]): selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ), + vol.Required(CONF_SIMULATED_COOLING, default=OPTIONS_DEFAULTS[CONF_SIMULATED_COOLING]): selector.NumberSelector( + selector.NumberSelectorConfig(min=1, max=100, step=1) + ), + vol.Required(CONF_MINIMUM_SETPOINT, default=OPTIONS_DEFAULTS[CONF_MINIMUM_SETPOINT]): selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1) + ), + vol.Required(CONF_SIMULATED_WARMING_UP, default=OPTIONS_DEFAULTS[CONF_SIMULATED_WARMING_UP]): selector.TimeSelector() + }), + ) + async def async_step_sensors(self, _user_input=None): - self._errors = {} + await self.async_set_unique_id(self._data[CONF_DEVICE], raise_on_progress=False) + self._abort_if_unique_id_configured() if _user_input is not None: self._data.update(_user_input) - return self.async_create_entry(title=self._data[CONF_NAME], data=self._data) - return await self.async_step_sensors_setup() + if self._data[CONF_MODE] in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: + return await self.async_step_heating_system() + + return await self.async_step_pid_controller() - async def async_step_sensors_setup(self): return self.async_show_form( step_id="sensors", data_schema=vol.Schema({ @@ -178,10 +221,130 @@ async def async_step_sensors_setup(self): }), ) - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry): - return SatOptionsFlowHandler(config_entry) + async def async_step_heating_system(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + + if (await self._create_coordinator()).supports_setpoint_management: + return await self.async_step_automatic_gains() + + return await self.async_step_finish() + + return self.async_show_form( + last_step=False, + step_id="heating_system", + data_schema=vol.Schema({ + vol.Required(CONF_HEATING_SYSTEM, default=OPTIONS_DEFAULTS[CONF_HEATING_SYSTEM]): selector.SelectSelector( + selector.SelectSelectorConfig(options=[ + {"value": HEATING_SYSTEM_RADIATORS, "label": "Radiators"}, + {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"} + ]) + ) + }) + ) + + async def async_step_automatic_gains(self, _user_input=None): + return self.async_show_menu( + step_id="automatic_gains", + menu_options=["calibrate", "overshoot_protection", "pid_controller"] + ) + + async def async_step_calibrate(self, _user_input=None): + coordinator = await self._create_coordinator() + + async def start_calibration(): + try: + overshoot_protection = OvershootProtection(coordinator) + self.overshoot_protection_value = await overshoot_protection.calculate() + except asyncio.CancelledError: + _LOGGER.warning("Cancelled calibration.") + return False + + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + return True + + if not self.calibration: + self.calibration = self.hass.async_create_task( + start_calibration() + ) + + return self.async_show_progress( + step_id="calibrate", + progress_action="calibration", + ) + + if self.overshoot_protection_value is None: + return self.async_abort(reason="unable_to_calibrate") + + await self._enable_overshoot_protection( + self.overshoot_protection_value + ) + + self.calibration = None + self.overshoot_protection_value = None + + return self.async_show_progress_done(next_step_id="calibrated") + + async def async_step_calibrated(self, _user_input=None): + return self.async_show_menu( + step_id="calibrated", + description_placeholders=self._data, + menu_options=["calibrate", "finish"], + ) + + async def async_step_overshoot_protection(self, _user_input=None): + if _user_input is not None: + await self._enable_overshoot_protection( + _user_input[CONF_MINIMUM_SETPOINT] + ) + + return await self.async_step_finish() + + return self.async_show_form( + step_id="overshoot_protection", + data_schema=vol.Schema({ + vol.Required(CONF_MINIMUM_SETPOINT, default=OPTIONS_DEFAULTS[CONF_MINIMUM_SETPOINT]): selector.NumberSelector( + selector.NumberSelectorConfig(min=MINIMUM_SETPOINT, max=OVERSHOOT_PROTECTION_SETPOINT, step=1, unit_of_measurement="°C") + ), + }) + ) + + async def async_step_pid_controller(self, _user_input=None): + self._data[CONF_AUTOMATIC_GAINS] = False + + if _user_input is not None: + self._data.update(_user_input) + return await self.async_step_finish() + + return self.async_show_form( + step_id="pid_controller", + data_schema=vol.Schema({ + vol.Required(CONF_PROPORTIONAL, default=OPTIONS_DEFAULTS[CONF_PROPORTIONAL]): str, + vol.Required(CONF_INTEGRAL, default=OPTIONS_DEFAULTS[CONF_INTEGRAL]): str, + vol.Required(CONF_DERIVATIVE, default=OPTIONS_DEFAULTS[CONF_DERIVATIVE]): str + }) + ) + + async def async_step_finish(self, _user_input=None): + return self.async_create_entry(title=self._data[CONF_NAME], data=self._data) + + async def _create_coordinator(self) -> SatDataUpdateCoordinator: + # Create a new config to use + config = ConfigEntry( + version=self.VERSION, domain=DOMAIN, title=self._data[CONF_NAME], data=self._data, source=SOURCE_USER + ) + + # 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] + ) + + async def _enable_overshoot_protection(self, overshoot_protection_value: float): + self._data[CONF_OVERSHOOT_PROTECTION] = True + self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value class SatOptionsFlowHandler(config_entries.OptionsFlow): @@ -192,54 +355,41 @@ def __init__(self, config_entry: ConfigEntry): self._options = dict(config_entry.options) async def async_step_init(self, _user_input=None): - return await self.async_step_user(_user_input) - - async def async_step_user(self, _user_input=None) -> FlowResult: menu_options = ["general", "areas", "presets", "system_configuration"] if self.show_advanced_options: menu_options.append("advanced") return self.async_show_menu( - step_id="user", + step_id="init", menu_options=menu_options ) async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: - _user_input[CONF_AUTOMATIC_GAINS] = _user_input[CONF_AUTOMATIC_GAINS] == STATE_ON - return await self.update_options(_user_input) schema = {} options = await self.get_options() - options[CONF_AUTOMATIC_GAINS] = STATE_ON if options[CONF_AUTOMATIC_GAINS] else STATE_OFF - - if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: - schema[vol.Required(CONF_HEATING_SYSTEM, default=options[CONF_HEATING_SYSTEM])] = selector.SelectSelector( - selector.SelectSelectorConfig(options=[ - {"value": HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES, "label": "Radiators ( High Temperatures )"}, - {"value": HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES, "label": "Radiators ( Medium Temperatures )"}, - {"value": HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES, "label": "Radiators ( Low Temperatures )"}, - {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"} - ]) - ) - schema[vol.Required(CONF_AUTOMATIC_GAINS, default=options.get(CONF_AUTOMATIC_GAINS))] = selector.SelectSelector( - selector.SelectSelectorConfig(options=[ - {"value": STATE_OFF, "label": "Manual"}, - {"value": STATE_ON, "label": "Automatic"}, - ]) + default_maximum_setpoint = calculate_default_maximum_setpoint(options.get(CONF_HEATING_SYSTEM)) + maximum_setpoint = float(options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) + + 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.get(CONF_AUTOMATIC_GAINS): + schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str + schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str + schema[vol.Required(CONF_DERIVATIVE, default=options.get(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) ) - if options.get(CONF_MODE) in [MODE_SWITCH]: - schema[vol.Required(CONF_MINIMUM_SETPOINT, default=50)] = selector.NumberSelector( - selector.NumberSelectorConfig(min=1, max=100, step=1) - ) + if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): + schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() return self.async_show_form(step_id="general", data_schema=vol.Schema(schema)) @@ -252,19 +402,19 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: step_id="presets", data_schema=vol.Schema({ vol.Required(CONF_ACTIVITY_TEMPERATURE, default=defaults[CONF_ACTIVITY_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), vol.Required(CONF_AWAY_TEMPERATURE, default=defaults[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), vol.Required(CONF_SLEEP_TEMPERATURE, default=defaults[CONF_SLEEP_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), vol.Required(CONF_HOME_TEMPERATURE, default=defaults[CONF_HOME_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), vol.Required(CONF_COMFORT_TEMPERATURE, default=defaults[CONF_COMFORT_TEMPERATURE]): selector.NumberSelector( - selector.NumberSelectorConfig(min=5, max=35, step=0.5) + selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=defaults[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, }) @@ -317,37 +467,24 @@ async def async_step_system_configuration(self, _user_input=None) -> FlowResult: options = await self.get_options() - schema = { - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, - } - - if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: - schema[vol.Required(CONF_OVERSHOOT_PROTECTION, default=options[CONF_OVERSHOOT_PROTECTION])] = bool - - if not options.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str - - schema[vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE))] = selector.TimeSelector() - schema[vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME))] = selector.TimeSelector() - return self.async_show_form( step_id="system_configuration", - data_schema=vol.Schema(schema) + data_schema=vol.Schema({ + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, + vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE)): selector.TimeSelector(), + vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)): selector.TimeSelector(), + }) ) async def async_step_advanced(self, _user_input=None) -> FlowResult: + options = await self.get_options() if _user_input is not None: return await self.update_options(_user_input) - options = await self.get_options() - - schema = { - vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, - } + schema = {} + schema[vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION])]: bool - if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL]: + if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( @@ -358,9 +495,6 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) ) - if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): - schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() - schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() return self.async_show_form( diff --git a/custom_components/sat/config_store.py b/custom_components/sat/config_store.py deleted file mode 100644 index 1217f251..00000000 --- a/custom_components/sat/config_store.py +++ /dev/null @@ -1,37 +0,0 @@ -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store - -from .const import * - - -class SatConfigStore: - _STORAGE_VERSION = 1 - _STORAGE_KEY = DOMAIN - - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): - self._data = {} - self._hass = hass - self._options = None - self._config_entry = config_entry - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) - - async def async_initialize(self): - if (data := await self._store.async_load()) is None: - data = {STORAGE_OVERSHOOT_PROTECTION_VALUE: None} - - self._data = data - self._options = OPTIONS_DEFAULTS.copy() - self._options.update(self._config_entry.data) - self._options.update(self._config_entry.options) - - def get(self, key: str, default=None): - return self._data.get(key) or default - - def update(self, key: str, value: float): - self._data[key] = value - self._store.async_delay_save(lambda: self._data, 1.0) - - @property - def options(self): - return self._options diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 47e29c09..86170329 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -1,7 +1,7 @@ # Base component constants NAME = "Smart Autotune Thermostat" DOMAIN = "sat" -VERSION = "2.3.x" +VERSION = "3.0.x" CLIMATE = "climate" COORDINATOR = "coordinator" CONFIG_STORE = "config_store" @@ -10,6 +10,7 @@ MODE_MQTT = "mqtt" MODE_SWITCH = "switch" MODE_SERIAL = "serial" +MODE_SIMULATOR = "simulator" DEADBAND = 0.1 HEATER_STARTUP_TIMEFRAME = 180 @@ -26,7 +27,11 @@ CONF_MODE = "mode" CONF_NAME = "name" CONF_DEVICE = "device" -CONF_MINIMUM_SETPOINT = "setpoint" +CONF_SIMULATED_HEATING = "simulated_heating" +CONF_SIMULATED_COOLING = "simulated_cooling" +CONF_SIMULATED_WARMING_UP = "simulated_warming_up" +CONF_MINIMUM_SETPOINT = "minimum_setpoint" +CONF_MAXIMUM_SETPOINT = "maximum_setpoint" CONF_CLIMATES = "climates" CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" @@ -59,10 +64,8 @@ CONF_COMFORT_TEMPERATURE = "comfort_temperature" CONF_ACTIVITY_TEMPERATURE = "activity_temperature" +HEATING_SYSTEM_RADIATORS = "radiators" HEATING_SYSTEM_UNDERFLOOR = "underfloor" -HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES = "radiator_low_temperatures" -HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES = "radiator_medium_temperatures" -HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES = "radiator_high_temperatures" OPTIONS_DEFAULTS = { CONF_MODE: MODE_SERIAL, @@ -70,17 +73,21 @@ CONF_INTEGRAL: "0", CONF_DERIVATIVE: "6000", + CONF_AUTOMATIC_GAINS: True, + CONF_AUTOMATIC_DUTY_CYCLE: True, + CONF_OVERSHOOT_PROTECTION: False, + CONF_CLIMATES: [], CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, CONF_WINDOW_SENSOR: None, - CONF_AUTOMATIC_GAINS: False, - CONF_AUTOMATIC_DUTY_CYCLE: False, CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, - CONF_MINIMUM_SETPOINT: 80, - CONF_OVERSHOOT_PROTECTION: False, + CONF_SIMULATED_HEATING: 20, + CONF_SIMULATED_COOLING: 5, + + CONF_MINIMUM_SETPOINT: 10, CONF_FORCE_PULSE_WIDTH_MODULATION: False, CONF_MQTT_TOPIC: "OTGW", @@ -89,6 +96,7 @@ CONF_CLIMATE_VALVE_OFFSET: 0, CONF_TARGET_TEMPERATURE_STEP: 0.5, CONF_SENSOR_MAX_VALUE_AGE: "06:00:00", + CONF_SIMULATED_WARMING_UP: "00:00:15", CONF_WINDOW_MINIMUM_OPEN_TIME: "00:00:15", CONF_ACTIVITY_TEMPERATURE: 10, @@ -98,7 +106,7 @@ CONF_COMFORT_TEMPERATURE: 20, CONF_HEATING_CURVE_COEFFICIENT: 1.0, - CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES, + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, } # Storage diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index e847831a..012c0c3f 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -6,11 +6,12 @@ from enum import Enum 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 .config_store import SatConfigStore from .const import * +from .util import calculate_default_maximum_setpoint if typing.TYPE_CHECKING: from .climate import SatClimate @@ -25,41 +26,43 @@ class DeviceState(str, Enum): class SatDataUpdateCoordinatorFactory: @staticmethod - async def resolve(hass: HomeAssistant, store: SatConfigStore, mode: str, device: str) -> DataUpdateCoordinator: + async def resolve(hass: HomeAssistant, config_entry: ConfigEntry, mode: str, device: str) -> SatDataUpdateCoordinator: if mode == MODE_FAKE: from .fake import SatFakeCoordinator - return SatFakeCoordinator(hass, store) + return SatFakeCoordinator(hass, config_entry) + + if mode == MODE_SIMULATOR: + from .simulator import SatSimulatorCoordinator + return SatSimulatorCoordinator(hass, config_entry) if mode == MODE_MQTT: from .mqtt import SatMqttCoordinator - return SatMqttCoordinator(hass, store, device) + return SatMqttCoordinator(hass, config_entry, device) if mode == MODE_SERIAL: from .serial import SatSerialCoordinator - return await SatSerialCoordinator(hass, store, device).async_connect() + return await SatSerialCoordinator(hass, config_entry, device).async_connect() if mode == MODE_SWITCH: from .switch import SatSwitchCoordinator - return SatSwitchCoordinator(hass, store, device) + return SatSwitchCoordinator(hass, config_entry, device) raise Exception(f'Invalid mode[{mode}]') class SatDataUpdateCoordinator(DataUpdateCoordinator): - def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize.""" - self._store = store + self._config_entry = config_entry self._device_state = DeviceState.OFF - self._simulation = bool(self._store.options.get(CONF_SIMULATION)) - self._minimum_setpoint = float(self._store.options.get(CONF_MINIMUM_SETPOINT)) - self._heating_system = str(self._store.options.get(CONF_HEATING_SYSTEM)) + self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) + self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) - super().__init__(hass, _LOGGER, name=DOMAIN) + default_maximum_setpoint = calculate_default_maximum_setpoint(self._heating_system) + self._maximum_setpoint = float(config_entry.data.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) + self._minimum_setpoint = float(config_entry.data.get(CONF_MINIMUM_SETPOINT)) - @property - def store(self): - """Return the configuration store for the integration.""" - return self._store + super().__init__(hass, _LOGGER, name=DOMAIN) @property def device_state(self): @@ -69,17 +72,7 @@ def device_state(self): @property def maximum_setpoint(self) -> float: """Return the maximum setpoint temperature that the device can support.""" - if self._heating_system == HEATING_SYSTEM_RADIATOR_HIGH_TEMPERATURES: - return 75.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_MEDIUM_TEMPERATURES: - return 65.0 - - if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: - return 55.0 - - if self._heating_system == HEATING_SYSTEM_UNDERFLOOR: - return 50.0 + return self._maximum_setpoint @property @abstractmethod @@ -131,6 +124,10 @@ def minimum_relative_modulation_value(self) -> float | None: def minimum_setpoint(self) -> float: return self._minimum_setpoint + @property + def maximum_setpoint(self) -> float: + return self._maximum_setpoint + @property def supports_setpoint_management(self): """Returns whether the device supports setting a boiler setpoint. @@ -175,9 +172,9 @@ async def async_will_remove_from_hass(self, climate: SatClimate) -> None: """Run when entity will be removed from hass.""" pass - async def async_control_heating_loop(self, climate: SatClimate, _time=None) -> None: + async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: """Control the heating loop for the device.""" - if climate.hvac_mode == HVACMode.OFF and self.device_active: + if climate is not None and climate.hvac_mode == HVACMode.OFF and self.device_active: # Send out a new command to turn off the device await self.async_set_heater_state(DeviceState.OFF) diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index 79114bce..ec73accc 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -2,9 +2,9 @@ import logging +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from ..config_store import SatConfigStore from ..coordinator import DeviceState, SatDataUpdateCoordinator _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -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, store: SatConfigStore) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.data = {} self.config = None @@ -36,7 +36,7 @@ def __init__(self, hass: HomeAssistant, store: SatConfigStore) -> None: self._hot_water_setpoint = None self._relative_modulation_value = 100 - super().__init__(hass, store) + super().__init__(hass, config_entry) @property def setpoint(self) -> float | None: diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 7f554515..31bc186f 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -6,12 +6,12 @@ from homeassistant.components import mqtt from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, Event, State from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.event import async_track_state_change_event -from ..config_store import SatConfigStore from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -46,14 +46,14 @@ def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str class SatMqttCoordinator(SatDataUpdateCoordinator): """Class to manage to fetch data from the OTGW Gateway using mqtt.""" - def __init__(self, hass: HomeAssistant, store: SatConfigStore, device_id: str) -> None: - super().__init__(hass, store) + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, device_id: str) -> None: + super().__init__(hass, config_entry) self.data = {} self._device = device_registry.async_get(hass).async_get(device_id) self._node_id = list(self._device.identifiers)[0][1] - self._topic = store.options.get(CONF_MQTT_TOPIC) + self._topic = config_entry.data.get(CONF_MQTT_TOPIC) self._entity_registry = entity_registry.async_get(hass) self._entities = entity_registry.async_entries_for_device(self._entity_registry, self._device.id) @@ -141,13 +141,6 @@ def minimum_relative_modulation_value(self) -> float | None: return super().boiler_capacity - @property - def minimum_setpoint(self) -> float: - if (value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is not None: - return float(value) - - return super().minimum_setpoint - async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 597d119b..e6d3a536 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -22,17 +22,22 @@ class OvershootProtection: def __init__(self, coordinator: SatDataUpdateCoordinator): self._coordinator = coordinator - async def calculate(self, solution: str) -> float | None: + async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Starting calculation") await self._coordinator.async_set_heater_state(DeviceState.ON) await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - await self._coordinator.async_set_control_max_relative_modulation(OVERSHOOT_PROTECTION_SETPOINT) + await self._coordinator.async_set_control_max_relative_modulation(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD) try: # First wait for a flame await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_TIMEOUT) + # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation + if solution == SOLUTION_AUTOMATIC and not self._coordinator.supports_relative_modulation_management: + solution = SOLUTION_WITH_MODULATION + _LOGGER.info("Relative modulation management is not supported, switching to with modulation") + if solution == SOLUTION_AUTOMATIC: # First run start_with_zero_modulation for at least 2 minutes start_with_zero_modulation_task = asyncio.create_task(self._calculate_with_zero_modulation()) @@ -52,6 +57,12 @@ async def calculate(self, solution: str) -> float | None: except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") return None + except asyncio.CancelledError as ex: + await self._coordinator.async_set_heater_state(DeviceState.OFF) + await self._coordinator.async_set_control_setpoint(MINIMUM_SETPOINT) + await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MOD) + + raise ex async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") @@ -85,7 +96,9 @@ async def _wait_for_flame(self): break _LOGGER.warning("Heating system is not running yet") + await asyncio.sleep(5) + await self._coordinator.async_control_heating_loop() async def _wait_for_stable_temperature(self, max_modulation: float) -> float: temps = deque(maxlen=50) @@ -108,4 +121,6 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) previous_average_temp = average_temp + await asyncio.sleep(3) + await self._coordinator.async_control_heating_loop() diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 3b3afb5d..984841d3 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -16,7 +16,7 @@ def __init__(self, kp: float, ki: float, kd: float, automatic_gains: bool = False, integral_time_limit: float = 300, sample_time_limit: Optional[float] = 10, - heating_system: str = HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES): + heating_system: str = HEATING_SYSTEM_RADIATORS): """ Initialize the PID controller. @@ -250,7 +250,7 @@ def kp(self) -> float | None: if self._automatic_gains: return round(self._last_heating_curve_value * 1.65, 6) - return self._kp + return float(self._kp) @property def ki(self) -> float | None: @@ -261,7 +261,7 @@ def ki(self) -> float | None: return round(self._last_heating_curve_value / 73900, 6) - return self._ki + return float(self._ki) @property def kd(self) -> float | None: @@ -278,7 +278,7 @@ def kd(self) -> float | None: return round(self._last_heating_curve_value * 2720, 6) - return self._kd + return float(self._kd) @property def proportional(self) -> float: diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index cdf3f580..7bdbff46 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -10,10 +10,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, MODE_SIMULATOR from .coordinator import SatDataUpdateCoordinator from .entity import SatEntity from .serial import sensor as serial_sensor +from .simulator import sensor as simulator_sensor if typing.TYPE_CHECKING: from .climate import SatClimate @@ -29,9 +30,13 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a coordinator = _hass.data[DOMAIN][_config_entry.entry_id][COORDINATOR] # Check if integration is set to use the serial protocol - if coordinator.store.options.get(CONF_MODE) == MODE_SERIAL: + if _config_entry.data.get(CONF_MODE) == MODE_SERIAL: await serial_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + # Check if integration is set to use the simulator + if _config_entry.data.get(CONF_MODE) == MODE_SIMULATOR: + await simulator_sensor.async_setup_entry(_hass, _config_entry, _async_add_entities) + _async_add_entities([ SatErrorValueSensor(coordinator, _config_entry, climate), SatHeatingCurveSensor(coordinator, _config_entry, climate), diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 70d81339..023100f9 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -5,14 +5,13 @@ import typing from typing import Optional, Any +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from pyotgw import vars as gw_vars, OpenThermGateway from pyotgw.vars import * from serial import SerialException -from ..config_store import SatConfigStore -from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator if typing.TYPE_CHECKING: @@ -31,9 +30,9 @@ class SatSerialCoordinator(SatDataUpdateCoordinator): """Class to manage to fetch data from the OTGW Gateway using pyotgw.""" - def __init__(self, hass: HomeAssistant, store: SatConfigStore, port: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, port: str) -> None: """Initialize.""" - super().__init__(hass, store) + super().__init__(hass, config_entry) self.data = DEFAULT_STATUS @@ -128,13 +127,6 @@ def minimum_relative_modulation_value(self) -> float | None: def flame_active(self) -> bool: return bool(self.get(DATA_SLAVE_FLAME_ON)) - @property - def minimum_setpoint(self) -> float: - if (value := self._store.get(STORAGE_OVERSHOOT_PROTECTION_VALUE)) is not None: - return float(value) - - return super().minimum_setpoint - def get(self, key: str) -> Optional[Any]: """Get the value for the given `key` from the boiler data. diff --git a/custom_components/sat/serial/sensor.py b/custom_components/sat/serial/sensor.py index a190ba3d..43d45443 100644 --- a/custom_components/sat/serial/sensor.py +++ b/custom_components/sat/serial/sensor.py @@ -2,7 +2,7 @@ import logging from typing import Optional, List -from homeassistant.components.sensor import SensorEntity, ENTITY_ID_FORMAT, SensorDeviceClass +from homeassistant.components.sensor import SensorEntity, DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower, UnitOfTemperature, PERCENTAGE, UnitOfPressure, UnitOfVolume, UnitOfTime from homeassistant.core import HomeAssistant @@ -110,7 +110,7 @@ def __init__(self, coordinator: SatSerialCoordinator, config_entry: ConfigEntry, super().__init__(coordinator, config_entry) self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_{source}_{key}", hass=coordinator.hass ) self._key = key diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py deleted file mode 100644 index 39f7b267..00000000 --- a/custom_components/sat/services.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import typing - -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -from homeassistant.core import ServiceCall - -from . import async_reload_entry -from .const import * -from .coordinator import SatDataUpdateCoordinator - -if typing.TYPE_CHECKING: - from .climate import SatClimate - - -async def set_overshoot_protection_value(coordinator: SatDataUpdateCoordinator, climate: SatClimate, call: ServiceCall): - """Service to set the overshoot protection value.""" - coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, call.data.get("value")) - climate.async_write_ha_state() - - -async def start_overshoot_protection_calculation(coordinator: SatDataUpdateCoordinator, climate: SatClimate, call: ServiceCall): - """Service to start the overshoot protection calculation process. - - This process will activate overshoot protection by turning on the heater and setting the control setpoint to - a fixed value. Then, it will collect return water temperature data and calculate the mean of the last 3 data - points. If the difference between the current return water temperature and the mean is small, it will - deactivate overshoot protection and store the calculated value. - """ - if climate.overshoot_protection_calculate: - coordinator.logger.warning("[Overshoot Protection] Calculation already in progress.") - return - - climate.overshoot_protection_calculate = True - - from .coordinator import DeviceState - await coordinator.async_set_heater_state(DeviceState.ON) - - saved_hvac_mode = climate.hvac_mode - saved_target_temperature = climate.target_temperature - - saved_target_temperatures = {} - for entity_id in coordinator.store.options.get(CONF_CLIMATES): - if state := climate.hass.states.get(entity_id): - saved_target_temperatures[entity_id] = float(state.attributes.get(ATTR_TEMPERATURE)) - - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 30} - await climate.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - await climate.async_set_target_temperature(30) - await climate.async_set_hvac_mode(HVACMode.HEAT) - - await climate.async_send_notification( - title="Overshoot Protection Calculation", - message="Calculation started. This process will run for at least 20 minutes until a stable boiler water temperature is found." - ) - - from .overshoot_protection import OvershootProtection - overshoot_protection_value = await OvershootProtection(coordinator).calculate(call.data.get("solution")) - climate.overshoot_protection_calculate = False - - await climate.async_set_hvac_mode(saved_hvac_mode) - await climate.async_set_target_temperature(saved_target_temperature) - - await coordinator.async_set_control_max_setpoint(coordinator.maximum_setpoint) - - for entity_id in coordinator.store.options.get(CONF_CLIMATES): - data = {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: saved_target_temperatures[entity_id]} - await climate.hass.services.async_call(CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, data, blocking=True) - - if overshoot_protection_value is None: - await climate.async_send_notification( - title="Overshoot Protection Calculation", - message=f"Timed out waiting for stable temperature" - ) - - return - - await climate.async_send_notification( - title="Overshoot Protection Calculation", - message=f"Finished calculating. Result: {round(overshoot_protection_value, 1)}" - ) - - # Store the new value - coordinator.store.update(STORAGE_OVERSHOOT_PROTECTION_VALUE, overshoot_protection_value) - - # Reload the system - await async_reload_entry(coordinator.hass, coordinator.config_entry) diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index 9affd0be..be968678 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -1,36 +1,3 @@ -start_overshoot_protection_calculation: - name: Overshoot Protection Calculation - description: "This service calculates the value that will be used to determine the setpoint and prevent overshooting." - fields: - solution: - name: Solution - required: true - default: auto - description: Select the solution for calculation - selector: - select: - options: - - label: "Automatic" - value: "auto" - - label: "With Modulation" - value: "with_modulation" - - label: "With Zero Modulation" - value: "with_zero_modulation" - -set_overshoot_protection_value: - name: Overshoot Protection Value - description: "Override the stored overshoot protection value without doing a calculation." - fields: - value: - name: Value - default: 60 - required: true - description: The value to set - selector: - number: - min: 10 - max: 100 - 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 diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py new file mode 100644 index 00000000..e918173b --- /dev/null +++ b/custom_components/sat/simulator/__init__.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import typing +from time import monotonic + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .. import CONF_SIMULATED_HEATING, CONF_SIMULATED_COOLING, MINIMUM_SETPOINT, CONF_SIMULATED_WARMING_UP +from ..coordinator import DeviceState, SatDataUpdateCoordinator +from ..util import convert_time_str_to_seconds + +if typing.TYPE_CHECKING: + from ..climate import SatClimate + + +class SatSimulatorCoordinator(SatDataUpdateCoordinator): + """Class to manage the Switch.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize.""" + super().__init__(hass, config_entry) + + self._started_on = None + self._setpoint = MINIMUM_SETPOINT + self._boiler_temperature = MINIMUM_SETPOINT + + self._heating = config_entry.data.get(CONF_SIMULATED_HEATING) + self._cooling = config_entry.data.get(CONF_SIMULATED_COOLING) + self._warming_up = convert_time_str_to_seconds(config_entry.data.get(CONF_SIMULATED_WARMING_UP)) + + @property + def supports_setpoint_management(self) -> bool: + return True + + @property + def setpoint(self) -> float: + return self._setpoint + + @property + def boiler_temperature(self) -> float | None: + return self._boiler_temperature + + @property + def device_active(self) -> bool: + return self._device_state == DeviceState.ON + + @property + def flame_active(self) -> bool: + return self.device_active and self.target > self._boiler_temperature + + async def async_set_heater_state(self, state: DeviceState) -> None: + self._started_on = monotonic() if state == DeviceState.ON else None + + await super().async_set_heater_state(state) + + async def async_set_control_setpoint(self, value: float) -> None: + self._setpoint = value + await super().async_set_control_setpoint(value) + + async def async_control_heating_loop(self, climate: 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}") + + # Heating + if self.target >= self._boiler_temperature: + if self._heating >= difference: + self._boiler_temperature = self.target + self.logger.debug(f"Reached boiler temperature") + else: + self._boiler_temperature += self._heating + self.logger.debug(f"Increasing boiler temperature with {self._heating}") + + # Cooling + elif self._boiler_temperature >= self.target: + if self._cooling >= difference: + self._boiler_temperature = self.target + self.logger.debug(f"Reached boiler temperature") + else: + self._boiler_temperature -= self._cooling + self.logger.debug(f"Decreasing boiler temperature with {self._cooling}") + + self.async_set_updated_data({}) + + @property + def target(self): + # Overshoot + if self.minimum_setpoint >= self.setpoint: + return self.minimum_setpoint + + # State check + if not self._started_on or (monotonic() - self._started_on) < self._warming_up: + return MINIMUM_SETPOINT + + return self.setpoint diff --git a/custom_components/sat/simulator/sensor.py b/custom_components/sat/simulator/sensor.py new file mode 100644 index 00000000..a9b0ad8b --- /dev/null +++ b/custom_components/sat/simulator/sensor.py @@ -0,0 +1,99 @@ +from homeassistant.components.sensor import SensorEntity, DOMAIN as SENSOR_DOMAIN, SensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import async_generate_entity_id + +from ..const import * +from ..entity import SatEntity +from ..simulator import SatSimulatorCoordinator + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities): + """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR] + + # Add all devices + async_add_entities([ + SatSetpointSensor(coordinator, config_entry), + SatBoilerTemperatureSensor(coordinator, config_entry), + ]) + + +class SatSetpointSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatSimulatorCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._config_entry = config_entry + self.entity_id = async_generate_entity_id( + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_setpoint", hass=coordinator.hass + ) + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return f"Current Setpoint {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.setpoint is not None + + @property + def native_value(self): + """Return the state of the device.""" + return self._coordinator.setpoint + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-setpoint" + + +class SatBoilerTemperatureSensor(SatEntity, SensorEntity): + def __init__(self, coordinator: SatSimulatorCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._config_entry = config_entry + self.entity_id = async_generate_entity_id( + SENSOR_DOMAIN + ".{}", f"{config_entry.data.get(CONF_NAME).lower()}_boiler_temperature", hass=coordinator.hass + ) + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return f"Current Temperature {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.TEMPERATURE + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement in native units.""" + return "°C" + + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.boiler_temperature is not None + + @property + def native_value(self): + """Return the state of the device.""" + return self._coordinator.boiler_temperature + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler_temperature" diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index 65bc6de1..cdf89f51 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -1,21 +1,23 @@ from __future__ import annotations +from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry 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 ..config_store import SatConfigStore from ..coordinator import DeviceState, SatDataUpdateCoordinator class SatSwitchCoordinator(SatDataUpdateCoordinator): """Class to manage the Switch.""" - def __init__(self, hass: HomeAssistant, store: SatConfigStore, entity_id: str) -> None: + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry, entity_id: str) -> None: """Initialize.""" - super().__init__(hass, store) + super().__init__(hass, config_entry) - self._entity_id = entity_id + self._entity = entity_registry.async_get(hass).async_get(entity_id) @property def setpoint(self) -> float: @@ -27,7 +29,7 @@ def maximum_setpoint(self) -> float: @property def device_active(self) -> bool: - if (state := self.hass.states.get(self._entity_id)) is None: + if (state := self.hass.states.get(self._entity.id)) is None: return False return state.state == STATE_ON @@ -35,6 +37,11 @@ def device_active(self) -> bool: async def async_set_heater_state(self, state: DeviceState) -> None: if not self._simulation: service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF - await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_id}, blocking=True) + + if self._entity.domain == SWITCH_DOMAIN: + await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.id}, blocking=True) + + if self._entity.domain == INPUT_BOOLEAN_DOMAIN: + await self.hass.services.async_call(INPUT_BOOLEAN_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.id}, blocking=True) await super().async_set_heater_state(state) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 1e0526ee..d063bf75 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -7,7 +7,8 @@ "menu_options": { "mosquitto": "OpenTherm Gateway ( MQTT )", "serial": "OpenTherm Gateway ( SERIAL )", - "switch": "PID Thermostat with PWM ( ON/OFF )" + "switch": "PID Thermostat with PWM ( ON/OFF )", + "simulator": "Simulated Gateway ( ADVANCED )" } }, "mqtt": { @@ -32,7 +33,20 @@ "description": "Please fill in the following details to setup the switch:", "data": { "name": "Name", - "device": "Switch" + "device": "Switch", + "minimum_setpoint": "Setpoint" + } + }, + "simulator": { + "title": "Simulated Gateway ( ADVANCED )", + "description": "This gateway allows you to simulate a boiler for testing and demonstration purposes. Please provide the following information to configure the simulator.\n\nNote: The Simulator Gateway is intended for testing and demonstration purposes only and should not be used in production environments.", + "data": { + "name": "Name", + "minimum_setpoint": "Minimum Setpoint", + "maximum_setpoint": "Maximum Setpoint", + "simulated_heating": "Simulated Heating", + "simulated_cooling": "Simulated Cooling", + "simulated_warming_up": "Simulated Warming Up" } }, "sensors": { @@ -42,19 +56,63 @@ "inside_sensor_entity_id": "Inside Sensor Entity", "outside_sensor_entity_id": "Outside Sensor Entity" } + }, + "heating_system": { + "title": "Heating System", + "description": "Selecting the correct heating system type is important for SAT to accurately control the temperature and optimize performance. Choose the option that matches your setup to ensure proper temperature regulation throughout your home.", + "data": { + "heating_system": "System" + } + }, + "automatic_gains": { + "title": "Automatic Gains", + "description": "Optimize your heating system by automatically determining the optimal PID values for your setup. When selecting Automatic Gains, please note that the system will go through a calibration process that may take approximately 20 minutes to complete.\n\nAutomatic Gains is recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains.\n\nPlease note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance.", + "menu_options": { + "calibrate": "Calibrate and determine your overshoot protection value (approx. 20 min).", + "overshoot_protection": "Manually enter the overshoot protection value.", + "pid_controller": "Manually enter PID values (not recommended)." + } + }, + "overshoot_protection": { + "title": "Overshoot Protection", + "description": "By providing the overshoot protection value, SAT will adjust the control parameters accordingly to maintain a stable and comfortable heating environment. This manual configuration allows you to fine-tune the system based on your specific setup.\n\nNote: If you are unsure about the overshoot protection value or have not performed the calibration process, it is recommended to cancel the configuration and go through the calibration process to let SAT automatically determine the value for optimal performance.", + "data": { + "minimum_setpoint": "Value" + } + }, + "pid_controller": { + "title": "Configure the PID controller manually.", + "description": "Configure the proportional, integral, and derivative gains manually to fine-tune your heating system. Use this option if you prefer to have full control over the PID controller parameters. Adjust the gains based on your specific heating system characteristics and preferences.", + "data": { + "integral": "Integral (kI)", + "derivative": "Derivative (kD)", + "proportional": "Proportional (kP)" + } + }, + "calibrated": { + "title": "Calibration Completed", + "description": "The calibration process has completed successfully.\n\nCongratulations! Your Smart Autotune Thermostat (SAT) has been calibrated to optimize the heating performance of your system. During the calibration process, SAT carefully analyzed the heating characteristics and determined the appropriate overshoot protection value to ensure precise temperature control.\n\nOvershoot Protection Value: {minimum_setpoint} °C\n\nThis value represents the maximum amount of overshoot allowed during the heating process. SAT will actively monitor and adjust the heating to prevent excessive overshooting, maintaining a comfortable and efficient heating experience in your home.\n\nPlease note that the overshoot protection value may vary depending on the specific characteristics of your heating system and environmental factors. It has been fine-tuned to provide optimal performance based on the calibration results.", + "menu_options": { + "calibrate": "Retry calibration", + "finish": "Continue with current calibration" + } } }, "error": { "connection": "Unable to connect to the gateway.", - "mqtt_component": "The MQTT component is unavailable." + "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." }, "abort": { "already_configured": "Gateway is already configured." + }, + "progress": { + "calibration": "Calibrating and finding the overshoot protection value...\n\nPlease wait while we optimize your heating system. This process may take approximately 20 minutes." } }, "options": { "step": { - "user": { + "init": { "menu_options": { "general": "General", "presets": "Presets", @@ -67,10 +125,12 @@ "title": "General", "description": "General settings and configurations.", "data": { - "automatic_gains": "Tuning", - "heating_system": "Heating System", - "minimum_setpoint": "Minimum Setpoint", - "heating_curve_coefficient": "Heating Curve Coefficient" + "integral": "Integral (kI)", + "derivative": "Derivative (kD)", + "proportional": "Proportional (kP)", + "maximum_setpoint": "Maximum Setpoint", + "heating_curve_coefficient": "Heating Curve Coefficient", + "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation" } }, "presets": { @@ -99,9 +159,6 @@ "title": "System Configuration", "description": "For fine-tuning and customization.", "data": { - "integral": "Integral (kI)", - "derivative": "Derivative (kD)", - "proportional": "Proportional (kP)", "automatic_duty_cycle": "Automatic duty cycle", "overshoot_protection": "Overshoot Protection (with PWM)", "window_minimum_open_time": "Minimum time for window to be open before reacting", @@ -114,7 +171,6 @@ "simulation": "Simulation ", "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", - "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", "force_pulse_width_modulation": "Force Pulse Width Modulation", "sample_time": "Minimum time interval between updates to the PID controller" } diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 874beb50..889a5262 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -29,6 +29,16 @@ def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: return round(temperature_error / time_taken_hours, 2) +def calculate_default_maximum_setpoint(heating_system: str) -> int | None: + if heating_system == HEATING_SYSTEM_UNDERFLOOR: + return 50 + + if heating_system == HEATING_SYSTEM_RADIATORS: + return 55 + + return None + + def create_pid_controller(options) -> PID: """Create and return a PID controller instance with the given configuration options.""" # Extract the configuration options From 48b1d3620ee843e7b441512b3c76fa9be9cfed75 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 9 Jun 2023 20:02:57 +0200 Subject: [PATCH 069/237] A few typos and some translation updates --- custom_components/sat/config_flow.py | 6 +++--- custom_components/sat/translations/en.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index f680b956..9aee2084 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -161,11 +161,11 @@ async def async_step_switch(self, _user_input=None): errors=self._errors, data_schema=vol.Schema({ vol.Required(CONF_NAME, default=DEFAULT_NAME): str, - [vol.Required(CONF_MINIMUM_SETPOINT, default=50)]: selector.NumberSelector( - selector.NumberSelectorConfig(min=10, max=100, step=1) - ), vol.Required(CONF_DEVICE): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SWITCH_DOMAIN, INPUT_BOOLEAN_DOMAIN]) + ), + vol.Required(CONF_MINIMUM_SETPOINT, default=50): selector.NumberSelector( + selector.NumberSelectorConfig(min=10, max=100, step=1) ) }), ) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index d063bf75..79d91404 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -34,7 +34,7 @@ "data": { "name": "Name", "device": "Switch", - "minimum_setpoint": "Setpoint" + "minimum_setpoint": "Equivalent Setpoint" } }, "simulator": { From 146f4f2335cdd5f38551146e2d80558ae7486afd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 9 Jun 2023 22:50:15 +0200 Subject: [PATCH 070/237] Some fixes for coordinators without a maximum configurable setpoint --- custom_components/sat/const.py | 1 + custom_components/sat/coordinator.py | 22 ++++++++-------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 86170329..64a7d2da 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -64,6 +64,7 @@ CONF_COMFORT_TEMPERATURE = "comfort_temperature" CONF_ACTIVITY_TEMPERATURE = "activity_temperature" +HEATING_SYSTEM_UNKNOWN = "unknown" HEATING_SYSTEM_RADIATORS = "radiators" HEATING_SYSTEM_UNDERFLOOR = "underfloor" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 012c0c3f..9e949be1 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -56,11 +56,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._config_entry = config_entry 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)) - - default_maximum_setpoint = calculate_default_maximum_setpoint(self._heating_system) - self._maximum_setpoint = float(config_entry.data.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) - self._minimum_setpoint = float(config_entry.data.get(CONF_MINIMUM_SETPOINT)) + self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) super().__init__(hass, _LOGGER, name=DOMAIN) @@ -69,11 +65,6 @@ def device_state(self): """Return the current state of the device.""" return self._device_state - @property - def maximum_setpoint(self) -> float: - """Return the maximum setpoint temperature that the device can support.""" - return self._maximum_setpoint - @property @abstractmethod def setpoint(self) -> float | None: @@ -121,12 +112,15 @@ def minimum_relative_modulation_value(self) -> float | None: return None @property - def minimum_setpoint(self) -> float: - return self._minimum_setpoint + 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.data.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) @property - def maximum_setpoint(self) -> float: - return self._maximum_setpoint + 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)) @property def supports_setpoint_management(self): From dc1680a55d21e42a79d5a2b33cedfdae30f3dcb9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 14:31:24 +0200 Subject: [PATCH 071/237] Fixed some issues with the switch coordinator --- custom_components/sat/switch/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index cdf89f51..48031a0c 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -39,9 +39,9 @@ async def async_set_heater_state(self, state: DeviceState) -> None: service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF if self._entity.domain == SWITCH_DOMAIN: - await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.id}, blocking=True) + await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) if self._entity.domain == INPUT_BOOLEAN_DOMAIN: - await self.hass.services.async_call(INPUT_BOOLEAN_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.id}, blocking=True) + await self.hass.services.async_call(INPUT_BOOLEAN_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) await super().async_set_heater_state(state) From 5bf307cf82153fca849ea562f508c8b6bb54894a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 14:32:05 +0200 Subject: [PATCH 072/237] Added a step to enable automatic gains for switches --- custom_components/sat/config_flow.py | 23 ++++++++++++++++++---- custom_components/sat/translations/en.json | 7 +++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 9aee2084..1a7ffcd3 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -207,7 +207,7 @@ async def async_step_sensors(self, _user_input=None): if self._data[CONF_MODE] in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: return await self.async_step_heating_system() - return await self.async_step_pid_controller() + return await self.async_step_automatic_gains() return self.async_show_form( step_id="sensors", @@ -226,9 +226,9 @@ async def async_step_heating_system(self, _user_input=None): self._data.update(_user_input) if (await self._create_coordinator()).supports_setpoint_management: - return await self.async_step_automatic_gains() + return await self.async_step_calibrate_system() - return await self.async_step_finish() + return await self.async_step_automatic_gains() return self.async_show_form( last_step=False, @@ -244,8 +244,23 @@ async def async_step_heating_system(self, _user_input=None): ) async def async_step_automatic_gains(self, _user_input=None): - return self.async_show_menu( + if _user_input is not None: + self._data.update(_user_input) + + if not self._data[CONF_AUTOMATIC_GAINS]: + return await self.async_step_pid_controller() + + return await self.async_step_finish() + + return self.async_show_form( + last_step=False, step_id="automatic_gains", + data_schema=vol.Schema({vol.Required(CONF_AUTOMATIC_GAINS, default=True): bool}) + ) + + async def async_step_calibrate_system(self, _user_input=None): + return self.async_show_menu( + step_id="calibrate_system", menu_options=["calibrate", "overshoot_protection", "pid_controller"] ) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 79d91404..529bb4e6 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -66,6 +66,13 @@ }, "automatic_gains": { "title": "Automatic Gains", + "description": "This feature adjusts the control parameters of your heating system dynamically, optimizing temperature control for better comfort and energy efficiency. Enabling this option allows SAT to continuously adapt and fine-tune the heating settings based on the environmental conditions. This helps maintain a stable and comfortable environment without manual intervention.\n\nNote: If you choose not to enable automatic gains, you will need to manually enter the PID values for precise temperature control. Please ensure that you have accurate PID values for your specific heating system to achieve optimal performance.", + "data": { + "automatic_gains": "Automatic Gains (recommended)" + } + }, + "calibrate_system": { + "title": "Calibrate System", "description": "Optimize your heating system by automatically determining the optimal PID values for your setup. When selecting Automatic Gains, please note that the system will go through a calibration process that may take approximately 20 minutes to complete.\n\nAutomatic Gains is recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains.\n\nPlease note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance.", "menu_options": { "calibrate": "Calibrate and determine your overshoot protection value (approx. 20 min).", From 1e0dfcba1064e021a047183df031dd8e15df0922 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 14:49:24 +0200 Subject: [PATCH 073/237] Improve the description of our coordinators during setup --- custom_components/sat/translations/en.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 529bb4e6..4763c9e5 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -11,9 +11,9 @@ "simulator": "Simulated Gateway ( ADVANCED )" } }, - "mqtt": { + "mosquitto": { "title": "OpenTherm Gateway ( MQTT )", - "description": "Please fill in the following details to set up the OpenTherm Gateway.", + "description": "Please provide the following details to set up the OpenTherm Gateway. In the Name field, enter a name for the gateway that will help you identify it within your system.\n\nSpecify the Climate entity to use for the OpenTherm Gateway. This entity is provided by the OpenTherm Gateway and represents your heating system.\n\nAdditionally, enter the Top Topic that will be used for publishing and subscribing to MQTT messages related to the OpenTherm Gateway.\n\nThese settings are essential for establishing communication and integration with your OpenTherm Gateway through MQTT. They allow for seamless data exchange and control of your heating system. Ensure that the provided details are accurate to ensure proper functionality.", "data": { "name": "Name", "device": "Device", @@ -22,7 +22,7 @@ }, "serial": { "title": "OpenTherm Gateway ( SERIAL )", - "description": "Please fill in the following details to set up the OpenTherm Gateway.", + "description": "To establish a connection with the OpenTherm Gateway using a socket connection, please provide the following details. In the Name field, enter a name for the gateway that will help you identify it within your system.\n\nSpecify the network address of the OpenTherm Gateway in the Device field. This could be in the format of \"socket://otgw.local:25238\", where \"otgw.local\" is the hostname or IP address of the gateway and \"25238\" is the port number.\n\nThese settings are essential for establishing communication and integration with your OpenTherm Gateway through the socket connection. Ensure that the provided details are accurate to ensure proper functionality.", "data": { "name": "Name", "device": "URL" @@ -30,11 +30,11 @@ }, "switch": { "title": "PID Thermostat with PWM ( ON/OFF )", - "description": "Please fill in the following details to setup the switch:", + "description": "Please fill in the following details to set up the switch. Enter a name for the switch in the Name field, which will help you identify it within your system. Choose the appropriate entity to use for your switch from the provided options.\n\nIn the Temperature Setting field, specify the desired target temperature for your heating system. If you are using a hot water boiler, fill in the Boiler Temperature Setting with the appropriate value. For electric heating systems, enter the value 100.\n\nThese settings are essential for precise temperature control and ensuring optimal performance of your heating system. Providing the correct Temperature Setting allows for accurate regulation and helps achieve a comfortable and energy-efficient environment in your home.", "data": { "name": "Name", - "device": "Switch", - "minimum_setpoint": "Equivalent Setpoint" + "device": "Entity", + "minimum_setpoint": "Temperature Setting" } }, "simulator": { From c9d2da93883486e4a5385ce4bdb35eaa2fdac3c8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 15:05:37 +0200 Subject: [PATCH 074/237] Fixed the tests --- custom_components/sat/__init__.py | 32 +++++++++++++------------- custom_components/sat/fake/__init__.py | 4 ++-- tests/test_climate.py | 5 +++- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 60476202..7ff4bd5f 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -84,28 +84,28 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool _LOGGER.debug("Migrating from version %s", _entry.version) if _entry.version == 1: - # Legacy Store - store = Store(_hass, 1, DOMAIN) - data = await store.async_load() - new = {**_entry.data} - if overshoot_protection_value := data.get("overshoot_protection_value"): - new[CONF_MINIMUM_SETPOINT] = overshoot_protection_value - else: - new[CONF_MINIMUM_SETPOINT] = 10 + if not _entry.data.get(CONF_MINIMUM_SETPOINT): + # Legacy Store + store = Store(_hass, 1, DOMAIN) + if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): + new[CONF_MINIMUM_SETPOINT] = overshoot_protection_value + else: + new[CONF_MINIMUM_SETPOINT] = 10 - if _entry.options[CONF_HEATING_SYSTEM] == "underfloor": - new[CONF_MAXIMUM_SETPOINT] = 50 + if not _entry.data.get(CONF_MAXIMUM_SETPOINT): + if _entry.options.get(CONF_HEATING_SYSTEM) == "underfloor": + new[CONF_MAXIMUM_SETPOINT] = 50 - if _entry.options[CONF_HEATING_SYSTEM] == "radiator_low_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 55 + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_low_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 55 - if _entry.options[CONF_HEATING_SYSTEM] == "radiator_medium_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 65 + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_medium_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 65 - if _entry.options[CONF_HEATING_SYSTEM] == "radiator_high_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 75 + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": + new[CONF_MAXIMUM_SETPOINT] = 75 _entry.version = 2 _hass.config_entries.async_update_entry(_entry, data=new) diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index ec73accc..d3508762 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -29,7 +29,7 @@ class SatFakeCoordinator(SatDataUpdateCoordinator): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self.data = {} - self.config = None + self.config = SatFakeConfig(True) self._setpoint = None self._maximum_setpoint = None @@ -51,7 +51,7 @@ def supports_setpoint_management(self): if self.config is None: return super().supports_setpoint_management - return self.config.supports_maximum_setpoint_management + return self.config.supports_setpoint_management @property def supports_hot_water_setpoint_management(self): diff --git a/tests/test_climate.py b/tests/test_climate.py index 0c2f8e2d..9d37f32e 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry -from custom_components.sat import CONF_HEATING_CURVE_COEFFICIENT, CONF_MINIMUM_SETPOINT from custom_components.sat.climate import SatClimate +from custom_components.sat.const import * from custom_components.sat.fake import SatFakeCoordinator @@ -18,6 +18,7 @@ [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 57, + CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 1.8 }, { @@ -56,6 +57,7 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 58, + CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 1.3 }, { @@ -95,6 +97,7 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: [(TEMPLATE_DOMAIN, 1)], { CONF_MINIMUM_SETPOINT: 41, + CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 0.9 }, { From 3a109602763579e23434bc39b39666fbb490e9bc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 16:42:55 +0200 Subject: [PATCH 075/237] Removed some legacy code from before PWM --- custom_components/sat/__init__.py | 2 +- custom_components/sat/pid.py | 8 +------- custom_components/sat/util.py | 11 +++++------ 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 7ff4bd5f..d36ae434 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -92,7 +92,7 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): new[CONF_MINIMUM_SETPOINT] = overshoot_protection_value else: - new[CONF_MINIMUM_SETPOINT] = 10 + new[CONF_MINIMUM_SETPOINT] = MINIMUM_SETPOINT if not _entry.data.get(CONF_MAXIMUM_SETPOINT): if _entry.options.get(CONF_HEATING_SYSTEM) == "underfloor": diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 984841d3..10a173ba 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -15,8 +15,7 @@ def __init__(self, kp: float, ki: float, kd: float, deadband: float = DEADBAND, automatic_gains: bool = False, integral_time_limit: float = 300, - sample_time_limit: Optional[float] = 10, - heating_system: str = HEATING_SYSTEM_RADIATORS): + sample_time_limit: Optional[float] = 10): """ Initialize the PID controller. @@ -27,14 +26,12 @@ def __init__(self, kp: float, ki: float, kd: float, :param deadband: The deadband of the PID controller. The range of error values where the controller will not make adjustments. :param integral_time_limit: The minimum time interval between integral updates to the PID controller, in seconds. :param sample_time_limit: The minimum time interval between updates to the PID controller, in seconds. - :param heating_system: The heating system type that we are controlling. """ self._kp = kp self._ki = ki self._kd = kd self._deadband = deadband self._history_size = max_history - self._heating_system = heating_system self._automatic_gains = automatic_gains self._last_interval_updated = monotonic() self._sample_time_limit = max(sample_time_limit, 1) @@ -273,9 +270,6 @@ def kd(self) -> float | None: if self._last_heating_curve_value is None: return 0 - if self._heating_system == HEATING_SYSTEM_RADIATOR_LOW_TEMPERATURES: - return round(self._last_heating_curve_value * 1650, 6) - return round(self._last_heating_curve_value * 2720, 6) return float(self._kd) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 889a5262..d632cfd2 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -1,9 +1,9 @@ from homeassistant.util import dt -from custom_components.sat.const import * -from custom_components.sat.heating_curve import HeatingCurve -from custom_components.sat.pid import PID -from custom_components.sat.pwm import PWM +from .const import * +from .heating_curve import HeatingCurve +from .pid import PID +from .pwm import PWM def convert_time_str_to_seconds(time_str: str) -> float: @@ -45,12 +45,11 @@ def create_pid_controller(options) -> PID: kp = float(options.get(CONF_PROPORTIONAL)) ki = float(options.get(CONF_INTEGRAL)) kd = float(options.get(CONF_DERIVATIVE)) - heating_system = options.get(CONF_HEATING_SYSTEM) automatic_gains = bool(options.get(CONF_AUTOMATIC_GAINS)) sample_time_limit = convert_time_str_to_seconds(options.get(CONF_SAMPLE_TIME)) # Return a new PID controller instance with the given configuration options - return PID(kp=kp, ki=ki, kd=kd, heating_system=heating_system, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) + return PID(kp=kp, ki=ki, kd=kd, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) def create_heating_curve_controller(options) -> HeatingCurve: From 9e0b947a1dd57243868b3a6a86621708817b0fe1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 10 Jun 2023 17:46:45 +0200 Subject: [PATCH 076/237] Some naming updates and added support to restore the temperatures state --- custom_components/sat/climate.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b32b5b8d..951c6d73 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -47,6 +47,9 @@ 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" + SENSOR_TEMPERATURE_ID = "sensor_temperature_id" _LOGGER = logging.getLogger(__name__) @@ -123,8 +126,8 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._hvac_mode = None self._target_temperature = None self._window_sensor_handle = None - self._saved_target_temperature_before_custom = None - self._saved_target_temperature_before_activity = None + self._pre_custom_temperature = None + self._pre_activity_temperature = None self._climates = config_data.get(CONF_CLIMATES) self._main_climates = config_data.get(CONF_MAIN_CLIMATES) @@ -247,6 +250,12 @@ async def _restore_previous_state_or_set_defaults(self): 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) + + 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_OPTIMAL_COEFFICIENT): self.heating_curve.restore_autotune( old_state.attributes.get(ATTR_OPTIMAL_COEFFICIENT), @@ -300,6 +309,9 @@ def extra_state_attributes(self): "collected_errors": self.pid.num_errors, "integral_enabled": self.pid.integral_enabled, + "pre_custom_temperature": self._pre_custom_temperature, + "pre_activity_temperature": self._pre_activity_temperature, + "derivative_enabled": self.pid.derivative_enabled, "derivative_raw": self.pid.raw_derivative, @@ -606,7 +618,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._saved_target_temperature_before_activity = self.target_temperature + self._pre_activity_temperature = self.target_temperature await self._window_sensor_handle await self.async_set_preset_mode(PRESET_ACTIVITY) @@ -622,7 +634,7 @@ async def _async_window_sensor_changed(self, event: Event) -> None: if self.preset_mode == PRESET_ACTIVITY: _LOGGER.debug(f"Restoring original target temperature.") - await self.async_set_temperature(temperature=self._saved_target_temperature_before_activity) + await self.async_set_temperature(temperature=self._pre_activity_temperature) return @@ -836,7 +848,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Reset the preset mode if `PRESET_NONE` is given if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE - await self.async_set_target_temperature(self._saved_target_temperature_before_custom) + await self.async_set_target_temperature(self._pre_custom_temperature) else: # Set the HVAC mode to `HEAT` if it is currently `OFF` if self.hvac_mode == HVACMode.OFF: @@ -844,7 +856,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: # Save the current target temperature if the preset mode is being set for the first time if self._attr_preset_mode == PRESET_NONE: - self._saved_target_temperature_before_custom = self._target_temperature + self._pre_custom_temperature = self._target_temperature # Set the preset mode and target temperature self._attr_preset_mode = preset_mode From 2d55bc940520bc9ceb7462a34ae77f1366be89d2 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" Date: Mon, 31 Jul 2023 20:02:43 +0300 Subject: [PATCH 077/237] Update __init__.py Fix typo --- custom_components/sat/mqtt/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 31bc186f..d490f825 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -22,8 +22,8 @@ DATA_REL_MOD_LEVEL = "RelModLevel" DATA_BOILER_TEMPERATURE = "Tboiler" DATA_CENTRAL_HEATING = "centralheating" -DATA_BOILER_CAPACITY = "MaxCapacityMinModLevell_hb_u8" -DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevell_lb_u8" +DATA_BOILER_CAPACITY = "MaxCapacityMinModLevel_hb_u8" +DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevel_lb_u8" DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" From 345d14fe25295c184b59e3db4922792499382ffa Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 19 Aug 2023 14:28:59 +0200 Subject: [PATCH 078/237] Update README.md --- README.md | 110 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 78 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3ba1ce4c..eddbac96 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,33 @@ -# Smart Autotune Thermostat (SAT) -The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant that works with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) in order to provide advanced temperature control functionality based on Outside Temperature compensation and Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficient, which means it can determine the optimal setpoint for your boiler without any manual intervention. +# Smart Autotune Thermostat (SAT) V3 +The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant that works with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) ( MQTT or Serial ) or acts as a PID ON/OFF thermostat in order to provide advanced temperature control functionality based on Outside Temperature compensation and Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficient, which means it can determine the optimal setpoint for your boiler without any manual intervention. ## Features +OpenTherm ( MQTT/Serial ): - Multi-room temperature control with support for temperature synchronization for main climates +- Overshoot protection value automatic calculation mechanism - Adjustable heating curve coefficients to fine-tune your heating system - Target temperature step for adjusting the temperature in smaller increments - Presets for different modes such as Away, Sleep, Home, Comfort - Automatic gains for PID control -- PWM and Automatic duty cycle -- Overshoot protection to prevent the boiler from overshooting the setpoint +- PWM and Automatic-duty cycle +- Overshoot protection to prevent the boiler from overshooting the setpoint ( Low-Load Control ) - Climate valve offset to adjust the temperature reading for your climate valve - Sample time for PID control to fine-tune your system's response time - Open Window detection - Control DHW setpoint +PID ON/OFF thermostat: + +- Multi-room temperature control with support for temperature synchronization for main climates +- Adjustable heating curve coefficients to fine-tune your heating system +- Target temperature step for adjusting the temperature in smaller increments +- Presets for different modes such as Away, Sleep, Home, Comfort +- Automatic gains for PID control +- PWM and Automatic-duty cycle +- Climate valve offset to adjust the temperature reading for your climate valve +- Sample time for PID control to fine-tune your system's response time +- Open Window detection + ## Installation ### Manual 1. Download the latest release of the SAT custom component from the GitHub repository. @@ -24,61 +38,93 @@ The Smart Autotune Thermostat (SAT) is a custom component for Home Assistant tha ### HACS 1. Install HACS if you haven't already. 2. Open the HACS web interface in Home Assistant and navigate to the Integrations section. -3. Click the three dots in the top-right corner and select "Custom repositories". -4. Enter the URL of the SAT custom component GitHub repository (https://github.com/Alexwijn/SAT) and select "Integration" as the category. Click "Add". +3. Click the three dots in the top-right corner and select "Custom repositories." +4. Enter the URL of the SAT custom component GitHub repository (https://github.com/Alexwijn/SAT) and select "Integration" as the category. Click "Add." 5. Once the SAT custom component appears in the list of available integrations, click "Install" to install it. 6. Restart Home Assistant to load the SAT custom component. 7. After installing the SAT custom component, you can configure it via the Home Assistant Config Flow interface. -## Configuration -SAT is configured using a config flow. After installation, go to the Integrations page in Home Assistant, click on the Add Integration button, and search for SAT. Follow the prompts to configure the integration. +# Configuration +SAT is configured using a config flow. After installation, go to the Integrations page in Home Assistant, click on the Add Integration button, and search for SAT if the autodiscovery feature fails. -## Multi-room setup -In multi-room mode, SAT monitors the climates in other rooms to determine the error and calculates how much heat is needed. It selects the highest error value as the error value for the current room, instead of using the average temperature across all rooms. This ensures that the temperature in each room is maintained at its desired level. +## OpenTherm -Note that SAT assumes that the climate control systems in the additional rooms are smart and won't exceed their target temperatures, as this can cause inefficiencies in the overall system. Once every climate control system in all rooms is around the target temperature, SAT can operate at its most efficient level. +1. OpenTherm Connection + - MQTT + - Name of the thermostat + - Top Topic ( *MQTT Top Topic* found in OTGW-firmware Settings ) + - Device + + - Serial: + - Name of the thermostat + - URL -## Heating Curve Coefficient +2. Configure sensors: + - Inside Sensor Entity ( Your Room Temperature sensor ) + - Outside temperature sensor ( Your Outside Temperature sensor ) + +3. Heating System: Selecting the correct heating system type is important for SAT to accurately control the temperature and optimize performance. Choose the option that matches your setup to ensure proper temperature regulation throughout your home. + +4. Calibrate System: Optimize your heating system by automatically determining the optimal PID values for your setup. When selecting Automatic Gains, please note that the system will go through a calibration process that may take approximately 20 minutes to complete. + +If you already know this value, then use the "Manually enter the overshoot protection value" option and fill the value. + +Automatic Gains is recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains. + +Please note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance. + +## PID ON/OFF + +TODO + +# Configure + +## General tab: +*Maximum Setpoint*: +You can choose the max water setpoint for your system. +For radiator installations it is recommended to choose a value between 55-75 °C. +For underfloor installations the recommended max water setpoint is 50 °C. + +Note for Radiators: Higher Max water setpoint values will cause a more aggressive warm up. + +*Heating Curve Coefficient*: The heating curve coefficient is a configurable parameter in SAT that allows you to adjust the relationship between the outdoor temperature and the heating system output. This is useful for optimizing the heating system's performance in different weather conditions, as it allows you to adjust how much heat the system delivers as the outdoor temperature changes. By tweaking this parameter, you can achieve a more efficient and comfortable heating system. -## Automatic gains -SAT supports automatic PID gain tuning. When this feature is enabled, SAT will continuously adjust the PID gains to optimize the temperature control performance based on the current conditions instead of manually filling in the PID-values. +## Areas tab: +*Multi-room setup*: +In multi-room mode, SAT monitors the climates in other rooms to determine the error and calculates how much heat is needed. It selects the highest error value as the error value for the current room, instead of using the average temperature across all rooms. This ensures that the temperature in each room is maintained at its desired level. -## Overshoot protection +Note that SAT assumes that the climate control systems in the additional rooms are smart and won't exceed their target temperatures, as this can cause inefficiencies in the overall system. Once every climate control system in all rooms is around the target temperature, SAT can operate at its most efficient level. -With overshoot protection enabled, SAT will automatically calculate the maximum allowed modulation value for the boiler based on the setpoint and the calculation overshoot -protection value. +*Contact Sensor*: You can add contact sensors in order not to waste energy when a door/window is open. When the door/window is closed again SAT restores heating. -## Tuning +## Presets tab: +Predefined temperature settings for different scenarios or activities. -*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given -setpoint based on the outside temperature. When this value is properly tuned then the room temperature should float around the setpoint. +# Terminology +*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given setpoint based on the outside temperature. When this value is properly tuned then the room temperature should float around the setpoint. *Gains*: SAT offers two ways of tuning the PID gains - manual and automatic. - Manual tuning: You can fill the Proportional, Integral and Derivative fields in the General tab with your own values. -- Automatic Gains ( Recommended ): You can enable this option in the Advanced Tab. Automatic gains dynamically change the kP, kI and kD values based on the heating curve - value. So, based on the outside temperature the gains are changing from mild to aggressive without intervention. +- Automatic Gains ( Recommended ): This option is enabled by default when the *Overshoot protection value* is present ( During initial configuration ). Automatic gains dynamically change the kP, kI and kD values based on the heating curve value. So, based on the outside temperature, the gains are changing from mild to aggressive without intervention. -*Overshoot Protection* (Experimental): This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler +*Overshoot Protection*: This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler overshoots the control setpoint, it may cycle, which can shorten the life of the burner. The solution is to adjust the boiler's on/off times to maintain the temperature at the setpoint while minimizing cycling. -Overshoot Protection Value (OPV) Calculation: The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is enabled. SAT -provides two ways to calculate it. +Overshoot Protection Value (OPV) Calculation: +The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is present ( During initial configuration ). -*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can use the service `Overshoot Protection Value` to set the value. +*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can fill this value during initial configuration. -*Automatic Calculation*: To calculate the OPV automatically, call the service `Overshoot Protection Calculation`. SAT will then send the MM=0 and CS=75 commands and attempt to -find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, -SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the -boiler's on/off times in the low load control algorithm. +*Automatic Calculation*: In order to calculate the OPV automatically, choose the ```Calibrate and determine your overshoot protection value (approx. 20 min)``` option during initial configuration. SAT then will send the MM=0 and CS=75 commands and attempt to find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the boiler's on/off times in the low-load control algorithm. If SAT detects that the boiler doesn't respect the 0 % Max Modulation command, then will automatically change the calibration algorithm to a more sophisticated algorithm in order to perform the calibration of the system. Note: If you have any TRVs, open all of them (set them to a high setpoint) to ensure accurate calculation of the OPV. Once the calculation is complete, you can lower the setpoint back to your desired temperature. -*Automatic Duty Cycle* ( Experimental ): When this option is enabled, SAT calculates the ON and OFF times of the boiler, in 15 minutes intervals, given that the kW needed to -heat the home are less than the minimum boiler capacity. Moreover using this feature SAT is able to regulate efficiently the room temperature even in mild weather by +*Automatic Duty Cycle*: When this option is enabled, SAT calculates the ON and OFF times of the boiler, in 15-minutes intervals, given that the kW needed to +heat the home is less than the minimum boiler capacity. Moreover, using this feature SAT is able to regulate efficiently the room temperature even in mild weather by automatically adjusting the duty cycle. ## Support From 03f5cdf7f45ef9f42c60be36d21a7d5c002ef325 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 26 Aug 2023 19:46:24 +0200 Subject: [PATCH 079/237] Fixed undefined property --- 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 9e949be1..9d42175b 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -115,12 +115,12 @@ def minimum_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.data.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) + return float(self._config_entry.data.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._config_entry.data.get(CONF_MINIMUM_SETPOINT)) @property def supports_setpoint_management(self): From 23567dcc344c4096747c5d109008bd93df24d07d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 17:05:13 +0200 Subject: [PATCH 080/237] Added support for a simulated max. setpoint --- custom_components/sat/simulator/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index e918173b..04484149 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .. import CONF_SIMULATED_HEATING, CONF_SIMULATED_COOLING, MINIMUM_SETPOINT, CONF_SIMULATED_WARMING_UP +from .. 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 @@ -27,12 +27,17 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._heating = config_entry.data.get(CONF_SIMULATED_HEATING) self._cooling = config_entry.data.get(CONF_SIMULATED_COOLING) + self._maximum_setpoint = config_entry.data.get(CONF_MAXIMUM_SETPOINT) self._warming_up = convert_time_str_to_seconds(config_entry.data.get(CONF_SIMULATED_WARMING_UP)) @property def supports_setpoint_management(self) -> bool: return True + @property + def supports_maximum_setpoint_management(self): + return True + @property def setpoint(self) -> float: return self._setpoint @@ -58,6 +63,10 @@ async def async_set_control_setpoint(self, value: float) -> None: self._setpoint = value await super().async_set_control_setpoint(value) + 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: # Calculate the difference, so we know when to slowdown difference = abs(self._boiler_temperature - self.target) From dd6b4c5250932f8b04d9b0604582b7c64134f143 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 17:05:24 +0200 Subject: [PATCH 081/237] Fixed retrieving a custom 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 9d42175b..2be53050 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -115,7 +115,7 @@ def minimum_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.data.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) + return float(self._config_entry.options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) @property def minimum_setpoint(self) -> float: From 2c7cdfd61a522d8e4bc3c4d7207f5331c22da71b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 17:10:37 +0200 Subject: [PATCH 082/237] Fixed the tests by adding a default heating system --- tests/test_climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_climate.py b/tests/test_climate.py index 9d37f32e..4af4016c 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -17,6 +17,7 @@ [( [(TEMPLATE_DOMAIN, 1)], { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 57, CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 1.8 @@ -56,6 +57,7 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: [( [(TEMPLATE_DOMAIN, 1)], { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 58, CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 1.3 @@ -96,6 +98,7 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: [( [(TEMPLATE_DOMAIN, 1)], { + CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 41, CONF_MAXIMUM_SETPOINT: 75, CONF_HEATING_CURVE_COEFFICIENT: 0.9 From 8cbf7116ebb9745367d0feb7e7d49a4de9dcc212 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 17:26:32 +0200 Subject: [PATCH 083/237] Make sure we revert back to None --- custom_components/sat/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 1a7ffcd3..a7e6527e 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -444,7 +444,7 @@ async def async_step_areas(self, _user_input=None) -> FlowResult: self._options[CONF_CLIMATES] = [] if _user_input.get(CONF_WINDOW_SENSOR) is None: - self._options[CONF_WINDOW_SENSOR] = [] + self._options[CONF_WINDOW_SENSOR] = None return await self.update_options(_user_input) From 8ac400f44283ae624e1fc0831c5b79b59cdd2872 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 23:39:00 +0200 Subject: [PATCH 084/237] Move the "areas" settings to the setup wizard --- configuration.yaml | 8 ++ custom_components/sat/__init__.py | 56 ++++++++----- custom_components/sat/climate.py | 2 +- custom_components/sat/config_flow.py | 91 ++++++++++------------ custom_components/sat/const.py | 4 +- custom_components/sat/translations/en.json | 24 +++--- custom_components/sat/util.py | 9 +++ 7 files changed, 108 insertions(+), 86 deletions(-) diff --git a/configuration.yaml b/configuration.yaml index 874310f8..6d4a7a2c 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -10,6 +10,12 @@ homeassistant: climate.heater: sensor_temperature_id: "sensor.heater_temperature" +climate: + - platform: generic_thermostat + name: Fake Thermostat + heater: switch.fake_thermostat + target_sensor: sensor.current_temperature + switch: - platform: template switches: @@ -68,3 +74,5 @@ input_boolean: name: Heater window_sensor: name: Window Sensor + fake_thermostat: + name: Fake Thermostat diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index d36ae434..09cfd354 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -81,34 +81,50 @@ async def async_reload_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> None: async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool: """Migrate old entry.""" + from custom_components.sat.config_flow import SatFlowHandler _LOGGER.debug("Migrating from version %s", _entry.version) - if _entry.version == 1: - new = {**_entry.data} + if _entry.version < SatFlowHandler.VERSION: + new_data = {**_entry.data} + new_options = {**_entry.options} - if not _entry.data.get(CONF_MINIMUM_SETPOINT): - # Legacy Store - store = Store(_hass, 1, DOMAIN) - if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): - new[CONF_MINIMUM_SETPOINT] = overshoot_protection_value - else: - new[CONF_MINIMUM_SETPOINT] = MINIMUM_SETPOINT + if _entry.version < 2: + if not _entry.data.get(CONF_MINIMUM_SETPOINT): + # Legacy Store + store = Store(_hass, 1, DOMAIN) + new_data[CONF_MINIMUM_SETPOINT] = MINIMUM_SETPOINT - if not _entry.data.get(CONF_MAXIMUM_SETPOINT): - if _entry.options.get(CONF_HEATING_SYSTEM) == "underfloor": - new[CONF_MAXIMUM_SETPOINT] = 50 + if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): + new_data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_low_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 55 + if not _entry.data.get(CONF_MAXIMUM_SETPOINT): + if _entry.options.get(CONF_HEATING_SYSTEM) == "underfloor": + new_data[CONF_MAXIMUM_SETPOINT] = 50 - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_medium_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 65 + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_low_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 55 - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": - new[CONF_MAXIMUM_SETPOINT] = 75 + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_medium_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 65 - _entry.version = 2 - _hass.config_entries.async_update_entry(_entry, data=new) + if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": + new_data[CONF_MAXIMUM_SETPOINT] = 75 + + if _entry.version < 3: + if main_climates := _entry.options.get("main_climates"): + new_data[CONF_MAIN_CLIMATES] = main_climates + new_options.pop("main_climates") + + if secondary_climates := _entry.options.get("climates"): + new_data[CONF_SECONDARY_CLIMATES] = secondary_climates + new_options.pop("climates") + + if sync_with_thermostat := _entry.options.get("sync_with_thermostat"): + new_data[CONF_SYNC_WITH_THERMOSTAT] = sync_with_thermostat + new_options.pop("sync_with_thermostat") + + _entry.version = SatFlowHandler.VERSION + _hass.config_entries.async_update_entry(_entry, 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 951c6d73..fc7ba261 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -129,7 +129,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._pre_custom_temperature = None self._pre_activity_temperature = None - self._climates = config_data.get(CONF_CLIMATES) + self._climates = config_data.get(CONF_SECONDARY_CLIMATES) self._main_climates = config_data.get(CONF_MAIN_CLIMATES) self._window_sensor_id = config_data.get(CONF_WINDOW_SENSOR) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index a7e6527e..206d422d 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -17,7 +17,7 @@ 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, entity_registry, device_registry +from homeassistant.helpers import selector, device_registry from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from pyotgw import OpenThermGateway @@ -25,7 +25,7 @@ from .const import * from .coordinator import SatDataUpdateCoordinator from .overshoot_protection import OvershootProtection -from .util import calculate_default_maximum_setpoint +from .util import calculate_default_maximum_setpoint, snake_case DEFAULT_NAME = "Living Room" @@ -34,7 +34,7 @@ class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SAT.""" - VERSION = 2 + VERSION = 3 calibration = None overshoot_protection_value = None @@ -174,7 +174,7 @@ async def async_step_simulator(self, _user_input=None): if _user_input is not None: self._data.update(_user_input) self._data[CONF_MODE] = MODE_SIMULATOR - self._data[CONF_DEVICE] = MODE_SIMULATOR + self._data[CONF_DEVICE] = f"%s_%s".format(MODE_SIMULATOR, snake_case(_user_input.get(CONF_NAME))) return await self.async_step_sensors() @@ -207,9 +207,10 @@ async def async_step_sensors(self, _user_input=None): if self._data[CONF_MODE] in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: return await self.async_step_heating_system() - return await self.async_step_automatic_gains() + return await self.async_step_areas() return self.async_show_form( + last_step=False, step_id="sensors", data_schema=vol.Schema({ vol.Required(CONF_INSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( @@ -225,10 +226,7 @@ async def async_step_heating_system(self, _user_input=None): if _user_input is not None: self._data.update(_user_input) - if (await self._create_coordinator()).supports_setpoint_management: - return await self.async_step_calibrate_system() - - return await self.async_step_automatic_gains() + return await self.async_step_areas() return self.async_show_form( last_step=False, @@ -243,6 +241,28 @@ async def async_step_heating_system(self, _user_input=None): }) ) + async def async_step_areas(self, _user_input=None): + if _user_input is not None: + self._data.update(_user_input) + + if (await self._create_coordinator()).supports_setpoint_management: + return await self.async_step_calibrate_system() + + return await self.async_step_automatic_gains() + + climate_selector = selector.EntitySelector(selector.EntitySelectorConfig( + domain=CLIMATE_DOMAIN, multiple=True + )) + + return self.async_show_form( + step_id="areas", + data_schema=vol.Schema({ + vol.Optional(CONF_MAIN_CLIMATES): climate_selector, + vol.Optional(CONF_SECONDARY_CLIMATES): climate_selector, + vol.Required(CONF_SYNC_WITH_THERMOSTAT): bool, + }) + ) + async def async_step_automatic_gains(self, _user_input=None): if _user_input is not None: self._data.update(_user_input) @@ -370,7 +390,7 @@ def __init__(self, config_entry: ConfigEntry): self._options = dict(config_entry.options) async def async_step_init(self, _user_input=None): - menu_options = ["general", "areas", "presets", "system_configuration"] + menu_options = ["general", "presets", "system_configuration"] if self.show_advanced_options: menu_options.append("advanced") @@ -382,6 +402,9 @@ async def async_step_init(self, _user_input=None): async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: + if _user_input.get(CONF_WINDOW_SENSOR) is None: + self._options[CONF_WINDOW_SENSOR] = None + return await self.update_options(_user_input) schema = {} @@ -406,6 +429,13 @@ async def async_step_general(self, _user_input=None) -> FlowResult: if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + schema[vol.Optional(CONF_WINDOW_SENSOR, default=options.get(CONF_WINDOW_SENSOR))] = selector.EntitySelector( + selector.EntitySelectorConfig( + domain=BINARY_SENSOR_DOMAIN, + device_class=[BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.GARAGE_DOOR] + ) + ) + return self.async_show_form(step_id="general", data_schema=vol.Schema(schema)) async def async_step_presets(self, _user_input=None) -> FlowResult: @@ -435,47 +465,6 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: }) ) - async def async_step_areas(self, _user_input=None) -> FlowResult: - if _user_input is not None: - if _user_input.get(CONF_MAIN_CLIMATES) is None: - self._options[CONF_MAIN_CLIMATES] = [] - - if _user_input.get(CONF_CLIMATES) is None: - self._options[CONF_CLIMATES] = [] - - if _user_input.get(CONF_WINDOW_SENSOR) is None: - self._options[CONF_WINDOW_SENSOR] = None - - return await self.update_options(_user_input) - - entities = entity_registry.async_get(self.hass) - device_name = self._config_entry.data.get(CONF_NAME) - climate_id = entities.async_get_entity_id(CLIMATE_DOMAIN, DOMAIN, str(device_name).lower()) - - climate_selector = selector.EntitySelector(selector.EntitySelectorConfig( - exclude_entities=[climate_id], domain=CLIMATE_DOMAIN, multiple=True - )) - - contact_sensor_selector = selector.EntitySelector(selector.EntitySelectorConfig( - domain=BINARY_SENSOR_DOMAIN, device_class=[ - BinarySensorDeviceClass.DOOR, - BinarySensorDeviceClass.WINDOW, - BinarySensorDeviceClass.GARAGE_DOOR - ]) - ) - - options = await self.get_options() - - return self.async_show_form( - step_id="areas", - data_schema=vol.Schema({ - vol.Optional(CONF_MAIN_CLIMATES, default=options[CONF_MAIN_CLIMATES]): climate_selector, - vol.Optional(CONF_CLIMATES, default=options[CONF_CLIMATES]): climate_selector, - vol.Optional(CONF_WINDOW_SENSOR, default=options[CONF_WINDOW_SENSOR]): contact_sensor_selector, - vol.Required(CONF_SYNC_WITH_THERMOSTAT, default=options[CONF_SYNC_WITH_THERMOSTAT]): bool, - }) - ) - async def async_step_system_configuration(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 64a7d2da..96c5778e 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -32,7 +32,7 @@ CONF_SIMULATED_WARMING_UP = "simulated_warming_up" CONF_MINIMUM_SETPOINT = "minimum_setpoint" CONF_MAXIMUM_SETPOINT = "maximum_setpoint" -CONF_CLIMATES = "climates" +CONF_SECONDARY_CLIMATES = "secondary_climates" CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" CONF_WINDOW_SENSOR = "window_sensor" @@ -78,7 +78,7 @@ CONF_AUTOMATIC_DUTY_CYCLE: True, CONF_OVERSHOOT_PROTECTION: False, - CONF_CLIMATES: [], + CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, CONF_WINDOW_SENSOR: None, diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 4763c9e5..ebfbc814 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -64,6 +64,15 @@ "heating_system": "System" } }, + "areas": { + "title": "Areas", + "description": "Settings related to climates, multi-room and temperature control.", + "data": { + "main_climates": "Climates", + "secondary_climates": "Rooms", + "sync_with_thermostat": "Synchronize setpoint with thermostat" + } + }, "automatic_gains": { "title": "Automatic Gains", "description": "This feature adjusts the control parameters of your heating system dynamically, optimizing temperature control for better comfort and energy efficiency. Enabling this option allows SAT to continuously adapt and fine-tune the heating settings based on the environmental conditions. This helps maintain a stable and comfortable environment without manual intervention.\n\nNote: If you choose not to enable automatic gains, you will need to manually enter the PID values for precise temperature control. Please ensure that you have accurate PID values for your specific heating system to achieve optimal performance.", @@ -123,7 +132,6 @@ "menu_options": { "general": "General", "presets": "Presets", - "areas": "Areas", "advanced": "Advanced Options", "system_configuration": "System Configuration" } @@ -136,8 +144,10 @@ "derivative": "Derivative (kD)", "proportional": "Proportional (kP)", "maximum_setpoint": "Maximum Setpoint", + "window_sensor": "Contact Sensor or group", "heating_curve_coefficient": "Heating Curve Coefficient", - "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation" + "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", + "sync_with_thermostat": "Synchronize setpoint with thermostat" } }, "presets": { @@ -152,16 +162,6 @@ "sync_climates_with_preset": "Synchronize climates with preset (sleep / away / activity)" } }, - "areas": { - "title": "Areas", - "description": "Settings related to climates, multi-room and temperature control.", - "data": { - "climates": "Rooms", - "main_climates": "Climates", - "window_sensor": "Contact Sensor or group", - "sync_with_thermostat": "Synchronize setpoint with thermostat" - } - }, "system_configuration": { "title": "System Configuration", "description": "For fine-tuning and customization.", diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index d632cfd2..49fca3fb 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -1,3 +1,5 @@ +from re import sub + from homeassistant.util import dt from .const import * @@ -71,3 +73,10 @@ def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: # 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, force=force) + + +def snake_case(s): + return '_'.join( + sub('([A-Z][a-z]+)', r' \1', + sub('([A-Z]+)', r' \1', + s.replace('-', ' '))).split()).lower() From 70abe886bfec0c753fe50462f280758b18312b3e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 23:43:13 +0200 Subject: [PATCH 085/237] Not everything needs to migrated every time --- 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 09cfd354..f3c09bac 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -88,7 +88,7 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool new_data = {**_entry.data} new_options = {**_entry.options} - if _entry.version < 2: + if _entry.version == 1: if not _entry.data.get(CONF_MINIMUM_SETPOINT): # Legacy Store store = Store(_hass, 1, DOMAIN) @@ -110,7 +110,7 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": new_data[CONF_MAXIMUM_SETPOINT] = 75 - if _entry.version < 3: + if _entry.version == 2: if main_climates := _entry.options.get("main_climates"): new_data[CONF_MAIN_CLIMATES] = main_climates new_options.pop("main_climates") From a7b8d6ebce4f05bc40f86267f89ad2b09ff0bb55 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 27 Aug 2023 23:43:47 +0200 Subject: [PATCH 086/237] Migrate based on version --- 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 f3c09bac..09cfd354 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -88,7 +88,7 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool new_data = {**_entry.data} new_options = {**_entry.options} - if _entry.version == 1: + if _entry.version < 2: if not _entry.data.get(CONF_MINIMUM_SETPOINT): # Legacy Store store = Store(_hass, 1, DOMAIN) @@ -110,7 +110,7 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": new_data[CONF_MAXIMUM_SETPOINT] = 75 - if _entry.version == 2: + if _entry.version < 3: if main_climates := _entry.options.get("main_climates"): new_data[CONF_MAIN_CLIMATES] = main_climates new_options.pop("main_climates") From 0aae30a0607e0e78ac988bc3553b2d0a08804bf3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 30 Aug 2023 19:57:41 +0200 Subject: [PATCH 087/237] Remove "sync_with_thermostat" since it wasn't doing anything --- custom_components/sat/config_flow.py | 1 - custom_components/sat/translations/en.json | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 206d422d..e9d5f06a 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -259,7 +259,6 @@ async def async_step_areas(self, _user_input=None): data_schema=vol.Schema({ vol.Optional(CONF_MAIN_CLIMATES): climate_selector, vol.Optional(CONF_SECONDARY_CLIMATES): climate_selector, - vol.Required(CONF_SYNC_WITH_THERMOSTAT): bool, }) ) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index ebfbc814..d98f51b5 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -69,8 +69,7 @@ "description": "Settings related to climates, multi-room and temperature control.", "data": { "main_climates": "Climates", - "secondary_climates": "Rooms", - "sync_with_thermostat": "Synchronize setpoint with thermostat" + "secondary_climates": "Rooms" } }, "automatic_gains": { From 146f82b9312091a1f3c5d902abdc822f8263f33e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 18 Oct 2023 13:51:44 +0200 Subject: [PATCH 088/237] Make sure we separate user and system configuration --- custom_components/sat/climate.py | 35 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index fc7ba261..05c715cb 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -110,7 +110,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self.pid = create_pid_controller(config_data) # Create Heating Curve controller with given configuration options - self.heating_curve = create_heating_curve_controller(config_data) + self.heating_curve = create_heating_curve_controller(config_entry.options, config_data) # Create PWM controller with given configuration options self.pwm = create_pwm_controller(self.heating_curve, config_data) @@ -129,6 +129,17 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._pre_custom_temperature = None self._pre_activity_temperature = None + self._attr_temperature_unit = unit + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = PRESET_NONE + self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] + self._attr_preset_modes = [PRESET_NONE] + list(self._presets.keys()) + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + + # System Configuration + self._attr_name = str(config_entry.data.get(CONF_NAME)) + self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() + self._climates = config_data.get(CONF_SECONDARY_CLIMATES) self._main_climates = config_data.get(CONF_MAIN_CLIMATES) self._window_sensor_id = config_data.get(CONF_WINDOW_SENSOR) @@ -137,22 +148,14 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._heating_system = str(config_data.get(CONF_HEATING_SYSTEM)) self._sync_with_thermostat = bool(config_data.get(CONF_SYNC_WITH_THERMOSTAT)) self._overshoot_protection = bool(config_data.get(CONF_OVERSHOOT_PROTECTION)) - self._climate_valve_offset = float(config_data.get(CONF_CLIMATE_VALVE_OFFSET)) - self._target_temperature_step = float(config_data.get(CONF_TARGET_TEMPERATURE_STEP)) - self._sync_climates_with_preset = bool(config_data.get(CONF_SYNC_CLIMATES_WITH_PRESET)) - self._force_pulse_width_modulation = bool(config_data.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - self._sensor_max_value_age = convert_time_str_to_seconds(config_data.get(CONF_SENSOR_MAX_VALUE_AGE)) - self._window_minimum_open_time = convert_time_str_to_seconds(config_data.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) - self._attr_name = str(config_entry.data.get(CONF_NAME)) - self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() - - self._attr_temperature_unit = unit - self._attr_hvac_mode = HVACMode.OFF - self._attr_preset_mode = PRESET_NONE - self._attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT] - self._attr_preset_modes = [PRESET_NONE] + list(self._presets.keys()) - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + # User Configuration + self._climate_valve_offset = float(config_entry.options.get(CONF_CLIMATE_VALVE_OFFSET)) + self._target_temperature_step = float(config_entry.options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._sync_climates_with_preset = bool(config_entry.options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) + self._force_pulse_width_modulation = bool(config_entry.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + self._sensor_max_value_age = convert_time_str_to_seconds(config_entry.options.get(CONF_SENSOR_MAX_VALUE_AGE)) + self._window_minimum_open_time = convert_time_str_to_seconds(config_entry.options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) if self._simulation: _LOGGER.warning("Simulation mode!") From 4984d8fa0e524aa5d7393901f8beec6eb4267636 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 18 Oct 2023 13:54:38 +0200 Subject: [PATCH 089/237] Making sure we honor the defaults --- custom_components/sat/climate.py | 38 ++++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 05c715cb..b2653fa2 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -102,18 +102,18 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn if isinstance(self.outside_sensor_entities, str): self.outside_sensor_entities = [self.outside_sensor_entities] - # Create config data dictionary with defaults - config_data = OPTIONS_DEFAULTS.copy() - config_data.update(config_entry.data) + # Create config options dictionary with defaults + config_options = OPTIONS_DEFAULTS.copy() + config_options.update(config_entry.options) # Create PID controller with given configuration options - self.pid = create_pid_controller(config_data) + self.pid = create_pid_controller(config_entry.data) # Create Heating Curve controller with given configuration options - self.heating_curve = create_heating_curve_controller(config_entry.options, config_data) + 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_data) + self.pwm = create_pwm_controller(self.heating_curve, config_entry.data) self._sensors = [] self._rooms = None @@ -140,22 +140,22 @@ 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._climates = config_data.get(CONF_SECONDARY_CLIMATES) - self._main_climates = config_data.get(CONF_MAIN_CLIMATES) - self._window_sensor_id = config_data.get(CONF_WINDOW_SENSOR) + self._climates = config_entry.data.get(CONF_SECONDARY_CLIMATES) + self._main_climates = config_entry.data.get(CONF_MAIN_CLIMATES) + self._window_sensor_id = config_entry.data.get(CONF_WINDOW_SENSOR) - self._simulation = bool(config_data.get(CONF_SIMULATION)) - self._heating_system = str(config_data.get(CONF_HEATING_SYSTEM)) - self._sync_with_thermostat = bool(config_data.get(CONF_SYNC_WITH_THERMOSTAT)) - self._overshoot_protection = bool(config_data.get(CONF_OVERSHOOT_PROTECTION)) + 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)) # User Configuration - self._climate_valve_offset = float(config_entry.options.get(CONF_CLIMATE_VALVE_OFFSET)) - self._target_temperature_step = float(config_entry.options.get(CONF_TARGET_TEMPERATURE_STEP)) - self._sync_climates_with_preset = bool(config_entry.options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) - self._force_pulse_width_modulation = bool(config_entry.options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) - self._sensor_max_value_age = convert_time_str_to_seconds(config_entry.options.get(CONF_SENSOR_MAX_VALUE_AGE)) - self._window_minimum_open_time = convert_time_str_to_seconds(config_entry.options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) + self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) + self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) + 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)) if self._simulation: _LOGGER.warning("Simulation mode!") From fc6f15dace1473fef2379204056b5ad4069c84a9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 18 Oct 2023 14:10:25 +0200 Subject: [PATCH 090/237] Fixed some more config issues and update tests --- custom_components/sat/climate.py | 8 ++++---- tests/conftest.py | 4 ++-- tests/test_climate.py | 16 +++++++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b2653fa2..cc06b40c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -107,13 +107,13 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn config_options.update(config_entry.options) # Create PID controller with given configuration options - self.pid = create_pid_controller(config_entry.data) + self.pid = create_pid_controller(config_options) # 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) + self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) self._sensors = [] self._rooms = None @@ -140,9 +140,9 @@ 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._climates = config_entry.data.get(CONF_SECONDARY_CLIMATES) - self._main_climates = config_entry.data.get(CONF_MAIN_CLIMATES) self._window_sensor_id = config_entry.data.get(CONF_WINDOW_SENSOR) + self._climates = config_entry.data.get(CONF_SECONDARY_CLIMATES) or [] + self._main_climates = config_entry.data.get(CONF_MAIN_CLIMATES) or [] self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) diff --git a/tests/conftest.py b/tests/conftest.py index 93afe0f7..2db936f1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture -async def entry(hass: HomeAssistant, domains: list, data: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: +async def entry(hass: HomeAssistant, domains: list, data: dict, options: dict, config: dict, caplog: LogCaptureFixture) -> MockConfigEntry: """Setup any given integration.""" for domain, count in domains: with assert_setup_component(count, domain): @@ -31,7 +31,7 @@ async def entry(hass: HomeAssistant, domains: list, data: dict, config: dict, ca user_data = DEFAULT_USER_DATA.copy() user_data.update(data) - config_entry = MockConfigEntry(domain=DOMAIN, data=user_data) + config_entry = MockConfigEntry(domain=DOMAIN, data=user_data, options=options) await hass.config_entries.async_add(config_entry) return config_entry diff --git a/tests/test_climate.py b/tests/test_climate.py index 4af4016c..4a230612 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -13,14 +13,16 @@ @pytest.mark.parametrize(*[ - "domains, data, config", + "domains, data, options, config", [( [(TEMPLATE_DOMAIN, 1)], { CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 57, CONF_MAXIMUM_SETPOINT: 75, - CONF_HEATING_CURVE_COEFFICIENT: 1.8 + }, + { + CONF_HEATING_CURVE_COEFFICIENT: 1.8, }, { TEMPLATE_DOMAIN: [ @@ -53,13 +55,15 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ - "domains, data, config", + "domains, data, options, config", [( [(TEMPLATE_DOMAIN, 1)], { CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 58, - CONF_MAXIMUM_SETPOINT: 75, + CONF_MAXIMUM_SETPOINT: 75 + }, + { CONF_HEATING_CURVE_COEFFICIENT: 1.3 }, { @@ -94,13 +98,15 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: @pytest.mark.parametrize(*[ - "domains, data, config", + "domains, data, options, config", [( [(TEMPLATE_DOMAIN, 1)], { CONF_HEATING_SYSTEM: HEATING_SYSTEM_RADIATORS, CONF_MINIMUM_SETPOINT: 41, CONF_MAXIMUM_SETPOINT: 75, + }, + { CONF_HEATING_CURVE_COEFFICIENT: 0.9 }, { From 9205726a1afd7d17d6cb504ddf8c9f66da045473 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 18 Oct 2023 14:14:07 +0200 Subject: [PATCH 091/237] Update helpers to reflect new config and parameters --- custom_components/sat/util.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 49fca3fb..9c2b8f16 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -41,35 +41,35 @@ def calculate_default_maximum_setpoint(heating_system: str) -> int | None: return None -def create_pid_controller(options) -> PID: +def create_pid_controller(config_options) -> PID: """Create and return a PID controller instance with the given configuration options.""" # Extract the configuration options - kp = float(options.get(CONF_PROPORTIONAL)) - ki = float(options.get(CONF_INTEGRAL)) - kd = float(options.get(CONF_DERIVATIVE)) - automatic_gains = bool(options.get(CONF_AUTOMATIC_GAINS)) - sample_time_limit = convert_time_str_to_seconds(options.get(CONF_SAMPLE_TIME)) + kp = float(config_options.get(CONF_PROPORTIONAL)) + ki = float(config_options.get(CONF_INTEGRAL)) + kd = float(config_options.get(CONF_DERIVATIVE)) + automatic_gains = bool(config_options.get(CONF_AUTOMATIC_GAINS)) + sample_time_limit = convert_time_str_to_seconds(config_options.get(CONF_SAMPLE_TIME)) # Return a new PID controller instance with the given configuration options return PID(kp=kp, ki=ki, kd=kd, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) -def create_heating_curve_controller(options) -> HeatingCurve: +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 - heating_system = options.get(CONF_HEATING_SYSTEM) - coefficient = float(options.get(CONF_HEATING_CURVE_COEFFICIENT)) + heating_system = config_data.get(CONF_HEATING_SYSTEM) + coefficient = float(config_options.get(CONF_HEATING_CURVE_COEFFICIENT)) # Return a new heating Curve controller instance with the given configuration options return HeatingCurve(heating_system=heating_system, coefficient=coefficient) -def create_pwm_controller(heating_curve: HeatingCurve, options) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, config_data, config_options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options - automatic_duty_cycle = bool(options.get(CONF_AUTOMATIC_DUTY_CYCLE)) - max_cycle_time = int(convert_time_str_to_seconds(options.get(CONF_DUTY_CYCLE))) - force = bool(options.get(CONF_MODE) == MODE_SWITCH) or bool(options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + automatic_duty_cycle = bool(config_options.get(CONF_AUTOMATIC_DUTY_CYCLE)) + 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)) # 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, force=force) From ffc3d49244053b347d590d3f59f0c5d8e46cebcf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 11 Nov 2023 22:55:44 +0100 Subject: [PATCH 092/237] Possible fix for 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 cc06b40c..ab710d5e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -500,7 +500,7 @@ def relative_modulation_enabled(self) -> bool: if self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME: return False - return self.max_error > DEADBAND or not self.pulse_width_modulation_enabled + return self.max_error > DEADBAND and not self.pulse_width_modulation_enabled def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" From 125704ce5326193cf7cd8bfd83fe295b0574130d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 14:07:03 +0100 Subject: [PATCH 093/237] Fixed migrating some config --- custom_components/sat/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 09cfd354..1c1aaf91 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -97,17 +97,22 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool if (data := await store.async_load()) and (overshoot_protection_value := data.get("overshoot_protection_value")): new_data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value + if _entry.options.get("heating_system") == "underfloor": + new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_UNDERFLOOR + else: + new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_RADIATORS + if not _entry.data.get(CONF_MAXIMUM_SETPOINT): - if _entry.options.get(CONF_HEATING_SYSTEM) == "underfloor": + if _entry.options.get("heating_system") == "underfloor": new_data[CONF_MAXIMUM_SETPOINT] = 50 - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_low_temperatures": + if _entry.options.get("heating_system") == "radiator_low_temperatures": new_data[CONF_MAXIMUM_SETPOINT] = 55 - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_medium_temperatures": + if _entry.options.get("heating_system") == "radiator_medium_temperatures": new_data[CONF_MAXIMUM_SETPOINT] = 65 - if _entry.options.get(CONF_HEATING_SYSTEM) == "radiator_high_temperatures": + if _entry.options.get("heating_system") == "radiator_high_temperatures": new_data[CONF_MAXIMUM_SETPOINT] = 75 if _entry.version < 3: From 751374b5cff5d564b164b0b0dcdb9d3388e627c5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 14:07:28 +0100 Subject: [PATCH 094/237] Set default value --- custom_components/sat/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 1c1aaf91..4862b76b 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -103,6 +103,8 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool new_data[CONF_HEATING_SYSTEM] = HEATING_SYSTEM_RADIATORS if not _entry.data.get(CONF_MAXIMUM_SETPOINT): + new_data[CONF_MAXIMUM_SETPOINT] = 55 + if _entry.options.get("heating_system") == "underfloor": new_data[CONF_MAXIMUM_SETPOINT] = 50 From ff67d64c38383d6077e3c774497a0376cc9b4808 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 14:17:22 +0100 Subject: [PATCH 095/237] Fixed retrieving the correct heating system --- custom_components/sat/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index e9d5f06a..6353d80d 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -409,7 +409,7 @@ async def async_step_general(self, _user_input=None) -> FlowResult: schema = {} options = await self.get_options() - default_maximum_setpoint = calculate_default_maximum_setpoint(options.get(CONF_HEATING_SYSTEM)) + 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_MAXIMUM_SETPOINT, default=maximum_setpoint)] = selector.NumberSelector( From 8cb3e4c47ed1f405a07d7edb97c9fddfd625302d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 15:23:05 +0100 Subject: [PATCH 096/237] Added support for more than one window sensor --- custom_components/sat/__init__.py | 4 ++ custom_components/sat/binary_sensor.py | 32 +++++++++++++++- custom_components/sat/climate.py | 13 +++++-- custom_components/sat/config_flow.py | 51 +++++++++++++------------- custom_components/sat/const.py | 4 +- 5 files changed, 71 insertions(+), 33 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 4862b76b..007ad7b1 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -130,6 +130,10 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool new_data[CONF_SYNC_WITH_THERMOSTAT] = sync_with_thermostat new_options.pop("sync_with_thermostat") + if _entry.version < 4: + if _entry.data.get("window_sensor") is not None: + new_data[CONF_WINDOW_SENSORS] = [_entry.data.get("window_sensor")] + _entry.version = SatFlowHandler.VERSION _hass.config_entries.async_update_entry(_entry, data=new_data, options=new_options) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 7de02a28..ccd4cde6 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -4,11 +4,14 @@ from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.components.climate import HVACAction +from homeassistant.components.group.binary_sensor import BinarySensorGroup from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE +from .climate import SatClimate +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS from .entity import SatClimateEntity from .serial import binary_sensor as serial_binary_sensor @@ -29,6 +32,9 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_setpoint_management: _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) + if len(_config_entry.data.get(CONF_WINDOW_SENSORS, [])) > 0: + _async_add_entities([SatWindowSensor(coordinator, climate, _config_entry)]) + _async_add_entities([SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry)]) @@ -93,3 +99,27 @@ def is_on(self) -> bool: def unique_id(self) -> str: """Return a unique ID to use for this entity.""" return f"{self._config_entry.data.get(CONF_NAME).lower()}-central-heating-synchro" + + +class SatWindowSensor(SatClimateEntity, BinarySensorGroup): + def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): + super().__init__(coordinator, climate, config_entry) + + self.mode = any + self._entity_ids = self._config_entry.data.get(CONF_WINDOW_SENSORS) + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_ids} + + @property + def name(self) -> str: + """Return the friendly name of the sensor.""" + return "Smart Autotune Thermostat Window Sensor" + + @property + def device_class(self) -> str: + """Return the device class.""" + return BinarySensorDeviceClass.WINDOW + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-window-sensor" diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index ab710d5e..577090f2 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -9,6 +9,7 @@ from time import monotonic from typing import List +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, @@ -32,6 +33,7 @@ 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.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 @@ -140,9 +142,9 @@ 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._window_sensor_id = config_entry.data.get(CONF_WINDOW_SENSOR) 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.data.get(CONF_WINDOW_SENSORS) or [] self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) self._heating_system = str(config_entry.data.get(CONF_HEATING_SYSTEM)) @@ -215,10 +217,13 @@ async def _register_event_listeners(self): ) ) - if self._window_sensor_id is not None: + if len(self._window_sensors) > 0: + entities = entity_registry.async_get(self.hass) + unique_id = f"{self._config_entry.data.get(CONF_NAME).lower()}-window-sensor" + self.async_on_remove( async_track_state_change_event( - self.hass, [self._window_sensor_id], self._async_window_sensor_changed + self.hass, [entities.async_get_entity_id(BINARY_SENSOR_DOMAIN, DOMAIN, unique_id)], self._async_window_sensor_changed ) ) @@ -613,7 +618,7 @@ async def _async_window_sensor_changed(self, event: Event) -> None: if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return - _LOGGER.debug(f"Window Sensor Changed to {new_state.state} ({new_state.entity_id}).") + _LOGGER.debug(f"Window Sensor Changed to {new_state.state}.") if new_state.state == STATE_ON: if self.preset_mode == PRESET_ACTIVITY: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 6353d80d..7b8fb696 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -34,7 +34,7 @@ class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SAT.""" - VERSION = 3 + VERSION = 4 calibration = None overshoot_protection_value = None @@ -401,9 +401,6 @@ async def async_step_init(self, _user_input=None): async def async_step_general(self, _user_input=None) -> FlowResult: if _user_input is not None: - if _user_input.get(CONF_WINDOW_SENSOR) is None: - self._options[CONF_WINDOW_SENSOR] = None - return await self.update_options(_user_input) schema = {} @@ -416,20 +413,21 @@ async def async_step_general(self, _user_input=None) -> FlowResult: selector.NumberSelectorConfig(min=10, max=100, step=1, unit_of_measurement="°C") ) - if not options.get(CONF_AUTOMATIC_GAINS): - schema[vol.Required(CONF_PROPORTIONAL, default=options.get(CONF_PROPORTIONAL))] = str - schema[vol.Required(CONF_INTEGRAL, default=options.get(CONF_INTEGRAL))] = str - schema[vol.Required(CONF_DERIVATIVE, default=options.get(CONF_DERIVATIVE))] = str + 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) ) - if not options.get(CONF_AUTOMATIC_DUTY_CYCLE): - schema[vol.Required(CONF_DUTY_CYCLE, default=options.get(CONF_DUTY_CYCLE))] = selector.TimeSelector() + if not options[CONF_AUTOMATIC_DUTY_CYCLE]: + schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() - schema[vol.Optional(CONF_WINDOW_SENSOR, default=options.get(CONF_WINDOW_SENSOR))] = selector.EntitySelector( + schema[vol.Optional(CONF_WINDOW_SENSORS, default=options[CONF_WINDOW_SENSORS])] = selector.EntitySelector( selector.EntitySelectorConfig( + multiple=True, domain=BINARY_SENSOR_DOMAIN, device_class=[BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.GARAGE_DOOR] ) @@ -441,26 +439,26 @@ async def async_step_presets(self, _user_input=None) -> FlowResult: if _user_input is not None: return await self.update_options(_user_input) - defaults = await self.get_options() + options = await self.get_options() return self.async_show_form( step_id="presets", data_schema=vol.Schema({ - vol.Required(CONF_ACTIVITY_TEMPERATURE, default=defaults[CONF_ACTIVITY_TEMPERATURE]): selector.NumberSelector( + vol.Required(CONF_ACTIVITY_TEMPERATURE, default=options[CONF_ACTIVITY_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_AWAY_TEMPERATURE, default=defaults[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( + vol.Required(CONF_AWAY_TEMPERATURE, default=options[CONF_AWAY_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_SLEEP_TEMPERATURE, default=defaults[CONF_SLEEP_TEMPERATURE]): selector.NumberSelector( + vol.Required(CONF_SLEEP_TEMPERATURE, default=options[CONF_SLEEP_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_HOME_TEMPERATURE, default=defaults[CONF_HOME_TEMPERATURE]): selector.NumberSelector( + vol.Required(CONF_HOME_TEMPERATURE, default=options[CONF_HOME_TEMPERATURE]): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C") ), - vol.Required(CONF_COMFORT_TEMPERATURE, default=defaults[CONF_COMFORT_TEMPERATURE]): selector.NumberSelector( + 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_CLIMATES_WITH_PRESET, default=defaults[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, + vol.Required(CONF_SYNC_CLIMATES_WITH_PRESET, default=options[CONF_SYNC_CLIMATES_WITH_PRESET]): bool, }) ) @@ -473,17 +471,18 @@ async def async_step_system_configuration(self, _user_input=None) -> FlowResult: return self.async_show_form( step_id="system_configuration", data_schema=vol.Schema({ - vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options.get(CONF_AUTOMATIC_DUTY_CYCLE)): bool, - vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options.get(CONF_SENSOR_MAX_VALUE_AGE)): selector.TimeSelector(), - vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)): selector.TimeSelector(), + vol.Required(CONF_AUTOMATIC_DUTY_CYCLE, default=options[CONF_AUTOMATIC_DUTY_CYCLE]): bool, + vol.Required(CONF_SENSOR_MAX_VALUE_AGE, default=options[CONF_SENSOR_MAX_VALUE_AGE]): selector.TimeSelector(), + vol.Required(CONF_WINDOW_MINIMUM_OPEN_TIME, default=options[CONF_WINDOW_MINIMUM_OPEN_TIME]): selector.TimeSelector(), }) ) async def async_step_advanced(self, _user_input=None) -> FlowResult: - options = await self.get_options() if _user_input is not None: return await self.update_options(_user_input) + options = await self.get_options() + schema = {} schema[vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION])]: bool @@ -498,7 +497,7 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) ) - schema[vol.Required(CONF_SAMPLE_TIME, default=options.get(CONF_SAMPLE_TIME))] = selector.TimeSelector() + schema[vol.Required(CONF_SAMPLE_TIME, default=options[CONF_SAMPLE_TIME])] = selector.TimeSelector() return self.async_show_form( step_id="advanced", @@ -510,7 +509,7 @@ async def update_options(self, _user_input) -> FlowResult: return self.async_create_entry(title=self._config_entry.data[CONF_NAME], data=self._options) async def get_options(self): - defaults = OPTIONS_DEFAULTS.copy() - defaults.update(self._options) + options = OPTIONS_DEFAULTS.copy() + options.update(self._options) - return defaults + return options diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 96c5778e..6cd46e01 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -35,7 +35,7 @@ CONF_SECONDARY_CLIMATES = "secondary_climates" CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" -CONF_WINDOW_SENSOR = "window_sensor" +CONF_WINDOW_SENSORS = "window_sensors" CONF_SYNC_WITH_THERMOSTAT = "sync_with_thermostat" CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" CONF_SIMULATION = "simulation" @@ -81,7 +81,7 @@ CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, - CONF_WINDOW_SENSOR: None, + CONF_WINDOW_SENSORS: [], CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, From 49c06efeaccb18e516f99895d97c3250599a5ffc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 15:26:33 +0100 Subject: [PATCH 097/237] Fixed translation --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index d98f51b5..6a71ddde 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -143,7 +143,7 @@ "derivative": "Derivative (kD)", "proportional": "Proportional (kP)", "maximum_setpoint": "Maximum Setpoint", - "window_sensor": "Contact Sensor or group", + "window_sensors": "Contact Sensors", "heating_curve_coefficient": "Heating Curve Coefficient", "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", "sync_with_thermostat": "Synchronize setpoint with thermostat" From 806ab6dc4d78fdf4061289af49dce7a40c7343de Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 15:34:39 +0100 Subject: [PATCH 098/237] Wrong config again --- custom_components/sat/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index ccd4cde6..d932476a 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_setpoint_management: _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) - if len(_config_entry.data.get(CONF_WINDOW_SENSORS, [])) > 0: + if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0: _async_add_entities([SatWindowSensor(coordinator, climate, _config_entry)]) _async_add_entities([SatCentralHeatingSynchroSensor(coordinator, climate, _config_entry)]) @@ -106,7 +106,7 @@ def __init__(self, coordinator, climate: SatClimate, config_entry: ConfigEntry): super().__init__(coordinator, climate, config_entry) self.mode = any - self._entity_ids = self._config_entry.data.get(CONF_WINDOW_SENSORS) + self._entity_ids = self._config_entry.options.get(CONF_WINDOW_SENSORS) self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._entity_ids} @property From ea4a9ccf6f25fff3a50291f9b2f5ba3c2a3bcc9e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 17:29:36 +0100 Subject: [PATCH 099/237] Add experimental support for Thermal Comfort --- configuration.yaml | 10 ++ custom_components/sat/climate.py | 104 ++++++++++++++++++++- custom_components/sat/config_flow.py | 14 ++- custom_components/sat/const.py | 4 + custom_components/sat/translations/en.json | 4 +- 5 files changed, 128 insertions(+), 8 deletions(-) diff --git a/configuration.yaml b/configuration.yaml index 6d4a7a2c..52d05384 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -48,6 +48,10 @@ template: name: Outside Temperature device_class: 'temperature' state: "{{ states('input_number.outside_temperature_raw') }}" + - unit_of_measurement: "%" + name: Current Humidity + device_class: 'humidity' + state: "{{ states('input_number.humidity_raw') }}" input_number: heater_temperature_raw: @@ -68,6 +72,12 @@ input_number: min: 0 max: 35 step: 0.01 + humidity_raw: + name: Humidity + initial: 50 + min: 0 + max: 100 + step: 0.1 input_boolean: heater: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 577090f2..bb698848 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -31,12 +31,13 @@ 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.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, Event 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 homeassistant.util.unit_conversion import TemperatureConverter from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState @@ -86,17 +87,24 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn # Create dictionary mapping preset keys to temperature values self._presets = {key: config_entry.data[value] for key, value in conf_presets.items() if value in config_entry.data} - # Get inside sensor entity ID + # Get some sensor entity IDs self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID) + self.humidity_sensor_entity_id = config_entry.data.get(CONF_HUMIDITY_SENSOR_ENTITY_ID) - # Get inside sensor entity state + # Get some sensor entity states inside_sensor_entity = coordinator.hass.states.get(self.inside_sensor_entity_id) + humidity_sensor_entity = coordinator.hass.states.get(self.humidity_sensor_entity_id) if self.humidity_sensor_entity_id is not None else None # Get current temperature self._current_temperature = None if inside_sensor_entity is not None and inside_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: self._current_temperature = float(inside_sensor_entity.state) + # Get current temperature + self._current_humidity = None + if humidity_sensor_entity is not None and humidity_sensor_entity.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + self._current_humidity = float(humidity_sensor_entity.state) + # Get outside sensor entity IDs self.outside_sensor_entities = config_entry.data.get(CONF_OUTSIDE_SENSOR_ENTITY_ID) @@ -152,6 +160,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._overshoot_protection = bool(config_entry.data.get(CONF_OVERSHOOT_PROTECTION)) # User Configuration + self._thermal_comfort = bool(config_options.get(CONF_THERMAL_COMFORT)) self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) @@ -205,6 +214,13 @@ async def _register_event_listeners(self): ) ) + if self.humidity_sensor_entity_id is not None: + self.async_on_remove( + async_track_state_change_event( + self.hass, [self.humidity_sensor_entity_id], self._async_humidity_sensor_changed + ) + ) + self.async_on_remove( async_track_state_change_event( self.hass, self._main_climates, self._async_main_climate_changed @@ -329,6 +345,9 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, + "current_humidity": self._current_humidity, + "summer_simmer_index:": self.summer_simmer_index, + "summer_simmer_perception:": self.summer_simmer_perception, "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, @@ -340,7 +359,7 @@ def extra_state_attributes(self): "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 + "pulse_width_modulation_duty_cycle": self.pwm.duty_cycle, } @property @@ -353,12 +372,20 @@ def target_temperature(self): """Return the temperature we try to reach.""" return self._target_temperature + @property + def current_humidity(self): + """Return the sensor humidity.""" + return self._current_humidity + @property def error(self): """Return the error value.""" if self._target_temperature is None or self._current_temperature is None: return 0 + if self._thermal_comfort and self._current_humidity is not None: + return round(self._target_temperature - self.summer_simmer_index, 2) + return round(self._target_temperature - self._current_temperature, 2) @property @@ -507,6 +534,62 @@ def relative_modulation_enabled(self) -> bool: return self.max_error > DEADBAND and not self.pulse_width_modulation_enabled + @property + def summer_simmer_index(self) -> float | None: + """ + Calculate the Summer Simmer Index. + + The Summer Simmer Index is a measure of heat and humidity. + + Formula: 1.98 * (F - (0.55 - 0.0055 * H) * (F - 58.0)) - 56.83 + If F < 58, the index is F. + + Returns: + float: Summer Simmer Index in Celsius. + """ + # Make sure we have a valid humidity value + if self._current_humidity is None: + return None + + # Convert temperature to Fahrenheit + fahrenheit = TemperatureConverter.convert( + self._current_temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + ) + + # Calculate Summer Simmer Index + index = 1.98 * (fahrenheit - (0.55 - 0.0055 * self._current_humidity) * (fahrenheit - 58.0)) - 56.83 + + # If the temperature is below 58°F, use the temperature as the index + if fahrenheit < 58: + index = fahrenheit + + # Convert the result back to Celsius + return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) + + @property + def summer_simmer_perception(self) -> str: + """.""" + index = self.summer_simmer_index + + if index < 21.1: + return "Cool" + elif index < 25.0: + return "Slightly Cool" + elif index < 28.3: + return "Comfortable" + elif index < 32.8: + return "Slightly Warm" + elif index < 37.8: + return "Increasing Discomfort" + elif index < 44.4: + return "Extremely Warm" + elif index < 51.7: + return "Danger Of Heatstroke" + elif index < 65.6: + return "Extreme Danger Of Heatstroke" + else: + return "Circulatory Collapse Imminent" + def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self.heating_curve.value is None: @@ -542,6 +625,19 @@ async def _async_outside_entity_changed(self, event: Event) -> None: await self.async_control_heating_loop() + async def _async_humidity_sensor_changed(self, event: Event) -> None: + """Handle changes to the inside temperature sensor.""" + new_state = event.data.get("new_state") + if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return + + _LOGGER.debug("Humidity Sensor Changed.") + self._current_humidity = float(new_state.state) + self.async_write_ha_state() + + await self._async_control_pid() + await self.async_control_heating_loop() + async def _async_main_climate_changed(self, event: Event) -> None: """Handle changes to the main climate entity.""" old_state = event.data.get("old_state") diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 7b8fb696..1884856f 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +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 @@ -219,6 +219,12 @@ async def async_step_sensors(self, _user_input=None): vol.Required(CONF_OUTSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, WEATHER_DOMAIN], multiple=True) ), + vol.Optional(CONF_HUMIDITY_SENSOR_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=[SensorDeviceClass.HUMIDITY] + ) + ) }), ) @@ -483,8 +489,10 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: options = await self.get_options() - schema = {} - schema[vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION])]: bool + schema = { + vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, + vol.Required(CONF_THERMAL_COMFORT, default=options[CONF_THERMAL_COMFORT]): bool + } if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: schema[vol.Required(CONF_FORCE_PULSE_WIDTH_MODULATION, default=options[CONF_FORCE_PULSE_WIDTH_MODULATION])] = bool diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 6cd46e01..1c07f3c9 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -38,6 +38,7 @@ CONF_WINDOW_SENSORS = "window_sensors" CONF_SYNC_WITH_THERMOSTAT = "sync_with_thermostat" CONF_WINDOW_MINIMUM_OPEN_TIME = "window_minimum_open_time" +CONF_THERMAL_COMFORT = "thermal_comfort" CONF_SIMULATION = "simulation" CONF_INTEGRAL = "integral" CONF_DERIVATIVE = "derivative" @@ -54,6 +55,7 @@ CONF_TARGET_TEMPERATURE_STEP = "target_temperature_step" 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_HEATING_SYSTEM = "heating_system" CONF_HEATING_CURVE_COEFFICIENT = "heating_curve_coefficient" @@ -82,6 +84,8 @@ CONF_MAIN_CLIMATES: [], CONF_SIMULATION: False, CONF_WINDOW_SENSORS: [], + CONF_THERMAL_COMFORT: False, + CONF_HUMIDITY_SENSOR_ENTITY_ID: None, CONF_SYNC_WITH_THERMOSTAT: False, CONF_SYNC_CLIMATES_WITH_PRESET: False, diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 6a71ddde..3016bebe 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -53,6 +53,7 @@ "title": "Configure sensors", "description": "Please select the sensors that will be used to track the temperature.", "data": { + "humidity_sensor": "Humidity Sensor", "inside_sensor_entity_id": "Inside Sensor Entity", "outside_sensor_entity_id": "Outside Sensor Entity" } @@ -174,7 +175,8 @@ "advanced": { "title": "Advanced", "data": { - "simulation": "Simulation ", + "simulation": "Simulation", + "thermal_comfort": "Thermal Comfort", "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", "force_pulse_width_modulation": "Force Pulse Width Modulation", From f7fa6341bb0d181a98cbcc9c255ec508fd366060 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 17:32:06 +0100 Subject: [PATCH 100/237] Make sure we have a default value --- 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 bb698848..00203fba 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -571,7 +571,9 @@ def summer_simmer_perception(self) -> str: """.""" index = self.summer_simmer_index - if index < 21.1: + if index is None: + return "Unknown" + elif index < 21.1: return "Cool" elif index < 25.0: return "Slightly Cool" From e0dc8b252100c3ed23e07595d02c41b1d14d7bc8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 17:44:18 +0100 Subject: [PATCH 101/237] 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 00203fba..bc71fa70 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -346,8 +346,8 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "summer_simmer_index:": self.summer_simmer_index, - "summer_simmer_perception:": self.summer_simmer_perception, + "summer_simmer_index": self.summer_simmer_index, + "summer_simmer_perception": self.summer_simmer_perception, "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, From 93f6ec69f77daca3b677bcf4c8eb9a7f22ec69c3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 17:49:26 +0100 Subject: [PATCH 102/237] Show "summer_simmer_index" when Thermal Comfort is on --- 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 bc71fa70..0acab156 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -365,6 +365,9 @@ def extra_state_attributes(self): @property def current_temperature(self): """Return the sensor temperature.""" + if self._thermal_comfort and self._current_humidity is not None: + return self.summer_simmer_index + return self._current_temperature @property From 5d505fd89ffab98fc84bac2d2430dd22ca54f9d7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 18:06:36 +0100 Subject: [PATCH 103/237] Fixed the presets --- custom_components/sat/climate.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 0acab156..027b9206 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -81,12 +81,6 @@ class SatClimate(SatEntity, ClimateEntity, RestoreEntity): def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry, unit: str): super().__init__(coordinator, config_entry) - # Create dictionary mapping preset keys to temperature options - conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} - - # Create dictionary mapping preset keys to temperature values - self._presets = {key: config_entry.data[value] for key, value in conf_presets.items() if value in config_entry.data} - # Get some sensor entity IDs self.inside_sensor_entity_id = config_entry.data.get(CONF_INSIDE_SENSOR_ENTITY_ID) self.humidity_sensor_entity_id = config_entry.data.get(CONF_HUMIDITY_SENSOR_ENTITY_ID) @@ -116,6 +110,12 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn config_options = OPTIONS_DEFAULTS.copy() config_options.update(config_entry.options) + # Create dictionary mapping preset keys to temperature options + conf_presets = {p: f"{p}_temperature" for p in (PRESET_ACTIVITY, PRESET_AWAY, PRESET_HOME, PRESET_SLEEP, PRESET_COMFORT)} + + # 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} + # Create PID controller with given configuration options self.pid = create_pid_controller(config_options) @@ -146,6 +146,8 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._attr_preset_modes = [PRESET_NONE] + list(self._presets.keys()) self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + _LOGGER.debug(self._attr_preset_modes) + # System Configuration self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() From e76b6326b9b252e3343349e3bf68f86939dfc435 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 19:58:31 +0100 Subject: [PATCH 104/237] Add Thermal Comfort to the rooms --- custom_components/sat/climate.py | 55 +++++++++----------------------- 1 file changed, 15 insertions(+), 40 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 027b9206..e782b606 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -348,8 +348,7 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "summer_simmer_index": self.summer_simmer_index, - "summer_simmer_perception": self.summer_simmer_perception, + "summer_simmer_index": self._calculate_summer_simmer_index(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, @@ -368,7 +367,7 @@ def extra_state_attributes(self): def current_temperature(self): """Return the sensor temperature.""" if self._thermal_comfort and self._current_humidity is not None: - return self.summer_simmer_index + return self._calculate_summer_simmer_index(self._current_temperature, self._current_humidity) return self._current_temperature @@ -385,13 +384,10 @@ def current_humidity(self): @property def error(self): """Return the error value.""" - if self._target_temperature is None or self._current_temperature is None: + if self.target_temperature is None or self.current_temperature is None: return 0 - if self._thermal_comfort and self._current_humidity is not None: - return round(self._target_temperature - self.summer_simmer_index, 2) - - return round(self._target_temperature - self._current_temperature, 2) + return round(self.target_temperature - self.current_temperature, 2) @property def current_outside_temperature(self): @@ -462,6 +458,7 @@ def climate_errors(self) -> List[float]: # Calculate temperature difference for this climate target_temperature = float(state.attributes.get("temperature")) + current_humidity = float(state.attributes.get("current_humidity") or self._current_humidity) current_temperature = float(state.attributes.get("current_temperature") or target_temperature) # Retrieve the overriden sensor temperature if set @@ -470,6 +467,10 @@ def climate_errors(self) -> List[float]: if sensor_state is not None and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]: current_temperature = float(sensor_state.state) + # Calculate the summer simmer index based on the climate temperature + if self._thermal_comfort and current_humidity is not None: + current_temperature = self._calculate_summer_simmer_index(current_temperature, current_humidity) + errors.append(round(target_temperature - current_temperature, 2)) return errors @@ -539,8 +540,8 @@ def relative_modulation_enabled(self) -> bool: return self.max_error > DEADBAND and not self.pulse_width_modulation_enabled - @property - def summer_simmer_index(self) -> float | None: + @staticmethod + def _calculate_summer_simmer_index(temperature: float, humidity: float) -> float | None: """ Calculate the Summer Simmer Index. @@ -552,17 +553,17 @@ def summer_simmer_index(self) -> float | None: Returns: float: Summer Simmer Index in Celsius. """ - # Make sure we have a valid humidity value - if self._current_humidity is None: + # Validate + if temperature is None or humidity is None: return None # Convert temperature to Fahrenheit fahrenheit = TemperatureConverter.convert( - self._current_temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ) # Calculate Summer Simmer Index - index = 1.98 * (fahrenheit - (0.55 - 0.0055 * self._current_humidity) * (fahrenheit - 58.0)) - 56.83 + index = 1.98 * (fahrenheit - (0.55 - 0.0055 * humidity) * (fahrenheit - 58.0)) - 56.83 # If the temperature is below 58°F, use the temperature as the index if fahrenheit < 58: @@ -571,32 +572,6 @@ def summer_simmer_index(self) -> float | None: # Convert the result back to Celsius return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) - @property - def summer_simmer_perception(self) -> str: - """.""" - index = self.summer_simmer_index - - if index is None: - return "Unknown" - elif index < 21.1: - return "Cool" - elif index < 25.0: - return "Slightly Cool" - elif index < 28.3: - return "Comfortable" - elif index < 32.8: - return "Slightly Warm" - elif index < 37.8: - return "Increasing Discomfort" - elif index < 44.4: - return "Extremely Warm" - elif index < 51.7: - return "Danger Of Heatstroke" - elif index < 65.6: - return "Extreme Danger Of Heatstroke" - else: - return "Circulatory Collapse Imminent" - def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self.heating_curve.value is None: From fce3a1a0a8c19d096e009a6260d942f08e97d1d7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 20:42:36 +0100 Subject: [PATCH 105/237] Revert since we don't control the TRVs fully --- custom_components/sat/climate.py | 48 +++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index e782b606..b0355b78 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -348,7 +348,8 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "summer_simmer_index": self._calculate_summer_simmer_index(self._current_temperature, self._current_humidity), + "summer_simmer_index": self.summer_simmer_index, + "summer_simmer_perception": self.summer_simmer_perception, "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, @@ -367,7 +368,7 @@ def extra_state_attributes(self): def current_temperature(self): """Return the sensor temperature.""" if self._thermal_comfort and self._current_humidity is not None: - return self._calculate_summer_simmer_index(self._current_temperature, self._current_humidity) + return self.summer_simmer_index return self._current_temperature @@ -458,7 +459,6 @@ def climate_errors(self) -> List[float]: # Calculate temperature difference for this climate target_temperature = float(state.attributes.get("temperature")) - current_humidity = float(state.attributes.get("current_humidity") or self._current_humidity) current_temperature = float(state.attributes.get("current_temperature") or target_temperature) # Retrieve the overriden sensor temperature if set @@ -467,10 +467,6 @@ def climate_errors(self) -> List[float]: if sensor_state is not None and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]: current_temperature = float(sensor_state.state) - # Calculate the summer simmer index based on the climate temperature - if self._thermal_comfort and current_humidity is not None: - current_temperature = self._calculate_summer_simmer_index(current_temperature, current_humidity) - errors.append(round(target_temperature - current_temperature, 2)) return errors @@ -540,8 +536,8 @@ def relative_modulation_enabled(self) -> bool: return self.max_error > DEADBAND and not self.pulse_width_modulation_enabled - @staticmethod - def _calculate_summer_simmer_index(temperature: float, humidity: float) -> float | None: + @property + def summer_simmer_index(self) -> float | None: """ Calculate the Summer Simmer Index. @@ -553,17 +549,17 @@ def _calculate_summer_simmer_index(temperature: float, humidity: float) -> float Returns: float: Summer Simmer Index in Celsius. """ - # Validate - if temperature is None or humidity is None: + # Make sure we have a valid humidity value + if self._current_humidity is None: return None # Convert temperature to Fahrenheit fahrenheit = TemperatureConverter.convert( - temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + self._current_temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT ) # Calculate Summer Simmer Index - index = 1.98 * (fahrenheit - (0.55 - 0.0055 * humidity) * (fahrenheit - 58.0)) - 56.83 + index = 1.98 * (fahrenheit - (0.55 - 0.0055 * self._current_humidity) * (fahrenheit - 58.0)) - 56.83 # If the temperature is below 58°F, use the temperature as the index if fahrenheit < 58: @@ -572,6 +568,32 @@ def _calculate_summer_simmer_index(temperature: float, humidity: float) -> float # Convert the result back to Celsius return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) + @property + def summer_simmer_perception(self) -> str: + """.""" + index = self.summer_simmer_index + + if index is None: + return "Unknown" + elif index < 21.1: + return "Cool" + elif index < 25.0: + return "Slightly Cool" + elif index < 28.3: + return "Comfortable" + elif index < 32.8: + return "Slightly Warm" + elif index < 37.8: + return "Increasing Discomfort" + elif index < 44.4: + return "Extremely Warm" + elif index < 51.7: + return "Danger Of Heatstroke" + elif index < 65.6: + return "Extreme Danger Of Heatstroke" + else: + return "Circulatory Collapse Imminent" + def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self.heating_curve.value is None: From f2e403d7b2dbfdfe2aceecaad2512bca20a1a3bb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 21:30:28 +0100 Subject: [PATCH 106/237] Removed some debug code --- custom_components/sat/climate.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b0355b78..fc5dae98 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -146,8 +146,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._attr_preset_modes = [PRESET_NONE] + list(self._presets.keys()) self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - _LOGGER.debug(self._attr_preset_modes) - # System Configuration self._attr_name = str(config_entry.data.get(CONF_NAME)) self._attr_id = str(config_entry.data.get(CONF_NAME)).lower() From fd57acec9532e0db36f310c4bd80421682d51060 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 12 Nov 2023 21:37:16 +0100 Subject: [PATCH 107/237] Add some debug logging --- custom_components/sat/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index fc5dae98..b95f4554 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -465,7 +465,12 @@ def climate_errors(self) -> List[float]: if sensor_state is not None and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]: current_temperature = float(sensor_state.state) - errors.append(round(target_temperature - current_temperature, 2)) + # Calculate the error value + error = round(target_temperature - current_temperature, 2) + _LOGGER.debug(f"{climate}: current: {current_temperature}, target: {target_temperature}, error: {error}") + + # Add to the list so we calculate the max. later + errors.append(error) return errors From 52b9997b705c48defd34886beb5a1171ad470750 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 13 Nov 2023 18:18:20 +0100 Subject: [PATCH 108/237] Coefficient derivative fix --- custom_components/sat/heating_curve.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/heating_curve.py b/custom_components/sat/heating_curve.py index d9450580..d65abc99 100644 --- a/custom_components/sat/heating_curve.py +++ b/custom_components/sat/heating_curve.py @@ -36,7 +36,7 @@ def autotune(self, setpoint: float, target_temperature: float, outside_temperatu return coefficient = self.calculate_coefficient(setpoint, target_temperature, outside_temperature) - self._coefficient_derivative = round(coefficient - self._optimal_coefficient, 1) + self._coefficient_derivative = round(coefficient - self._optimal_coefficient, 1) if self._optimal_coefficient else coefficient # Fuzzy logic for when the derivative is positive if self._coefficient_derivative > 1: From 87723f682793e08bcc07c1abd0e9fd98d6b56e62 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Nov 2023 16:35:55 +0100 Subject: [PATCH 109/237] Finetune the PID --- custom_components/sat/climate.py | 7 +++++-- custom_components/sat/pid.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b95f4554..f0d1a91c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -792,8 +792,11 @@ async def _async_control_pid(self, reset: bool = False) -> None: _LOGGER.info("Reached deadband, turning off warming up.") self._warming_up_data = None - # Update the pid controller - self.pid.update(error=max_error, heating_curve_value=self.heating_curve.value) + self.pid.update( + error=max_error, + heating_curve_value=self.heating_curve.value, + boiler_temperature=self.coordinator.boiler_temperature or 0 + ) elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 10a173ba..27bf4e43 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -43,6 +43,7 @@ def reset(self) -> None: self._last_error = 0.0 self._time_elapsed = 0 self._last_updated = monotonic() + self._last_boiler_temperature = 0 self._last_heating_curve_value = 0 # Reset the integral and derivative @@ -53,11 +54,12 @@ def reset(self) -> None: self._times = deque(maxlen=self._history_size) self._errors = deque(maxlen=self._history_size) - def update(self, error: float, heating_curve_value: float) -> None: + 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. :param error: The max error between all the target temperatures and the current temperatures. :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 @@ -76,6 +78,7 @@ def update(self, error: float, heating_curve_value: float) -> None: self._time_elapsed = time_elapsed self._last_error = error + self._last_boiler_temperature = boiler_temperature self._last_heating_curve_value = heating_curve_value def update_reset(self, error: float, heating_curve_value: Optional[float]) -> None: @@ -287,7 +290,13 @@ def integral(self) -> float: @property def derivative(self) -> float: """Return the derivative value.""" - return round(self.kd * self._raw_derivative, 3) + derivative = self.kd * self._raw_derivative + output = self._last_heating_curve_value + self.proportional + self.integral + + if abs(derivative) <= 0 or abs(self._last_boiler_temperature - output) < 3: + return 0 + + return round(derivative, 3) @property def raw_derivative(self) -> float: From ba30a3c3bc77d7c4ec4ba24d0f65d3ac5788a56a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Nov 2023 16:45:56 +0100 Subject: [PATCH 110/237] 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 27bf4e43..6ab9eaf6 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -293,7 +293,7 @@ def derivative(self) -> float: derivative = self.kd * self._raw_derivative output = self._last_heating_curve_value + self.proportional + self.integral - if abs(derivative) <= 0 or abs(self._last_boiler_temperature - output) < 3: + if derivative <= 0 or abs(self._last_boiler_temperature - output) < 3: return 0 return round(derivative, 3) From a422ffe6a441269eb21e44f37915ef4877a0b8fd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 18 Nov 2023 16:48:39 +0100 Subject: [PATCH 111/237] PID refinement --- 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 6ab9eaf6..4150e111 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -293,7 +293,7 @@ def derivative(self) -> float: derivative = self.kd * self._raw_derivative output = self._last_heating_curve_value + self.proportional + self.integral - if derivative <= 0 or abs(self._last_boiler_temperature - output) < 3: + if abs(derivative) > 0 and abs(self._last_boiler_temperature - output) < 3: return 0 return round(derivative, 3) From 95cc0863b18701ab99cfc133cbe04ec753330650 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 16:23:17 +0100 Subject: [PATCH 112/237] Add support for a consumption sensor --- configuration.yaml | 2 +- custom_components/sat/climate.py | 25 ++++---- custom_components/sat/config_flow.py | 8 +++ custom_components/sat/const.py | 6 ++ custom_components/sat/coordinator.py | 29 +++++++++ custom_components/sat/sensor.py | 71 ++++++++++++++++----- custom_components/sat/simulator/__init__.py | 8 +++ custom_components/sat/translations/en.json | 2 + 8 files changed, 121 insertions(+), 30 deletions(-) diff --git a/configuration.yaml b/configuration.yaml index 52d05384..1709f622 100644 --- a/configuration.yaml +++ b/configuration.yaml @@ -13,7 +13,7 @@ homeassistant: climate: - platform: generic_thermostat name: Fake Thermostat - heater: switch.fake_thermostat + heater: input_boolean.fake_thermostat target_sensor: sensor.current_temperature switch: diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index f0d1a91c..82812797 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -896,22 +896,21 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) - if self._coordinator.device_state == DeviceState.ON: - # If the setpoint is too low or the valves are closed or HVAC is off, turn off the heater - if self._setpoint <= MINIMUM_SETPOINT or not self.valves_open or self.hvac_mode == HVACMode.OFF: - await self._coordinator.async_set_heater_state(DeviceState.OFF) - else: - await self._coordinator.async_set_heater_state(DeviceState.ON) - - if self._coordinator.device_state == DeviceState.OFF: - # If the setpoint is high and the valves are open and the HVAC is not off, turn on the heater - if self._setpoint > MINIMUM_SETPOINT and self.valves_open and self.hvac_mode != HVACMode.OFF: - await self._coordinator.async_set_heater_state(DeviceState.ON) - else: - await self._coordinator.async_set_heater_state(DeviceState.OFF) + # If the setpoint is high and the HVAC is not off, turn on the heater + if self._setpoint > MINIMUM_SETPOINT and self.hvac_mode != HVACMode.OFF: + await self.async_set_heater_state(DeviceState.ON) + else: + await self.async_set_heater_state(DeviceState.OFF) self.async_write_ha_state() + async def async_set_heater_state(self, state: DeviceState): + if state == DeviceState.ON and not self.valves_open: + _LOGGER.warning('No valves are open at the moment.') + return await self._coordinator.async_set_heater_state(DeviceState.OFF) + + return await self._coordinator.async_set_heater_state(state) + async def async_set_temperature(self, **kwargs) -> None: """Set the target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 1884856f..c5566b77 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -497,6 +497,14 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: if options.get(CONF_MODE) in [MODE_MQTT, 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( + selector.NumberSelectorConfig(min=0, max=8, step=0.1) + ) + + schema[vol.Required(CONF_MAXIMUM_CONSUMPTION, default=options[CONF_MAXIMUM_CONSUMPTION])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=8, step=0.1) + ) + schema[vol.Required(CONF_CLIMATE_VALVE_OFFSET, default=options[CONF_CLIMATE_VALVE_OFFSET])] = selector.NumberSelector( selector.NumberSelectorConfig(min=-1, max=1, step=0.1) ) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 1c07f3c9..9eb0ca0e 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -60,6 +60,9 @@ CONF_HEATING_SYSTEM = "heating_system" CONF_HEATING_CURVE_COEFFICIENT = "heating_curve_coefficient" +CONF_MINIMUM_CONSUMPTION = "minimum_consumption" +CONF_MAXIMUM_CONSUMPTION = "maximum_consumption" + CONF_AWAY_TEMPERATURE = "away_temperature" CONF_HOME_TEMPERATURE = "home_temperature" CONF_SLEEP_TEMPERATURE = "sleep_temperature" @@ -95,6 +98,9 @@ CONF_MINIMUM_SETPOINT: 10, CONF_FORCE_PULSE_WIDTH_MODULATION: False, + CONF_MINIMUM_CONSUMPTION: 0, + CONF_MAXIMUM_CONSUMPTION: 0, + CONF_MQTT_TOPIC: "OTGW", CONF_DUTY_CYCLE: "00:13:00", CONF_SAMPLE_TIME: "00:01:00", diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2be53050..2ef67d4b 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -107,6 +107,35 @@ def relative_modulation_value(self) -> float | None: def boiler_capacity(self) -> float | None: return None + @property + def minimum_boiler_capacity(self) -> float | None: + if (minimum_relative_modulation_value := self.minimum_relative_modulation_value) is None: + return None + + if (boiler_capacity := self.boiler_capacity) is None: + return None + + if boiler_capacity == 0: + return 0 + + return boiler_capacity / (100 / minimum_relative_modulation_value) + + @property + def boiler_power(self) -> float | None: + if (boiler_capacity := self.boiler_capacity) is None: + return None + + if (minimum_boiler_capacity := self.minimum_boiler_capacity) is None: + return None + + if (relative_modulation_value := self.relative_modulation_value) is None: + return None + + if self.flame_active is False: + return 0 + + return ((boiler_capacity - minimum_boiler_capacity) / 100) * relative_modulation_value + @property def minimum_relative_modulation_value(self) -> float | None: return None diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 7bdbff46..1f0015cd 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -5,12 +5,12 @@ from homeassistant.components.sensor import SensorEntity, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower, UnitOfTemperature +from homeassistant.const import UnitOfPower, UnitOfTemperature, UnitOfVolume from homeassistant.core import HomeAssistant, Event from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, MODE_SIMULATOR +from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, MODE_SIMULATOR, CONF_MINIMUM_CONSUMPTION, CONF_MAXIMUM_CONSUMPTION from .coordinator import SatDataUpdateCoordinator from .entity import SatEntity from .serial import sensor as serial_sensor @@ -45,6 +45,9 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_relative_modulation_management: _async_add_entities([SatCurrentPowerSensor(coordinator, _config_entry)]) + if _config_entry.options.get(CONF_MINIMUM_CONSUMPTION) > 0 and _config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) > 0: + _async_add_entities([SatCurrentConsumptionSensor(coordinator, _config_entry)]) + class SatCurrentPowerSensor(SatEntity, SensorEntity): @@ -65,35 +68,71 @@ def native_unit_of_measurement(self): @property def available(self): """Return availability of the sensor.""" - return self._coordinator.relative_modulation_value is not None + return self._coordinator.boiler_power is not None @property def native_value(self) -> float: """Return the state of the device in native units. - In this case, the state represents the current capacity of the boiler in kW. + In this case, the state represents the current power of the boiler in kW. """ - # If the flame is off, return 0 kW - if self._coordinator.flame_active is False: - return 0 + return self._coordinator.boiler_power + + @property + def unique_id(self) -> str: + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" + + +class SatCurrentConsumptionSensor(SatEntity, SensorEntity): + + def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEntry): + super().__init__(coordinator, config_entry) + + self._minimum_consumption = self._config_entry.options.get(CONF_MINIMUM_CONSUMPTION) + self._maximum_consumption = self._config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) + + @property + def name(self) -> str: + return f"Boiler Current Consumption {self._config_entry.data.get(CONF_NAME)} (Boiler)" + + @property + def device_class(self): + """Return the device class.""" + return SensorDeviceClass.GAS + + @property + def native_unit_of_measurement(self): + """Return the unit of measurement.""" + return UnitOfVolume.CUBIC_METERS - # Get the relative modulation level from the data - relative_modulation = self._coordinator.relative_modulation_value + @property + def available(self): + """Return availability of the sensor.""" + return self._coordinator.relative_modulation_value is not None - # Get the boiler capacity from the data - if (boiler_capacity := self._coordinator.boiler_capacity) == 0: + @property + def native_value(self) -> float: + """Return the state of the device in native units. + + In this case, the state represents the current consumption of the boiler in m³/h. + """ + + if self._coordinator.device_active is False: return 0 - # Get and calculate the minimum capacity from the data - minimum_capacity = boiler_capacity / (100 / self._coordinator.minimum_relative_modulation_value) + if self._coordinator.flame_active is False: + return self._minimum_consumption + + gas_consumption_per_percentage = (self._maximum_consumption - self._minimum_consumption) / 100 + relative_modulation_value = self._coordinator.relative_modulation_value - # Calculate and return the current capacity in kW - return minimum_capacity + (((boiler_capacity - minimum_capacity) / 100) * relative_modulation) + return round(relative_modulation_value * gas_consumption_per_percentage, 3) @property def unique_id(self) -> str: """Return a unique ID to use for this entity.""" - return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-power" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-boiler-current-consumption" class SatHeatingCurveSensor(SatEntity, SensorEntity): diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 04484149..15348d58 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -38,6 +38,10 @@ def supports_setpoint_management(self) -> bool: def supports_maximum_setpoint_management(self): return True + @property + def supports_relative_modulation_management(self) -> float | None: + return True + @property def setpoint(self) -> float: return self._setpoint @@ -54,6 +58,10 @@ def device_active(self) -> bool: def flame_active(self) -> bool: return self.device_active and self.target > self._boiler_temperature + @property + def relative_modulation_value(self) -> float | None: + return 100 if self.flame_active else 0 + async def async_set_heater_state(self, state: DeviceState) -> None: self._started_on = monotonic() if state == DeviceState.ON else None diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 3016bebe..0cef5efc 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -177,6 +177,8 @@ "data": { "simulation": "Simulation", "thermal_comfort": "Thermal Comfort", + "minimum_consumption": "Minimum Consumption", + "maximum_consumption": "Maximum Consumption", "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", "force_pulse_width_modulation": "Force Pulse Width Modulation", From 052c507c58655d30b82274ec07e1fdc994aa1b20 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 16:26:53 +0100 Subject: [PATCH 113/237] Make sure we account for the boiler temperature not being set yet --- custom_components/sat/pid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 4150e111..0088088b 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -43,8 +43,8 @@ def reset(self) -> None: self._last_error = 0.0 self._time_elapsed = 0 self._last_updated = monotonic() - self._last_boiler_temperature = 0 self._last_heating_curve_value = 0 + self._last_boiler_temperature = None # Reset the integral and derivative self._integral = 0.0 @@ -293,7 +293,7 @@ def derivative(self) -> float: derivative = self.kd * self._raw_derivative output = self._last_heating_curve_value + self.proportional + self.integral - if abs(derivative) > 0 and abs(self._last_boiler_temperature - output) < 3: + if self._last_boiler_temperature is not None and abs(derivative) > 0 and abs(self._last_boiler_temperature - output) < 3: return 0 return round(derivative, 3) From e1ee012478c7053a4ed0a9a46569606cca45bb58 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 17:39:11 +0100 Subject: [PATCH 114/237] Fixed the binary sensors of the MQTT integration --- custom_components/sat/mqtt/__init__.py | 34 ++++++++++++++------------ 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index d490f825..97f7f688 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -4,6 +4,7 @@ import typing from homeassistant.components import mqtt +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry @@ -75,15 +76,15 @@ def supports_relative_modulation_management(self): @property def device_active(self) -> bool: - return bool(self.data.get(DATA_CENTRAL_HEATING)) + return self.data.get(DATA_CENTRAL_HEATING) == DeviceState.ON @property def flame_active(self) -> bool: - return bool(self.data.get(DATA_FLAME_ACTIVE)) + return bool(self.data.get(DATA_FLAME_ACTIVE)) == DeviceState.ON @property def hot_water_active(self) -> bool: - return bool(self.data.get(DATA_DHW_ENABLE)) + return bool(self.data.get(DATA_DHW_ENABLE)) == DeviceState.ON @property def setpoint(self) -> float | None: @@ -147,17 +148,18 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: await self._send_command("PM=48") entities = list(filter(lambda entity: entity is not None, [ - self._get_entity_id(DATA_FLAME_ACTIVE), - self._get_entity_id(DATA_DHW_SETPOINT), - self._get_entity_id(DATA_CONTROL_SETPOINT), - self._get_entity_id(DATA_DHW_ENABLE), - self._get_entity_id(DATA_REL_MOD_LEVEL), - self._get_entity_id(DATA_BOILER_TEMPERATURE), - self._get_entity_id(DATA_CENTRAL_HEATING), - self._get_entity_id(DATA_BOILER_CAPACITY), - self._get_entity_id(DATA_REL_MIN_MOD_LEVEL), - self._get_entity_id(DATA_DHW_SETPOINT_MINIMUM), - self._get_entity_id(DATA_DHW_SETPOINT_MAXIMUM), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), + + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), ])) for entity_id in entities: @@ -201,8 +203,8 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) - def _get_entity_id(self, key: str): - return self._entity_registry.async_get_entity_id(SENSOR_DOMAIN, MQTT_DOMAIN, f"{self._node_id}-{key}") + def _get_entity_id(self, domain: str, key: str): + return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") async def _on_state_change(self, entity_id: str, state: State): key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) From ea08e16417077311bbf1d18eb30762ba93670d70 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 17:43:56 +0100 Subject: [PATCH 115/237] Some casting --- custom_components/sat/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 1f0015cd..2362876a 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_relative_modulation_management: _async_add_entities([SatCurrentPowerSensor(coordinator, _config_entry)]) - if _config_entry.options.get(CONF_MINIMUM_CONSUMPTION) > 0 and _config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) > 0: + if float(_config_entry.options.get(CONF_MINIMUM_CONSUMPTION) or 0) > 0 and float(_config_entry.options.get(CONF_MAXIMUM_CONSUMPTION) or 0) > 0: _async_add_entities([SatCurrentConsumptionSensor(coordinator, _config_entry)]) From 91b86e17a2c884f484194fbe7b04a10cf32fac79 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 17:48:14 +0100 Subject: [PATCH 116/237] Added legacy value for DATA_REL_MIN_MOD_LEVEL --- custom_components/sat/mqtt/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 97f7f688..875ff932 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -25,6 +25,7 @@ DATA_CENTRAL_HEATING = "centralheating" DATA_BOILER_CAPACITY = "MaxCapacityMinModLevel_hb_u8" DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevel_lb_u8" +DATA_REL_MIN_MOD_LEVELL = "MaxCapacityMinModLevell_lb_u8" DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" @@ -140,6 +141,10 @@ def minimum_relative_modulation_value(self) -> float | None: if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: return float(value) + # Legacy + if (value := self.data.get(DATA_REL_MIN_MOD_LEVELL)) is not None: + return float(value) + return super().boiler_capacity async def async_added_to_hass(self, climate: SatClimate) -> None: @@ -158,6 +163,7 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), ])) From 4925744e8d90e1abb84c870695ea0389735a97f1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 19 Nov 2023 23:12:50 +0100 Subject: [PATCH 117/237] Improve Kd math --- 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 0088088b..ca119ef1 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -273,7 +273,7 @@ def kd(self) -> float | None: if self._last_heating_curve_value is None: return 0 - return round(self._last_heating_curve_value * 2720, 6) + return round(self._last_heating_curve_value * 2980, 6) return float(self._kd) From c894f1618275613ea16ef07d02c505df1502815e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 22 Nov 2023 17:52:19 +0100 Subject: [PATCH 118/237] Also update heating curve when the outside temperature changes --- custom_components/sat/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 82812797..c98037d9 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -630,6 +630,7 @@ async def _async_outside_entity_changed(self, event: Event) -> None: if event.data.get("new_state") is None: return + await self._async_control_pid() await self.async_control_heating_loop() async def _async_humidity_sensor_changed(self, event: Event) -> None: From a2af341c6414896524a88d1401753010691ae9d8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 22 Nov 2023 17:52:46 +0100 Subject: [PATCH 119/237] Some logging --- 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 c98037d9..aeaaafa2 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -630,6 +630,9 @@ async def _async_outside_entity_changed(self, event: Event) -> None: if event.data.get("new_state") is None: return + _LOGGER.debug("Outside Sensor Changed.") + self.async_write_ha_state() + await self._async_control_pid() await self.async_control_heating_loop() From c0badeacc8f967898225205f947629fb478e839b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 22 Nov 2023 18:41:01 +0100 Subject: [PATCH 120/237] Added support for the Automatic Gains Value --- custom_components/sat/config_flow.py | 4 ++++ custom_components/sat/const.py | 2 ++ custom_components/sat/pid.py | 14 +++++++++++--- custom_components/sat/translations/en.json | 1 + custom_components/sat/util.py | 12 +++++++++++- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index c5566b77..8d38f6a4 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -428,6 +428,10 @@ async def async_step_general(self, _user_input=None) -> FlowResult: 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 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 9eb0ca0e..184f02d3 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -47,6 +47,7 @@ CONF_SAMPLE_TIME = "sample_time" CONF_AUTOMATIC_GAINS = "automatic_gains" CONF_AUTOMATIC_DUTY_CYCLE = "automatic_duty_cycle" +CONF_AUTOMATIC_GAINS_VALUE = "automatic_gains_value" CONF_CLIMATE_VALVE_OFFSET = "climate_valve_offset" CONF_SENSOR_MAX_VALUE_AGE = "sensor_max_value_age" CONF_OVERSHOOT_PROTECTION = "overshoot_protection" @@ -81,6 +82,7 @@ CONF_AUTOMATIC_GAINS: True, CONF_AUTOMATIC_DUTY_CYCLE: True, + CONF_AUTOMATIC_GAINS_VALUE: 5.0, CONF_OVERSHOOT_PROTECTION: False, CONF_SECONDARY_CLIMATES: [], diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index ca119ef1..3f58037a 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -10,7 +10,9 @@ class PID: """A proportional-integral-derivative (PID) controller.""" - def __init__(self, kp: float, ki: float, kd: float, + def __init__(self, + heating_system: str, automatic_gain_value: float, + kp: float, ki: float, kd: float, max_history: int = 2, deadband: float = DEADBAND, automatic_gains: bool = False, @@ -19,6 +21,8 @@ def __init__(self, kp: float, ki: float, kd: float, """ Initialize the PID controller. + :param heating_system: The type of heating system, either "underfloor" or "radiator" + :param automatic_gain_value: The value to finetune the aggression value. :param kp: The proportional gain of the PID controller. :param ki: The integral gain of the PID controller. :param kd: The derivative gain of the PID controller. @@ -32,7 +36,9 @@ def __init__(self, kp: float, ki: float, kd: float, self._kd = kd 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._last_interval_updated = monotonic() self._sample_time_limit = max(sample_time_limit, 1) self._integral_time_limit = max(integral_time_limit, 1) @@ -248,7 +254,8 @@ def last_updated(self) -> float: def kp(self) -> float | None: """Return the value of kp based on the current configuration.""" if self._automatic_gains: - return round(self._last_heating_curve_value * 1.65, 6) + automatic_gain_value = 0.243 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 0.33 + return round(self._automatic_gains_value * automatic_gain_value * self._last_heating_curve_value, 6) return float(self._kp) @@ -273,7 +280,8 @@ def kd(self) -> float | None: if self._last_heating_curve_value is None: return 0 - return round(self._last_heating_curve_value * 2980, 6) + aggression_value = 438.2 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 596 + return round(self._automatic_gains_value * aggression_value * self._last_heating_curve_value, 6) return float(self._kd) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 0cef5efc..2aa743ee 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -145,6 +145,7 @@ "proportional": "Proportional (kP)", "maximum_setpoint": "Maximum Setpoint", "window_sensors": "Contact Sensors", + "automatic_gains_value": "Automatic Gains Value", "heating_curve_coefficient": "Heating Curve Coefficient", "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", "sync_with_thermostat": "Synchronize setpoint with thermostat" diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 9c2b8f16..9c228e86 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -47,11 +47,21 @@ def create_pid_controller(config_options) -> PID: kp = float(config_options.get(CONF_PROPORTIONAL)) ki = float(config_options.get(CONF_INTEGRAL)) kd = float(config_options.get(CONF_DERIVATIVE)) + + heating_system = config_options.get(CONF_HEATING_SYSTEM) automatic_gains = bool(config_options.get(CONF_AUTOMATIC_GAINS)) + automatic_gains_value = float(config_options.get(CONF_AUTOMATIC_GAINS_VALUE)) sample_time_limit = convert_time_str_to_seconds(config_options.get(CONF_SAMPLE_TIME)) # Return a new PID controller instance with the given configuration options - return PID(kp=kp, ki=ki, kd=kd, automatic_gains=automatic_gains, sample_time_limit=sample_time_limit) + return PID( + heating_system=heating_system, + automatic_gain_value=automatic_gains_value, + + kp=kp, ki=ki, kd=kd, + automatic_gains=automatic_gains, + sample_time_limit=sample_time_limit + ) def create_heating_curve_controller(config_data, config_options) -> HeatingCurve: From c25e7089cc12b7adca68b48ac18355d0838ec99f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 26 Nov 2023 18:32:20 +0100 Subject: [PATCH 121/237] Use the average of the boiler temperature of the past 5 minutes --- custom_components/sat/climate.py | 2 +- custom_components/sat/pid.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index aeaaafa2..5d2e3976 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -799,7 +799,7 @@ async def _async_control_pid(self, reset: bool = False) -> None: self.pid.update( error=max_error, heating_curve_value=self.heating_curve.value, - boiler_temperature=self.coordinator.boiler_temperature or 0 + boiler_temperature=self._coordinator.boiler_temperature ) elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 3f58037a..33a431cb 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -1,4 +1,6 @@ +import logging from collections import deque +from datetime import datetime, timedelta from time import monotonic from typing import Optional @@ -6,6 +8,10 @@ from .const import * +_LOGGER = logging.getLogger(__name__) + +MAX_BOILER_TEMPERATURE_AGE = 300 + class PID: """A proportional-integral-derivative (PID) controller.""" @@ -57,6 +63,7 @@ def reset(self) -> None: self._raw_derivative = 0.0 # Reset all lists + self._boiler_temperatures = [] self._times = deque(maxlen=self._history_size) self._errors = deque(maxlen=self._history_size) @@ -77,6 +84,7 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f return self.update_integral(error, heating_curve_value, True) + self.update_boiler_temperature(boiler_temperature) self.update_derivative(error) self.update_history_size() @@ -84,7 +92,6 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f self._time_elapsed = time_elapsed self._last_error = error - self._last_boiler_temperature = boiler_temperature self._last_heating_curve_value = heating_curve_value def update_reset(self, error: float, heating_curve_value: Optional[float]) -> None: @@ -335,3 +342,26 @@ def num_errors(self) -> int: def history_size(self) -> int: """Return the number of values that we store.""" return int(self._history_size) + + def update_boiler_temperature(self, boiler_temperature: float): + current_time = datetime.now() + + # Make sure we have valid value + if boiler_temperature is not None: + self._boiler_temperatures.append((current_time, 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() + + # Not able to use if we do not have at least 2 values + if len(self._boiler_temperatures) < 2: + return + + # 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:]) + ) + + # Store it for later use + self._last_boiler_temperature = round(difference_boiler_temperature_sum / (len(self._boiler_temperatures) - 1), 2) From 974367658e80cf07ad0e7cad3dd16a99d9f8816f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 26 Nov 2023 18:32:26 +0100 Subject: [PATCH 122/237] Improve derivative --- custom_components/sat/pid.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 33a431cb..51845692 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -308,8 +308,12 @@ def derivative(self) -> float: derivative = self.kd * self._raw_derivative output = self._last_heating_curve_value + self.proportional + self.integral - if self._last_boiler_temperature is not None and abs(derivative) > 0 and abs(self._last_boiler_temperature - output) < 3: - return 0 + if self._last_boiler_temperature is not None: + if abs(self._last_error) > 0.1 and abs(self._last_boiler_temperature - output) < 3: + return 0 + + if abs(self._last_error) <= 0.1 and abs(self._last_boiler_temperature - output) < 7: + return 0 return round(derivative, 3) From dfc7b996dea3a958f34d9800f15fc3d32e5ebbbf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 19:44:16 +0100 Subject: [PATCH 123/237] Exclude our own binary window sensor from being able to be selected --- custom_components/sat/config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 8d38f6a4..8165ba9e 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -17,7 +17,7 @@ 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 +from homeassistant.helpers import selector, device_registry, entity_registry from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from pyotgw import OpenThermGateway @@ -435,10 +435,15 @@ async def async_step_general(self, _user_input=None) -> FlowResult: if not options[CONF_AUTOMATIC_DUTY_CYCLE]: schema[vol.Required(CONF_DUTY_CYCLE, default=options[CONF_DUTY_CYCLE])] = selector.TimeSelector() + entities = entity_registry.async_get(self.hass) + device_name = self._config_entry.data.get(CONF_NAME) + window_id = entities.async_get_entity_id(BINARY_SENSOR_DOMAIN, DOMAIN, f"{device_name.lower()}-window-sensor") + schema[vol.Optional(CONF_WINDOW_SENSORS, default=options[CONF_WINDOW_SENSORS])] = selector.EntitySelector( selector.EntitySelectorConfig( multiple=True, domain=BINARY_SENSOR_DOMAIN, + exclude_entities=[window_id] if window_id else [], device_class=[BinarySensorDeviceClass.DOOR, BinarySensorDeviceClass.WINDOW, BinarySensorDeviceClass.GARAGE_DOOR] ) ) From 3b9ef9c470f04cf523ba1cbd6baa93354f290a7b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 19:49:19 +0100 Subject: [PATCH 124/237] Typo? --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 2aa743ee..474011d7 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -53,7 +53,7 @@ "title": "Configure sensors", "description": "Please select the sensors that will be used to track the temperature.", "data": { - "humidity_sensor": "Humidity Sensor", + "humidity_sensor_entity_id": "Humidity Sensor", "inside_sensor_entity_id": "Inside Sensor Entity", "outside_sensor_entity_id": "Outside Sensor Entity" } From 92d19e10622a66942729d14d2277844b05f189dd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 19:49:38 +0100 Subject: [PATCH 125/237] Make sure we are consistent --- custom_components/sat/translations/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 474011d7..ef7380da 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -53,9 +53,9 @@ "title": "Configure sensors", "description": "Please select the sensors that will be used to track the temperature.", "data": { - "humidity_sensor_entity_id": "Humidity Sensor", "inside_sensor_entity_id": "Inside Sensor Entity", - "outside_sensor_entity_id": "Outside Sensor Entity" + "outside_sensor_entity_id": "Outside Sensor Entity", + "humidity_sensor_entity_id": "Humidity Sensor Entity" } }, "heating_system": { From 946702c82e4c14de0aeb115c98ddc2dd5074298c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 22:59:57 +0100 Subject: [PATCH 126/237] Add some descriptions to the fields --- custom_components/sat/translations/en.json | 36 +++++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index ef7380da..1fae077a 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -149,6 +149,18 @@ "heating_curve_coefficient": "Heating Curve Coefficient", "duty_cycle": "Maximum Duty Cycle for Pulse Width Modulation", "sync_with_thermostat": "Synchronize setpoint with thermostat" + }, + "data_description": { + "integral": "The integral term (kI) in the PID controller, responsible for reducing steady-state error.", + "derivative": "The derivative term (kD) in the PID controller, responsible for mitigating overshooting.", + "proportional": "The proportional term (kP) in the PID controller, responsible for the immediate response to errors.", + "maximum_setpoint": "The optimal temperature for efficient boiler operation.", + "overdrive_setpoint": "The temperature for overdrive mode, used when a room needs to warm up quickly.", + "window_sensors": "Contact Sensors that trigger the system to react when a window or door is open for a period of time.", + "automatic_gains_value": "The value used for automatic gains in the PID controller.", + "heating_curve_coefficient": "The coefficient used to adjust the heating curve.", + "duty_cycle": "The maximum duty cycle for Pulse Width Modulation (PWM), controlling the boiler's on-off cycles.", + "sync_with_thermostat": "Synchronize setpoint with thermostat to ensure coordinated temperature control." } }, "presets": { @@ -169,21 +181,37 @@ "data": { "automatic_duty_cycle": "Automatic duty cycle", "overshoot_protection": "Overshoot Protection (with PWM)", - "window_minimum_open_time": "Minimum time for window to be open before reacting", - "sensor_max_value_age": "Temperature Sensor maximum value age (before considering stall)" + "window_minimum_open_time": "Minimum time for window to be open", + "sensor_max_value_age": "Temperature Sensor maximum value age" + }, + "data_description": { + "automatic_duty_cycle": "Enable or disable automatic duty cycle for Pulse Width Modulation (PWM).", + "overshoot_protection": "Enable overshoot protection with Pulse Width Modulation (PWM) to prevent boiler temperature overshooting.", + "window_minimum_open_time": "The minimum time a window must be open before the system reacts.", + "sensor_max_value_age": "The maximum age of the temperature sensor value before considering it as a stall." } }, "advanced": { "title": "Advanced", "data": { "simulation": "Simulation", + "sample_time": "Sample Time", "thermal_comfort": "Thermal Comfort", "minimum_consumption": "Minimum Consumption", "maximum_consumption": "Maximum Consumption", "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", - "force_pulse_width_modulation": "Force Pulse Width Modulation", - "sample_time": "Minimum time interval between updates to the PID controller" + "force_pulse_width_modulation": "Force Pulse Width Modulation" + }, + "data_description": { + "simulation": "Enable simulation mode to prevent sending commands to the boiler.", + "thermal_comfort": "Enable the use of the Simmer Index for thermal comfort adjustment.", + "minimum_consumption": "The minimum gas consumption when the boiler is active.", + "maximum_consumption": "The maximum gas consumption when the boiler is active.", + "climate_valve_offset": "Offset to adjust the opening degree of the climate valve.", + "target_temperature_step": "Adjust the target temperature step for fine-tuning comfort levels.", + "force_pulse_width_modulation": "Force the use of Pulse Width Modulation (PWM) for boiler control.", + "sample_time": "The minimum time interval between updates to the PID controller." } } } From bea0f51e3b3a83dcca665331e10cad4fc5740bab Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 23:00:20 +0100 Subject: [PATCH 127/237] Make sure we migrate the overshoot_protection value properly --- custom_components/sat/__init__.py | 6 ++++++ custom_components/sat/climate.py | 3 ++- custom_components/sat/config_flow.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index 007ad7b1..d9004115 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -133,6 +133,12 @@ async def async_migrate_entry(_hass: HomeAssistant, _entry: ConfigEntry) -> bool if _entry.version < 4: if _entry.data.get("window_sensor") is not None: new_data[CONF_WINDOW_SENSORS] = [_entry.data.get("window_sensor")] + del new_options["window_sensor"] + + if _entry.version < 5: + if _entry.options.get("overshoot_protection") is not None: + new_data[CONF_OVERSHOOT_PROTECTION] = _entry.options.get("overshoot_protection") + del new_options["overshoot_protection"] _entry.version = SatFlowHandler.VERSION _hass.config_entries.async_update_entry(_entry, data=new_data, options=new_options) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5d2e3976..b2f02899 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -889,7 +889,8 @@ async def async_control_heating_loop(self, _time=None) -> None: await self._coordinator.async_control_heating_loop(self) # Pulse Width Modulation - await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint) + if self.pulse_width_modulation_enabled: + await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint) # 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 8165ba9e..e31007fc 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -34,7 +34,7 @@ class SatFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SAT.""" - VERSION = 4 + VERSION = 5 calibration = None overshoot_protection_value = None From 35d57c0f353f8b72508d1d902244e98f72b39de8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 23:35:08 +0100 Subject: [PATCH 128/237] Use the boiler temperature as base setpoint for PWM Duty Cycle calculation --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/pwm.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index b2f02899..4506503a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -469,7 +469,7 @@ def climate_errors(self) -> List[float]: error = round(target_temperature - current_temperature, 2) _LOGGER.debug(f"{climate}: current: {current_temperature}, target: {target_temperature}, error: {error}") - # Add to the list so we calculate the max. later + # Add to the list, so we calculate the max. later errors.append(error) return errors @@ -890,7 +890,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint) + await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint, 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/pwm.py b/custom_components/sat/pwm.py index afdf3ec7..03395d73 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -43,7 +43,7 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, requested_setpoint: float, minimum_setpoint: float) -> None: + async def update(self, requested_setpoint: float, minimum_setpoint: float, boiler_temperature: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float) -> No return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, minimum_setpoint) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature + 1) if self._duty_cycle is None: self._state = PWMState.IDLE From 07922e794a245229d0aca304b041dac74bb3d25d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 23:37:53 +0100 Subject: [PATCH 129/237] Make sure we always have a valid value --- custom_components/sat/pwm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 03395d73..ba693f3f 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature + 1) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 1) if self._duty_cycle is None: self._state = PWMState.IDLE From f9d21fce080ebd5893baba560a36dcf2bb4a11f5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 23:42:46 +0100 Subject: [PATCH 130/237] Fallback to the regular minimum setpoint when we do not have any --- custom_components/sat/pwm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index ba693f3f..63536c38 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 1) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or minimum_setpoint) if self._duty_cycle is None: self._state = PWMState.IDLE From bb9fb737586cd85b1b59730da5e9416f07efe0d7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 27 Nov 2023 23:56:13 +0100 Subject: [PATCH 131/237] Remove unused label --- custom_components/sat/translations/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 1fae077a..c1351172 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -155,7 +155,6 @@ "derivative": "The derivative term (kD) in the PID controller, responsible for mitigating overshooting.", "proportional": "The proportional term (kP) in the PID controller, responsible for the immediate response to errors.", "maximum_setpoint": "The optimal temperature for efficient boiler operation.", - "overdrive_setpoint": "The temperature for overdrive mode, used when a room needs to warm up quickly.", "window_sensors": "Contact Sensors that trigger the system to react when a window or door is open for a period of time.", "automatic_gains_value": "The value used for automatic gains in the PID controller.", "heating_curve_coefficient": "The coefficient used to adjust the heating curve.", From 2597d16c6f937f82ab7b0a5e182ae6ffd9ed9b6c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 11:32:47 +0100 Subject: [PATCH 132/237] Additional logging --- custom_components/sat/pwm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 63536c38..cd90ff9a 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -91,6 +91,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: flo self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) + _LOGGER.debug("Minimum Setpoint %.1f", minimum_setpoint) _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) _LOGGER.debug("Calculated duty cycle %.2f%%", self._last_duty_cycle_percentage * 100) From 02f29ff686a1b442daadc6388b82fe7a82e7c316 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 11:40:50 +0100 Subject: [PATCH 133/237] Added support for an adjustable maximum_relative_modulation --- custom_components/sat/climate.py | 3 ++- custom_components/sat/config_flow.py | 4 ++++ custom_components/sat/const.py | 2 ++ custom_components/sat/translations/en.json | 4 +++- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 4506503a..c58629f5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -163,6 +163,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._thermal_comfort = bool(config_options.get(CONF_THERMAL_COMFORT)) self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._maximum_relative_modulation = config_options.get(CONF_MAXIMUM_RELATIVE_MODULATION) self._sync_climates_with_preset = bool(config_options.get(CONF_SYNC_CLIMATES_WITH_PRESET)) 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)) @@ -836,7 +837,7 @@ async def _async_control_relative_modulation(self) -> None: """Control the relative modulation value based on the conditions""" if self._coordinator.supports_relative_modulation_management: await self._coordinator.async_set_control_max_relative_modulation( - MAXIMUM_RELATIVE_MOD if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD + self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD ) async def _async_update_rooms_from_climates(self) -> None: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index e31007fc..d70e14d8 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -522,6 +522,10 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: selector.NumberSelectorConfig(min=0.1, max=1, step=0.05) ) + schema[vol.Required(CONF_MAXIMUM_RELATIVE_MODULATION, default=options[CONF_MAXIMUM_RELATIVE_MODULATION])] = selector.NumberSelector( + selector.NumberSelectorConfig(min=0, max=100, step=1) + ) + schema[vol.Required(CONF_SAMPLE_TIME, default=options[CONF_SAMPLE_TIME])] = selector.TimeSelector() return self.async_show_form( diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 184f02d3..7403d8b9 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -32,6 +32,7 @@ CONF_SIMULATED_WARMING_UP = "simulated_warming_up" CONF_MINIMUM_SETPOINT = "minimum_setpoint" CONF_MAXIMUM_SETPOINT = "maximum_setpoint" +CONF_MAXIMUM_RELATIVE_MODULATION = "maximum_relative_modulation" CONF_SECONDARY_CLIMATES = "secondary_climates" CONF_MQTT_TOPIC = "mqtt_topic" CONF_MAIN_CLIMATES = "main_climates" @@ -98,6 +99,7 @@ CONF_SIMULATED_COOLING: 5, CONF_MINIMUM_SETPOINT: 10, + CONF_MAXIMUM_RELATIVE_MODULATION: 100, CONF_FORCE_PULSE_WIDTH_MODULATION: False, CONF_MINIMUM_CONSUMPTION: 0, diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index c1351172..f6ee05b1 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -200,6 +200,7 @@ "maximum_consumption": "Maximum Consumption", "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", + "maximum_relative_modulation": "Maximum Relative Modulation", "force_pulse_width_modulation": "Force Pulse Width Modulation" }, "data_description": { @@ -210,7 +211,8 @@ "climate_valve_offset": "Offset to adjust the opening degree of the climate valve.", "target_temperature_step": "Adjust the target temperature step for fine-tuning comfort levels.", "force_pulse_width_modulation": "Force the use of Pulse Width Modulation (PWM) for boiler control.", - "sample_time": "The minimum time interval between updates to the PID controller." + "sample_time": "The minimum time interval between updates to the PID controller.", + "maximum_relative_modulation": "The maximum relative modulation value, representing the highest modulation level for a efficient heating system." } } } From 292180e2bf080ead816ee032adc7690a49caa102 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 11:45:56 +0100 Subject: [PATCH 134/237] Make sure we always set to at least the minimum setpoint --- custom_components/sat/pwm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index cd90ff9a..5211c1da 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or minimum_setpoint) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 0) if self._duty_cycle is None: self._state = PWMState.IDLE @@ -83,9 +83,10 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset + minimum_setpoint = max(base_offset, boiler_temperature) self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) From edee54b92f8e8f62fecc9de96c55030e8717ed5a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 11:52:47 +0100 Subject: [PATCH 135/237] Make sure we still use the fixed minimum setpoint as a real minimum --- custom_components/sat/pwm.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 5211c1da..10e7958e 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 0) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, minimum_setpoint, boiler_temperature or 0) if self._duty_cycle is None: self._state = PWMState.IDLE @@ -83,10 +83,10 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, requested_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset - minimum_setpoint = max(base_offset, boiler_temperature) + minimum_setpoint = max(minimum_setpoint, boiler_temperature) self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) From 57c13194ad363526ec7a25754b54546da98804a9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 11:56:02 +0100 Subject: [PATCH 136/237] Translation improvements --- custom_components/sat/translations/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index f6ee05b1..0761443d 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -212,7 +212,7 @@ "target_temperature_step": "Adjust the target temperature step for fine-tuning comfort levels.", "force_pulse_width_modulation": "Force the use of Pulse Width Modulation (PWM) for boiler control.", "sample_time": "The minimum time interval between updates to the PID controller.", - "maximum_relative_modulation": "The maximum relative modulation value, representing the highest modulation level for a efficient heating system." + "maximum_relative_modulation": "Representing the highest modulation level for an efficient heating system." } } } From 596cf15405ccb0f5293d41cdeb9fddcb56d9b02f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 12:42:06 +0100 Subject: [PATCH 137/237] Add one so we are never zero --- custom_components/sat/pwm.py | 2 +- tests/test_climate.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 10e7958e..1b658239 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -88,7 +88,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: flo base_offset = self._heating_curve.base_offset minimum_setpoint = max(minimum_setpoint, boiler_temperature) - self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset + 1) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) diff --git a/tests/test_climate.py b/tests/test_climate.py index 4a230612..69978b3d 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -50,8 +50,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.heating_curve.value == 32.6 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 36.17 - assert climate.pwm.duty_cycle == (325, 574) + assert climate.pwm.last_duty_cycle_percentage == 35.0 + assert climate.pwm.duty_cycle == (314, 585) @pytest.mark.parametrize(*[ @@ -93,8 +93,8 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.requested_setpoint == 30.597 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 11.03 - assert climate.pwm.duty_cycle == (180, 1452) + assert climate.pwm.last_duty_cycle_percentage == 10.68 + assert climate.pwm.duty_cycle == (180, 1505) @pytest.mark.parametrize(*[ @@ -136,5 +136,5 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.requested_setpoint == 37.397 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 73.89 - assert climate.pwm.duty_cycle == (665, 234) + assert climate.pwm.last_duty_cycle_percentage == 68.9 + assert climate.pwm.duty_cycle == (620, 279) From 3401338f56c59cf8d4ec7502aab5be722d9dc805 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 12:50:33 +0100 Subject: [PATCH 138/237] Make sure the minimum setpoint is at least 1 for calculation --- custom_components/sat/pwm.py | 4 ++-- tests/test_climate.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 1b658239..c8ca582b 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -86,9 +86,9 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" base_offset = self._heating_curve.base_offset - minimum_setpoint = max(minimum_setpoint, boiler_temperature) + minimum_setpoint = max(minimum_setpoint, boiler_temperature) or 1 - self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset + 1) + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) diff --git a/tests/test_climate.py b/tests/test_climate.py index 69978b3d..4a230612 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -50,8 +50,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.heating_curve.value == 32.6 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 35.0 - assert climate.pwm.duty_cycle == (314, 585) + assert climate.pwm.last_duty_cycle_percentage == 36.17 + assert climate.pwm.duty_cycle == (325, 574) @pytest.mark.parametrize(*[ @@ -93,8 +93,8 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.requested_setpoint == 30.597 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 10.68 - assert climate.pwm.duty_cycle == (180, 1505) + assert climate.pwm.last_duty_cycle_percentage == 11.03 + assert climate.pwm.duty_cycle == (180, 1452) @pytest.mark.parametrize(*[ @@ -136,5 +136,5 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.requested_setpoint == 37.397 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 68.9 - assert climate.pwm.duty_cycle == (620, 279) + assert climate.pwm.last_duty_cycle_percentage == 73.89 + assert climate.pwm.duty_cycle == (665, 234) From 0699e4128a23733375f5b76c060dfb859150ecb3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 12:53:19 +0100 Subject: [PATCH 139/237] Another attempt --- custom_components/sat/pwm.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index c8ca582b..c1f64876 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -58,7 +58,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile return elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, minimum_setpoint, boiler_temperature or 0) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 0) if self._duty_cycle is None: self._state = PWMState.IDLE @@ -83,10 +83,13 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, requested_setpoint: float, minimum_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" + minimum_setpoint = boiler_temperature base_offset = self._heating_curve.base_offset - minimum_setpoint = max(minimum_setpoint, boiler_temperature) or 1 + + if boiler_temperature < base_offset: + minimum_setpoint = base_offset + 1 self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) From 3892be3000695f50de759b9b8d718f654c8fc689 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 14:38:15 +0100 Subject: [PATCH 140/237] Some cleaning up and fixed the tests --- custom_components/sat/climate.py | 6 +++--- custom_components/sat/const.py | 1 + custom_components/sat/coordinator.py | 28 +++++++++++++++++++++++++- custom_components/sat/fake/__init__.py | 10 ++++++++- custom_components/sat/pid.py | 27 +------------------------ custom_components/sat/pwm.py | 7 ++++--- custom_components/sat/util.py | 4 ++-- tests/test_climate.py | 3 +++ 8 files changed, 50 insertions(+), 36 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c58629f5..87af3272 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -123,7 +123,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn 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) + self.pwm = create_pwm_controller(self.heating_curve, self._coordinator.minimum_setpoint, config_entry.data, config_options) self._sensors = [] self._rooms = None @@ -800,7 +800,7 @@ async def _async_control_pid(self, reset: bool = False) -> None: self.pid.update( error=max_error, heating_curve_value=self.heating_curve.value, - boiler_temperature=self._coordinator.boiler_temperature + boiler_temperature=self._coordinator.filtered_boiler_temperature ) elif max_error != self.pid.last_error: _LOGGER.info(f"Updating error value to {max_error} (Reset: True)") @@ -891,7 +891,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.requested_setpoint, self._coordinator.minimum_setpoint, self._coordinator.boiler_temperature) + await self.pwm.update(self.requested_setpoint, 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/const.py b/custom_components/sat/const.py index 7403d8b9..3685ae10 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -19,6 +19,7 @@ MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 +MAX_BOILER_TEMPERATURE_AGE = 300 OVERSHOOT_PROTECTION_SETPOINT = 75 OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 2ef67d4b..5b6d6d30 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -3,6 +3,7 @@ import logging import typing from abc import abstractmethod +from datetime import datetime, timedelta from enum import Enum from homeassistant.components.climate import HVACMode @@ -53,6 +54,7 @@ async def resolve(hass: HomeAssistant, config_entry: ConfigEntry, mode: str, dev class SatDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize.""" + self.boiler_temperatures = [] self._config_entry = config_entry self._device_state = DeviceState.OFF self._simulation = bool(config_entry.data.get(CONF_SIMULATION)) @@ -91,6 +93,20 @@ def hot_water_setpoint(self) -> float | None: def boiler_temperature(self) -> float | None: return None + @property + 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 None + + # 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:]) + ) + + # Average it and return it + return round(difference_boiler_temperature_sum / (len(self.boiler_temperatures) - 1), 2) + @property def minimum_hot_water_setpoint(self) -> float: return 30 @@ -192,7 +208,7 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: await self.async_set_control_max_setpoint(self.maximum_setpoint) async def async_will_remove_from_hass(self, climate: SatClimate) -> None: - """Run when entity will be removed from hass.""" + """Run when an entity is removed from hass.""" pass async def async_control_heating_loop(self, climate: SatClimate = None, _time=None) -> None: @@ -201,6 +217,16 @@ async def async_control_heating_loop(self, climate: SatClimate = None, _time=Non # Send out a new command to turn off the device await self.async_set_heater_state(DeviceState.OFF) + current_time = datetime.now() + + # Make sure we have valid value + 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() + async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" self._device_state = state diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index d3508762..7d479dae 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -34,6 +34,7 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: self._setpoint = None self._maximum_setpoint = None self._hot_water_setpoint = None + self._boiler_temperature = None self._relative_modulation_value = 100 super().__init__(hass, config_entry) @@ -42,6 +43,10 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: def setpoint(self) -> float | None: return self._setpoint + @property + def boiler_temperature(self) -> float | None: + return self._boiler_temperature + @property def device_active(self) -> bool: return self._device_state == DeviceState.ON @@ -73,6 +78,9 @@ def supports_relative_modulation_management(self): return self.config.supports_relative_modulation_management + async def async_set_boiler_temperature(self, value: float) -> None: + self._boiler_temperature = value + async def async_set_control_setpoint(self, value: float) -> None: self._setpoint = value @@ -91,4 +99,4 @@ async def async_set_control_max_relative_modulation(self, value: float) -> 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) \ No newline at end of file diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 51845692..a0d30264 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -1,6 +1,5 @@ import logging from collections import deque -from datetime import datetime, timedelta from time import monotonic from typing import Optional @@ -63,7 +62,6 @@ def reset(self) -> None: self._raw_derivative = 0.0 # Reset all lists - self._boiler_temperatures = [] self._times = deque(maxlen=self._history_size) self._errors = deque(maxlen=self._history_size) @@ -84,7 +82,6 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f return self.update_integral(error, heating_curve_value, True) - self.update_boiler_temperature(boiler_temperature) self.update_derivative(error) self.update_history_size() @@ -92,6 +89,7 @@ def update(self, error: float, heating_curve_value: float, boiler_temperature: f self._time_elapsed = time_elapsed self._last_error = error + self._last_boiler_temperature = boiler_temperature self._last_heating_curve_value = heating_curve_value def update_reset(self, error: float, heating_curve_value: Optional[float]) -> None: @@ -346,26 +344,3 @@ def num_errors(self) -> int: def history_size(self) -> int: """Return the number of values that we store.""" return int(self._history_size) - - def update_boiler_temperature(self, boiler_temperature: float): - current_time = datetime.now() - - # Make sure we have valid value - if boiler_temperature is not None: - self._boiler_temperatures.append((current_time, 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() - - # Not able to use if we do not have at least 2 values - if len(self._boiler_temperatures) < 2: - return - - # 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:]) - ) - - # Store it for later use - self._last_boiler_temperature = round(difference_boiler_temperature_sum / (len(self._boiler_temperatures) - 1), 2) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index c1f64876..a691e525 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -26,13 +26,14 @@ class PWMState(str, Enum): class PWM: """A class for implementing Pulse Width Modulation (PWM) control.""" - def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): + def __init__(self, heating_curve: HeatingCurve, minimum_setpoint: float, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._force = force self._last_duty_cycle_percentage = None self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time + self._minimum_setpoint = minimum_setpoint self._automatic_duty_cycle = automatic_duty_cycle self.reset() @@ -43,7 +44,7 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, requested_setpoint: float, minimum_setpoint: float, boiler_temperature: float) -> None: + async def update(self, requested_setpoint: float, boiler_temperature: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE @@ -51,7 +52,7 @@ async def update(self, requested_setpoint: float, minimum_setpoint: float, boile _LOGGER.warning("Invalid heating curve value") return - if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): + if requested_setpoint is None or (not self._force and requested_setpoint > self._minimum_setpoint): self._state = PWMState.IDLE self._last_update = monotonic() _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 9c228e86..b8e86803 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -74,7 +74,7 @@ def create_heating_curve_controller(config_data, config_options) -> HeatingCurve return HeatingCurve(heating_system=heating_system, coefficient=coefficient) -def create_pwm_controller(heating_curve: HeatingCurve, config_data, config_options) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, minimum_setpoint: float, config_data, config_options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options automatic_duty_cycle = bool(config_options.get(CONF_AUTOMATIC_DUTY_CYCLE)) @@ -82,7 +82,7 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data, config_optio force = bool(config_data.get(CONF_MODE) == MODE_SWITCH) or bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) # 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, force=force) + return PWM(heating_curve=heating_curve, minimum_setpoint=minimum_setpoint, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) def snake_case(s): diff --git a/tests/test_climate.py b/tests/test_climate.py index 4a230612..187a9caf 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -43,6 +43,7 @@ )], ]) async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(57) await climate.async_set_target_temperature(21.0) await climate.async_set_hvac_mode(HVACMode.HEAT) @@ -85,6 +86,7 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: )], ]) async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(58) await climate.async_set_target_temperature(19.0) await climate.async_set_hvac_mode(HVACMode.HEAT) @@ -128,6 +130,7 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: )], ]) async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: SatClimate, coordinator: SatFakeCoordinator) -> None: + await coordinator.async_set_boiler_temperature(41) await climate.async_set_target_temperature(20.0) await climate.async_set_hvac_mode(HVACMode.HEAT) From 8b63e27856e37bf1b54977bdca2bbc793289442f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 15:18:39 +0100 Subject: [PATCH 141/237] Some fixes when setting up OPV with MQTT --- custom_components/sat/config_flow.py | 7 ++++++- custom_components/sat/coordinator.py | 2 +- custom_components/sat/mqtt/__init__.py | 2 +- custom_components/sat/overshoot_protection.py | 8 ++++---- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index d70e14d8..aca1b77b 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -378,10 +378,15 @@ async def _create_coordinator(self) -> SatDataUpdateCoordinator: ) # Resolve the coordinator by using the factory according to the mode - return await SatDataUpdateCoordinatorFactory().resolve( + coordinator = await SatDataUpdateCoordinatorFactory().resolve( hass=self.hass, config_entry=config, mode=self._data[CONF_MODE], device=self._data[CONF_DEVICE] ) + # Make sure the coordinator could set itself up + await coordinator.async_added_to_hass() + + return coordinator + async def _enable_overshoot_protection(self, overshoot_protection_value: float): self._data[CONF_OVERSHOOT_PROTECTION] = True self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 5b6d6d30..e7984e74 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -203,7 +203,7 @@ def supports_maximum_setpoint_management(self): """ return False - async def async_added_to_hass(self, climate: SatClimate) -> None: + async def async_added_to_hass(self, climate: SatClimate = None) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 875ff932..2c69878d 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -147,7 +147,7 @@ def minimum_relative_modulation_value(self) -> float | None: return super().boiler_capacity - async def async_added_to_hass(self, climate: SatClimate) -> None: + async def async_added_to_hass(self, climate: SatClimate = None) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) await self._send_command("PM=48") diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index e6d3a536..a1f835b2 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -14,8 +14,8 @@ OVERSHOOT_PROTECTION_SETPOINT = 75 OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0.00 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 -OVERSHOOT_PROTECTION_TIMEOUT = 7200 # 2 hours in seconds -OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # 2 minutes in seconds +OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds +OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds class OvershootProtection: @@ -26,12 +26,11 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Starting calculation") await self._coordinator.async_set_heater_state(DeviceState.ON) - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) await self._coordinator.async_set_control_max_relative_modulation(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD) try: # First wait for a flame - await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_TIMEOUT) + await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation if solution == SOLUTION_AUTOMATIC and not self._coordinator.supports_relative_modulation_management: @@ -96,6 +95,7 @@ async def _wait_for_flame(self): break _LOGGER.warning("Heating system is not running yet") + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) await asyncio.sleep(5) await self._coordinator.async_control_heating_loop() From b75e5bd6b4cbe2d5050371a236f0e573fb2fe8e0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 15:33:32 +0100 Subject: [PATCH 142/237] Use the direct state values for the MQTT coordinator instead of waiting for a value --- custom_components/sat/config_flow.py | 7 +-- custom_components/sat/coordinator.py | 2 +- custom_components/sat/mqtt/__init__.py | 87 +++++++------------------- 3 files changed, 23 insertions(+), 73 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index aca1b77b..d70e14d8 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -378,15 +378,10 @@ async def _create_coordinator(self) -> SatDataUpdateCoordinator: ) # Resolve the coordinator by using the factory according to the mode - coordinator = await SatDataUpdateCoordinatorFactory().resolve( + return await SatDataUpdateCoordinatorFactory().resolve( hass=self.hass, config_entry=config, mode=self._data[CONF_MODE], device=self._data[CONF_DEVICE] ) - # Make sure the coordinator could set itself up - await coordinator.async_added_to_hass() - - return coordinator - async def _enable_overshoot_protection(self, overshoot_protection_value: float): self._data[CONF_OVERSHOOT_PROTECTION] = True self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index e7984e74..5b6d6d30 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -203,7 +203,7 @@ def supports_maximum_setpoint_management(self): """ return False - async def async_added_to_hass(self, climate: SatClimate = None) -> None: + async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 2c69878d..f31a9024 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -8,10 +8,9 @@ from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, Event, State +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry -from homeassistant.helpers.event import async_track_state_change_event from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -35,16 +34,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) -def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): - entities = entity_registry.async_get(hass) - entity = entities.async_get(entity_id) - - if entity.unique_id: - return entity.unique_id[len(node_id) + 1:] - - return None - - class SatMqttCoordinator(SatDataUpdateCoordinator): """Class to manage to fetch data from the OTGW Gateway using mqtt.""" @@ -77,106 +66,80 @@ def supports_relative_modulation_management(self): @property def device_active(self) -> bool: - return self.data.get(DATA_CENTRAL_HEATING) == DeviceState.ON + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING) == DeviceState.ON @property def flame_active(self) -> bool: - return bool(self.data.get(DATA_FLAME_ACTIVE)) == DeviceState.ON + return bool(self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE)) == DeviceState.ON @property def hot_water_active(self) -> bool: - return bool(self.data.get(DATA_DHW_ENABLE)) == DeviceState.ON + return bool(self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE)) == DeviceState.ON @property def setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT)) is not None: return float(setpoint) return None @property def hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT)) is not None: return float(setpoint) return None @property def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM)) is not None: return float(setpoint) return super().minimum_hot_water_setpoint @property def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM)) is not None: return float(setpoint) return super().maximum_hot_water_setpoint @property def boiler_temperature(self) -> float | None: - if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE)) is not None: return float(value) return super().boiler_temperature @property def relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL)) is not None: return float(value) return None @property def boiler_capacity(self) -> float | None: - if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_CAPACITY)) is not None: return float(value) return super().boiler_capacity @property def minimum_relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL)) is not None: return float(value) # Legacy - if (value := self.data.get(DATA_REL_MIN_MOD_LEVELL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL)) is not None: return float(value) return super().boiler_capacity - async def async_added_to_hass(self, climate: SatClimate = None) -> None: + async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) await self._send_command("PM=48") - - entities = list(filter(lambda entity: entity is not None, [ - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), - - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), - self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), - self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), - self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), - ])) - - for entity_id in entities: - if state := self.hass.states.get(entity_id): - await self._on_state_change(entity_id, state) - - async def async_coroutine(event: Event): - await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) - - async_track_state_change_event(self.hass, entities, async_coroutine) - await super().async_added_to_hass(climate) async def async_set_control_setpoint(self, value: float) -> None: @@ -209,23 +172,15 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) - def _get_entity_id(self, domain: str, key: str): - return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") - - async def _on_state_change(self, entity_id: str, state: State): - key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) - if key is None: - return - + def _get_entity_state(self, domain: str, key: str): + state = self.hass.states.get(self._get_entity_id(domain, key)) if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self.data[key] = None - else: - self.data[key] = state.state + return None - if self._listeners: - self._schedule_refresh() + return state.state - self.async_update_listeners() + def _get_entity_id(self, domain: str, key: str): + return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") async def _send_command(self, payload: str): if not self._simulation: From 640dc2377c4d4b15290db2b272bcd260afadf9de Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 15:38:27 +0100 Subject: [PATCH 143/237] Revert...and fix the other states properly --- custom_components/sat/config_flow.py | 7 ++- custom_components/sat/coordinator.py | 2 +- custom_components/sat/mqtt/__init__.py | 87 +++++++++++++++++++------- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index d70e14d8..aca1b77b 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -378,10 +378,15 @@ async def _create_coordinator(self) -> SatDataUpdateCoordinator: ) # Resolve the coordinator by using the factory according to the mode - return await SatDataUpdateCoordinatorFactory().resolve( + coordinator = await SatDataUpdateCoordinatorFactory().resolve( hass=self.hass, config_entry=config, mode=self._data[CONF_MODE], device=self._data[CONF_DEVICE] ) + # Make sure the coordinator could set itself up + await coordinator.async_added_to_hass() + + return coordinator + async def _enable_overshoot_protection(self, overshoot_protection_value: float): self._data[CONF_OVERSHOOT_PROTECTION] = True self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 5b6d6d30..e7984e74 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -203,7 +203,7 @@ def supports_maximum_setpoint_management(self): """ return False - async def async_added_to_hass(self, climate: SatClimate) -> None: + async def async_added_to_hass(self, climate: SatClimate = None) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index f31a9024..40bfcd38 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -8,9 +8,10 @@ from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, Event, State from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -34,6 +35,16 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) +def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): + entities = entity_registry.async_get(hass) + entity = entities.async_get(entity_id) + + if entity.unique_id: + return entity.unique_id[len(node_id) + 1:] + + return None + + class SatMqttCoordinator(SatDataUpdateCoordinator): """Class to manage to fetch data from the OTGW Gateway using mqtt.""" @@ -66,80 +77,106 @@ def supports_relative_modulation_management(self): @property def device_active(self) -> bool: - return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING) == DeviceState.ON + return self.data.get(DATA_CENTRAL_HEATING) == DeviceState.ON @property def flame_active(self) -> bool: - return bool(self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE)) == DeviceState.ON + return self.data.get(DATA_FLAME_ACTIVE) == DeviceState.ON @property def hot_water_active(self) -> bool: - return bool(self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE)) == DeviceState.ON + return self.data.get(DATA_DHW_ENABLE) == DeviceState.ON @property def setpoint(self) -> float | None: - if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT)) is not None: + if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: return float(setpoint) return None @property def hot_water_setpoint(self) -> float | None: - if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT)) is not None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: return float(setpoint) return None @property def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM)) is not None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: return float(setpoint) return super().minimum_hot_water_setpoint @property def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM)) is not None: + if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: return float(setpoint) return super().maximum_hot_water_setpoint @property def boiler_temperature(self) -> float | None: - if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE)) is not None: + if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: return float(value) return super().boiler_temperature @property def relative_modulation_value(self) -> float | None: - if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL)) is not None: + if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: return float(value) return None @property def boiler_capacity(self) -> float | None: - if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_CAPACITY)) is not None: + if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: return float(value) return super().boiler_capacity @property def minimum_relative_modulation_value(self) -> float | None: - if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL)) is not None: + if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: return float(value) # Legacy - if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL)) is not None: + if (value := self.data.get(DATA_REL_MIN_MOD_LEVELL)) is not None: return float(value) return super().boiler_capacity - async def async_added_to_hass(self, climate: SatClimate) -> None: + async def async_added_to_hass(self, climate: SatClimate = None) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) await self._send_command("PM=48") + + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), + + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), + ])) + + for entity_id in entities: + if state := self.hass.states.get(entity_id): + await self._on_state_change(entity_id, state) + + async def async_coroutine(event: Event): + await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) + + async_track_state_change_event(self.hass, entities, async_coroutine) + await super().async_added_to_hass(climate) async def async_set_control_setpoint(self, value: float) -> None: @@ -172,15 +209,23 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) - def _get_entity_state(self, domain: str, key: str): - state = self.hass.states.get(self._get_entity_id(domain, key)) + def _get_entity_id(self, domain: str, key: str): + return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") + + async def _on_state_change(self, entity_id: str, state: State): + key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) + if key is None: + return + if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - return None + self.data[key] = None + else: + self.data[key] = state.state - return state.state + if self._listeners: + self._schedule_refresh() - def _get_entity_id(self, domain: str, key: str): - return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") + self.async_update_listeners() async def _send_command(self, payload: str): if not self._simulation: From 7421adc4d637d306ec2130bd0a17bdcfab72fc5e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 15:44:12 +0100 Subject: [PATCH 144/237] Use direct values, again... --- custom_components/sat/config_flow.py | 7 +-- custom_components/sat/coordinator.py | 2 +- custom_components/sat/mqtt/__init__.py | 87 +++++++------------------- 3 files changed, 23 insertions(+), 73 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index aca1b77b..d70e14d8 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -378,15 +378,10 @@ async def _create_coordinator(self) -> SatDataUpdateCoordinator: ) # Resolve the coordinator by using the factory according to the mode - coordinator = await SatDataUpdateCoordinatorFactory().resolve( + return await SatDataUpdateCoordinatorFactory().resolve( hass=self.hass, config_entry=config, mode=self._data[CONF_MODE], device=self._data[CONF_DEVICE] ) - # Make sure the coordinator could set itself up - await coordinator.async_added_to_hass() - - return coordinator - async def _enable_overshoot_protection(self, overshoot_protection_value: float): self._data[CONF_OVERSHOOT_PROTECTION] = True self._data[CONF_MINIMUM_SETPOINT] = overshoot_protection_value diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index e7984e74..5b6d6d30 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -203,7 +203,7 @@ def supports_maximum_setpoint_management(self): """ return False - async def async_added_to_hass(self, climate: SatClimate = None) -> None: + async def async_added_to_hass(self, climate: SatClimate) -> None: """Perform setup when the integration is added to Home Assistant.""" await self.async_set_control_max_setpoint(self.maximum_setpoint) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 40bfcd38..541e5495 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -8,10 +8,9 @@ from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, Event, State +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry -from homeassistant.helpers.event import async_track_state_change_event from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -35,16 +34,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__) -def entity_id_to_opentherm_key(hass: HomeAssistant, node_id: str, entity_id: str): - entities = entity_registry.async_get(hass) - entity = entities.async_get(entity_id) - - if entity.unique_id: - return entity.unique_id[len(node_id) + 1:] - - return None - - class SatMqttCoordinator(SatDataUpdateCoordinator): """Class to manage to fetch data from the OTGW Gateway using mqtt.""" @@ -77,106 +66,80 @@ def supports_relative_modulation_management(self): @property def device_active(self) -> bool: - return self.data.get(DATA_CENTRAL_HEATING) == DeviceState.ON + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING) == DeviceState.ON @property def flame_active(self) -> bool: - return self.data.get(DATA_FLAME_ACTIVE) == DeviceState.ON + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE) == DeviceState.ON @property def hot_water_active(self) -> bool: - return self.data.get(DATA_DHW_ENABLE) == DeviceState.ON + return self._get_entity_state(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE) == DeviceState.ON @property def setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_CONTROL_SETPOINT)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT)) is not None: return float(setpoint) return None @property def hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT)) is not None: return float(setpoint) return None @property def minimum_hot_water_setpoint(self) -> float: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MINIMUM)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM)) is not None: return float(setpoint) return super().minimum_hot_water_setpoint @property def maximum_hot_water_setpoint(self) -> float | None: - if (setpoint := self.data.get(DATA_DHW_SETPOINT_MAXIMUM)) is not None: + if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM)) is not None: return float(setpoint) return super().maximum_hot_water_setpoint @property def boiler_temperature(self) -> float | None: - if (value := self.data.get(DATA_BOILER_TEMPERATURE)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE)) is not None: return float(value) return super().boiler_temperature @property def relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MOD_LEVEL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL)) is not None: return float(value) return None @property def boiler_capacity(self) -> float | None: - if (value := self.data.get(DATA_BOILER_CAPACITY)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_BOILER_CAPACITY)) is not None: return float(value) return super().boiler_capacity @property def minimum_relative_modulation_value(self) -> float | None: - if (value := self.data.get(DATA_REL_MIN_MOD_LEVEL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL)) is not None: return float(value) # Legacy - if (value := self.data.get(DATA_REL_MIN_MOD_LEVELL)) is not None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL)) is not None: return float(value) return super().boiler_capacity - async def async_added_to_hass(self, climate: SatClimate = None) -> None: + async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) await self._send_command("PM=48") - - entities = list(filter(lambda entity: entity is not None, [ - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), - self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), - - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), - self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), - self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), - self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), - self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), - self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), - ])) - - for entity_id in entities: - if state := self.hass.states.get(entity_id): - await self._on_state_change(entity_id, state) - - async def async_coroutine(event: Event): - await self._on_state_change(event.data.get("entity_id"), event.data.get("new_state")) - - async_track_state_change_event(self.hass, entities, async_coroutine) - await super().async_added_to_hass(climate) async def async_set_control_setpoint(self, value: float) -> None: @@ -209,23 +172,15 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) - def _get_entity_id(self, domain: str, key: str): - return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") - - async def _on_state_change(self, entity_id: str, state: State): - key = entity_id_to_opentherm_key(self.hass, self._node_id, entity_id) - if key is None: - return - + def _get_entity_state(self, domain: str, key: str): + state = self.hass.states.get(self._get_entity_id(domain, key)) if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): - self.data[key] = None - else: - self.data[key] = state.state + return None - if self._listeners: - self._schedule_refresh() + return state.state - self.async_update_listeners() + def _get_entity_id(self, domain: str, key: str): + return self._entity_registry.async_get_entity_id(domain, MQTT_DOMAIN, f"{self._node_id}-{key}") async def _send_command(self, payload: str): if not self._simulation: From 9f7dd39665a0bff218f07a6ad5974bbb13788e81 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 15:58:37 +0100 Subject: [PATCH 145/237] OTGW doesn't support decimals for Modulation? --- custom_components/sat/overshoot_protection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index a1f835b2..551dda25 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -12,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0.00 +OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds From 903456f8a6e0176c31258023e85051aa1bc51ad8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:15:57 +0100 Subject: [PATCH 146/237] Switch the logic and start with a modulation run --- custom_components/sat/overshoot_protection.py | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 551dda25..9d1337d7 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -12,7 +12,6 @@ _LOGGER = logging.getLogger(__name__) OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds @@ -26,7 +25,6 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Starting calculation") await self._coordinator.async_set_heater_state(DeviceState.ON) - await self._coordinator.async_set_control_max_relative_modulation(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD) try: # First wait for a flame @@ -38,17 +36,17 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Relative modulation management is not supported, switching to with modulation") if solution == SOLUTION_AUTOMATIC: - # First run start_with_zero_modulation for at least 2 minutes - start_with_zero_modulation_task = asyncio.create_task(self._calculate_with_zero_modulation()) + # First run start_with_modulation for at least 2 minutes + start_with_modulation_task = asyncio.create_task(self._calculate_with_modulation()) await asyncio.sleep(OVERSHOOT_PROTECTION_INITIAL_WAIT) - # Check if relative modulation is still zero - if float(self._coordinator.relative_modulation_value) == OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: - return await start_with_zero_modulation_task + # Check if relative modulation is zero + if float(self._coordinator.relative_modulation_value) == 0: + return await start_with_modulation_task else: - start_with_zero_modulation_task.cancel() - _LOGGER.info("Relative modulation is not zero, switching to with modulation") - return await self._calculate_with_modulation() + start_with_modulation_task.cancel() + _LOGGER.info("Relative modulation is not zero, switching to with zero modulation") + return await self._calculate_with_zero_modulation() elif solution == SOLUTION_WITH_MODULATION: return await self._calculate_with_modulation() elif solution == SOLUTION_WITH_ZERO_MODULATION: @@ -65,13 +63,11 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.async_set_control_max_relative_modulation( - OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD - ) + await self._coordinator.async_set_control_max_relative_modulation(0) try: return await asyncio.wait_for( - self._wait_for_stable_temperature(OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD), + self._wait_for_stable_temperature(0), timeout=OVERSHOOT_PROTECTION_TIMEOUT, ) except asyncio.TimeoutError: @@ -79,10 +75,11 @@ async def _calculate_with_zero_modulation(self) -> float: async def _calculate_with_modulation(self) -> float: _LOGGER.info("Running calculation with modulation") + await self._coordinator.async_set_control_max_relative_modulation(100) try: return await asyncio.wait_for( - self._wait_for_stable_temperature(OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD), + self._wait_for_stable_temperature(100), timeout=OVERSHOOT_PROTECTION_TIMEOUT, ) except asyncio.TimeoutError: @@ -115,7 +112,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp - if max_modulation != OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD: + if max_modulation > 0: await self._coordinator.async_set_control_setpoint(actual_temp) else: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) From 301d697f52fa75ca5bba6962d09a94ed0baa9de2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:19:32 +0100 Subject: [PATCH 147/237] Finetune the setpoints --- custom_components/sat/overshoot_protection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 9d1337d7..5ca35ec2 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -12,6 +12,7 @@ _LOGGER = logging.getLogger(__name__) OVERSHOOT_PROTECTION_SETPOINT = 75 +OVERSHOOT_PROTECTION_INITIAL_SETPOINT = 40 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds @@ -64,6 +65,7 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") await self._coordinator.async_set_control_max_relative_modulation(0) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( @@ -76,6 +78,7 @@ async def _calculate_with_zero_modulation(self) -> float: async def _calculate_with_modulation(self) -> float: _LOGGER.info("Running calculation with modulation") await self._coordinator.async_set_control_max_relative_modulation(100) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( @@ -92,7 +95,7 @@ async def _wait_for_flame(self): break _LOGGER.warning("Heating system is not running yet") - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_INITIAL_SETPOINT) await asyncio.sleep(5) await self._coordinator.async_control_heating_loop() From b933a526a9a2c45a427f315ba9d2d4777dd0cc4d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:29:05 +0100 Subject: [PATCH 148/237] Do not start with a "solution" check if we're already using modulation with the low setpoint --- custom_components/sat/overshoot_protection.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 5ca35ec2..f989d7c7 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -37,16 +37,12 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Relative modulation management is not supported, switching to with modulation") if solution == SOLUTION_AUTOMATIC: - # First run start_with_modulation for at least 2 minutes - start_with_modulation_task = asyncio.create_task(self._calculate_with_modulation()) - await asyncio.sleep(OVERSHOOT_PROTECTION_INITIAL_WAIT) - - # Check if relative modulation is zero + # Check if relative modulation is zero after the flame is on if float(self._coordinator.relative_modulation_value) == 0: - return await start_with_modulation_task + _LOGGER.info("Relative modulation is zero, starting with modulation") + return await self._calculate_with_zero_modulation() else: - start_with_modulation_task.cancel() - _LOGGER.info("Relative modulation is not zero, switching to with zero modulation") + _LOGGER.info("Relative modulation is not zero, starting with zero modulation") return await self._calculate_with_zero_modulation() elif solution == SOLUTION_WITH_MODULATION: return await self._calculate_with_modulation() From 9c7aefaa2f574b2194a81e226c0d74d5bac4ff13 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:36:22 +0100 Subject: [PATCH 149/237] Add support to catch the timeouts --- custom_components/sat/config_flow.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index d70e14d8..94df0abd 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -296,6 +296,9 @@ async def start_calibration(): try: overshoot_protection = OvershootProtection(coordinator) self.overshoot_protection_value = await overshoot_protection.calculate() + except asyncio.TimeoutError: + _LOGGER.warning("Calibration time-out.") + return False except asyncio.CancelledError: _LOGGER.warning("Cancelled calibration.") return False From d02ddd1be2b66e4b05a0602a58aa928eedb52e66 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:38:08 +0100 Subject: [PATCH 150/237] Use a setpoint that's close to the current, so it has to modulate --- custom_components/sat/overshoot_protection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index f989d7c7..06a0c11e 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -12,7 +12,6 @@ _LOGGER = logging.getLogger(__name__) OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_INITIAL_SETPOINT = 40 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds @@ -85,13 +84,15 @@ async def _calculate_with_modulation(self) -> float: _LOGGER.warning("Timed out waiting for stable temperature") async def _wait_for_flame(self): + initial_setpoint = self._coordinator.boiler_temperature + 10 + while True: if bool(self._coordinator.flame_active): _LOGGER.info("Heating system has started to run") break _LOGGER.warning("Heating system is not running yet") - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_INITIAL_SETPOINT) + await self._coordinator.async_set_control_setpoint(initial_setpoint) await asyncio.sleep(5) await self._coordinator.async_control_heating_loop() From 2b683a355305e34cbebc366871ec0704a6fa3880 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:49:14 +0100 Subject: [PATCH 151/237] Typo? --- custom_components/sat/overshoot_protection.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 06a0c11e..2e4bff7f 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -38,10 +38,8 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: if solution == SOLUTION_AUTOMATIC: # Check if relative modulation is zero after the flame is on if float(self._coordinator.relative_modulation_value) == 0: - _LOGGER.info("Relative modulation is zero, starting with modulation") - return await self._calculate_with_zero_modulation() + return await self._calculate_with_modulation() else: - _LOGGER.info("Relative modulation is not zero, starting with zero modulation") return await self._calculate_with_zero_modulation() elif solution == SOLUTION_WITH_MODULATION: return await self._calculate_with_modulation() From 1d5d898f91f8dbe203db40c2553101453d1b4634 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 16:49:22 +0100 Subject: [PATCH 152/237] Restrict to only temperature sensors --- custom_components/sat/config_flow.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 94df0abd..29c28f3a 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -214,10 +214,16 @@ async def async_step_sensors(self, _user_input=None): step_id="sensors", data_schema=vol.Schema({ vol.Required(CONF_INSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN]) + selector.EntitySelectorConfig( + domain=SENSOR_DOMAIN, + device_class=[SensorDeviceClass.TEMPERATURE] + ) ), vol.Required(CONF_OUTSIDE_SENSOR_ENTITY_ID): selector.EntitySelector( - selector.EntitySelectorConfig(domain=[SENSOR_DOMAIN, WEATHER_DOMAIN], multiple=True) + selector.EntitySelectorConfig( + multiple=True, + domain=[SENSOR_DOMAIN, WEATHER_DOMAIN] + ) ), vol.Optional(CONF_HUMIDITY_SENSOR_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig( From 0b181b42f3689fd1f8af2e4286adb5258e6c0e02 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:02:32 +0100 Subject: [PATCH 153/237] Increase the timeout --- custom_components/sat/overshoot_protection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 2e4bff7f..e5df3adc 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -12,9 +12,10 @@ _LOGGER = logging.getLogger(__name__) OVERSHOOT_PROTECTION_SETPOINT = 75 +OVERSHOOT_PROTECTION_INITIAL_SETPOINT = 40 OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds -OVERSHOOT_PROTECTION_INITIAL_WAIT = 120 # Two minutes in seconds +OVERSHOOT_PROTECTION_INITIAL_WAIT = 300 # Five minutes in seconds class OvershootProtection: @@ -82,7 +83,7 @@ async def _calculate_with_modulation(self) -> float: _LOGGER.warning("Timed out waiting for stable temperature") async def _wait_for_flame(self): - initial_setpoint = self._coordinator.boiler_temperature + 10 + initial_setpoint = max(self._coordinator.boiler_temperature + 10, OVERSHOOT_PROTECTION_INITIAL_SETPOINT) while True: if bool(self._coordinator.flame_active): From 799c1bb10aac8f704cd6a3724426c55ba8350025 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:03:06 +0100 Subject: [PATCH 154/237] Make sure we also above the minimum setpoint --- 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 87af3272..16bc5317 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -823,7 +823,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self.pulse_width_modulation_enabled or pwm_state == pwm_state.IDLE: _LOGGER.info("Running Normal cycle") - self._setpoint = mean(list(self._outputs)[-5:]) + self._setpoint = max(self._coordinator.minimum_setpoint, mean(list(self._outputs)[-5:])) else: _LOGGER.info(f"Running PWM cycle: {pwm_state}") self._setpoint = self._coordinator.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT From 14d4125a473f0e42e18e0d9ee2813ecd8c12f32c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:10:27 +0100 Subject: [PATCH 155/237] Only add the temperature if the flame is active --- custom_components/sat/overshoot_protection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index e5df3adc..c392c1d1 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -103,9 +103,10 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: while True: actual_temp = float(self._coordinator.boiler_temperature) - temps.append(actual_temp) - average_temp = sum(temps) / 50 + if self._coordinator.flame_active: + temps.append(actual_temp) + average_temp = sum(temps) / 50 if previous_average_temp is not None: if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) From 35b8b43018d6f6ab9dfef7d9ce09df50a9933417 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:13:47 +0100 Subject: [PATCH 156/237] Fallback to high setpoint when the flame turned off --- custom_components/sat/overshoot_protection.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index c392c1d1..49b6ef6b 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -103,16 +103,15 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: while True: actual_temp = float(self._coordinator.boiler_temperature) - if self._coordinator.flame_active: - temps.append(actual_temp) - + temps.append(actual_temp) average_temp = sum(temps) / 50 + if previous_average_temp is not None: if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp - if max_modulation > 0: + if self._coordinator.flame_active and max_modulation > 0: await self._coordinator.async_set_control_setpoint(actual_temp) else: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) From 61d3b07ea81cb1c0e50832f043bbb7c1a2d30b64 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:20:00 +0100 Subject: [PATCH 157/237] Some modulation fine-tuning for overshoot protection calculation --- custom_components/sat/overshoot_protection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 49b6ef6b..76cb7e31 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -26,6 +26,7 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: _LOGGER.info("Starting calculation") await self._coordinator.async_set_heater_state(DeviceState.ON) + await self._coordinator.async_set_control_max_relative_modulation(100) try: # First wait for a flame @@ -36,6 +37,9 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: solution = SOLUTION_WITH_MODULATION _LOGGER.info("Relative modulation management is not supported, switching to with modulation") + await self._coordinator.async_set_control_max_relative_modulation(0) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + if solution == SOLUTION_AUTOMATIC: # Check if relative modulation is zero after the flame is on if float(self._coordinator.relative_modulation_value) == 0: @@ -58,8 +62,6 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.async_set_control_max_relative_modulation(0) - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( @@ -71,8 +73,6 @@ async def _calculate_with_zero_modulation(self) -> float: async def _calculate_with_modulation(self) -> float: _LOGGER.info("Running calculation with modulation") - await self._coordinator.async_set_control_max_relative_modulation(100) - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( From 0139f25ed29500bae4f191db1449e5b5b3620d2f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:21:02 +0100 Subject: [PATCH 158/237] Revert --- custom_components/sat/overshoot_protection.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 76cb7e31..45166da4 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -37,9 +37,6 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: solution = SOLUTION_WITH_MODULATION _LOGGER.info("Relative modulation management is not supported, switching to with modulation") - await self._coordinator.async_set_control_max_relative_modulation(0) - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - if solution == SOLUTION_AUTOMATIC: # Check if relative modulation is zero after the flame is on if float(self._coordinator.relative_modulation_value) == 0: @@ -62,6 +59,8 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") + await self._coordinator.async_set_control_max_relative_modulation(0) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( @@ -73,6 +72,8 @@ async def _calculate_with_zero_modulation(self) -> float: async def _calculate_with_modulation(self) -> float: _LOGGER.info("Running calculation with modulation") + await self._coordinator.async_set_control_max_relative_modulation(0) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( From 82c5bb07575c95c3a148730c1c3d9ee55ba99ee5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:23:00 +0100 Subject: [PATCH 159/237] Modulation correction --- custom_components/sat/overshoot_protection.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 45166da4..a96368c9 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -39,13 +39,18 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: if solution == SOLUTION_AUTOMATIC: # Check if relative modulation is zero after the flame is on - if float(self._coordinator.relative_modulation_value) == 0: + zero_modulation_support = float(self._coordinator.relative_modulation_value) == 0 + await self._coordinator.async_set_control_max_relative_modulation(0) + + if zero_modulation_support == 0: return await self._calculate_with_modulation() else: return await self._calculate_with_zero_modulation() elif solution == SOLUTION_WITH_MODULATION: + await self._coordinator.async_set_control_max_relative_modulation(100) return await self._calculate_with_modulation() elif solution == SOLUTION_WITH_ZERO_MODULATION: + await self._coordinator.async_set_control_max_relative_modulation(0) return await self._calculate_with_zero_modulation() except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") @@ -59,7 +64,6 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.async_set_control_max_relative_modulation(0) await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: @@ -72,7 +76,6 @@ async def _calculate_with_zero_modulation(self) -> float: async def _calculate_with_modulation(self) -> float: _LOGGER.info("Running calculation with modulation") - await self._coordinator.async_set_control_max_relative_modulation(0) await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: From 4ee978905ed6d5ef8ea8a8761be6db5b4c57ef38 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:29:08 +0100 Subject: [PATCH 160/237] Improved solution naming --- custom_components/sat/overshoot_protection.py | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index a96368c9..93c6b04c 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -6,7 +6,7 @@ from custom_components.sat.coordinator import DeviceState, SatDataUpdateCoordinator SOLUTION_AUTOMATIC = "auto" -SOLUTION_WITH_MODULATION = "with_modulation" +SOLUTION_WITH_NO_MODULATION_MANAGEMENT = "with_modulation" SOLUTION_WITH_ZERO_MODULATION = "with_zero_modulation" _LOGGER = logging.getLogger(__name__) @@ -34,8 +34,11 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation if solution == SOLUTION_AUTOMATIC and not self._coordinator.supports_relative_modulation_management: - solution = SOLUTION_WITH_MODULATION - _LOGGER.info("Relative modulation management is not supported, switching to with modulation") + solution = SOLUTION_WITH_NO_MODULATION_MANAGEMENT + _LOGGER.info("Relative modulation management is not supported") + + await self._coordinator.async_set_control_max_relative_modulation(0) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) if solution == SOLUTION_AUTOMATIC: # Check if relative modulation is zero after the flame is on @@ -43,15 +46,13 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: await self._coordinator.async_set_control_max_relative_modulation(0) if zero_modulation_support == 0: - return await self._calculate_with_modulation() + return await self._calculate_with_no_modulation_management() else: return await self._calculate_with_zero_modulation() - elif solution == SOLUTION_WITH_MODULATION: - await self._coordinator.async_set_control_max_relative_modulation(100) - return await self._calculate_with_modulation() elif solution == SOLUTION_WITH_ZERO_MODULATION: - await self._coordinator.async_set_control_max_relative_modulation(0) return await self._calculate_with_zero_modulation() + elif solution == SOLUTION_WITH_NO_MODULATION_MANAGEMENT: + return await self._calculate_with_no_modulation_management() except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") return None @@ -64,7 +65,6 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) try: return await asyncio.wait_for( @@ -74,9 +74,8 @@ async def _calculate_with_zero_modulation(self) -> float: except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") - async def _calculate_with_modulation(self) -> float: - _LOGGER.info("Running calculation with modulation") - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + async def _calculate_with_no_modulation_management(self) -> float: + _LOGGER.info("Running calculation with no modulation management") try: return await asyncio.wait_for( From bedbbc597de3ff144a7b774eae6a72e42e72e31c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:32:39 +0100 Subject: [PATCH 161/237] Solution fixes --- custom_components/sat/overshoot_protection.py | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 93c6b04c..126a1f6e 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -32,26 +32,25 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: # First wait for a flame await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) - # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation - if solution == SOLUTION_AUTOMATIC and not self._coordinator.supports_relative_modulation_management: + supports_relative_modulation_management = self._coordinator.supports_relative_modulation_management + if float(self._coordinator.relative_modulation_value) > 0: + supports_relative_modulation_management = False + + # Since the coordinator doesn't support modulation management, so we need to fall back to find it without it + if solution == SOLUTION_AUTOMATIC and not supports_relative_modulation_management: solution = SOLUTION_WITH_NO_MODULATION_MANAGEMENT _LOGGER.info("Relative modulation management is not supported") + else: + solution = SOLUTION_WITH_ZERO_MODULATION + _LOGGER.info("Relative modulation management is supported") await self._coordinator.async_set_control_max_relative_modulation(0) await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - if solution == SOLUTION_AUTOMATIC: - # Check if relative modulation is zero after the flame is on - zero_modulation_support = float(self._coordinator.relative_modulation_value) == 0 - await self._coordinator.async_set_control_max_relative_modulation(0) - - if zero_modulation_support == 0: - return await self._calculate_with_no_modulation_management() - else: - return await self._calculate_with_zero_modulation() - elif solution == SOLUTION_WITH_ZERO_MODULATION: + if solution == SOLUTION_WITH_ZERO_MODULATION: return await self._calculate_with_zero_modulation() - elif solution == SOLUTION_WITH_NO_MODULATION_MANAGEMENT: + + if solution == SOLUTION_WITH_NO_MODULATION_MANAGEMENT: return await self._calculate_with_no_modulation_management() except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") From 14d3978e32afb9726b4048e7486e689e52ae9b32 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:33:41 +0100 Subject: [PATCH 162/237] Typo? --- custom_components/sat/overshoot_protection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 126a1f6e..2b00ce3b 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -33,7 +33,7 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) supports_relative_modulation_management = self._coordinator.supports_relative_modulation_management - if float(self._coordinator.relative_modulation_value) > 0: + if float(self._coordinator.relative_modulation_value) == 0: supports_relative_modulation_management = False # Since the coordinator doesn't support modulation management, so we need to fall back to find it without it From 4f53b587720ab89725d2d77ad8f72138e7d2defb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 17:48:26 +0100 Subject: [PATCH 163/237] Make sure the flame is still on when checking if we are stable --- custom_components/sat/overshoot_protection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 2b00ce3b..830048de 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -108,7 +108,7 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: temps.append(actual_temp) average_temp = sum(temps) / 50 - if previous_average_temp is not None: + if self._coordinator.flame_active and previous_average_temp is not None: if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp From 5c81f23f091fba1026a829786f7f2e6814b931bc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Tue, 28 Nov 2023 17:50:56 +0100 Subject: [PATCH 164/237] Create LICENSE --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f288702d --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From 51945bb856dc615ae3ea8c52498b4dde5f6eb836 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 18:03:52 +0100 Subject: [PATCH 165/237] Add the max. setpoint to the average when the flame is off --- custom_components/sat/overshoot_protection.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 830048de..e6f3db57 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -104,21 +104,20 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: while True: actual_temp = float(self._coordinator.boiler_temperature) + temps.append(actual_temp if self._coordinator.flame_active else OVERSHOOT_PROTECTION_SETPOINT) - temps.append(actual_temp) average_temp = sum(temps) / 50 - if self._coordinator.flame_active and previous_average_temp is not None: if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp + previous_average_temp = average_temp + if self._coordinator.flame_active and max_modulation > 0: await self._coordinator.async_set_control_setpoint(actual_temp) else: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - previous_average_temp = average_temp - await asyncio.sleep(3) await self._coordinator.async_control_heating_loop() From 6084f2820f94560d9df1e228155368761efbacbc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 28 Nov 2023 19:01:04 +0100 Subject: [PATCH 166/237] Improved Overshoot Protection calculation --- custom_components/sat/overshoot_protection.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index e6f3db57..9c20dae8 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -30,7 +30,7 @@ async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: try: # First wait for a flame - await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) + await asyncio.wait_for(self._wait_for_warming_up(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) supports_relative_modulation_management = self._coordinator.supports_relative_modulation_management if float(self._coordinator.relative_modulation_value) == 0: @@ -84,11 +84,13 @@ async def _calculate_with_no_modulation_management(self) -> float: except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") - async def _wait_for_flame(self): + async def _wait_for_warming_up(self): initial_setpoint = max(self._coordinator.boiler_temperature + 10, OVERSHOOT_PROTECTION_INITIAL_SETPOINT) while True: - if bool(self._coordinator.flame_active): + actual_temp = float(self._coordinator.boiler_temperature) + + if actual_temp >= initial_setpoint: _LOGGER.info("Heating system has started to run") break @@ -104,20 +106,20 @@ async def _wait_for_stable_temperature(self, max_modulation: float) -> float: while True: actual_temp = float(self._coordinator.boiler_temperature) - temps.append(actual_temp if self._coordinator.flame_active else OVERSHOOT_PROTECTION_SETPOINT) + temps.append(actual_temp) average_temp = sum(temps) / 50 - if self._coordinator.flame_active and previous_average_temp is not None: + if previous_average_temp is not None: if abs(actual_temp - previous_average_temp) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temp) return actual_temp previous_average_temp = average_temp - if self._coordinator.flame_active and max_modulation > 0: + if max_modulation > 0: await self._coordinator.async_set_control_setpoint(actual_temp) else: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) - await asyncio.sleep(3) + await asyncio.sleep(5) await self._coordinator.async_control_heating_loop() From 78e2e716bf556c4fd83297481878e5070e5f42a0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 15:35:23 +0100 Subject: [PATCH 167/237] Always enable relative modulation, even outside deadband, except when PWM is on --- custom_components/sat/climate.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 16bc5317..0851a006 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -526,9 +526,6 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_enabled(self) -> bool: """Return True if relative modulation is enabled, False otherwise.""" - if not self._coordinator.supports_relative_modulation_management: - return False - if self.hvac_mode == HVACMode.OFF or self._setpoint is None: return True @@ -538,7 +535,7 @@ def relative_modulation_enabled(self) -> bool: if self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME: return False - return self.max_error > DEADBAND and not self.pulse_width_modulation_enabled + return not self.pulse_width_modulation_enabled @property def summer_simmer_index(self) -> float | None: From 34aaf6771e088e82a2b2c8aee88f3b6d157d6fbe Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 15:48:22 +0100 Subject: [PATCH 168/237] Simplify the overshoot protection autotune --- custom_components/sat/const.py | 1 - custom_components/sat/overshoot_protection.py | 73 ++++++------------- 2 files changed, 24 insertions(+), 50 deletions(-) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 3685ae10..fe7388e2 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -21,7 +21,6 @@ MAX_BOILER_TEMPERATURE_AGE = 300 OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_MAX_RELATIVE_MOD = 0 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 # Configuration and options diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 9c20dae8..52cb7379 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -5,65 +5,44 @@ from custom_components.sat.const import * from custom_components.sat.coordinator import DeviceState, SatDataUpdateCoordinator -SOLUTION_AUTOMATIC = "auto" -SOLUTION_WITH_NO_MODULATION_MANAGEMENT = "with_modulation" -SOLUTION_WITH_ZERO_MODULATION = "with_zero_modulation" - _LOGGER = logging.getLogger(__name__) -OVERSHOOT_PROTECTION_SETPOINT = 75 -OVERSHOOT_PROTECTION_INITIAL_SETPOINT = 40 -OVERSHOOT_PROTECTION_ERROR_RELATIVE_MOD = 0.01 OVERSHOOT_PROTECTION_TIMEOUT = 7200 # Two hours in seconds -OVERSHOOT_PROTECTION_INITIAL_WAIT = 300 # Five minutes in seconds +OVERSHOOT_PROTECTION_INITIAL_WAIT = 180 # Three minutes in seconds class OvershootProtection: def __init__(self, coordinator: SatDataUpdateCoordinator): self._coordinator = coordinator - async def calculate(self, solution: str = SOLUTION_AUTOMATIC) -> float | None: + async def calculate(self) -> float | None: _LOGGER.info("Starting calculation") await self._coordinator.async_set_heater_state(DeviceState.ON) - await self._coordinator.async_set_control_max_relative_modulation(100) try: # First wait for a flame - await asyncio.wait_for(self._wait_for_warming_up(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) - - supports_relative_modulation_management = self._coordinator.supports_relative_modulation_management - if float(self._coordinator.relative_modulation_value) == 0: - supports_relative_modulation_management = False - - # Since the coordinator doesn't support modulation management, so we need to fall back to find it without it - if solution == SOLUTION_AUTOMATIC and not supports_relative_modulation_management: - solution = SOLUTION_WITH_NO_MODULATION_MANAGEMENT - _LOGGER.info("Relative modulation management is not supported") - else: - solution = SOLUTION_WITH_ZERO_MODULATION - _LOGGER.info("Relative modulation management is supported") - - await self._coordinator.async_set_control_max_relative_modulation(0) - await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) + await asyncio.wait_for(self._wait_for_flame(), timeout=OVERSHOOT_PROTECTION_INITIAL_WAIT) - if solution == SOLUTION_WITH_ZERO_MODULATION: - return await self._calculate_with_zero_modulation() - - if solution == SOLUTION_WITH_NO_MODULATION_MANAGEMENT: + # Since the coordinator doesn't support modulation management, so we need to fall back to find it with modulation + if not self._coordinator.supports_relative_modulation_management: return await self._calculate_with_no_modulation_management() + + # Run with maximum power of the boiler, zero modulation. + return await self._calculate_with_zero_modulation() except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") return None - except asyncio.CancelledError as ex: + except asyncio.CancelledError as exception: await self._coordinator.async_set_heater_state(DeviceState.OFF) await self._coordinator.async_set_control_setpoint(MINIMUM_SETPOINT) await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MOD) - raise ex + raise exception async def _calculate_with_zero_modulation(self) -> float: _LOGGER.info("Running calculation with zero modulation") + await self._coordinator.async_set_control_max_relative_modulation(MINIMUM_RELATIVE_MOD) try: return await asyncio.wait_for( @@ -84,40 +63,36 @@ async def _calculate_with_no_modulation_management(self) -> float: except asyncio.TimeoutError: _LOGGER.warning("Timed out waiting for stable temperature") - async def _wait_for_warming_up(self): - initial_setpoint = max(self._coordinator.boiler_temperature + 10, OVERSHOOT_PROTECTION_INITIAL_SETPOINT) - + async def _wait_for_flame(self): while True: - actual_temp = float(self._coordinator.boiler_temperature) - - if actual_temp >= initial_setpoint: + if bool(self._coordinator.flame_active): _LOGGER.info("Heating system has started to run") break _LOGGER.warning("Heating system is not running yet") - await self._coordinator.async_set_control_setpoint(initial_setpoint) + await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) await asyncio.sleep(5) await self._coordinator.async_control_heating_loop() async def _wait_for_stable_temperature(self, max_modulation: float) -> float: temps = deque(maxlen=50) - previous_average_temp = None + previous_average_temperature = None while True: - actual_temp = float(self._coordinator.boiler_temperature) - temps.append(actual_temp) + actual_temperature = float(self._coordinator.boiler_temperature) + + temps.append(actual_temperature) + average_temperature = sum(temps) / 50 - average_temp = sum(temps) / 50 - if previous_average_temp is not None: - if abs(actual_temp - previous_average_temp) <= DEADBAND: - _LOGGER.info("Stable temperature reached: %s", actual_temp) - return actual_temp + if previous_average_temperature is not None and abs(actual_temperature - previous_average_temperature) <= DEADBAND: + _LOGGER.info("Stable temperature reached: %s", actual_temperature) + return actual_temperature - previous_average_temp = average_temp + previous_average_temperature = average_temperature if max_modulation > 0: - await self._coordinator.async_set_control_setpoint(actual_temp) + await self._coordinator.async_set_control_setpoint(actual_temperature) else: await self._coordinator.async_set_control_setpoint(OVERSHOOT_PROTECTION_SETPOINT) From 684aa216f72d24738e2fbc876bdfb0c1d37c21a5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 15:57:30 +0100 Subject: [PATCH 169/237] OTGW doesn't support non-integers for Modulation --- 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 541e5495..289df5d4 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -163,7 +163,7 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await super().async_set_heater_state(state) async def async_set_control_max_relative_modulation(self, value: float) -> None: - await self._send_command(f"MM={value}") + await self._send_command(f"MM={int(value)}") await super().async_set_control_max_relative_modulation(value) From bfa0f95a1c7caf303cdab1cb5984b8d0cc1e3034 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 16:11:55 +0100 Subject: [PATCH 170/237] Add RelativeModulationSynchroSensor --- custom_components/sat/binary_sensor.py | 31 ++++++++++++++++++++++++++ custom_components/sat/climate.py | 10 +++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index d932476a..6ecb543c 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -32,6 +32,9 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_setpoint_management: _async_add_entities([SatControlSetpointSynchroSensor(coordinator, climate, _config_entry)]) + if coordinator.supports_relative_modulation_management: + _async_add_entities([SatRelativeModulationSynchroSensor(coordinator, climate, _config_entry)]) + if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0: _async_add_entities([SatWindowSensor(coordinator, climate, _config_entry)]) @@ -66,6 +69,34 @@ def unique_id(self): return f"{self._config_entry.data.get(CONF_NAME).lower()}-control-setpoint-synchro" +class SatRelativeModulationSynchroSensor(SatClimateEntity, BinarySensorEntity): + + @property + def name(self): + """Return the friendly name of the sensor.""" + return "Relative Modulation Synchro" + + @property + def device_class(self): + """Return the device class.""" + return BinarySensorDeviceClass.PROBLEM + + @property + def available(self): + """Return availability of the sensor.""" + return self._climate.relative_modulation_value is not None and self._coordinator.relative_modulation_value is not None + + @property + def is_on(self): + """Return the state of the sensor.""" + return round(self._climate.relative_modulation_value, 1) != round(self._coordinator.relative_modulation_value, 1) + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return f"{self._config_entry.data.get(CONF_NAME).lower()}-relative-modulation-synchro" + + class SatCentralHeatingSynchroSensor(SatClimateEntity, BinarySensorEntity): @property diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 0851a006..cc248c24 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -358,6 +358,7 @@ def extra_state_attributes(self): "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, "relative_modulation_enabled": self.relative_modulation_enabled, + "relative_modulation_value": self.relative_modulation_value, "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, @@ -468,7 +469,6 @@ def climate_errors(self) -> List[float]: # Calculate the error value error = round(target_temperature - current_temperature, 2) - _LOGGER.debug(f"{climate}: current: {current_temperature}, target: {target_temperature}, error: {error}") # Add to the list, so we calculate the max. later errors.append(error) @@ -537,6 +537,10 @@ def relative_modulation_enabled(self) -> bool: return not self.pulse_width_modulation_enabled + @property + def relative_modulation_value(self) -> float: + return self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD + @property def summer_simmer_index(self) -> float | None: """ @@ -833,9 +837,7 @@ 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 self._coordinator.supports_relative_modulation_management: - await self._coordinator.async_set_control_max_relative_modulation( - self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD - ) + await self._coordinator.async_set_control_max_relative_modulation(self.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.""" From 003397c6ace887473f176c2122c656ac420aa2ca Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 16:27:10 +0100 Subject: [PATCH 171/237] Check for a valid state --- 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 289df5d4..465afdd4 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -174,7 +174,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: def _get_entity_state(self, domain: str, key: str): state = self.hass.states.get(self._get_entity_id(domain, key)) - if state.state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None return state.state From ca1dd64c4b4738576039fba6875d1d06c682fa10 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 16:50:13 +0100 Subject: [PATCH 172/237] Do not be so strict with relative modulation --- custom_components/sat/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index 6ecb543c..de5ff491 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -89,7 +89,7 @@ def available(self): @property def is_on(self): """Return the state of the sensor.""" - return round(self._climate.relative_modulation_value, 1) != round(self._coordinator.relative_modulation_value, 1) + return int(self._climate.relative_modulation_value) != int(self._coordinator.relative_modulation_value) @property def unique_id(self): From 686bad96e64396ba313102f50b6c4885d612aabb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 17:13:11 +0100 Subject: [PATCH 173/237] Track some states --- custom_components/sat/mqtt/__init__.py | 29 +++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 465afdd4..311d1259 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -9,8 +9,9 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, Event from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers.event import async_track_state_change_event from ..const import * from ..coordinator import DeviceState, SatDataUpdateCoordinator @@ -139,9 +140,35 @@ def minimum_relative_modulation_value(self) -> float | None: async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) + # Create a list of entities that we track + entities = list(filter(lambda entity: entity is not None, [ + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_CENTRAL_HEATING), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_FLAME_ACTIVE), + self._get_entity_id(BINARY_SENSOR_DOMAIN, DATA_DHW_ENABLE), + + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_CONTROL_SETPOINT), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_TEMPERATURE), + self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), + self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), + self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), + ])) + + # Track those entities so the coordinator can be updated when something changes + async_track_state_change_event(self.hass, entities, self.async_state_change_event) + await self._send_command("PM=48") await super().async_added_to_hass(climate) + async def async_state_change_event(self, event: Event): + if self._listeners: + self._schedule_refresh() + + self.async_update_listeners() + async def async_set_control_setpoint(self, value: float) -> None: await self._send_command(f"CS={value}") From 284c9b4c213121a49a83ca1b0dd13828760b76cc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 18:09:55 +0100 Subject: [PATCH 174/237] Use the correct coordinator field --- custom_components/sat/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index de5ff491..ef409796 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -84,12 +84,12 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._climate.relative_modulation_value is not None and self._coordinator.relative_modulation_value is not None + return self._climate.relative_modulation_value is not None and self._coordinator.minimum_relative_modulation_value is not None @property def is_on(self): """Return the state of the sensor.""" - return int(self._climate.relative_modulation_value) != int(self._coordinator.relative_modulation_value) + return int(self._climate.relative_modulation_value) != int(self._coordinator.minimum_relative_modulation_value) @property def unique_id(self): From 61be6a7dbba9f19902c9236874f02c1ff2149b77 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 18:22:31 +0100 Subject: [PATCH 175/237] Added missing field to track --- custom_components/sat/coordinator.py | 4 ++++ custom_components/sat/mqtt/__init__.py | 11 ++++++++++- custom_components/sat/serial/__init__.py | 7 +++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index 5b6d6d30..f146ee1f 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -156,6 +156,10 @@ def boiler_power(self) -> float | None: def minimum_relative_modulation_value(self) -> float | None: return None + @property + def maximum_relative_modulation_value(self) -> float | None: + return None + @property def maximum_setpoint(self) -> float: """Return the maximum setpoint temperature that the device can support.""" diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 311d1259..3f847caf 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -26,6 +26,7 @@ DATA_BOILER_CAPACITY = "MaxCapacityMinModLevel_hb_u8" DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevel_lb_u8" DATA_REL_MIN_MOD_LEVELL = "MaxCapacityMinModLevell_lb_u8" +DATA_MAX_REL_MOD_LEVEL_SETTING = "MaxRelModLevelSetting" DATA_DHW_SETPOINT_MINIMUM = "TdhwSetUBTdhwSetLB_value_lb" DATA_DHW_SETPOINT_MAXIMUM = "TdhwSetUBTdhwSetLB_value_hb" @@ -135,7 +136,14 @@ def minimum_relative_modulation_value(self) -> float | None: if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL)) is not None: return float(value) - return super().boiler_capacity + return super().minimum_relative_modulation_value + + @property + def maximum_relative_modulation_value(self) -> float | None: + if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_MAX_REL_MOD_LEVEL_SETTING)) is not None: + return float(value) + + return super().maximum_relative_modulation_value async def async_added_to_hass(self, climate: SatClimate) -> None: await mqtt.async_wait_for_mqtt_client(self.hass) @@ -153,6 +161,7 @@ async def async_added_to_hass(self, climate: SatClimate) -> None: self._get_entity_id(SENSOR_DOMAIN, DATA_BOILER_CAPACITY), self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVEL), self._get_entity_id(SENSOR_DOMAIN, DATA_REL_MIN_MOD_LEVELL), + self._get_entity_id(SENSOR_DOMAIN, DATA_MAX_REL_MOD_LEVEL_SETTING), self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MINIMUM), self._get_entity_id(SENSOR_DOMAIN, DATA_DHW_SETPOINT_MAXIMUM), ])) diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index 023100f9..d3383b66 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -123,6 +123,13 @@ def minimum_relative_modulation_value(self) -> float | None: return super().minimum_relative_modulation_value + @property + def maximum_relative_modulation_value(self) -> float | None: + if (value := self.get(DATA_SLAVE_MAX_RELATIVE_MOD)) is not None: + return float(value) + + return super().maximum_relative_modulation_value + @property def flame_active(self) -> bool: return bool(self.get(DATA_SLAVE_FLAME_ON)) From c016c3a5c109545b3bb8d08da6d9bc847c9b68cd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 18:23:19 +0100 Subject: [PATCH 176/237] Use correct field in binary sensor --- custom_components/sat/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index ef409796..e17010cf 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -84,12 +84,12 @@ def device_class(self): @property def available(self): """Return availability of the sensor.""" - return self._climate.relative_modulation_value is not None and self._coordinator.minimum_relative_modulation_value is not None + return self._climate.relative_modulation_value is not None and self._coordinator.maximum_relative_modulation_value is not None @property def is_on(self): """Return the state of the sensor.""" - return int(self._climate.relative_modulation_value) != int(self._coordinator.minimum_relative_modulation_value) + return int(self._climate.relative_modulation_value) != int(self._coordinator.maximum_relative_modulation_value) @property def unique_id(self): From 2716e4844b8be68001f24fa898cb43c29ad1a6a8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 29 Nov 2023 23:48:53 +0100 Subject: [PATCH 177/237] Some sanity checks --- custom_components/sat/mqtt/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 3f847caf..5b32652d 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -90,7 +90,7 @@ def hot_water_setpoint(self) -> float | None: if (setpoint := self._get_entity_state(SENSOR_DOMAIN, DATA_DHW_SETPOINT)) is not None: return float(setpoint) - return None + return super().hot_water_setpoint @property def minimum_hot_water_setpoint(self) -> float: @@ -118,7 +118,7 @@ def relative_modulation_value(self) -> float | None: if (value := self._get_entity_state(SENSOR_DOMAIN, DATA_REL_MOD_LEVEL)) is not None: return float(value) - return None + return super().relative_modulation_value @property def boiler_capacity(self) -> float | None: @@ -209,6 +209,10 @@ async def async_set_control_max_setpoint(self, value: float) -> None: await super().async_set_control_max_setpoint(value) def _get_entity_state(self, domain: str, key: str): + entity_id = self._get_entity_id(domain, key) + if entity_id is None: + return None + state = self.hass.states.get(self._get_entity_id(domain, key)) if state is None or state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None From 935997f854a0f356bce57efaa630f3c2b5ffb311 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 1 Dec 2023 00:38:55 +0100 Subject: [PATCH 178/237] Make sure that the max_ relative modulation is an integer --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/coordinator.py | 2 +- custom_components/sat/fake/__init__.py | 2 +- custom_components/sat/mqtt/__init__.py | 4 ++-- custom_components/sat/serial/__init__.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index cc248c24..c4b5fe69 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -163,8 +163,8 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._thermal_comfort = bool(config_options.get(CONF_THERMAL_COMFORT)) self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) - self._maximum_relative_modulation = config_options.get(CONF_MAXIMUM_RELATIVE_MODULATION) 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._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)) @@ -538,7 +538,7 @@ def relative_modulation_enabled(self) -> bool: return not self.pulse_width_modulation_enabled @property - def relative_modulation_value(self) -> float: + def relative_modulation_value(self) -> int: return self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD @property diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index f146ee1f..ea9d4f10 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -251,7 +251,7 @@ async def async_set_control_max_setpoint(self, value: float) -> None: if self.supports_maximum_setpoint_management: self.logger.info("Set maximum setpoint to %d", value) - async def async_set_control_max_relative_modulation(self, value: float) -> None: + 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) diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index 7d479dae..64ccc0dc 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -91,7 +91,7 @@ async def async_set_control_hot_water_setpoint(self, value: float) -> None: await super().async_set_control_hot_water_setpoint(value) - async def async_set_control_max_relative_modulation(self, value: float) -> None: + async def async_set_control_max_relative_modulation(self, value: int) -> None: self._relative_modulation_value = value await super().async_set_control_max_relative_modulation(value) diff --git a/custom_components/sat/mqtt/__init__.py b/custom_components/sat/mqtt/__init__.py index 5b32652d..dcc806e0 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -198,8 +198,8 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await super().async_set_heater_state(state) - async def async_set_control_max_relative_modulation(self, value: float) -> None: - await self._send_command(f"MM={int(value)}") + async def async_set_control_max_relative_modulation(self, value: int) -> None: + await self._send_command(f"MM={value}") await super().async_set_control_max_relative_modulation(value) diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index d3383b66..5939b6a0 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -181,7 +181,7 @@ async def async_set_heater_state(self, state: DeviceState) -> None: await super().async_set_heater_state(state) - async def async_set_control_max_relative_modulation(self, value: float) -> None: + async def async_set_control_max_relative_modulation(self, value: int) -> None: if not self._simulation: await self._api.set_max_relative_mod(value) From 0dee03398be895acd4c8dc55522409ee25e4a203 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 17:37:05 +0100 Subject: [PATCH 179/237] Add an experimental minimum setpoint calculator and move some code --- custom_components/sat/climate.py | 93 +++++++++----------------- custom_components/sat/summer_simmer.py | 61 +++++++++++++++++ 2 files changed, 91 insertions(+), 63 deletions(-) create mode 100644 custom_components/sat/summer_simmer.py diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c4b5fe69..9367fb9c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -31,18 +31,18 @@ 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, UnitOfTemperature +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.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 homeassistant.util.unit_conversion import TemperatureConverter from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity from .pwm import PWMState +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 ATTR_ROOMS = "rooms" @@ -347,8 +347,9 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "summer_simmer_index": self.summer_simmer_index, - "summer_simmer_perception": self.summer_simmer_perception, + "experimental_minimum_setpoint": self._calculate_minimum_setpoint(), + "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, @@ -368,7 +369,7 @@ def extra_state_attributes(self): def current_temperature(self): """Return the sensor temperature.""" if self._thermal_comfort and self._current_humidity is not None: - return self.summer_simmer_index + return SummerSimmer.index(self.current_temperature, self.current_humidity) return self._current_temperature @@ -541,64 +542,6 @@ def relative_modulation_enabled(self) -> bool: def relative_modulation_value(self) -> int: return self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD - @property - def summer_simmer_index(self) -> float | None: - """ - Calculate the Summer Simmer Index. - - The Summer Simmer Index is a measure of heat and humidity. - - Formula: 1.98 * (F - (0.55 - 0.0055 * H) * (F - 58.0)) - 56.83 - If F < 58, the index is F. - - Returns: - float: Summer Simmer Index in Celsius. - """ - # Make sure we have a valid humidity value - if self._current_humidity is None: - return None - - # Convert temperature to Fahrenheit - fahrenheit = TemperatureConverter.convert( - self._current_temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) - - # Calculate Summer Simmer Index - index = 1.98 * (fahrenheit - (0.55 - 0.0055 * self._current_humidity) * (fahrenheit - 58.0)) - 56.83 - - # If the temperature is below 58°F, use the temperature as the index - if fahrenheit < 58: - index = fahrenheit - - # Convert the result back to Celsius - return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) - - @property - def summer_simmer_perception(self) -> str: - """.""" - index = self.summer_simmer_index - - if index is None: - return "Unknown" - elif index < 21.1: - return "Cool" - elif index < 25.0: - return "Slightly Cool" - elif index < 28.3: - return "Comfortable" - elif index < 32.8: - return "Slightly Warm" - elif index < 37.8: - return "Increasing Discomfort" - elif index < 44.4: - return "Extremely Warm" - elif index < 51.7: - return "Danger Of Heatstroke" - elif index < 65.6: - return "Extreme Danger Of Heatstroke" - else: - return "Circulatory Collapse Imminent" - def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self.heating_curve.value is None: @@ -614,6 +557,30 @@ def _calculate_control_setpoint(self) -> float: # Ensure setpoint is limited to our max return min(requested_setpoint, self._coordinator.maximum_setpoint) + def _calculate_minimum_setpoint(self, adjustment_percentage=10) -> float: + # Extract relevant values from the coordinator for clarity + boiler_temperature = self._coordinator.boiler_temperature + target_setpoint_temperature = self._coordinator.setpoint + minimum_setpoint = self._coordinator.minimum_setpoint + is_flame_active = self._coordinator.flame_active + + # Check if either boiler_temperature or target_setpoint_temperature is None + if boiler_temperature is None or target_setpoint_temperature is None: + return minimum_setpoint + + # Check if the boiler temperature is stable at the target temperature + is_temperature_stable = abs(boiler_temperature - target_setpoint_temperature) <= 1 + + if is_temperature_stable: + # Boiler temperature is stable, return the coordinator's minimum setpoint + return minimum_setpoint + + # Calculate the adjustment value based on the specified percentage + adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) + + # Determine the minimum setpoint based on flame state and adjustment + return max(boiler_temperature, target_setpoint_temperature - adjustment_value) if is_flame_active else minimum_setpoint + 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/summer_simmer.py b/custom_components/sat/summer_simmer.py new file mode 100644 index 00000000..156890aa --- /dev/null +++ b/custom_components/sat/summer_simmer.py @@ -0,0 +1,61 @@ +from homeassistant.const import UnitOfTemperature +from homeassistant.util.unit_conversion import TemperatureConverter + + +class SummerSimmer: + @staticmethod + def index(temperature: float, humidity: float) -> float | None: + """ + Calculate the Summer Simmer Index. + + The Summer Simmer Index is a measure of heat and humidity. + + Formula: 1.98 * (F - (0.55 - 0.0055 * H) * (F - 58.0)) - 56.83 + If F < 58, the index is F. + + Returns: + float: Summer Simmer Index in Celsius. + """ + # Make sure we have a valid values + if temperature is None or humidity is None: + return None + + # Convert temperature to Fahrenheit + fahrenheit = TemperatureConverter.convert( + temperature, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + ) + + # Calculate Summer Simmer Index + index = 1.98 * (fahrenheit - (0.55 - 0.0055 * humidity) * (fahrenheit - 58.0)) - 56.83 + + # If the temperature is below 58°F, use the temperature as the index + if fahrenheit < 58: + index = fahrenheit + + # Convert the result back to Celsius + return round(TemperatureConverter.convert(index, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS), 1) + + @staticmethod + def perception(temperature: float, humidity: float) -> str: + index = SummerSimmer.index(temperature, humidity) + + if index is None: + return "Unknown" + elif index < 21.1: + return "Cool" + elif index < 25.0: + return "Slightly Cool" + elif index < 28.3: + return "Comfortable" + elif index < 32.8: + return "Slightly Warm" + elif index < 37.8: + return "Increasing Discomfort" + elif index < 44.4: + return "Extremely Warm" + elif index < 51.7: + return "Danger Of Heatstroke" + elif index < 65.6: + return "Extreme Danger Of Heatstroke" + else: + return "Circulatory Collapse Imminent" From 134b3e7b99e1ff217713c4622889771a065738ed Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 17:38:15 +0100 Subject: [PATCH 180/237] Fixed some infinite looping --- custom_components/sat/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 9367fb9c..3d2ec0aa 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -348,8 +348,8 @@ def extra_state_attributes(self): "setpoint": self._setpoint, "current_humidity": self._current_humidity, "experimental_minimum_setpoint": self._calculate_minimum_setpoint(), - "summer_simmer_index": SummerSimmer.index(self.current_temperature, self.current_humidity), - "summer_simmer_perception": SummerSimmer.perception(self.current_temperature, 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, @@ -369,7 +369,7 @@ def extra_state_attributes(self): def current_temperature(self): """Return the sensor temperature.""" if self._thermal_comfort and self._current_humidity is not None: - return SummerSimmer.index(self.current_temperature, self.current_humidity) + return SummerSimmer.index(self._current_temperature, self._current_humidity) return self._current_temperature From 84e855bec1abf56ee582c1949b6b762502ec4bf7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 17:51:31 +0100 Subject: [PATCH 181/237] Keep track of the minimum setpoints --- custom_components/sat/climate.py | 33 ++++------------ custom_components/sat/minimum_setpoint.py | 46 +++++++++++++++++++++++ 2 files changed, 54 insertions(+), 25 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 3d2ec0aa..5f034051 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -41,6 +41,7 @@ from .const import * from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity +from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState 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 @@ -125,6 +126,9 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn # Create PWM controller with given configuration options self.pwm = create_pwm_controller(self.heating_curve, self._coordinator.minimum_setpoint, config_entry.data, config_options) + # Create the Minimum Setpoint controller + self.minimum_setpoint = MinimumSetpoint(coordinator) + self._sensors = [] self._rooms = None self._setpoint = None @@ -347,7 +351,7 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "experimental_minimum_setpoint": self._calculate_minimum_setpoint(), + "experimental_minimum_setpoint": self.minimum_setpoint.current(), "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, @@ -557,30 +561,6 @@ def _calculate_control_setpoint(self) -> float: # Ensure setpoint is limited to our max return min(requested_setpoint, self._coordinator.maximum_setpoint) - def _calculate_minimum_setpoint(self, adjustment_percentage=10) -> float: - # Extract relevant values from the coordinator for clarity - boiler_temperature = self._coordinator.boiler_temperature - target_setpoint_temperature = self._coordinator.setpoint - minimum_setpoint = self._coordinator.minimum_setpoint - is_flame_active = self._coordinator.flame_active - - # Check if either boiler_temperature or target_setpoint_temperature is None - if boiler_temperature is None or target_setpoint_temperature is None: - return minimum_setpoint - - # Check if the boiler temperature is stable at the target temperature - is_temperature_stable = abs(boiler_temperature - target_setpoint_temperature) <= 1 - - if is_temperature_stable: - # Boiler temperature is stable, return the coordinator's minimum setpoint - return minimum_setpoint - - # Calculate the adjustment value based on the specified percentage - adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) - - # Determine the minimum setpoint based on flame state and adjustment - return max(boiler_temperature, target_setpoint_temperature - adjustment_value) if is_flame_active else minimum_setpoint - async def _async_inside_sensor_changed(self, event: Event) -> None: """Handle changes to the inside temperature sensor.""" new_state = event.data.get("new_state") @@ -868,6 +848,9 @@ async def async_control_heating_loop(self, _time=None) -> None: # Control the integral (if exceeded the time limit) self.pid.update_integral(self.max_error, self.heating_curve.value) + # Calculate the minimum setpoint + self.minimum_setpoint.calculate() + # If the setpoint is high and the HVAC is not off, turn on the heater if self._setpoint > MINIMUM_SETPOINT and self.hvac_mode != HVACMode.OFF: await self.async_set_heater_state(DeviceState.ON) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py new file mode 100644 index 00000000..ff0688a0 --- /dev/null +++ b/custom_components/sat/minimum_setpoint.py @@ -0,0 +1,46 @@ +from custom_components.sat.coordinator import SatDataUpdateCoordinator + +MOVING_AVERAGE_WINDOW = 10 + + +class MinimumSetpoint: + def __init__(self, coordinator: SatDataUpdateCoordinator): + self._coordinator = coordinator + self._previous_minimum_setpoints = [] + + def calculate(self, adjustment_percentage=10) -> float: + # Extract relevant values from the coordinator for clarity + boiler_temperature = self._coordinator.boiler_temperature + target_setpoint_temperature = self._coordinator.setpoint + minimum_setpoint = self._coordinator.minimum_setpoint + is_flame_active = self._coordinator.flame_active + + # Check if either boiler_temperature or target_setpoint_temperature is None + if boiler_temperature is None or target_setpoint_temperature is None: + return minimum_setpoint + + # Check if the boiler temperature is stable at the target temperature + is_temperature_stable = abs(boiler_temperature - target_setpoint_temperature) <= 1 + + if is_temperature_stable: + # Boiler temperature is stable, return the coordinator's minimum setpoint + return minimum_setpoint + + # Calculate the adjustment value based on the specified percentage + adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) + + # Determine the minimum setpoint based on flame state and adjustment + adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) if is_flame_active else minimum_setpoint + + # Keep track of the previous minimum setpoints + self._previous_minimum_setpoints.append(adjusted_setpoint) + + # Maintain a moving average over the specified window size + if len(self._previous_minimum_setpoints) > MOVING_AVERAGE_WINDOW: + self._previous_minimum_setpoints.pop(0) + + def current(self) -> float: + if len(self._previous_minimum_setpoints) < 2: + return self._coordinator.minimum_setpoint + + return sum(self._previous_minimum_setpoints) / len(self._previous_minimum_setpoints) From 5e6b9e36945dc29d5c0d7b4ffde99700c58ca156 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 18:02:03 +0100 Subject: [PATCH 182/237] Save the previous boiler temperature --- custom_components/sat/pwm.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index a691e525..ce281edf 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -29,6 +29,7 @@ class PWM: def __init__(self, heating_curve: HeatingCurve, minimum_setpoint: float, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._force = force + self._last_boiler_temperature = None self._last_duty_cycle_percentage = None self._heating_curve = heating_curve @@ -58,8 +59,11 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") return + if boiler_temperature is not None and self._last_boiler_temperature is None: + self._last_boiler_temperature = boiler_temperature + elapsed = monotonic() - self._last_update - self._duty_cycle = self._calculate_duty_cycle(requested_setpoint, boiler_temperature or 0) + self._duty_cycle = self._calculate_duty_cycle(requested_setpoint) if self._duty_cycle is None: self._state = PWMState.IDLE @@ -73,6 +77,7 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON self._last_update = monotonic() + self._last_boiler_temperature = boiler_temperature or 0 _LOGGER.debug("Starting duty cycle.") return @@ -84,12 +89,12 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Cycle time elapsed %.0f seconds in %s", elapsed, self._state) - def _calculate_duty_cycle(self, requested_setpoint: float, boiler_temperature: float) -> Optional[Tuple[int, int]]: + def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" - minimum_setpoint = boiler_temperature + minimum_setpoint = self._last_boiler_temperature base_offset = self._heating_curve.base_offset - if boiler_temperature < base_offset: + if self._last_boiler_temperature < base_offset: minimum_setpoint = base_offset + 1 self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) From 522262f5267418d7ae25865a60d7fcbe06469934 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 18:23:38 +0100 Subject: [PATCH 183/237] Do not use a time average, use an exponential moving average --- custom_components/sat/minimum_setpoint.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index ff0688a0..cc288895 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,12 +1,11 @@ from custom_components.sat.coordinator import SatDataUpdateCoordinator -MOVING_AVERAGE_WINDOW = 10 - class MinimumSetpoint: def __init__(self, coordinator: SatDataUpdateCoordinator): + self._alpha = 0.2 self._coordinator = coordinator - self._previous_minimum_setpoints = [] + self._previous_minimum_setpoint = None def calculate(self, adjustment_percentage=10) -> float: # Extract relevant values from the coordinator for clarity @@ -32,15 +31,19 @@ def calculate(self, adjustment_percentage=10) -> float: # Determine the minimum setpoint based on flame state and adjustment adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) if is_flame_active else minimum_setpoint - # Keep track of the previous minimum setpoints - self._previous_minimum_setpoints.append(adjusted_setpoint) + # Update the exponential moving average + if self._previous_minimum_setpoint is None: + # If it's the first observation, set the EMA to the current value + ema = adjusted_setpoint + else: + # Update the EMA using the smoothing factor (alpha) + ema = self._alpha * adjusted_setpoint + (1 - self._alpha) * self._previous_minimum_setpoint - # Maintain a moving average over the specified window size - if len(self._previous_minimum_setpoints) > MOVING_AVERAGE_WINDOW: - self._previous_minimum_setpoints.pop(0) + # Keep track of the current EMA for future calculations + self._previous_minimum_setpoint = ema def current(self) -> float: - if len(self._previous_minimum_setpoints) < 2: + if self._previous_minimum_setpoint is None: return self._coordinator.minimum_setpoint - return sum(self._previous_minimum_setpoints) / len(self._previous_minimum_setpoints) + return self._previous_minimum_setpoint From 0c57c83a77f5d79c8d31f5478d038ab00c06c4fd Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 19:32:55 +0100 Subject: [PATCH 184/237] Do nothing when the temperature is below target --- custom_components/sat/minimum_setpoint.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index cc288895..52d6fbb6 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -7,23 +7,18 @@ def __init__(self, coordinator: SatDataUpdateCoordinator): self._coordinator = coordinator self._previous_minimum_setpoint = None - def calculate(self, adjustment_percentage=10) -> float: + def calculate(self, adjustment_percentage=10) -> None: # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature target_setpoint_temperature = self._coordinator.setpoint minimum_setpoint = self._coordinator.minimum_setpoint is_flame_active = self._coordinator.flame_active - # Check if either boiler_temperature or target_setpoint_temperature is None if boiler_temperature is None or target_setpoint_temperature is None: - return minimum_setpoint + return - # Check if the boiler temperature is stable at the target temperature - is_temperature_stable = abs(boiler_temperature - target_setpoint_temperature) <= 1 - - if is_temperature_stable: - # Boiler temperature is stable, return the coordinator's minimum setpoint - return minimum_setpoint + if boiler_temperature < (target_setpoint_temperature - 1): + return # Calculate the adjustment value based on the specified percentage adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) From 9f3ed7fafa414f9fe2c6a8be4dfdf470b90c645f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 19:33:33 +0100 Subject: [PATCH 185/237] Also do nothing flame is not active --- custom_components/sat/minimum_setpoint.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 52d6fbb6..e9d3c27d 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -17,14 +17,14 @@ def calculate(self, adjustment_percentage=10) -> None: if boiler_temperature is None or target_setpoint_temperature is None: return - if boiler_temperature < (target_setpoint_temperature - 1): + if not is_flame_active or boiler_temperature < (target_setpoint_temperature - 1): return # Calculate the adjustment value based on the specified percentage adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) # Determine the minimum setpoint based on flame state and adjustment - adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) if is_flame_active else minimum_setpoint + adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) # Update the exponential moving average if self._previous_minimum_setpoint is None: From b580519b72a111960948d1b3e06963f4b823f161 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 3 Dec 2023 19:33:44 +0100 Subject: [PATCH 186/237] Cleanup --- custom_components/sat/minimum_setpoint.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index e9d3c27d..58374aeb 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -11,7 +11,6 @@ def calculate(self, adjustment_percentage=10) -> None: # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature target_setpoint_temperature = self._coordinator.setpoint - minimum_setpoint = self._coordinator.minimum_setpoint is_flame_active = self._coordinator.flame_active if boiler_temperature is None or target_setpoint_temperature is None: From b566d4ac8731cdbe77e797eb78d298dd3039b21c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 4 Dec 2023 20:02:25 +0100 Subject: [PATCH 187/237] Store the adjusted minimum setpoints for later use --- custom_components/sat/climate.py | 9 +++-- custom_components/sat/minimum_setpoint.py | 40 +++++++++++------------ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5f034051..26f299ed 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_ADJUSTED_MINIMUM_SETPOINTS = "adjusted_minimum_setpoints" SENSOR_TEMPERATURE_ID = "sensor_temperature_id" @@ -295,6 +296,9 @@ async def _restore_previous_state_or_set_defaults(self): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: await self._async_update_rooms_from_climates() + + if old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS): + self.minimum_setpoint.restore(old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS)) else: if self._rooms is None: await self._async_update_rooms_from_climates() @@ -351,7 +355,6 @@ def extra_state_attributes(self): "rooms": self._rooms, "setpoint": self._setpoint, "current_humidity": self._current_humidity, - "experimental_minimum_setpoint": self.minimum_setpoint.current(), "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, @@ -359,6 +362,8 @@ def extra_state_attributes(self): "valves_open": self.valves_open, "heating_curve": self.heating_curve.value, "minimum_setpoint": self._coordinator.minimum_setpoint, + "adjusted_minimum_setpoint": self.minimum_setpoint.current(), + "adjusted_minimum_setpoints": self.minimum_setpoint.cache(), "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -837,7 +842,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.requested_setpoint, self._coordinator.boiler_temperature) + await self.pwm.update(self.requested_setpoint, self._coordinator.boiler_temperature or 0) # 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/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 58374aeb..774ea76a 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -5,39 +5,39 @@ class MinimumSetpoint: def __init__(self, coordinator: SatDataUpdateCoordinator): self._alpha = 0.2 self._coordinator = coordinator - self._previous_minimum_setpoint = None + self._adjusted_setpoints = {} - def calculate(self, adjustment_percentage=10) -> None: + def restore(self, adjusted_setpoints): + self._adjusted_setpoints = adjusted_setpoints + + def calculate(self, adjustment_percentage=10): # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature target_setpoint_temperature = self._coordinator.setpoint is_flame_active = self._coordinator.flame_active + # Check for None values if boiler_temperature is None or target_setpoint_temperature is None: return - if not is_flame_active or boiler_temperature < (target_setpoint_temperature - 1): + # Check for flame activity and if we are stable + if not is_flame_active or abs(target_setpoint_temperature - boiler_temperature) <= 1: return - # Calculate the adjustment value based on the specified percentage - adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) - # Determine the minimum setpoint based on flame state and adjustment - adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) + raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_percentage) - # Update the exponential moving average - if self._previous_minimum_setpoint is None: - # If it's the first observation, set the EMA to the current value - ema = adjusted_setpoint - else: - # Update the EMA using the smoothing factor (alpha) - ema = self._alpha * adjusted_setpoint + (1 - self._alpha) * self._previous_minimum_setpoint + # Use the moving average to adjust the calculated setpoint + adjusted_setpoint = raw_adjusted_setpoint + if target_setpoint_temperature in self._adjusted_setpoints: + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[target_setpoint_temperature] - # Keep track of the current EMA for future calculations - self._previous_minimum_setpoint = ema + # Keep track of the adjusted setpoint for the current target setpoint + self._adjusted_setpoints[target_setpoint_temperature] = round(adjusted_setpoint, 1) - def current(self) -> float: - if self._previous_minimum_setpoint is None: - return self._coordinator.minimum_setpoint + def current(self): + # Return the adjusted setpoint if available, else return the configured minimum setpoint + return self._adjusted_setpoints.get(self._coordinator.setpoint, self._coordinator.minimum_setpoint) - return self._previous_minimum_setpoint + def cache(self): + return self._adjusted_setpoints From 60031d1c81a3a05bcd93a5b8ee608d38b892eb72 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 4 Dec 2023 20:06:30 +0100 Subject: [PATCH 188/237] 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 26f299ed..5f7197c5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -842,7 +842,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.requested_setpoint, self._coordinator.boiler_temperature or 0) + await self.pwm.update(self.requested_setpoint, self._coordinator.boiler_temperature) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) From 4beb7c0fc216c2cda4f79a9c182325c6cedc9f87 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 4 Dec 2023 20:32:56 +0100 Subject: [PATCH 189/237] Used the climate error states as a cache key --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/minimum_setpoint.py | 28 ++++++++++++++++++----- custom_components/sat/pwm.py | 4 ++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5f7197c5..58868bb5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -362,7 +362,7 @@ def extra_state_attributes(self): "valves_open": self.valves_open, "heating_curve": self.heating_curve.value, "minimum_setpoint": self._coordinator.minimum_setpoint, - "adjusted_minimum_setpoint": self.minimum_setpoint.current(), + "adjusted_minimum_setpoint": self.minimum_setpoint.current([self.error] + self.climate_errors), "adjusted_minimum_setpoints": self.minimum_setpoint.cache(), "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, @@ -854,7 +854,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self.pid.update_integral(self.max_error, self.heating_curve.value) # Calculate the minimum setpoint - self.minimum_setpoint.calculate() + self.minimum_setpoint.calculate([self.error] + self.climate_errors) # If the setpoint is high and the HVAC is not off, turn on the heater if self._setpoint > MINIMUM_SETPOINT and self.hvac_mode != HVACMode.OFF: diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 774ea76a..869f43f7 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,3 +1,5 @@ +import hashlib + from custom_components.sat.coordinator import SatDataUpdateCoordinator @@ -10,7 +12,10 @@ def __init__(self, coordinator: SatDataUpdateCoordinator): def restore(self, adjusted_setpoints): self._adjusted_setpoints = adjusted_setpoints - def calculate(self, adjustment_percentage=10): + def calculate(self, climate_errors, adjustment_percentage=10): + # Calculate a hash key + hash_key = self._get_cache_key(climate_errors) + # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature target_setpoint_temperature = self._coordinator.setpoint @@ -29,15 +34,26 @@ def calculate(self, adjustment_percentage=10): # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint - if target_setpoint_temperature in self._adjusted_setpoints: - adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[target_setpoint_temperature] + if hash_key in self._adjusted_setpoints: + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key] # Keep track of the adjusted setpoint for the current target setpoint - self._adjusted_setpoints[target_setpoint_temperature] = round(adjusted_setpoint, 1) + self._adjusted_setpoints[hash_key] = round(adjusted_setpoint, 1) + + def current(self, climate_errors): + # Get the cache key + cache_key = self._get_cache_key(climate_errors) - def current(self): # Return the adjusted setpoint if available, else return the configured minimum setpoint - return self._adjusted_setpoints.get(self._coordinator.setpoint, self._coordinator.minimum_setpoint) + return self._adjusted_setpoints.get(cache_key, self._coordinator.minimum_setpoint) def cache(self): return self._adjusted_setpoints + + @staticmethod + def _get_cache_key(climate_errors) -> str: + cache_hash = hashlib.sha256() + cache_hash.update(','.join(map(str, climate_errors)).encode('utf-8')) + + # Create a hash from the error values, so we can use cache + return cache_hash.hexdigest() diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index ce281edf..f5904753 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -91,10 +91,10 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" - minimum_setpoint = self._last_boiler_temperature + minimum_setpoint = self._last_boiler_temperature or requested_setpoint base_offset = self._heating_curve.base_offset - if self._last_boiler_temperature < base_offset: + if minimum_setpoint < base_offset: minimum_setpoint = base_offset + 1 self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) From 0a1a1ff56c42564bcf2997047da9e34c52bb0c83 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 4 Dec 2023 20:45:54 +0100 Subject: [PATCH 190/237] Cleanup --- custom_components/sat/minimum_setpoint.py | 25 +++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 869f43f7..7fdc8399 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,9 +1,16 @@ import hashlib +from typing import List from custom_components.sat.coordinator import SatDataUpdateCoordinator class MinimumSetpoint: + @staticmethod + def _get_cache_key(errors: List[float]) -> str: + errors_str = ','.join(map(str, errors)) + cache_hash = hashlib.sha256(errors_str.encode('utf-8')) + return cache_hash.hexdigest() + def __init__(self, coordinator: SatDataUpdateCoordinator): self._alpha = 0.2 self._coordinator = coordinator @@ -12,9 +19,9 @@ def __init__(self, coordinator: SatDataUpdateCoordinator): def restore(self, adjusted_setpoints): self._adjusted_setpoints = adjusted_setpoints - def calculate(self, climate_errors, adjustment_percentage=10): + def calculate(self, errors, adjustment_percentage=10): # Calculate a hash key - hash_key = self._get_cache_key(climate_errors) + hash_key = self._get_cache_key(errors) # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature @@ -40,20 +47,12 @@ def calculate(self, climate_errors, adjustment_percentage=10): # Keep track of the adjusted setpoint for the current target setpoint self._adjusted_setpoints[hash_key] = round(adjusted_setpoint, 1) - def current(self, climate_errors): + def current(self, errors) -> float: # Get the cache key - cache_key = self._get_cache_key(climate_errors) + cache_key = self._get_cache_key(errors) # Return the adjusted setpoint if available, else return the configured minimum setpoint return self._adjusted_setpoints.get(cache_key, self._coordinator.minimum_setpoint) - def cache(self): + def cache(self) -> dict[str, float]: return self._adjusted_setpoints - - @staticmethod - def _get_cache_key(climate_errors) -> str: - cache_hash = hashlib.sha256() - cache_hash.update(','.join(map(str, climate_errors)).encode('utf-8')) - - # Create a hash from the error values, so we can use cache - return cache_hash.hexdigest() From 8b02960612a6b4c28b9ffe60692a78feb0343ec0 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 5 Dec 2023 10:00:12 +0100 Subject: [PATCH 191/237] Also update the boiler temperature when the PWM is ON every tick --- custom_components/sat/pwm.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index f5904753..e53e831e 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -74,6 +74,9 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) + if self._state == PWMState.ON: + self._last_boiler_temperature = boiler_temperature or 0 + if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON self._last_update = monotonic() From 9db3a9b0bf0044a025b78ccbd024d5a29cdfb729 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 5 Dec 2023 10:00:53 +0100 Subject: [PATCH 192/237] Only update if the value is valid --- custom_components/sat/pwm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index e53e831e..42688722 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -74,8 +74,8 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) - if self._state == PWMState.ON: - self._last_boiler_temperature = boiler_temperature or 0 + if self._state == PWMState.ON and boiler_temperature is not None: + self._last_boiler_temperature = boiler_temperature if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON From 47eb899dc45b7415cdbe8d9c8611bdc16320074f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 5 Dec 2023 18:39:09 +0100 Subject: [PATCH 193/237] Add support to enable dynamic minimum setpoint --- custom_components/sat/climate.py | 42 ++++++++----- custom_components/sat/config_flow.py | 3 +- custom_components/sat/const.py | 2 + custom_components/sat/minimum_setpoint.py | 72 +++++++++++++++++----- custom_components/sat/pwm.py | 13 ++-- custom_components/sat/translations/en.json | 5 +- custom_components/sat/util.py | 4 +- 7 files changed, 100 insertions(+), 41 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 58868bb5..31742bdd 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -44,7 +44,8 @@ from .minimum_setpoint import MinimumSetpoint from .pwm import PWMState 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 +from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, convert_time_str_to_seconds, \ + calculate_derivative_per_hour ATTR_ROOMS = "rooms" ATTR_WARMING_UP = "warming_up_data" @@ -125,10 +126,10 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn 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, self._coordinator.minimum_setpoint, config_entry.data, config_options) + self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) # Create the Minimum Setpoint controller - self.minimum_setpoint = MinimumSetpoint(coordinator) + self._minimum_setpoint = MinimumSetpoint(coordinator) self._sensors = [] self._rooms = None @@ -168,6 +169,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._thermal_comfort = bool(config_options.get(CONF_THERMAL_COMFORT)) self._climate_valve_offset = float(config_options.get(CONF_CLIMATE_VALVE_OFFSET)) self._target_temperature_step = float(config_options.get(CONF_TARGET_TEMPERATURE_STEP)) + self._dynamic_minimum_setpoint = bool(config_options.get(CONF_DYNAMIC_MINIMUM_SETPOINT)) 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._force_pulse_width_modulation = bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) @@ -298,7 +300,7 @@ async def _restore_previous_state_or_set_defaults(self): await self._async_update_rooms_from_climates() if old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS): - self.minimum_setpoint.restore(old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS)) + self._minimum_setpoint.restore(old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS)) else: if self._rooms is None: await self._async_update_rooms_from_climates() @@ -361,9 +363,9 @@ def extra_state_attributes(self): "warming_up_derivative": self._warming_up_derivative, "valves_open": self.valves_open, "heating_curve": self.heating_curve.value, - "minimum_setpoint": self._coordinator.minimum_setpoint, - "adjusted_minimum_setpoint": self.minimum_setpoint.current([self.error] + self.climate_errors), - "adjusted_minimum_setpoints": self.minimum_setpoint.cache(), + "minimum_setpoint": self.minimum_setpoint, + "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, + "adjusted_minimum_setpoints": self._minimum_setpoint.cache, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -471,7 +473,7 @@ def climate_errors(self) -> List[float]: target_temperature = float(state.attributes.get("temperature")) current_temperature = float(state.attributes.get("current_temperature") or target_temperature) - # Retrieve the overriden sensor temperature if set + # Retrieve the overridden sensor temperature if set if sensor_temperature_id := state.attributes.get(SENSOR_TEMPERATURE_ID): sensor_state = self.hass.states.get(sensor_temperature_id) if sensor_state is not None and sensor_state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, HVACMode.OFF]: @@ -531,7 +533,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._coordinator.minimum_setpoint - 2) + return self._overshoot_protection and self._calculate_control_setpoint() < (self.minimum_setpoint - 2) @property def relative_modulation_enabled(self) -> bool: @@ -551,6 +553,17 @@ def relative_modulation_enabled(self) -> bool: def relative_modulation_value(self) -> int: return self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD + @property + def minimum_setpoint(self) -> float: + if not self._dynamic_minimum_setpoint: + return self._coordinator.minimum_setpoint + + return self.adjusted_minimum_setpoint + + @property + def adjusted_minimum_setpoint(self) -> float: + return self._minimum_setpoint.current(self._coordinator.setpoint, [self.error] + self.climate_errors) + def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" if self.heating_curve.value is None: @@ -650,7 +663,8 @@ async def _async_climate_changed(self, event: Event) -> None: await self._async_control_pid(True) # If the current temperature has changed, update the PID controller - elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get("current_temperature"): + elif not hasattr(new_state.attributes, SENSOR_TEMPERATURE_ID) and new_attrs.get("current_temperature") != old_attrs.get( + "current_temperature"): await self._async_control_pid(False) if (self._rooms is not None and new_state.entity_id not in self._rooms) or self.preset_mode in [PRESET_HOME, PRESET_COMFORT]: @@ -776,10 +790,10 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self.pulse_width_modulation_enabled or pwm_state == pwm_state.IDLE: _LOGGER.info("Running Normal cycle") - self._setpoint = max(self._coordinator.minimum_setpoint, mean(list(self._outputs)[-5:])) + self._setpoint = max(self.minimum_setpoint, mean(list(self._outputs)[-5:])) else: _LOGGER.info(f"Running PWM cycle: {pwm_state}") - self._setpoint = self._coordinator.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT + self._setpoint = self.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT else: self._outputs.clear() self._setpoint = MINIMUM_SETPOINT @@ -842,7 +856,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.requested_setpoint, self._coordinator.boiler_temperature) + await self.pwm.update(self.minimum_setpoint, self.requested_setpoint, self._coordinator.boiler_temperature) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -854,7 +868,7 @@ async def async_control_heating_loop(self, _time=None) -> None: self.pid.update_integral(self.max_error, self.heating_curve.value) # Calculate the minimum setpoint - self.minimum_setpoint.calculate([self.error] + self.climate_errors) + self._minimum_setpoint.calculate(self._coordinator.setpoint, [self.error] + self.climate_errors) # If the setpoint is high and the HVAC is not off, turn on the heater if self._setpoint > MINIMUM_SETPOINT and self.hvac_mode != HVACMode.OFF: diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 29c28f3a..32c8db68 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -509,7 +509,8 @@ async def async_step_advanced(self, _user_input=None) -> FlowResult: schema = { vol.Required(CONF_SIMULATION, default=options[CONF_SIMULATION]): bool, - vol.Required(CONF_THERMAL_COMFORT, default=options[CONF_THERMAL_COMFORT]): bool + vol.Required(CONF_THERMAL_COMFORT, default=options[CONF_THERMAL_COMFORT]): bool, + vol.Required(CONF_DYNAMIC_MINIMUM_SETPOINT, default=options[CONF_DYNAMIC_MINIMUM_SETPOINT]): bool } if options.get(CONF_MODE) in [MODE_MQTT, MODE_SERIAL, MODE_SIMULATOR]: diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index fe7388e2..a75d98f7 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -58,6 +58,7 @@ 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_SYSTEM = "heating_system" CONF_HEATING_CURVE_COEFFICIENT = "heating_curve_coefficient" @@ -85,6 +86,7 @@ CONF_AUTOMATIC_DUTY_CYCLE: True, CONF_AUTOMATIC_GAINS_VALUE: 5.0, CONF_OVERSHOOT_PROTECTION: False, + CONF_DYNAMIC_MINIMUM_SETPOINT: False, CONF_SECONDARY_CLIMATES: [], CONF_MAIN_CLIMATES: [], diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 7fdc8399..fc07399b 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,27 +1,55 @@ import hashlib +import logging +import time +from datetime import timedelta from typing import List from custom_components.sat.coordinator import SatDataUpdateCoordinator +_LOGGER = logging.getLogger(__name__) -class MinimumSetpoint: - @staticmethod - def _get_cache_key(errors: List[float]) -> str: - errors_str = ','.join(map(str, errors)) - cache_hash = hashlib.sha256(errors_str.encode('utf-8')) - return cache_hash.hexdigest() +def _is_valid(data): + _LOGGER.debug(data) + if not isinstance(data, dict): + return False + + if not 'value' in data or not isinstance(data['value'], float): + _LOGGER.debug("Value not found") + return False + + if not 'timestamp' in data or not isinstance(data['timestamp'], int): + _LOGGER.debug("Timestamp not found") + return False + + return True + + +class MinimumSetpoint: def __init__(self, coordinator: SatDataUpdateCoordinator): self._alpha = 0.2 self._coordinator = coordinator self._adjusted_setpoints = {} + @staticmethod + def _get_cache_key(setpoint: float, errors: List[float]) -> str: + errors_str = str(setpoint) + ','.join(map(str, errors)) + cache_hash = hashlib.sha256(errors_str.encode('utf-8')) + return cache_hash.hexdigest() + def restore(self, adjusted_setpoints): self._adjusted_setpoints = adjusted_setpoints - def calculate(self, errors, adjustment_percentage=10): - # Calculate a hash key - hash_key = self._get_cache_key(errors) + def calculate(self, setpoint: float, errors: List[float], adjustment_percentage=10): + # Check for a valid setpoint + if setpoint is None: + return + + # Calculate a cache key for adjusted setpoints + hash_key = self._get_cache_key(setpoint, errors) + + # Cleanup old setpoints + self._cleanup_old_setpoints() # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature @@ -42,17 +70,31 @@ def calculate(self, errors, adjustment_percentage=10): # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint if hash_key in self._adjusted_setpoints: - adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key] + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key]['value'] - # Keep track of the adjusted setpoint for the current target setpoint - self._adjusted_setpoints[hash_key] = round(adjusted_setpoint, 1) + # Keep track of the adjusted setpoint and update the timestamp + self._adjusted_setpoints[hash_key] = {'value': round(adjusted_setpoint, 1), 'setpoint': setpoint, 'errors': errors, + 'timestamp': int(time.time())} - def current(self, errors) -> float: + def current(self, setpoint: float, errors: List[float]) -> float: # Get the cache key - cache_key = self._get_cache_key(errors) + cache_key = self._get_cache_key(setpoint, errors) # Return the adjusted setpoint if available, else return the configured minimum setpoint - return self._adjusted_setpoints.get(cache_key, self._coordinator.minimum_setpoint) + return self._adjusted_setpoints.get(cache_key, {'value': self._coordinator.minimum_setpoint})['value'] + @property def cache(self) -> dict[str, float]: return self._adjusted_setpoints + + def _cleanup_old_setpoints(self): + outdated_keys = [ + key + for key, data in self._adjusted_setpoints.items() + if (not _is_valid(data) or (int(time.time()) - data['timestamp']) > timedelta(days=7).total_seconds()) + ] + + _LOGGER.debug(outdated_keys) + + for key in outdated_keys: + del self._adjusted_setpoints[key] diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 42688722..6fc5c19f 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -26,7 +26,7 @@ class PWMState(str, Enum): class PWM: """A class for implementing Pulse Width Modulation (PWM) control.""" - def __init__(self, heating_curve: HeatingCurve, minimum_setpoint: float, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): + def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._force = force self._last_boiler_temperature = None @@ -34,7 +34,6 @@ def __init__(self, heating_curve: HeatingCurve, minimum_setpoint: float, max_cyc self._heating_curve = heating_curve self._max_cycle_time = max_cycle_time - self._minimum_setpoint = minimum_setpoint self._automatic_duty_cycle = automatic_duty_cycle self.reset() @@ -45,7 +44,7 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, requested_setpoint: float, boiler_temperature: float) -> None: + async def update(self, minimum_setpoint: float, requested_setpoint: float, boiler_temperature: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE @@ -53,7 +52,7 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.warning("Invalid heating curve value") return - if requested_setpoint is None or (not self._force and requested_setpoint > self._minimum_setpoint): + if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): self._state = PWMState.IDLE self._last_update = monotonic() _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") @@ -77,14 +76,16 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> if self._state == PWMState.ON and boiler_temperature is not None: self._last_boiler_temperature = boiler_temperature - 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): self._state = PWMState.ON self._last_update = monotonic() self._last_boiler_temperature = boiler_temperature or 0 _LOGGER.debug("Starting duty 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): + 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.debug("Finished duty cycle.") diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 0761443d..fb15b0e7 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -201,16 +201,15 @@ "climate_valve_offset": "Climate valve offset", "target_temperature_step": "Target Temperature Step", "maximum_relative_modulation": "Maximum Relative Modulation", - "force_pulse_width_modulation": "Force Pulse Width Modulation" + "force_pulse_width_modulation": "Force Pulse Width Modulation", + "dynamic_minimum_setpoint": "Dynamic Minimum Setpoint (Experimental)" }, "data_description": { - "simulation": "Enable simulation mode to prevent sending commands to the boiler.", "thermal_comfort": "Enable the use of the Simmer Index for thermal comfort adjustment.", "minimum_consumption": "The minimum gas consumption when the boiler is active.", "maximum_consumption": "The maximum gas consumption when the boiler is active.", "climate_valve_offset": "Offset to adjust the opening degree of the climate valve.", "target_temperature_step": "Adjust the target temperature step for fine-tuning comfort levels.", - "force_pulse_width_modulation": "Force the use of Pulse Width Modulation (PWM) for boiler control.", "sample_time": "The minimum time interval between updates to the PID controller.", "maximum_relative_modulation": "Representing the highest modulation level for an efficient heating system." } diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index b8e86803..9c228e86 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -74,7 +74,7 @@ def create_heating_curve_controller(config_data, config_options) -> HeatingCurve return HeatingCurve(heating_system=heating_system, coefficient=coefficient) -def create_pwm_controller(heating_curve: HeatingCurve, minimum_setpoint: float, config_data, config_options) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, config_data, config_options) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options automatic_duty_cycle = bool(config_options.get(CONF_AUTOMATIC_DUTY_CYCLE)) @@ -82,7 +82,7 @@ def create_pwm_controller(heating_curve: HeatingCurve, minimum_setpoint: float, force = bool(config_data.get(CONF_MODE) == MODE_SWITCH) or bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) # Return a new PWM controller instance with the given configuration options - return PWM(heating_curve=heating_curve, minimum_setpoint=minimum_setpoint, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) + return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, force=force) def snake_case(s): From d60a9cd1bae8f89b4bf2a818d2c0239f63c9eec1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 5 Dec 2023 18:39:44 +0100 Subject: [PATCH 194/237] Remove some debug code --- custom_components/sat/minimum_setpoint.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index fc07399b..e49f4ad0 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -10,16 +10,13 @@ def _is_valid(data): - _LOGGER.debug(data) if not isinstance(data, dict): return False if not 'value' in data or not isinstance(data['value'], float): - _LOGGER.debug("Value not found") return False if not 'timestamp' in data or not isinstance(data['timestamp'], int): - _LOGGER.debug("Timestamp not found") return False return True @@ -94,7 +91,5 @@ def _cleanup_old_setpoints(self): if (not _is_valid(data) or (int(time.time()) - data['timestamp']) > timedelta(days=7).total_seconds()) ] - _LOGGER.debug(outdated_keys) - for key in outdated_keys: del self._adjusted_setpoints[key] From 7ebbc2c0017ea36e34e807ba34cd016d8471908b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 5 Dec 2023 19:12:18 +0100 Subject: [PATCH 195/237] Improved collecting and adjusting the minimum setpoint --- custom_components/sat/climate.py | 4 +-- custom_components/sat/minimum_setpoint.py | 44 ++++++++++------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 31742bdd..55f423dd 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -562,7 +562,7 @@ def minimum_setpoint(self) -> float: @property def adjusted_minimum_setpoint(self) -> float: - return self._minimum_setpoint.current(self._coordinator.setpoint, [self.error] + self.climate_errors) + return self._minimum_setpoint.current([self.error] + self.climate_errors) def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" @@ -856,7 +856,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.minimum_setpoint, self.requested_setpoint, self._coordinator.boiler_temperature) + await self.pwm.update(self.minimum_setpoint, self._calculate_control_setpoint(), 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/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index e49f4ad0..8f601b50 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -1,7 +1,6 @@ import hashlib import logging import time -from datetime import timedelta from typing import List from custom_components.sat.coordinator import SatDataUpdateCoordinator @@ -29,13 +28,13 @@ def __init__(self, coordinator: SatDataUpdateCoordinator): self._adjusted_setpoints = {} @staticmethod - def _get_cache_key(setpoint: float, errors: List[float]) -> str: - errors_str = str(setpoint) + ','.join(map(str, errors)) + def _get_cache_key(errors: List[float]) -> str: + errors_str = ','.join(map(str, errors)) cache_hash = hashlib.sha256(errors_str.encode('utf-8')) return cache_hash.hexdigest() def restore(self, adjusted_setpoints): - self._adjusted_setpoints = adjusted_setpoints + pass def calculate(self, setpoint: float, errors: List[float], adjustment_percentage=10): # Check for a valid setpoint @@ -43,10 +42,7 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= return # Calculate a cache key for adjusted setpoints - hash_key = self._get_cache_key(setpoint, errors) - - # Cleanup old setpoints - self._cleanup_old_setpoints() + hash_key = self._get_cache_key(errors) # Extract relevant values from the coordinator for clarity boiler_temperature = self._coordinator.boiler_temperature @@ -67,29 +63,25 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint if hash_key in self._adjusted_setpoints: - adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key]['value'] + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key][setpoint]['value'] + else: + self._adjusted_setpoints[hash_key] = {} # Keep track of the adjusted setpoint and update the timestamp - self._adjusted_setpoints[hash_key] = {'value': round(adjusted_setpoint, 1), 'setpoint': setpoint, 'errors': errors, - 'timestamp': int(time.time())} + self._adjusted_setpoints[hash_key][setpoint] = { + 'errors': errors, + 'timestamp': int(time.time()), + 'value': round(adjusted_setpoint, 1) + } + + def current(self, errors: List[float]) -> float: + cache_key = self._get_cache_key(errors) - def current(self, setpoint: float, errors: List[float]) -> float: - # Get the cache key - cache_key = self._get_cache_key(setpoint, errors) + if (data := self._adjusted_setpoints.get(cache_key)) is None: + return self._coordinator.minimum_setpoint - # Return the adjusted setpoint if available, else return the configured minimum setpoint - return self._adjusted_setpoints.get(cache_key, {'value': self._coordinator.minimum_setpoint})['value'] + return min(data.values(), key=lambda x: x['value'])['value'] @property def cache(self) -> dict[str, float]: return self._adjusted_setpoints - - def _cleanup_old_setpoints(self): - outdated_keys = [ - key - for key, data in self._adjusted_setpoints.items() - if (not _is_valid(data) or (int(time.time()) - data['timestamp']) > timedelta(days=7).total_seconds()) - ] - - for key in outdated_keys: - del self._adjusted_setpoints[key] From 9fba342b27312efc170babbe8dad2e8d5bc134c9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 6 Dec 2023 00:43:53 +0100 Subject: [PATCH 196/237] Some dynamic minimum setpoint calculation improvements --- custom_components/sat/minimum_setpoint.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 8f601b50..3cdda046 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -57,8 +57,13 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= if not is_flame_active or abs(target_setpoint_temperature - boiler_temperature) <= 1: return - # Determine the minimum setpoint based on flame state and adjustment - raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_percentage) + # Check if we are above configured minimum setpoint, does not make sense if we are below it + if boiler_temperature <= self._coordinator.minimum_setpoint: + return + + # Dynamically adjust the minimum setpoint + adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) + raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint From c75fb853e138c4fec8f56d7951b46b54368a6c9e Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 6 Dec 2023 17:56:44 +0100 Subject: [PATCH 197/237] Update boiler temperature when we exceed overshoot protection and when we are above maximum duty cycle --- custom_components/sat/pwm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 6fc5c19f..f0ca7604 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -55,6 +55,7 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): self._state = PWMState.IDLE self._last_update = monotonic() + self._last_boiler_temperature = boiler_temperature _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") return @@ -67,6 +68,7 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile if self._duty_cycle is None: self._state = PWMState.IDLE self._last_update = monotonic() + self._last_boiler_temperature = boiler_temperature _LOGGER.debug("Turned off PWM because we are above maximum duty cycle") return From b0a1b587b3c0b9698af9173b41c626bd3510e349 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 6 Dec 2023 22:40:40 +0100 Subject: [PATCH 198/237] Some adjusted_minimum_setpoint fine tuning --- custom_components/sat/climate.py | 2 +- custom_components/sat/pwm.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 55f423dd..7d22ed58 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -562,7 +562,7 @@ def minimum_setpoint(self) -> float: @property def adjusted_minimum_setpoint(self) -> float: - return self._minimum_setpoint.current([self.error] + self.climate_errors) + return self._minimum_setpoint.current([self.error] + self.climate_errors) - 2 def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index f0ca7604..847a18ec 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -52,7 +52,7 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile _LOGGER.warning("Invalid heating curve value") return - if requested_setpoint is None or (not self._force and requested_setpoint > minimum_setpoint): + if requested_setpoint is None or (not self._force and requested_setpoint >= (minimum_setpoint - 2)): self._state = PWMState.IDLE self._last_update = monotonic() self._last_boiler_temperature = boiler_temperature From fb145559bd4f31031dd251b12fede6b5b22bfe67 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Wed, 6 Dec 2023 22:46:33 +0100 Subject: [PATCH 199/237] Let's default to plus two to correct itself with the earlier adjustment --- 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 3cdda046..27a8a712 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -83,7 +83,7 @@ def current(self, errors: List[float]) -> float: cache_key = self._get_cache_key(errors) if (data := self._adjusted_setpoints.get(cache_key)) is None: - return self._coordinator.minimum_setpoint + return self._coordinator.minimum_setpoint + 2 return min(data.values(), key=lambda x: x['value'])['value'] From 1144f98958ad0e6ca2183eb55802729e72d97c66 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Thu, 7 Dec 2023 18:41:16 +0100 Subject: [PATCH 200/237] Make sure the setpoint dictionary exists --- custom_components/sat/minimum_setpoint.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index 27a8a712..bce30b75 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -68,7 +68,8 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint if hash_key in self._adjusted_setpoints: - adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key][setpoint]['value'] + if setpoint in self._adjusted_setpoints[hash_key]: + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key][setpoint]['value'] else: self._adjusted_setpoints[hash_key] = {} From 67ad176a9c35672d0b6fa3d46b186be47c421b31 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 8 Dec 2023 20:25:16 +0100 Subject: [PATCH 201/237] Added "requested_setpoint" to the states for debugging --- custom_components/sat/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 7d22ed58..19c1539d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -364,6 +364,7 @@ def extra_state_attributes(self): "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, "adjusted_minimum_setpoints": self._minimum_setpoint.cache, "outside_temperature": self.current_outside_temperature, From 20e028299a05bb9d85f0eca3e17d06eedad663f3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 8 Dec 2023 20:40:06 +0100 Subject: [PATCH 202/237] Use moving average to detect a stable temperature --- custom_components/sat/overshoot_protection.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 52cb7379..684fcefd 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -1,6 +1,5 @@ import asyncio import logging -from collections import deque from custom_components.sat.const import * from custom_components.sat.coordinator import DeviceState, SatDataUpdateCoordinator @@ -13,6 +12,7 @@ class OvershootProtection: def __init__(self, coordinator: SatDataUpdateCoordinator): + self._alpha = 0.2 self._coordinator = coordinator async def calculate(self) -> float | None: @@ -76,14 +76,11 @@ async def _wait_for_flame(self): await self._coordinator.async_control_heating_loop() async def _wait_for_stable_temperature(self, max_modulation: float) -> float: - temps = deque(maxlen=50) - previous_average_temperature = None + previous_average_temperature = float(self._coordinator.boiler_temperature) while True: actual_temperature = float(self._coordinator.boiler_temperature) - - temps.append(actual_temperature) - average_temperature = sum(temps) / 50 + average_temperature = self._alpha * actual_temperature + (1 - self._alpha) * previous_average_temperature if previous_average_temperature is not None and abs(actual_temperature - previous_average_temperature) <= DEADBAND: _LOGGER.info("Stable temperature reached: %s", actual_temperature) From 504aeb878459ea9f243334a9dd3fe8a2a9b35eb3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Fri, 8 Dec 2023 20:43:20 +0100 Subject: [PATCH 203/237] Restrict 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 a75d98f7..6dbdbb74 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -19,7 +19,7 @@ MINIMUM_RELATIVE_MOD = 0 MAXIMUM_RELATIVE_MOD = 100 -MAX_BOILER_TEMPERATURE_AGE = 300 +MAX_BOILER_TEMPERATURE_AGE = 60 OVERSHOOT_PROTECTION_SETPOINT = 75 OVERSHOOT_PROTECTION_REQUIRED_DATASET = 40 From 21fa79fd690ea7ee2e34f9ed94be655f826750e3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 00:01:03 +0100 Subject: [PATCH 204/237] Store the dynamic minimum setpoint for later use and also make use of a "previous" value --- custom_components/sat/climate.py | 5 +++- custom_components/sat/minimum_setpoint.py | 33 ++++++++++++++++++----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 19c1539d..5b687929 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -129,7 +129,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(coordinator) + self._minimum_setpoint = MinimumSetpoint(hass, coordinator) self._sensors = [] self._rooms = None @@ -199,6 +199,9 @@ async def async_added_to_hass(self) -> None: # Register services await self._register_services() + # Initialize minimum setpoint system + await self._minimum_setpoint.async_initialize() + # Let the coordinator know we are ready await self._coordinator.async_added_to_hass(self) diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index bce30b75..f870ab79 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -3,6 +3,8 @@ import time from typing import List +from homeassistant.helpers.storage import Store + from custom_components.sat.coordinator import SatDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -22,10 +24,15 @@ def _is_valid(data): class MinimumSetpoint: - def __init__(self, coordinator: SatDataUpdateCoordinator): + _STORAGE_VERSION = 1 + _STORAGE_KEY = "minimum_setpoint" + + def __init__(self, hass, coordinator: SatDataUpdateCoordinator): self._alpha = 0.2 - self._coordinator = coordinator self._adjusted_setpoints = {} + self._coordinator = coordinator + self._previous_adjusted_setpoint = None + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) @staticmethod def _get_cache_key(errors: List[float]) -> str: @@ -33,8 +40,11 @@ def _get_cache_key(errors: List[float]) -> str: cache_hash = hashlib.sha256(errors_str.encode('utf-8')) return cache_hash.hexdigest() - def restore(self, adjusted_setpoints): - pass + async def async_initialize(self): + if (adjusted_setpoints := await self._store.async_load()) is None: + adjusted_setpoints = {} + + self._adjusted_setpoints = adjusted_setpoints def calculate(self, setpoint: float, errors: List[float], adjustment_percentage=10): # Check for a valid setpoint @@ -65,11 +75,16 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) + # Determine some defaults + previous_adjusted_setpoint = self._previous_adjusted_setpoint + if setpoint in self._adjusted_setpoints[hash_key]: + previous_adjusted_setpoint = self._adjusted_setpoints[hash_key][setpoint]['value'] + # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint if hash_key in self._adjusted_setpoints: - if setpoint in self._adjusted_setpoints[hash_key]: - adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * self._adjusted_setpoints[hash_key][setpoint]['value'] + if previous_adjusted_setpoint is not None: + adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * previous_adjusted_setpoint else: self._adjusted_setpoints[hash_key] = {} @@ -80,6 +95,12 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= 'value': round(adjusted_setpoint, 1) } + # Store the change calibration + self._store.async_delay_save(lambda: self._adjusted_setpoints) + + # Store previous value, so we have a moving value + self._previous_adjusted_setpoint = round(adjusted_setpoint, 1) + def current(self, errors: List[float]) -> float: cache_key = self._get_cache_key(errors) From e0ac2bbb98680aed240ed2872171d3e4f072aa7f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 00:03:51 +0100 Subject: [PATCH 205/237] 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 5b687929..0f15aa7d 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -129,7 +129,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(hass, coordinator) + self._minimum_setpoint = MinimumSetpoint(self.hass, coordinator) self._sensors = [] self._rooms = None From 7f3c0f85a525c8a8fe84bd3bb42ca21a506ebe4f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 00:24:27 +0100 Subject: [PATCH 206/237] Make sure the setpoint is round up to 1 decimal --- 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 0f15aa7d..40a012b5 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -794,7 +794,8 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: if not self.pulse_width_modulation_enabled or pwm_state == pwm_state.IDLE: _LOGGER.info("Running Normal cycle") - self._setpoint = max(self.minimum_setpoint, mean(list(self._outputs)[-5:])) + setpoint = round(mean(list(self._outputs)[-5:]), 1) + self._setpoint = max(self.minimum_setpoint, setpoint) else: _LOGGER.info(f"Running PWM cycle: {pwm_state}") self._setpoint = self.minimum_setpoint if pwm_state == pwm_state.ON else MINIMUM_SETPOINT From 349d6c5a7177c716e59affee5468280753396748 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 00:43:12 +0100 Subject: [PATCH 207/237] Added some logging for relative modulation --- custom_components/sat/climate.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 40a012b5..aba1fc50 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -542,16 +542,28 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_enabled(self) -> bool: """Return True if relative modulation is enabled, False otherwise.""" - if self.hvac_mode == HVACMode.OFF or self._setpoint is None: + if self.hvac_mode == HVACMode.OFF: + _LOGGER.debug("Relative modulation is enabled because HVACMode is OFF.") + return True + + if self._setpoint is None or self._setpoint <= MINIMUM_SETPOINT: + _LOGGER.debug("Relative modulation is enabled the setpoint is below 10 degrees.") return True if self._coordinator.hot_water_active or self._setpoint <= MINIMUM_SETPOINT: + _LOGGER.debug("Relative modulation is enabled because Hot Water is active.") return True if self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME: + _LOGGER.debug("Relative modulation is disabled because we are warming up.") + return False + + if self.pulse_width_modulation_enabled: + _LOGGER.debug("Relative modulation is disabled because PWM is enabled.") return False - return not self.pulse_width_modulation_enabled + _LOGGER.debug("Relative modulation is enabled because PWM is disabled.") + return True @property def relative_modulation_value(self) -> int: From a3e0c9d5d93da2a008d660237589d7c8842bd9db Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 02:51:02 +0100 Subject: [PATCH 208/237] Streamlined the relative modulation value and fixed an issue restoring the minimum setpoint states --- custom_components/sat/climate.py | 48 ++++++----------- custom_components/sat/minimum_setpoint.py | 28 +++++----- custom_components/sat/relative_modulation.py | 55 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 43 deletions(-) create mode 100644 custom_components/sat/relative_modulation.py diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index aba1fc50..c7c8ac15 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -43,6 +43,7 @@ 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, \ calculate_derivative_per_hour @@ -129,7 +130,10 @@ 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.hass, coordinator) + self._minimum_setpoint = MinimumSetpoint(coordinator) + + # Create Relative Modulation controller + self._relative_modulation = RelativeModulation(coordinator) self._sensors = [] self._rooms = None @@ -200,7 +204,7 @@ async def async_added_to_hass(self) -> None: await self._register_services() # Initialize minimum setpoint system - await self._minimum_setpoint.async_initialize() + await self._minimum_setpoint.async_initialize(self.hass) # Let the coordinator know we are ready await self._coordinator.async_added_to_hass(self) @@ -301,9 +305,6 @@ async def _restore_previous_state_or_set_defaults(self): self._rooms = old_state.attributes.get(ATTR_ROOMS) else: await self._async_update_rooms_from_climates() - - if old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS): - self._minimum_setpoint.restore(old_state.attributes.get(ATTR_ADJUSTED_MINIMUM_SETPOINTS)) else: if self._rooms is None: await self._async_update_rooms_from_climates() @@ -373,8 +374,9 @@ 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_enabled": self.relative_modulation_enabled, "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, @@ -540,34 +542,17 @@ def pulse_width_modulation_enabled(self) -> bool: return self._overshoot_protection and self._calculate_control_setpoint() < (self.minimum_setpoint - 2) @property - def relative_modulation_enabled(self) -> bool: - """Return True if relative modulation is enabled, False otherwise.""" - if self.hvac_mode == HVACMode.OFF: - _LOGGER.debug("Relative modulation is enabled because HVACMode is OFF.") - return True - - if self._setpoint is None or self._setpoint <= MINIMUM_SETPOINT: - _LOGGER.debug("Relative modulation is enabled the setpoint is below 10 degrees.") - return True - - if self._coordinator.hot_water_active or self._setpoint <= MINIMUM_SETPOINT: - _LOGGER.debug("Relative modulation is enabled because Hot Water is active.") - return True - - if self._warming_up_data is not None and self._warming_up_data.elapsed < HEATER_STARTUP_TIMEFRAME: - _LOGGER.debug("Relative modulation is disabled because we are warming up.") - return False - - if self.pulse_width_modulation_enabled: - _LOGGER.debug("Relative modulation is disabled because PWM is enabled.") - return False + def relative_modulation_value(self) -> int: + return self._maximum_relative_modulation if self._relative_modulation.enabled else MINIMUM_RELATIVE_MOD - _LOGGER.debug("Relative modulation is enabled because PWM is disabled.") - return True + @property + def relative_modulation_state(self) -> RelativeModulationState: + return self._relative_modulation.state @property - def relative_modulation_value(self) -> int: - return self._maximum_relative_modulation if self.relative_modulation_enabled else MINIMUM_RELATIVE_MOD + def warming_up(self): + """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: @@ -820,6 +805,7 @@ 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 self._coordinator.supports_relative_modulation_management: + await self._relative_modulation.update(self.warming_up, self.pwm.state) await self._coordinator.async_set_control_max_relative_modulation(self.relative_modulation_value) async def _async_update_rooms_from_climates(self) -> None: diff --git a/custom_components/sat/minimum_setpoint.py b/custom_components/sat/minimum_setpoint.py index f870ab79..388989a0 100644 --- a/custom_components/sat/minimum_setpoint.py +++ b/custom_components/sat/minimum_setpoint.py @@ -3,6 +3,7 @@ import time from typing import List +from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import Store from custom_components.sat.coordinator import SatDataUpdateCoordinator @@ -27,12 +28,12 @@ class MinimumSetpoint: _STORAGE_VERSION = 1 _STORAGE_KEY = "minimum_setpoint" - def __init__(self, hass, coordinator: SatDataUpdateCoordinator): + def __init__(self, coordinator: SatDataUpdateCoordinator): self._alpha = 0.2 + self._store = None self._adjusted_setpoints = {} self._coordinator = coordinator self._previous_adjusted_setpoint = None - self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) @staticmethod def _get_cache_key(errors: List[float]) -> str: @@ -40,7 +41,9 @@ def _get_cache_key(errors: List[float]) -> str: cache_hash = hashlib.sha256(errors_str.encode('utf-8')) return cache_hash.hexdigest() - async def async_initialize(self): + async def async_initialize(self, hass: HomeAssistant) -> None: + self._store = Store(hass, self._STORAGE_VERSION, self._STORAGE_KEY) + if (adjusted_setpoints := await self._store.async_load()) is None: adjusted_setpoints = {} @@ -75,14 +78,14 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= adjustment_value = (adjustment_percentage / 100) * (target_setpoint_temperature - boiler_temperature) raw_adjusted_setpoint = max(boiler_temperature, target_setpoint_temperature - adjustment_value) - # Determine some defaults - previous_adjusted_setpoint = self._previous_adjusted_setpoint - if setpoint in self._adjusted_setpoints[hash_key]: - previous_adjusted_setpoint = self._adjusted_setpoints[hash_key][setpoint]['value'] - - # Use the moving average to adjust the calculated setpoint adjusted_setpoint = raw_adjusted_setpoint if hash_key in self._adjusted_setpoints: + # Determine some defaults + previous_adjusted_setpoint = self._previous_adjusted_setpoint + if setpoint in self._adjusted_setpoints[hash_key]: + previous_adjusted_setpoint = self._adjusted_setpoints[hash_key][setpoint]['value'] + + # Use the moving average to adjust the calculated setpoint if previous_adjusted_setpoint is not None: adjusted_setpoint = self._alpha * raw_adjusted_setpoint + (1 - self._alpha) * previous_adjusted_setpoint else: @@ -95,12 +98,13 @@ def calculate(self, setpoint: float, errors: List[float], adjustment_percentage= 'value': round(adjusted_setpoint, 1) } - # Store the change calibration - self._store.async_delay_save(lambda: self._adjusted_setpoints) - # Store previous value, so we have a moving value self._previous_adjusted_setpoint = round(adjusted_setpoint, 1) + # Store the change calibration + if self._store is not None: + self._store.async_delay_save(lambda: self._adjusted_setpoints) + def current(self, errors: List[float]) -> float: cache_key = self._get_cache_key(errors) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py new file mode 100644 index 00000000..440e2fd5 --- /dev/null +++ b/custom_components/sat/relative_modulation.py @@ -0,0 +1,55 @@ +from enum import Enum + +from custom_components.sat import MINIMUM_SETPOINT +from custom_components.sat.coordinator import SatDataUpdateCoordinator +from custom_components.sat.pwm import PWMState + + +# Enum to represent different states of relative modulation +class RelativeModulationState(str, Enum): + OFF = "off" + COLD = "cold" + HOT_WATER = "hot_water" + WARMING_UP = "warming_up" + PULSE_MODULATION_OFF = "pulse_modulation_off" + + +class RelativeModulation: + def __init__(self, coordinator: SatDataUpdateCoordinator): + """Initialize instance variables""" + self._pwm_state = None # Tracks the current state of the PWM (Pulse Width Modulation) system + self._warming_up = False # Stores data related to the warming up state of the heating system + self._coordinator = coordinator # Reference to the data coordinator responsible for system-wide information + + async def update(self, warming_up: bool, state: PWMState) -> None: + """Update internal state with new data received from the coordinator""" + self._pwm_state = state + self._warming_up = warming_up + + @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 heating system is currently in the process of warming up, it's considered WARMING_UP + if self._warming_up: + return RelativeModulationState.WARMING_UP + + # If the PWM state is not in the ON state, it's considered PULSE_MODULATION_OFF + if self._pwm_state != PWMState.ON: + return RelativeModulationState.PULSE_MODULATION_OFF + + # 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 or in the WARMING_UP state + return self.state != RelativeModulationState.OFF and self.state != RelativeModulationState.WARMING_UP From 7b5bc8547c4f2492a8336cd13ff29289b80a3dd9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 12:54:42 +0100 Subject: [PATCH 209/237] Use the correct attribute for DHW --- 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 dcc806e0..fd225514 100644 --- a/custom_components/sat/mqtt/__init__.py +++ b/custom_components/sat/mqtt/__init__.py @@ -19,9 +19,9 @@ DATA_FLAME_ACTIVE = "flame" DATA_DHW_SETPOINT = "TdhwSet" DATA_CONTROL_SETPOINT = "TSet" -DATA_DHW_ENABLE = "dhw_enable" DATA_REL_MOD_LEVEL = "RelModLevel" DATA_BOILER_TEMPERATURE = "Tboiler" +DATA_DHW_ENABLE = "domestichotwater" DATA_CENTRAL_HEATING = "centralheating" DATA_BOILER_CAPACITY = "MaxCapacityMinModLevel_hb_u8" DATA_REL_MIN_MOD_LEVEL = "MaxCapacityMinModLevel_lb_u8" From 751055618f3294f960fcada550ffc41ba5e0d55b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 13:02:02 +0100 Subject: [PATCH 210/237] Make sure we round up some values --- 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 c7c8ac15..f594732c 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -370,7 +370,6 @@ def extra_state_attributes(self): "minimum_setpoint": self.minimum_setpoint, "requested_setpoint": self.requested_setpoint, "adjusted_minimum_setpoint": self.adjusted_minimum_setpoint, - "adjusted_minimum_setpoints": self._minimum_setpoint.cache, "outside_temperature": self.current_outside_temperature, "optimal_coefficient": self.heating_curve.optimal_coefficient, "coefficient_derivative": self.heating_curve.coefficient_derivative, @@ -463,7 +462,7 @@ def requested_setpoint(self) -> float: if self.heating_curve.value is None: return MINIMUM_SETPOINT - return max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT) + return round(max(self.heating_curve.value + self.pid.output, MINIMUM_SETPOINT), 1) @property def climate_errors(self) -> List[float]: From 9c40a1edee0ac5aa4e4eae8d3531c35b4b38b1af Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 13:06:04 +0100 Subject: [PATCH 211/237] Fixed test caused by round issues --- tests/test_climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index 187a9caf..d27034ca 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -51,8 +51,8 @@ async def test_scenario_1(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.heating_curve.value == 32.6 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 36.17 - assert climate.pwm.duty_cycle == (325, 574) + assert climate.pwm.last_duty_cycle_percentage == 36.24 + assert climate.pwm.duty_cycle == (326, 573) @pytest.mark.parametrize(*[ From f0afc461ccb1af0d83de5810c915ae5244d802f7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 9 Dec 2023 13:10:17 +0100 Subject: [PATCH 212/237] Fixed test caused by round issues --- 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 d27034ca..6efc2080 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -92,11 +92,11 @@ async def test_scenario_2(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.setpoint == 58 assert climate.heating_curve.value == 30.1 - assert climate.requested_setpoint == 30.597 + assert climate.requested_setpoint == 30.6 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 11.03 - assert climate.pwm.duty_cycle == (180, 1452) + assert climate.pwm.last_duty_cycle_percentage == 11.04 + assert climate.pwm.duty_cycle == (180, 1450) @pytest.mark.parametrize(*[ @@ -136,8 +136,8 @@ async def test_scenario_3(hass: HomeAssistant, entry: MockConfigEntry, climate: assert climate.setpoint == 41 assert climate.heating_curve.value == 32.1 - assert climate.requested_setpoint == 37.397 + assert climate.requested_setpoint == 37.4 assert climate.pulse_width_modulation_enabled - assert climate.pwm.last_duty_cycle_percentage == 73.89 + assert climate.pwm.last_duty_cycle_percentage == 73.91 assert climate.pwm.duty_cycle == (665, 234) From d0a578924cdb91b5e606c66e1173bfe2f3c60da9 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 10 Dec 2023 13:12:16 +0100 Subject: [PATCH 213/237] Add support for a heat pump --- custom_components/sat/climate.py | 30 ++++++++++---------- custom_components/sat/config_flow.py | 3 +- custom_components/sat/const.py | 1 + custom_components/sat/relative_modulation.py | 7 +++-- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index f594732c..5c60ae7a 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -120,21 +120,6 @@ 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} - # Create PID controller with given configuration options - self.pid = create_pid_controller(config_options) - - # 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 = MinimumSetpoint(coordinator) - - # Create Relative Modulation controller - self._relative_modulation = RelativeModulation(coordinator) - self._sensors = [] self._rooms = None self._setpoint = None @@ -180,6 +165,21 @@ 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 PID controller with given configuration options + self.pid = create_pid_controller(config_options) + + # 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 = MinimumSetpoint(coordinator) + + # Create Relative Modulation controller + self._relative_modulation = RelativeModulation(coordinator, self._heating_system) + if self._simulation: _LOGGER.warning("Simulation mode!") diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 32c8db68..da9619be 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -246,8 +246,9 @@ async def async_step_heating_system(self, _user_input=None): data_schema=vol.Schema({ vol.Required(CONF_HEATING_SYSTEM, default=OPTIONS_DEFAULTS[CONF_HEATING_SYSTEM]): selector.SelectSelector( selector.SelectSelectorConfig(options=[ + {"value": HEATING_SYSTEM_HEAT_PUMP, "label": "Heat Pump"}, {"value": HEATING_SYSTEM_RADIATORS, "label": "Radiators"}, - {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"} + {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"}, ]) ) }) diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index 6dbdbb74..46fc2d07 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -73,6 +73,7 @@ CONF_ACTIVITY_TEMPERATURE = "activity_temperature" HEATING_SYSTEM_UNKNOWN = "unknown" +HEATING_SYSTEM_HEAT_PUMP = "heat_pump" HEATING_SYSTEM_RADIATORS = "radiators" HEATING_SYSTEM_UNDERFLOOR = "underfloor" diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 440e2fd5..28464fa3 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -1,6 +1,6 @@ from enum import Enum -from custom_components.sat import MINIMUM_SETPOINT +from custom_components.sat import MINIMUM_SETPOINT, HEATING_SYSTEM_HEAT_PUMP from custom_components.sat.coordinator import SatDataUpdateCoordinator from custom_components.sat.pwm import PWMState @@ -15,8 +15,9 @@ class RelativeModulationState(str, Enum): class RelativeModulation: - def __init__(self, coordinator: SatDataUpdateCoordinator): + def __init__(self, coordinator: SatDataUpdateCoordinator, heating_system: str): """Initialize instance variables""" + self._heating_system = heating_system # The heating system that is being controlled self._pwm_state = None # Tracks the current state of the PWM (Pulse Width Modulation) system self._warming_up = False # Stores data related to the warming up state of the heating system self._coordinator = coordinator # Reference to the data coordinator responsible for system-wide information @@ -38,7 +39,7 @@ def state(self) -> RelativeModulationState: 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: + if self._warming_up and self._heating_system != HEATING_SYSTEM_HEAT_PUMP: return RelativeModulationState.WARMING_UP # If the PWM state is not in the ON state, it's considered PULSE_MODULATION_OFF From db896dcb5694a7a63c9542747b60618a90d9b15f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 10 Dec 2023 13:14:43 +0100 Subject: [PATCH 214/237] Use timestamps for WarmingUp instead of MonoTonic --- custom_components/sat/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 5c60ae7a..54f7dc48 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -6,7 +6,7 @@ from collections import deque from datetime import timedelta from statistics import mean -from time import monotonic +from time import monotonic, time from typing import List from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -74,11 +74,11 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a class SatWarmingUp: def __init__(self, error: float, started: int = None): self.error = error - self.started = started if started is not None else monotonic() + self.started = started if started is not None else int(time()) @property def elapsed(self): - return monotonic() - self.started + return int(time()) - self.started class SatClimate(SatEntity, ClimateEntity, RestoreEntity): From 1b0e846fd5cc07729ba3ecb3c70405fbe3516984 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 10 Dec 2023 13:17:25 +0100 Subject: [PATCH 215/237] Re-order listing --- custom_components/sat/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index da9619be..dfc9f1b3 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -246,8 +246,8 @@ async def async_step_heating_system(self, _user_input=None): data_schema=vol.Schema({ vol.Required(CONF_HEATING_SYSTEM, default=OPTIONS_DEFAULTS[CONF_HEATING_SYSTEM]): selector.SelectSelector( selector.SelectSelectorConfig(options=[ - {"value": HEATING_SYSTEM_HEAT_PUMP, "label": "Heat Pump"}, {"value": HEATING_SYSTEM_RADIATORS, "label": "Radiators"}, + {"value": HEATING_SYSTEM_HEAT_PUMP, "label": "Heat Pump"}, {"value": HEATING_SYSTEM_UNDERFLOOR, "label": "Underfloor"}, ]) ) From e55b5070edb2e45577855b56a94e1774fadc7608 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 10 Dec 2023 13:25:46 +0100 Subject: [PATCH 216/237] Default maximum setpoint to 55 --- custom_components/sat/util.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 9c228e86..77f3576f 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -31,14 +31,11 @@ def calculate_derivative_per_hour(temperature_error: float, time_taken_seconds: return round(temperature_error / time_taken_hours, 2) -def calculate_default_maximum_setpoint(heating_system: str) -> int | None: +def calculate_default_maximum_setpoint(heating_system: str) -> int: if heating_system == HEATING_SYSTEM_UNDERFLOOR: return 50 - if heating_system == HEATING_SYSTEM_RADIATORS: - return 55 - - return None + return 55 def create_pid_controller(config_options) -> PID: From 4e99845de182ad87e86f10054117850fc1c517f1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 10 Dec 2023 15:31:59 +0100 Subject: [PATCH 217/237] Use 28.2 in the first three minutes of a duty cycle --- custom_components/sat/pwm.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 847a18ec..d773af9e 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -75,19 +75,20 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) - if self._state == PWMState.ON and boiler_temperature is not None: - self._last_boiler_temperature = boiler_temperature + if self._state == PWMState.ON: + if elapsed <= HEATER_STARTUP_TIMEFRAME: + self._last_boiler_temperature = 28.2 + elif boiler_temperature is not None: + self._last_boiler_temperature = boiler_temperature - 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): self._state = PWMState.ON self._last_update = monotonic() self._last_boiler_temperature = boiler_temperature or 0 _LOGGER.debug("Starting duty 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): + 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.debug("Finished duty cycle.") From 986495fef1c69c93c4cb129e35564b39a501bc7b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 20:05:58 +0100 Subject: [PATCH 218/237] Also enable relative modulation when PWM is IDLE --- custom_components/sat/relative_modulation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 28464fa3..6af84037 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -11,7 +11,7 @@ class RelativeModulationState(str, Enum): COLD = "cold" HOT_WATER = "hot_water" WARMING_UP = "warming_up" - PULSE_MODULATION_OFF = "pulse_modulation_off" + PULSE_WIDTH_MODULATION_OFF = "pulse_width_modulation_off" class RelativeModulation: @@ -42,9 +42,9 @@ def state(self) -> RelativeModulationState: if self._warming_up and self._heating_system != HEATING_SYSTEM_HEAT_PUMP: return RelativeModulationState.WARMING_UP - # If the PWM state is not in the ON state, it's considered PULSE_MODULATION_OFF - if self._pwm_state != PWMState.ON: - return RelativeModulationState.PULSE_MODULATION_OFF + # If the PWM state is in the OFF state, it's considered PULSE_WIDTH_MODULATION_OFF + if self._pwm_state == PWMState.OFF: + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF # Default case, when none of the above conditions are met, it's considered OFF return RelativeModulationState.OFF From bbbe48296d7c1da80764448e799694a311d25813 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 20:09:31 +0100 Subject: [PATCH 219/237] Improved SatCurrentConsumptionSensor math --- custom_components/sat/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 2362876a..554ee4cd 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -127,7 +127,7 @@ def native_value(self) -> float: gas_consumption_per_percentage = (self._maximum_consumption - self._minimum_consumption) / 100 relative_modulation_value = self._coordinator.relative_modulation_value - return round(relative_modulation_value * gas_consumption_per_percentage, 3) + return round(self._minimum_consumption + ((relative_modulation_value / 100) * gas_consumption_per_percentage), 3) @property def unique_id(self) -> str: From f32bc7f814cd255b37bb5442054b67167f950e6a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 20:47:20 +0100 Subject: [PATCH 220/237] Start PWM earlier --- custom_components/sat/climate.py | 4 ++-- custom_components/sat/pwm.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 54f7dc48..c645cbca 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -538,7 +538,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 - 2) + return self._overshoot_protection and self._calculate_control_setpoint() < self.minimum_setpoint @property def relative_modulation_value(self) -> int: @@ -562,7 +562,7 @@ def minimum_setpoint(self) -> float: @property def adjusted_minimum_setpoint(self) -> float: - return self._minimum_setpoint.current([self.error] + self.climate_errors) - 2 + return self._minimum_setpoint.current([self.error] + self.climate_errors) def _calculate_control_setpoint(self) -> float: """Calculate the control setpoint based on the heating curve and PID output.""" diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index d773af9e..d576b517 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -52,11 +52,11 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile _LOGGER.warning("Invalid heating curve value") return - if requested_setpoint is None or (not self._force and requested_setpoint >= (minimum_setpoint - 2)): + if requested_setpoint is None: self._state = PWMState.IDLE self._last_update = monotonic() self._last_boiler_temperature = boiler_temperature - _LOGGER.debug("Turned off PWM due exceeding the overshoot protection value.") + _LOGGER.debug("Turned off PWM due since we do not have a request setpoint.") return if boiler_temperature is not None and self._last_boiler_temperature is None: From 177396cb40386aa76ffe05006f2068136aaebe9c Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 21:00:57 +0100 Subject: [PATCH 221/237] Improved the Pulse Width Modulation duty cycles --- custom_components/sat/climate.py | 2 +- custom_components/sat/pwm.py | 27 ++++++++------------ custom_components/sat/relative_modulation.py | 4 +-- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index c645cbca..64cccd38 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -858,7 +858,7 @@ async def async_control_heating_loop(self, _time=None) -> None: # Pulse Width Modulation if self.pulse_width_modulation_enabled: - await self.pwm.update(self.minimum_setpoint, self._calculate_control_setpoint(), self._coordinator.boiler_temperature) + await self.pwm.update(self._calculate_control_setpoint(), 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/pwm.py b/custom_components/sat/pwm.py index d576b517..c8c47093 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -44,19 +44,19 @@ def reset(self) -> None: self._state = PWMState.IDLE self._last_update = monotonic() - async def update(self, minimum_setpoint: float, requested_setpoint: float, boiler_temperature: float) -> None: + async def update(self, requested_setpoint: float, boiler_temperature: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value: self._state = PWMState.IDLE self._last_update = monotonic() - _LOGGER.warning("Invalid heating curve value") + _LOGGER.warning("Turned off PWM due since we do not have a valid heating curve value.") return if requested_setpoint is None: self._state = PWMState.IDLE self._last_update = monotonic() self._last_boiler_temperature = boiler_temperature - _LOGGER.debug("Turned off PWM due since we do not have a request setpoint.") + _LOGGER.debug("Turned off PWM due since we do not have a valid requested setpoint.") return if boiler_temperature is not None and self._last_boiler_temperature is None: @@ -65,13 +65,6 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile elapsed = monotonic() - self._last_update self._duty_cycle = self._calculate_duty_cycle(requested_setpoint) - if self._duty_cycle is None: - self._state = PWMState.IDLE - self._last_update = monotonic() - self._last_boiler_temperature = boiler_temperature - _LOGGER.debug("Turned off PWM because we are above maximum duty cycle") - return - _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) @@ -98,25 +91,25 @@ async def update(self, minimum_setpoint: float, requested_setpoint: float, boile def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" - minimum_setpoint = self._last_boiler_temperature or requested_setpoint + calculated_setpoint = self._last_boiler_temperature or requested_setpoint base_offset = self._heating_curve.base_offset - if minimum_setpoint < base_offset: - minimum_setpoint = base_offset + 1 + if calculated_setpoint < base_offset: + calculated_setpoint = base_offset + 1 - self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (minimum_setpoint - base_offset) + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (calculated_setpoint - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) - _LOGGER.debug("Minimum Setpoint %.1f", minimum_setpoint) _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) + _LOGGER.debug("Calculated Setpoint %.1f", calculated_setpoint) _LOGGER.debug("Calculated duty cycle %.2f%%", self._last_duty_cycle_percentage * 100) if not self._automatic_duty_cycle: return int(self._last_duty_cycle_percentage * self._max_cycle_time), int((1 - self._last_duty_cycle_percentage) * self._max_cycle_time) if self._last_duty_cycle_percentage < MIN_DUTY_CYCLE_PERCENTAGE: - return 0, 0 + return 0, 1800 if self._last_duty_cycle_percentage <= DUTY_CYCLE_20_PERCENT: on_time = ON_TIME_20_PERCENT @@ -137,7 +130,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int return int(on_time), int(off_time) if self._last_duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: - return None + return 1800, 0 @property def state(self) -> PWMState: diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index 6af84037..e35062ff 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -42,8 +42,8 @@ def state(self) -> RelativeModulationState: if self._warming_up and self._heating_system != HEATING_SYSTEM_HEAT_PUMP: return RelativeModulationState.WARMING_UP - # If the PWM state is in the OFF state, it's considered PULSE_WIDTH_MODULATION_OFF - if self._pwm_state == PWMState.OFF: + # 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 # Default case, when none of the above conditions are met, it's considered OFF From 5078c58dd542d374f2e6b6f6e3e50238dde815e1 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 21:51:13 +0100 Subject: [PATCH 222/237] Dropped the fixed 28.2 value --- custom_components/sat/pwm.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index c8c47093..db6e82b5 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -68,11 +68,8 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Calculated duty cycle %.0f seconds ON", self._duty_cycle[0]) _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) - if self._state == PWMState.ON: - if elapsed <= HEATER_STARTUP_TIMEFRAME: - self._last_boiler_temperature = 28.2 - elif boiler_temperature is not None: - self._last_boiler_temperature = boiler_temperature + if self._state == PWMState.ON and boiler_temperature is not None: + self._last_boiler_temperature = boiler_temperature if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON From d520b73488e59c40411cc44890dacdb5e12490d7 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Mon, 11 Dec 2023 22:08:26 +0100 Subject: [PATCH 223/237] Use a moving average the first three minutes of a cycle for the boiler temperature --- custom_components/sat/pwm.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index db6e82b5..e867a94b 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -28,6 +28,7 @@ class PWM: def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" + self._alpha = 0.2 self._force = force self._last_boiler_temperature = None self._last_duty_cycle_percentage = None @@ -69,7 +70,10 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> _LOGGER.debug("Calculated duty cycle %.0f seconds OFF", self._duty_cycle[1]) if self._state == PWMState.ON and boiler_temperature is not None: - self._last_boiler_temperature = boiler_temperature + if elapsed <= HEATER_STARTUP_TIMEFRAME: + self._last_boiler_temperature = self._alpha * boiler_temperature + (1 - self._alpha) * self._last_boiler_temperature + else: + self._last_boiler_temperature = boiler_temperature if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): self._state = PWMState.ON @@ -88,18 +92,18 @@ async def update(self, requested_setpoint: float, boiler_temperature: float) -> def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int, int]]: """Calculates the duty cycle in seconds based on the output of a PID controller and a heating curve value.""" - calculated_setpoint = self._last_boiler_temperature or requested_setpoint + boiler_temperature = self._last_boiler_temperature or requested_setpoint base_offset = self._heating_curve.base_offset - if calculated_setpoint < base_offset: - calculated_setpoint = base_offset + 1 + if boiler_temperature < base_offset: + boiler_temperature = base_offset + 1 - self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (calculated_setpoint - base_offset) + self._last_duty_cycle_percentage = (requested_setpoint - base_offset) / (boiler_temperature - base_offset) self._last_duty_cycle_percentage = min(self._last_duty_cycle_percentage, 1) self._last_duty_cycle_percentage = max(self._last_duty_cycle_percentage, 0) _LOGGER.debug("Requested setpoint %.1f", requested_setpoint) - _LOGGER.debug("Calculated Setpoint %.1f", calculated_setpoint) + _LOGGER.debug("Boiler Temperature %.1f", boiler_temperature) _LOGGER.debug("Calculated duty cycle %.2f%%", self._last_duty_cycle_percentage * 100) if not self._automatic_duty_cycle: From 33a7540604c54b53ef2f5f1454e2ad4b9aca8e8c Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 08:26:37 +0200 Subject: [PATCH 224/237] Increase ON time when DC > 0.9 --- custom_components/sat/pwm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index e867a94b..4192c320 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -131,7 +131,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int return int(on_time), int(off_time) if self._last_duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: - return 1800, 0 + return 7200, 0 @property def state(self) -> PWMState: From e66ab7ca523e1b0e68799fd0a3b2220f4f6205d8 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 08:29:02 +0200 Subject: [PATCH 225/237] Remove integral --- custom_components/sat/pid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index a0d30264..ebd75af4 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -271,9 +271,9 @@ def ki(self) -> float | None: if self._last_heating_curve_value is None: return 0 - return round(self._last_heating_curve_value / 73900, 6) + return 0 - return float(self._ki) + return 0 @property def kd(self) -> float | None: From 7c86334ef6c3b746df1199e7fa66c9e17baac2cd Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 08:30:33 +0200 Subject: [PATCH 226/237] Revert --- custom_components/sat/pwm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index 4192c320..e867a94b 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -131,7 +131,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float) -> Optional[Tuple[int return int(on_time), int(off_time) if self._last_duty_cycle_percentage > MAX_DUTY_CYCLE_PERCENTAGE: - return 7200, 0 + return 1800, 0 @property def state(self) -> PWMState: From ebf9b060630c68a0e5642bdf9145bdff678d2f58 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 08:32:19 +0200 Subject: [PATCH 227/237] Revert --- custom_components/sat/pid.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index ebd75af4..a0d30264 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -271,9 +271,9 @@ def ki(self) -> float | None: if self._last_heating_curve_value is None: return 0 - return 0 + return round(self._last_heating_curve_value / 73900, 6) - return 0 + return float(self._ki) @property def kd(self) -> float | None: From ce61d1b0e935042220b7ddf594b3589d1a286fa1 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:17:57 +0200 Subject: [PATCH 228/237] Enable integral during warmup. Disable integral during deadband. --- custom_components/sat/pid.py | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index a0d30264..38b33e4a 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -9,7 +9,7 @@ _LOGGER = logging.getLogger(__name__) -MAX_BOILER_TEMPERATURE_AGE = 300 +MAX_BOILER_TEMPERATURE_AGE = 60 class PID: @@ -129,7 +129,7 @@ def update_integral(self, error: float, heating_curve_value: float, force: bool return current_time = monotonic() - limit = heating_curve_value / 10 + limit = self.kp * heating_curve_value / 10 time_elapsed = current_time - self._last_interval_updated # Check if the integral gain `ki` is set @@ -268,10 +268,11 @@ def kp(self) -> float | None: def ki(self) -> float | None: """Return the value of ki based on the current configuration.""" if self._automatic_gains: + automatic_gain_value = 0.243 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 0.33 if self._last_heating_curve_value is None: return 0 - return round(self._last_heating_curve_value / 73900, 6) + return round(self._automatic_gains_value * automatic_gain_value * self._last_heating_curve_value / 90000, 6) return float(self._ki) @@ -303,17 +304,7 @@ def integral(self) -> float: @property def derivative(self) -> float: """Return the derivative value.""" - derivative = self.kd * self._raw_derivative - output = self._last_heating_curve_value + self.proportional + self.integral - - if self._last_boiler_temperature is not None: - if abs(self._last_error) > 0.1 and abs(self._last_boiler_temperature - output) < 3: - return 0 - - if abs(self._last_error) <= 0.1 and abs(self._last_boiler_temperature - output) < 7: - return 0 - - return round(derivative, 3) + return round(self.kd * self._raw_derivative, 3) @property def raw_derivative(self) -> float: @@ -328,7 +319,7 @@ def output(self) -> float: @property def integral_enabled(self) -> bool: """Return whether the updates of the integral are enabled.""" - return abs(self._last_error) <= self._deadband + return self._last_error > self._deadband or self.previous_error > self._deadband @property def derivative_enabled(self) -> bool: From 7e4c3200250b695a1c1c479e65dbd59b72ccbeb0 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Tue, 12 Dec 2023 10:23:48 +0200 Subject: [PATCH 229/237] Revert Accidental changes... --- custom_components/sat/pid.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/pid.py b/custom_components/sat/pid.py index 38b33e4a..a0d30264 100644 --- a/custom_components/sat/pid.py +++ b/custom_components/sat/pid.py @@ -9,7 +9,7 @@ _LOGGER = logging.getLogger(__name__) -MAX_BOILER_TEMPERATURE_AGE = 60 +MAX_BOILER_TEMPERATURE_AGE = 300 class PID: @@ -129,7 +129,7 @@ def update_integral(self, error: float, heating_curve_value: float, force: bool return current_time = monotonic() - limit = self.kp * heating_curve_value / 10 + limit = heating_curve_value / 10 time_elapsed = current_time - self._last_interval_updated # Check if the integral gain `ki` is set @@ -268,11 +268,10 @@ def kp(self) -> float | None: def ki(self) -> float | None: """Return the value of ki based on the current configuration.""" if self._automatic_gains: - automatic_gain_value = 0.243 if self._heating_system == HEATING_SYSTEM_UNDERFLOOR else 0.33 if self._last_heating_curve_value is None: return 0 - return round(self._automatic_gains_value * automatic_gain_value * self._last_heating_curve_value / 90000, 6) + return round(self._last_heating_curve_value / 73900, 6) return float(self._ki) @@ -304,7 +303,17 @@ def integral(self) -> float: @property def derivative(self) -> float: """Return the derivative value.""" - return round(self.kd * self._raw_derivative, 3) + derivative = self.kd * self._raw_derivative + output = self._last_heating_curve_value + self.proportional + self.integral + + if self._last_boiler_temperature is not None: + if abs(self._last_error) > 0.1 and abs(self._last_boiler_temperature - output) < 3: + return 0 + + if abs(self._last_error) <= 0.1 and abs(self._last_boiler_temperature - output) < 7: + return 0 + + return round(derivative, 3) @property def raw_derivative(self) -> float: @@ -319,7 +328,7 @@ def output(self) -> float: @property def integral_enabled(self) -> bool: """Return whether the updates of the integral are enabled.""" - return self._last_error > self._deadband or self.previous_error > self._deadband + return abs(self._last_error) <= self._deadband @property def derivative_enabled(self) -> bool: From fe14742b8a3cecae99af05c6cd138e0142f05fd9 Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:24:44 +0200 Subject: [PATCH 230/237] SatCurrentConsumptionSensor Fix Correrct current consumption sensor math. --- custom_components/sat/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/sensor.py b/custom_components/sat/sensor.py index 554ee4cd..cc0536c1 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -122,12 +122,12 @@ def native_value(self) -> float: return 0 if self._coordinator.flame_active is False: - return self._minimum_consumption + return 0 - gas_consumption_per_percentage = (self._maximum_consumption - self._minimum_consumption) / 100 + differential_gas_consumption = self._maximum_consumption - self._minimum_consumption relative_modulation_value = self._coordinator.relative_modulation_value - return round(self._minimum_consumption + ((relative_modulation_value / 100) * gas_consumption_per_percentage), 3) + return round(self._minimum_consumption + ((relative_modulation_value / 100) * differential_gas_consumption), 3) @property def unique_id(self) -> str: From 40715f9aff92403aac21d7c80c4cc35380934fca Mon Sep 17 00:00:00 2001 From: "Sergeant \"D" <67208412+sergeantd83@users.noreply.github.com> Date: Sat, 16 Dec 2023 15:33:08 +0200 Subject: [PATCH 231/237] boiler_power sensor fix Correct formula for boiler power sensor. --- 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 ea9d4f10..be9c56bd 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -134,7 +134,7 @@ def minimum_boiler_capacity(self) -> float | None: if boiler_capacity == 0: return 0 - return boiler_capacity / (100 / minimum_relative_modulation_value) + return boiler_capacity * (minimum_relative_modulation_value / 100) @property def boiler_power(self) -> float | None: @@ -150,7 +150,7 @@ def boiler_power(self) -> float | None: if self.flame_active is False: return 0 - return ((boiler_capacity - minimum_boiler_capacity) / 100) * relative_modulation_value + return minimum_boiler_capacity + ((boiler_capacity - minimum_boiler_capacity) * (relative_modulation_value / 100)) @property def minimum_relative_modulation_value(self) -> float | None: From 82faba1570d776f9b07dee13aff290e7126d876f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 16 Dec 2023 22:07:32 +0100 Subject: [PATCH 232/237] Improve README.md with images --- .github/images/opentherm-mqtt.png | Bin 0 -> 67414 bytes .github/images/overshoot_protection.png | Bin 0 -> 12557 bytes .github/images/setup.png | Bin 0 -> 130282 bytes README.md | 33 +++++++++++++++++++----- 4 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 .github/images/opentherm-mqtt.png create mode 100644 .github/images/overshoot_protection.png create mode 100644 .github/images/setup.png diff --git a/.github/images/opentherm-mqtt.png b/.github/images/opentherm-mqtt.png new file mode 100644 index 0000000000000000000000000000000000000000..e5d59577692ce152bdfeede1bc5bb70837b0bb2f GIT binary patch literal 67414 zcmd?Q^LM1d7O>l~F|n+`Z95a&$;6!4w#|ucqm$cn&iTHd?ppT`xV_ff zU0qeZ-(9r}yPkJ`Ywj>~zfZOaOpnM7D~XX5teT|Mrt9in{R1bzQ1W z0Dz3V5_SYts{r*Ez%UpUO>i(I99|s*^GlYb+CbnUykvIxZXQjGxlm;Ik0>=A&P(Er z(my_)R~Hv=%WoY=Sx4_v9@8lPU(l!$4hyURT*@K5+$+R!3X%h(o&MmMl7AsdP!GrZ z%%KJWfNKa3cVFjPfp@SPdjL4VVUeB!ZFuK1s7;Yd13;1h_)CU%ga8(=3>cwT9l-#M z-~eh>8cg5;NdSP2hqo{ppd1NselNuj1z0Id+`tB`6cCib0%E`b=`<1);DYS{1GNC< z@8CUsfV@gUGJ43KYA}DTCT0n6|4t}?YpiBGil70&KVA6y1b~DS93Vo3J_$i03z5e- z*ZsFl-yx($5)9BYo;;Z*bXnHn@_xY2c)mn=aJ~x?<>V`l@DdWhBOqK*8s&E~?nUu`vh3LZ zjq-g2m42*yUITt+2&Px{6b1o*8rr#5E5{y?tjr!y06=2*OPnAr7+^BBXb%ABIL6WWorEnr1PcI&<^_DK6@o$R#i;B- z&gv!X=)ndzgccPA)Mc`$^ zRtdV275`QZ+QhK7j47eL{ z*Trj!S5jS)5|QRm>QN)3>nBQ5Admjqmxm{%jAxcYkg}Fsm!vCFDdL|)wak*GI*Yd* z-P(t(_pc;LO=ylsiC6gbHrl`6jKMt^zAFn?K%rcegeJ{qDsTW#6kS(dW^ zTOQ7x`$Nh$&sC8@C9Djc)g^;+G80Q=yoj!FaVEpk-15nS;l#NOZZn8TEOjPz&iWYU zm-VG5c6e(y}(ufAv8rKWA6sH-NAziivYCbi6M3r%w89&`T4JFN9P4nC1H*d^@ zF|=gvWc6g<)e1F~3O#k75^=Q##l14P1+0q2GD5ZXGWO~zWjEzG z#c3t|!Y+Ma;HxoGeQ1byUZ`ZDs&VrWZVV~b5VPrxrtp%ZI>gy`>ohqd?fREkx2k-p;GH%h9Fkn$QD^*C{LUkEt zzQ^W{&2Ls|PheEZx@Edy&1-06$-G`PP&ByT^>s4II>W4OwsGEhVq)a`M&(N7=y@#H zVwQD|Vb^NM`g=@Bu~^oibQF8kaLQVW_c&CFT*^~rnmWI_hx&v1a;4Og&e``f+a(Pf zMSP7+#7yqY@uuHZnNnYP;)yBKj_=e|Cu*sU?0zn{aOXPiS{a6KA6Qa;)~P7_WL zpb;+dIP-WrI=f7`+BkHa745M0mkpHlpJI8y+h#x%HGdHZOq>F5uuZ!Gw z$v52><~`?)=XD$y_uReXaf@{$4`l$>3w8k38Gski0AUS544DpD48sN$1Jw@AfXI(2 zj0lIMMeLYp--CUN|6DVtm7+=3Jm(!mrHL5uObbk82RJpJ9X5JZl6IT}9%Cm0OL)$U@#IUiaeexHlyLC*P5ZT$nM&)WnWGq>R-?Z-9INI(llAV%27Ff2; zK2BfA5dyKiJL78=TWT9tTHLj6{*rZzwPTS-sbIU&GwCX~nYdeC&-sj7jMt|C6?Zk2 z)o!gkHg02;gXW5hMQ8&xSd+_=M#t@{?|-x!V;Ux#s9jm5E^4d$eZx-HuXL#VUHx1u z-g3CBA+Nva{orA{wzehRJa%y=c~*30SNWvXT_fFTCA2sbdW}?te-m?nuWu{8<(%(n z^$6!Dh&Ip*_)5xqh3^Uij#l?Wa4g4y>wZ?pmc;>~Ec6DauY;Uts)7@PUXRFK} zLnO)VaFFS5zm|EC;r7Sj$2a6Yp@UxP?GkUVJ;mevss4@r$?bH%<_Fj_Ne|jN?rxV# zxuI5kQE2ceTmtnRzjhwt}8oryC{lLV%Qj^l0YC^ay25+_Vz1$hk?A5&At5wgU)AI8&y>6~Y=he_TezP6bCP52j+rzW#;{~=G{H5%M zu4kNk&3o4x@D%11<b(Kc}RO=ep2edQYAC2G36+N12w@v5S%hI^QN zn7CuggYTx~y=rRR=c?tZZF{(rIGyHQ@I&!#_SJZ}bVlKc|H%8}o#83??u|}@a~kAi zk{C-W%K-qMR3NuA7yx+v=YAdo04|IGz^MTMz?}vFU^`^BDe-~|;2b2ioB#j>^nVu^ zAR`M00C;zk5*1Q)&pPk)an~Mp`3t<0ARP`MJ5n}8T%@CE4?ISqDGkiV6s-~b8ZsbW zq^)$@fYHb{%1xH3mNx*Uo$8>rU1?>Ih*qQ9S1sfDhhkHMaa+6qJ?J#j$QZ4hv|K5# zO0K1(1}|zwPK=IRcbxJ}RnxD-4?rP=mWv^({P>RKardfi-}&eAFw=g^d7Q3U)2{8LB-6g_gz8_SBb5>r77{!_Fdm86@BL=qkJ#sV zCoCY2|MhquhS+=N zzdP`7F`Z7&he5x$X1@wxs^3d$ zR(5d~VP#-opl$P}>-tTmeHYhq{eP4JNv8qnPX2MR{ZYlcT-cvQP|4tX6IElynlZ^G zL;c^zIzXVuh`)VKf4&H4*URN52mLV`j5N&!o||uXAEamxG$sGj6EaYNlGyioN84=#gFwPflm*gl159nTK>afAv{4CSp8?Rzwk#Gw z#Z%!wDq&CkR~_(P9lNdp!P1BEFW?z zK|w*S4^-g%ub8=T)`A@y1H&d0_!f;*Iqh@l#6hRu^-lhimF9nAfRNbd{>$Fp9tU;T zjZY}G%erl|3+$2fzrFwuAo2Qlcf0nBrr3e)64(D|f9?dbSvOs%!GBu_$-(`3%katk zpt`f*x>#>IzJ{eh@gJK*2I6Om>RVaCoK?49zG`+Ts{U7~f$x7h<=kAL9d4IL^Pk4S zPX8N{jDDaIN=21R(}DXp)%PTo8(ZW53d@zsJvH+O4eW=gDB9TnSPQ&2X1YkhtdVOZ2}Q{Y~rx<#e1- zWy?S1Ki4+=SGJJv^L0OpK-)`6rXAYDj3ur3Yb!kDiApEzJ_v13{+TeB!;2?8p zqE)?OXWA5g*a+Fd7Wq^W+^8Ds*a$&__fkjxtMrq%#eW3_3JyA`@k-l93NlwVdIiO! z&YniFP^*2VZv(Y*(Sl~hVAyMfKI=PZernk19)mxq!^Cc{OOHy^WoUyH$m*!#yc^=v zF@!2&|H#X#nYZa80lC^BoL|w1Z2yZkNMYHqdr;ym)@mQZ zzV^(3d2f<@;W)1Efx2l7P#)RZjcujVAzP1o?Mj#&Xp1?cFa zvw5@9IF%2qsVTD;u75pTl-pini)l2>x7`|`FN#Dsn1r0gbM|!P$lY36lldDEtUVxN ziJ`AM5}Go=dy#?$jlljc`vI4=>WrX3YZwtZIeEw1WxFWZ7jbcMER7xoPJ}}P+$RqQ zn*>0zVnuXTt#)xU`;M!6p1wxVx=%oc4s|$GaSU*M%?!xSHCln_$)DY>#zNz^Q_WUD zDZQT-MWsQkf{z_SWtGMJfFh}hx4DpIndfA~6au6mQ3AGW&!SU$cZ6&_8QdqSXnq^5y!sudyv0UKdIvUC`u?df>4Vae|K^h&^2cKtfOeCuwPP8 z;cz-{5zK9cyPcJAS^93!snV@BS;@M{~5_7|C+egK( z`)SLovAOwf%j=4+F+3L@iwe4;7W$1I0{G<*x&(HUuIVWovd zLphl_VIT)doYlWvo&TzNmft;Fi_O=6gU_;=}jZ|^>ig9dC zMmU0TFNRKim^>G~BtqR$kuX*cFP!^Aq`#LSPD z&FM#P;Jf4ZtEEod*h3<{II!@p2lTf264^jJGFE5{xunVJXNp0Ww5R}!v>5^MM2g>r z>z;IsLdRiS7!vP>yfwk$;J+;Kmvjy~E}icZn+yDjR6-nHp*X0pqC)EH>v6j~LBr<# zFO0xnp6wUpY2G_Tf#+TnvvbWV&ALEP14oz@+*%Dy{6J^*PfD5BdZmRTRB+e5pLn+AoD>!J2Fjyb2StExfTa1Ml6_&7ao(E-|qz5y^LXsL6JH z&Lu`vRSmWqm+F=wuXA4Xp?3-r_XGD}l5vMaVy})9J-Q2*>iuv% zS|?rN4JlW+6?UU5!-eJaYu&|G8g&WVhCqei7~^TcZ{gq8w;be;B7`IDvd4D0B)4Yn z(ocx$JX<33ZKbc!uyO!A2z+aEwwcgL9E3fOPex$1YH=N`e?oKHT#3w>`OpcHH~@)K z`!o?cR=a$B1_IsO3(hx*Lg?j(x~2aytO!Ht*9zJRSbx=lHp&*6g@+|rz^m6dmKt(^ z0Y3&NCZ$Vtj>jRco*(d`tXm)SUNjuo{c+YUucR~!E|tmW;^LyhV|zkSDs^<)^>(?} z4D#r(<$GAahX*}`9Mt`tNBFFuw&gxBPc`Z-3u&XE)j^uwk$?Ogo>X14QT^do-z^hS z70$8x^HNf>O-j58cP0TT7sYQU_* z6o~9xn)bA<@p&Zd**( zhp@egZRT+Za>LH#=t^xm8-oq6z}|V*g0{G^F=?zM9gG%F{DfbTOZTJS4tnpQkvC85 zB*QMqHWU9CFBTl_a66-YnmMc>P}AV8Th4*&D5Y-h*$a!JfTk-{vwM-lpkgE9jSVxt zme(e3xf$Qh2LkBq@YGjH-B)OtSyAg78`P^NMCXL>`p=DbH z`!}2M<5?lp2e~|wEf3n^b6DEu0qN&v)I(iK%dfSsFx8n`4d3#5udmFTs#dK;VMN0e zW>eV3C&$*>jd(%}uS^z4ShV!~9|ytulxDokzK`Clm^ieL{Y7cla*_BYSn1_l$DY+f za22JRWv&XPDK9k3Ytp&Z!GgE`lkWSEKpq=#A3Q&SXNbi6WWe3}|&0!m5Y!qbVib$f^aRbf3C zhE80l#c`dFnRcq9=Wszsnxw=8}M0T&+)JWpof1__z|t zf};yOQu2;MML*17)leIQad+h7ntHs2hJ$CcFPRD4=(Ng)Vnb0Fa_tow_Sxt3eCq59 zB}yxNDth0}8;-HJ6Rg)#T;$1fRq3-B&Ip=ll12v4GH(hjF;s~|h;E%`kG1~| z8mF-j#XY~7Pdvuy+&MVs0id;cmH1u05!W>>Vc5%ZQnA+F=n^vi4o;&>(ISxwIOxE6j5|%x;3S z2j?7XnVHNfYvx?PHANM_BpVs2h(JBnL{uGz1v;TnBwtI~2+Qn#mzCAYL1!s?Cwug7 z7Qbt?P8FDX_HrszrT-*5{6XLD;~AE>ySzLE9@XF5DpApI&sYGSp;no#n}FCZF&lTt z-^j0}urTg4E`9cFHf}PpQ2B|xiasZMDc$N6M=32lb+L>Yc|oydo8{P6-fbf&57eJisIC zbu#R7IW!I6eAdiF7(uG^nMu_sxL+x-=T&d#J=ZxQM55VNsn^MUa<%>O*z9pTN^Q<5 zml&&7P7U+$s;V>@CGm~g`YVq{Ei40`+&JNnIX<|Yq^Q(%07VRy@0At&B_O_bL@3aa zA4*44y(W55a*YgqgRE5EeOy>Fk=zZ#AT(IFQgdQKHT*u3EZU9o%oc9G8rsS=Rf)_} zs@d>Y#K!ux!gID~^q^Rh4NR=s&(BW0mfsFmD+>4jG83<&nT=<_%7w4*E8nT~n_9bI zr83LFgPx(uHQkPxcxw?(LfF;S(~OuDc2qomvNr6R3IxTa)nR3nZGZ)&2JCW=brpd& zHI-Z6JQAj%zk}z=|WwjSEu4U50E! zQkru;nsNQV!9sba(+kd}DL@=Y=vVc+2v6cdka8b#U_laA80sv`_N3hiGA zB{hrV!e|;6_2GGGl4;c2cdrCtEe?7{E@kfYziCxKxzy$gsc!LCDC*kuJgdQV5%6$F zf858Ux3x{lzvT|^b`@Y_3Y%k+88bC2e;QzxP)Ha{D};|A^S!cBxnkmJ`sNa)865wC za;otMbQw&{ve;Gyr1CJya(kM4%?}3voiQ*Wel?Li`in@b567|8pmqKd7`+?*2Hyxs z#o=pTsfUGJO}tQX%FfP?j*gb?q^8Bq{d_;VqtWtBzc7*O;&u7Ttx+q8ycEHKiATg( zz_t+=l7pduCVl?xE4YF4KJ}`3bsWr!c;U~mlcVTvlns9Q=WY&?=`G!sfHY^>;eJ9^ zf(gC?OC?VB0m(w#y$kDBKN#m?uW}o9{702`sleaD%gv|PR9#B#8)E5;@%=|ihs(Yy zQ&XMzWi7>!aLGLuFzo>GuZfy7b~HMc7jN=nKJ7KU0lPYL^-tpa*p4U!j z4nwIPLXz@nUj%H_6_^5s7FBJJ2SeA9{k`P{jw(l&? z%Hrqx1*nK#Wk2_CSnJ9b`L6nrPhXQclwT;3Pz|r&R=Ls97H=dLsKvL!uTrx9ZhV5z z7=Bau&_gL>+`jtL#-GK>0X`8MC6%W*HoBvU!@UPK|6>A$$MMa-S}G3tZ*~JqHm$!l zE7z6YWCf5)e>soEkT$_!*VdQWLzVTM8Ut(+5539Dxd(QqTdKq3HKHs3V^yC#evaUv zoPeK@DiT`61ru5j>A|cuiig0D*`Bh^+UMT3GDf9eX$I#jk3qid=yLhjvJn%{;kWtukf_N%$ksTQ zUA}?=D%8x(6bV@tD)!Dl5v~&FdsU}W#1~RknE_SXRT($G6oJUpUQ5GNjf&g*-?R@L z^l7kCO>K)jh9~^_fXL}h{Sub;6Wb~Q%Lor|bn@pz*oJ7e;?z=+V;z_=h+#LLXi4SQL z?5xG^>$_DG6RJ;i^x)XSX7*QGGj4@}p;%v^bDm!bc?=Z_R};r~M;B@4h^j1)M|`S- z5KkzdgL6?=fILkJu%4BXIcES=AO?jOnFD)btV)S{Rq?A&p9$E-YDb?TlU6Os)3p;a zuQGnDBVkD>*1L-mRl#gJu_fVU!PiMQ*kTyM=hw6FtoEM8Kr%$Pp%R@36WU2QurjOE zXg9ga?@onr9};9V0>tJo*oemnY}Wyiw@d5GtwSCLl?biHoR46e!EJHY8ivoNaFbPe zU9api)l~hqoXGUU^Qs~FE%2)v0e54j;#=B7<=__!!`>eH`DfGUU1Cp;FnpL)IQEhmTJu2MGbK=*D zwFiOMf>Xe;S3S!LVK4Uk>ynDIJ)1*J9HBf^d!mm6&%kSkhgxOTvfBK41sUFzKn#1= z+jE!jliR_w8*D1k(w~amnoS%{8zsoVED-F%B`l@R^=7WdqnM-PGIX)5bcu;Z^xY{Z1F8mi9FKwZ}`^sXq zmgS$&x?mghrx|r~679z}gNF<9W(yXrc7U(}OTZCo<2W73ipWHwi=5{ZSU`f1zWf!c zil?wBV6yY6^d-ljX&`WuH0tl8*6(2x*TpgCY*oc=>LtG)NGfr$R_X=qEIv5zyN0F> zO5kL{p8PKiMX28O?oXR;clCFyFC|aKq9qzkCY}mQMvn8kZkrD`MhFQ+AZ}oVNaLKm zw4-bD6Eg5D5NufgkBW<1@TG*UyCXdKuiw6hl;2)e~#xi9czvWnp~8!JWEaAVwl7QKB&5eZ;d3Hg{zr$8Xw_l=l8ES5gXv zo?VfUkU)9E{)B%fRtQ3h7{*mWRc;Vh!PiFwhE;P9+X|>cL3Q4KxBeeul(MOXp+JVv zbitshmM_NTD47iK;shv}vuOE89y>a4cCw%on+r5F^r6pZPfyP;r)mHKD%B_^}If#=+jn->sDc?&6 zcOBYKt+#^1dhvDK4?(^X>aD4CBAX^C>FXLzEpDmvvO^%c?SRnhU+5zcNFYary$&LN zC#ev;8bu*TI68fGfcPga@SI8cB4L&Nw5VDO(%M{jZCQGK6F3q}iG@yuszaVgY^T@h z$tETy#-ca$BJg?i35goJ=pxRk`9FZ|Ax`iG{(meN_aDdoPyYV@|B~sHdj_i@JWW*3 z(1arcy%+0w>V`94R69>}zxQogKmAce=-tlxgBdc~OZ)l>2F{Gux)s6CXl>Q+NQ1i^ z@3icP8{e&bZUBU!ap8n+-*Bx%(MEDW+RXM9zis@Jy`-BxiZkV9B64{qHc*sm*rL!?_Ec4UvsW zW)-ydpo{f^wK)+mNn^9M8rd%8dwBJfQU0l}Meo*OsQ_woOs9lJowg?Dc-jO%^}Aa( zHiU+PbSH4xKusjDuGwJT`wyB3jd^9ziEx)t>+1>bR^ z97KN?WS#T^RZ+g!!ew@+kZBo;*{Gpy-`skgF=5K_I}r?#_;7kSBzIC*BnnP+9TkbYQIr4+1R(B7)WsPDR9sNWx+(^ODAy z{p6~~!`G}m1BHNeWm)*aB=8jLHHe>Jkg>Hsquj59_#kxDc#@=(KRpt%(^!{}?&G8E zwLRyiSYK#LUZ1ypS-IJA%s1oD+3cqy!UNoJR=Ro!?yxvP#k#_^Kh)Q)u=(NP)~B6& zrJp)Ac(N?6mX3BoS`%DbIe*9v1uzD#l1twTK4B-D_L)noIUHnqCw{u;86DcGAiIjK zS@%1d2Va(O^VT3TyL-mxl%6>}lpQy(mq{g2X9Gu7?btoDDWPzO#Hn!2Rz^m*k)Mx| zDE40j`_iP0g3y?Fjrz&Njy>k2%=Obo(uDJj1txvIwaM|64)ckZZ}NNY)jtv6k=m!0 ztaFI@l{UKlsk(<*uTxm^3z|qU5TKh6H4Eia;4mxpxqvLSLGuR-W03UI_POLq=X!9N z_)x=!7+iEZPdn7Z)$h0jex$Qg&A(?wM?}zsTEVcL%$XXlo5dOn(0R$JPAugQFdvdy zcWE*+X|gaGedYvJ5hMR?Og8fB2)Ywv8;O(q=F4^)V5R<5@64k|OhxNMRMmIyyCuzJAbAx)p`$n*3MMsO1Lp z`a-vj>9uw8V9#G(Pbm=0Y(qcgTTW|zHpQ;Y)eB`>=xyzXblX!Buup$)D22VT)t;x; zVNXwvzgc`O=7&-|{zg}P*&6K4o~f0=i!dhTF7|ssPs(DR$N8!h#XFc-uJxnD2}%CFg;V+2>5Ch;{r{ZNlw7DMH69eco%CJnxA zaj$yVydRjZ2i+UTK+S=L#OGcVJ|P+F0_J2pH;E?)S$(38?zq|v)InqOgtTvp!Eq@^TO;)OIN*}WXK*R z)!Da|d&jORPL!`pDCtG5 z-Z>kq13$Dd?s2-gxdd$cf5#PMaRp`=qsTQ`y82ucS2reA8yd7k&CPL_Jke23Xj6Ei ztkwGT%fOl|BBW3VZ|WoIPC8nZXi{UmThvStL9Vx5PQcIYdpZ@-LY zFklV$&SjRZYJ~E03qL#mIy+!*s>Uron&w3mC%OGWJq=AJIW$?P8{gO9%ziPvb0Url z@erNbaOMB8Rqqb1Z_r9L_OcA4IHa0HZrkX3kJ?s?1~}QtPP0e&`FwM^@?gwd(}5iN zU@8?H;dBYnAx+UWGoppDU+8(%|H5@Ufm_s_a&ZaD^v>o@HlbHosgH)+mIE- zCW=dquvD=?49AbQEAn;hN8{B58wOLQ&08cg+f3wJn()4kzx*;>i7rzFpPE^tIr6-_ zs1TvbG1oJ^2r-VP{b;?yuIMy@ncpx;f@E5C7pdyxx1}FyW85|*BJ@vvW@ zb6v0JGVx@W__QjQn73;_Xk_;v7b_dkI8?tR8PbKqO!yA~#kf@|M-0Z8$?ka%N{)0O z4~oJ-T~Pg-!nMpBzl28(THj&rAvDmjJ`p%PZ{s#R52KehQG@;>YV`WjST?YVoqhi4 zgS~mAii5Dj#{=$$|8;!DH{R05#Ei(teb_POC+>kn;>o%NA9K&O#GP%>3)77;@ufze zC|Qtm+k0@Kr!3}bYq5%`XS^;S&6g0tJ1y~<{B8!g&$?M#cNROVZ2^(76kX^i~|Z(-3n~*V_0F6tu^M zN$f(}92Q>PKuWC}ZEC)bHeA4T_7MxQqB2`)T;>LYQZfym^1 zi1A^E!gnKN+h$F1IK{h`-X;J?{Hrzt_8Q$3{A`SYf}%y5HJ`quUA^xZ00DPn zdOAas%9O8-LU6Ue;6V51j5gqlbQC3-kRQB=QnY-eqag)pj)C|_2AnRbMdSr!5KCNW z5`mZdE_UkChr3MISoi@G9*(4iOmhCG4{ldMKjeLJ7gzgRm_oizWuHSXnD46OtQgeG zxtdNfK2x6gWQ{o2c4~D}IuM7v8vE%uzGdL}=6i)#9P(GsfII3^KV^Xo?c9wuRWaQ8 zUH5{-!#`*lWzO)HjT`~@#^fw_$NhW2EYAVikY?=p7t%fmh-YLk#i5eB@vuuXve((g z6M>;v)YygEKY}Q@rALszQ^o*Ct2bCZv)Rh-(z+2TR+usQvtE}wYKbF> zJ;3U8r&p?F_l=V3hc`z2swH^XOn?7ePl=ZGEOR)mQYydH4GU1ixgrwtxc#oNX~vEf zZq`WI<4i`t}@8}NFX>d?U?k+ zoqnqEG~JbvY@5jxD-exFDShAnKAwSsMLNPKQ((Bp&M}jI{-cg?RcmmJ*vss3&OA<}K#!>-wDFok;uf%O(9rV&dK)Kc3r|r7QDXp>D}&mIeDb8w*D9 z-4&+QQ`+yXT0CRJ;9iX3pjBqijs#tpBkjULT9Jy@COC^jU=SoG=2@qyY8-ycI|?DB zZ(~CEocWd6-%NR;>47mA4U=5R=2{C z=iS2Gwn{Op76k>$+-R~@=$#eF*$t?Fop%!(7MHP6VRZB$xMZ!Z;}rYAWx5^@!7JDk zP)H}cZ;;7jQB1)}(^vIRk%^Y?s?OWP7^|YRsbI7rzpf;izUIajSgF@>lXkKi4@{QIJsiWc(G24oLtDt_;Eph|Gbefx z<25bst+aoMb=kGEGFZNHM(f%XUDY7XZL4VI!j|yi1083c58nB(JTM>D4SEQ4k2`VXlZ{N5Nm)vh^kcK{n<6)-od zL{~@+eC&m8no1k4o^8@a(W*%(J~8M#H@_~xy1Qx`*h((uP}w`*FFvw1QYhBJJ^Ch= zhN5bqV_wwv4@vd`Ik1hl7X3FZ#ZZokJBWdV4ldr{fP+E3J|#X(1y;kwpvICws%fRQ zeO4jjL2b)2wnB-xmpZC%_uqnsRqR84Z(mV@JcX0W-bxr?kn2W!wCMD`qr25vbK=Kx zZ;QObzD@a_kUzhtA8(|IWu9d_7!VKP@k*5f(L3akI3!|AH{iUnK%%diBa~KJrOx~qA}7~n*Nov{#7OV{9-}-o?7u~@7L=&Y+2O>_UlOr zSxHG8c3jQk2rp$igCWWP;y=qPSL*`^J7acpo@ed)V4Q+PShzM zs&BG9&8aln3%dT2J18k6w-XMFm`&hMsPZnwnuq1gYWT2dY*?O8cbseGXnn_B!0vEH zv99Cairphc4;AJcLc-)+stG*iOqBVX+f6LStghTJ8vauSX;Pn~P;SCP-p}0lO5Wkp z&vIt7Z0G%~ZXWl5Ekvo4XfqndvL3%7+pnf4o4d%ZPh~i0F2ys0sDdo@sM6`+y%;Xu zS#t@8(d5oq7qq-0B&l;SAXz7vAYZFeI*y*fGw8y?j`9eZK{8_p{}Rxa${PO znkRN;F4~MdN7%h`sJ+47bpIrq4gNixYHA zie@-2?fgl;SL1HUl}^@g+=(dKsEORP=P{7fbncOw@I8(m_bBQ}OLv~YdOQps$8$Df zBY88DuHtRj6HbEURUyBuG#q-G=J)S9!YU7R!4G+A5jUISlNZ;u+?An$5ewH79`8m0 z!^2x1mU(lVc`hJp_*rMCBSAaq z(Ali>PtRAFa0|K?Zr+8h)Ja0z!=p+?i?=(wB#T}9WsCLd%*wl?lJ?8Cvpp!9qtKz# zM{?|(mY;fE)GfXk(vrrfNnot3uNKYGB*Xe5q?#F^K+RR|6k~#8G@+GT1-JwHcNpia z6{RqZ4(~VgUbM|#G}sg}_5Gzn-m}UcMc7vuLRQ=5!qB&nf3d8G68T{B(6q5(lNQ?q zvaqCIHjclqs04%JRTYTowv0AhVJ_A){|3>V;%AR2AUuJ>Z6^}ntP)HS=8{pfXr-?|pQ{=+{aj|(6sD`E&$Uz2LU=ni}<-3Sp_iJb}Lty@@Ym=T^dX4Blwy{l~l zsytGxZa35s!|%G8zfBJAKW)>ni?1mM?R}BSu&>to`7*!0U~Q)EH|X1rlm6n}bmU=x~JMR^5TD7WGlbynQS*+o*RR=}ife>bQC@y9Q=eP1;1f|JBDj4S8KNd8vGn{N8}qVDQR zqQ+T+R^JA3{xut^#&&~IhfTLyP%naT7X^Z&gXbS6H&X_s8+V?M50YMkcwaw+2b7{M zi{z&9XHV++rur(u#30p^v3d|woqyLf`k-4FZZ5tE3#@xJ$nzYkGxdA{eR=Mr|KSx> z7RLlnx3d0z53}ph!K-V>d=4t%i#?%x@&)mn7_B37mp3?9R)uZi<3YDP@cFHj^ashL z{Mx{h*yo@2wZz@cL@JRCwd*KQ2^><2I&u!;+9zd>R849;a_gn*R?76tvmg6BA)tXR zgc}7pN97^!g~cj6zI~QhCYfZf%Qq>3eoCuR{8HW~ItWJG8Q`wFuG2X6g9+c3-h23U z?}FI6TT{+xcx`cT;4D`$F3HwHoS&QJGvnwD?v+5+S7mGFm>k6NT!wCqE8g@J7M)$n zwU1F=6F3fP_*7DINvCnRmN~gIN-i!pyjoZD#Tz}| z*Oa_v{)>ndsPanu<0YJ36XZ+Tl)iXD?cNg@9DLAIsavlNUm~Ih9$`#BSmzR!#SBCo z(|r!=WW7oQ!NqjnK!$P6T^|c8i$h6oJ@`kWe2>tNA!p{F-V;gG)eV>Go)BejA4M0p z6HI)?FzL3DOoo@RAh>MFNNQ@Ku(vh9{SxmctG&O{@pXRpN&%;#1X5StXfG9Yd9(Kv z1bh}s{qY3p>EvGO)CxC)3JIW*S7`pQ%`w$yWC&Qw?u0M=wQH<@uVTluWj|+#uCiwL5*5`($OsIJE zm;G~T+o4}m z9JB9ioQ?)|o4w&cFXSd)PXBrJ$cr1~DeOfOffVmNPhvVT{<1y??BWR9#p5AEUHRiy z?r&}jJHX*&RnFYO1Wv9hCMDK|$n$H)Ba)q##4+V`e|Qj5pTi~o!EWH;khJ89?;^LU zGow{z8&3Qc%PyZwr07@9u0%Reogkm;+iGl*S>fQTn<*!D;B2* z`|K5InYurSbsZbAPB8wdZF9rlljuz}{7$(OBKFl3$aw2;O;HqU5a$F=r>g|y>Ovg(qjM|)mii8+A@ zesM}c&M%ph@@FI85Zx#!Myza#W+|7Sn}TH$Z#x|$eo2@-+DmWd%srZI{ISOMAZ5pH(T5oK#r3*r z6#c*4u`QG>?L!5@wI6k>hPqBlCYtgVBOh~J9inh}qpoUigd}?!2{l1_C2OEf3MJK$ zB6@pq)PO?x8Iz-nSYf}ek|w|Z?Zlf-ct3{Tv;P|?_^yPhadniK&nYeR$PHiHd$*5!=r}v*<;ZUH#%`b8Umun6Futt8#<_XyNA{aq)?+!uJGg9Gr+m^b%(q@*F zBXGxv6EI?(&5IvSg3D2AnMq-5I$H?cF(QGnz!Y*hoOyyjuhPUrnktD>zah4L!q`ek zC3@^JCO6#*J0^uUT+Rw+z zhb_p3T{65lWneTM^LoR1KB9nv21ds4G=#YS%376>kMyP|prA^8Re0-CDK6Bi8GK`1 z*QD*08k$fmP;vYAApxuQ&CrRXIGW8W;uNEY?fasEy` z7}S)Jn&5~n(El1ZrRM7TLH_MvtuG212{0w0KuV%uLp)uN@;XcQi3{^Vha<*sDXg5K zKPA;YIgS3VgqTb3i6}L0COH^GD<+1ujZtCY3I#0|PxG@DQu4g5Cj7V+RpR!(ckiq~ zKY_?Z14UYtbjCa7i}Uf-xWp^Jr^sm|-=?Z`L=l=N8$j~BE@P&{BdO#FIsz2zV+o4k zEbBGdOcYV{0bw%ejv7QX?$n70Aow_&(o{D;>U_}uL8;7ChTNHs|A!CW{+abO@!yG4 z9wn$9k05??=tiSL!yQ;>85pQSM|7E7>VIpn3kWfZ z@IG(F$p$?vzJPS2_^4)f9N$;~k*`2JBN{_gJs?fSa6J_W(sbUhjtSgEja%L-Dh)Mq z<{TG!R0W%rn`5LDx6)eXH1Ef6hspAIC0@KJMK12~aZ}u@62dDdKeu!L67O3D#b*{| z5zA*CLS%L04F1qWOgx*>7gcf`amlC<4Kx45s;E1uPIhPq^J3(@TlvYUg^)Re8Hh(D z54>3%fhtH5-+-AozDnaSP@j(}m$hBN6t?_5sEKsQ2Hp6%UwSY`pQT?Nx~pFz8HxJ; zQ1@0*ajomRE(8k%2<{TxU4y&3ySuvv2p&9y;O_1OcXxLU!97T!0d^;2WUjg99H;Gb zyYGtD3aaS+E9uYwKJR}Yo1%M1@c&3FD5Sm~bYt z5_Oli14+xtBr-gEPzokiqR1xQ*5Kk=x)`=c;uE>pakQMR1ox`SSPPuv4{gB2REGMJ z)yf{Y@P%Ias|y#Yq2{L=bQ_7JEIUSnHZ2U^W!tKRJl&Yyh9jbyU_ZoL+8vpGmw^I3 zME|J7GTI-d`n5*jMXug-U^9H~E5~b8(7h6I@C#|vAN)foFC|s_jz&6bpIbmzvj00N zp$8HEX0FG9P{o|u{xv1DFis6H#cl?hg6u6Zx+ASiR)c3$3e{kuRyPJ+dl4gfn&kv3 zU&p_2nrGC~s97?3B385YcO$|mZd2&XSanYlr@&-sag#$tfZ&%k{$j>nxUyWCe2HMz z`xb{s-JoTq?dP3s$Z+12^;k`1cNBe3PupfrAk2DpMYkR@D?^2LF(zuQje)5QzMJ=J z2eqB*Y+)s^TCO30LW$5;>}2#ycMmK5FBT5)!c1vrKPM4q8k1O)Ppj)x{rr)gz2}aX7;T_1i9KT$kV9?9+HUkOx^Zq0jNQs46?z{1~jSk7Mz~WCB z%`YLU0rz%zmJ%xs-Lxv0$Y52JBIAXHX;J!^o^)t-a)QmguN^V5Op_R8a=IPfE)s{p zH8aGy>M}EH@-n9}Zpl?Uy5PM#`)CJ`(5?N)3T5~;HKFwUPy5dV85v-;l>81nz5D6p zIHUKTcyG-N_&}M$h@iHLZ9{lJ&6sFM`k1^%zWM%QLq?TTBTzZ4qZmKFXqyjPU)Wnh z8?8Pm6*0e*pDT2umyc3JVgp&<)um8QGndqxqN0A~v^&5-LBzkU4B;Vo+wZJTd>>B- z?llpeqrLG_GqZ6E?@0KCw8@kHMcQDQ+HhE@+;wWdcUj@ZsMa2SqhH<@vx#T+QFpPa zlgW%Ltf2}y0dZd21sSI|rs6DuxYL8|ITWwwRDwktKR~&JM6vw9$nTaP*X~XD6gU_4 z{7tbA_t3(#Hz-goXSpbWK?IOz5r zH8mwMdX_FJ7j1Z~d5OEBIN1J;&>W@Ya96R&w``l9l)4@dLVGZ+f3TRs0WK3Ti0}#_dts){t4=llWnVFsKqQXfc*CDQ%YZq;Xzv;8Re?D`;AGHI55K6@p*l zvs`leS-v90Phf+F4>p+#k@A29$>BHqAOQ#kt zoM}>TYB1lus2yQBy4O?RWeFi0^-d|$AR#I*&hdxo*QMr z2VR7f*1Pc1$!8=04ZG8wCxH~;s#X0$TW%2dY#3GR7@9M^aFtlnjPcOPOx-PUJxN{O>_HNSjTTzw2Fia5s}lD z@s-{KP>yRUbf4+Wx%N_8QOGBB%a(n#s3bqHn;;dF&??7HIC^||tS`x(&A?r23gRB- zOxzDf2Vee^qnx2Ky-$~js4c|C2jDCR8OHgmrciF3^`nkj%Krlu9P^oeZyl+^J1_E> zkiVHnhYa)P6%4+|eZQvm3r5NHypamwYIA9iBR$ukkR9`h+|ZVy+6Fk)l zhZmb)kN-8)oi;4h4-gq*En1kBa~`8%`3(3Uz9n(lI~XY$_W7>*u(^djBAOxOnv=%? zdoV}>Pd;ybwycYN`sF7VBFh~4*;Mjx}&@|?LN?fV-^Grh>ll5316vc>N z_eYxg##ZMJ0NzyXelE-V9=VV@?N}edU-*dZPdT_}`lRkWv(e-{0|T1cdpT`A(!x_v zUuj@rKU@Hs&z`9p9e>~V34w>?fMQbKM+lKR3>|y|tFyqe<)62>X?}!a8(eJYEt_v+ zz|PS<5O;dsJqlA7311P)FK}L3OhoROC!5!U+Qzr}LMRmI(WWMM2R1f4SqZX06Kw~^ zFx*0vgdd%*9Rf&@`)9U^9HgfP;>7*z7waN9;*`5=RM>@#5EYY7uF*2rZ=1IJ1 zejkT(P2K~3*5Ns_k@}l-|J@HywFTC#H}@94MIW;<(+73qZ0E%<^{*Z87D|jSjf7#h zVr>YY!kv}GeC?2=(r*I;v_!Mrln`1|ci&0T?C*t%xf;+kEzn~g7iqMuV{X7YpzZtwv{X!>AImqj`-(hAl z$GyhtN?T7FfQ$X8$ASM=H-D>zfGgfq<^8omvugH{cm$oM+x|kbCg{wq=9>oDEEW7_ zj*>;EOG8G@3&i>m*3;eeNR5fHejiIvB z7V3e4DA%>}QuU$|p#2jwcv!ZrNUjMIf^a_Rg-tFHGn5xbPgk92kIkZqhvD7m^dz*r zV^~#>pw<3d5wve(_3)J&WhRLrHnu@RChes6yt}YXwSM#Gtbqr=&;|?LozFWmdEj5e z%^dv~9uqYgf^7O*$Nr#%gqZNRz@35o|L)B@7fy{}3@d`tE+x*#xH{eb1zb`r_~~|r zx29iPegX-hJ)%;bDnd9E9#+kg*pd*hl6kr+Bxx{9X@qETT8Rj{!5pK!^`k<5EYf{< z!?p}jx-abv2td&7v2hqv8&R+V;G5?y$}y4o=HeGNGjXHA0Y?MfoEk?Zj(1+hA^cc* z&x^<@xH4}Fdv9#b7Amydp8B`nnq5Cvps!6dTh=Xo_I4VZ3?Iq#&Dw?GogdITzZqDj z!`dI%Fq3EDWUm=h3oP&cfN!c*)LfXo8tgX@X}(++QPP}llm5$4pS#Q25v&6A)R#3s zehNbJYDQe)(1zWi+X_B(hK=A=-0*(?L$>hjZorpP&u#kI4$@~*dB-Nxl7%a!XnOC< z01DPt6X4Wl)@;;Nj^J_EbdKsU_UNSgM)|Oq)1&Fg+qFH3YB=(m4t&UmnP`Y{vXTk3 zbmo1yVc*L3Z&us6kC>!TUP`!gaKjeg}-QgB&nm(9N~zRp_82T;NDhkV(KwA`eCkc8qn z<4XAg;zTY|rQN<80Ai=p?Zr$CSjk74b8Zdt<)s6Q$ke%F@C+3!w}r^aG5s{F(g=HZI%~7tJK^|C0j#&T)+ zK0zeZ!$W}9s!4d5j^U;zPrawy z13)O#_!~b~v?e^_8WoSpVLybFrg0kN*3E`+wBjB4Qm7sr>S+jGDzlY67OEY(sVLpF z305t}-e7-`u4}_~KhYA?E6Q~e$3I=@tkLh+OZ#jlj6DsDomvilrpdT;JPYO{8~>F6 zJ}c)XDQG&gx|4J>r6lD8mo*8zM*2!N_#*-cw$!)!a~*KSe9(4AVV9rdbdg_oOt+SzeS<4)SZ;>Q5zz`bSdg#Aq)YFk4x$%BKJtd?3?fuYc zNH?rHg}*cMS7SRZ2f784O`~y+FqwOUD$f3sf1uAp#N%@1bt{@qTi%mW; z4axc`37=<@AnN$4CcI7|%WBpbL3|*K$HBWtOi96;<^$s*N>(kMe8`wOVKwOxzyI_2 zO`r?!N%qk<8`JPJf_7x0ixkXfewof*B);GD>*v)=B#om_Nq_%*@|F&J!W#LVkoz-I zi5uNb;rok3g7vVwdZbXoGcjys#pt2d3ofkrL^A2I3~H8TmQYKcoUCZ7E4&ZE*>B;? z^33KcyeLNSTOmZ1xXy}v8;W-_g==OrnMYeET+lW2N^r>vI(46 z2{yHso?bhd`lxLPyE7l=jH_j5SPa+<7d zqu)a14Ut%5LotF`aZjQn3LGc5&V8PG8kea^RjYl-&QW-4G4V6I_! zWk(VZwqvcCk4^N8qq=o^CFunfx@g-{=Zh%8wxx9RjOo~nVA?Gp;oreR?5WH;uhl)8 zQt+D7S21h;D7)t!OG>6#V0O=M0H(qe+^Fi^v9r;GHUzqUtz!LLubMXtdIxGwttG#$ zJt-+(>XZ5R(G?kuH@B}k|3-Dp#`045VUHaK?^9?nJ-EB7igcL~bWJ9Pe1(kFWN>L6 zOj-K7@8Azj`fW9j{gMwI);^&;I^0*55{ho-QmQNGMe9T=T_@q*O@J)e;yOwkqz_SK z+{=Sj5EcWwk_9_^M!w{WslQG!;_muB`q_|75SPN&(et$2(qu4lIF<#j#a7G>y?YJ> z&3S_&-p{;&or{%@w)4v&M-GzHk170H71l0t(-bDB=-MNC-F1pWw;{A1XzHmBWkZoh zjKbenoVyD@%)L0$WDRj?IIS-)?W^eQ&D}B_7yvJw%3tLT8Lu;Mv#YVxRPy@H;tMB7 zN`~vav$E{<>5P1eE*6YVF=HQO?QeM!%r=aPwHigkt)qOxpvUB5QtkP!D=HC-)OOA{ zVq*NNGDM-AT#BPBsPEI)9Xpx&Z6j^3XL=FQ^z_`PP%OAmPS@kt8OBs!xQdTlxnmP$O@4mLS4oi0eUtxlAKcMj z46l~f{K|u*1<@>(i@%5;K6}y4_+E=m-4EewM}{{~63qx}%4nF9<7(8&u_ASQeOwf- z?BY`uzP2Z>n>R(Za0cZjCi~2!W0cVNj}(*f;MVE9hb?aHpjx7v z7I&nov$W9GT0)v=auHB;-Yh(RntWLk-H`GzLbA$B_4iAYXrZ%jv?fcr8Mu4j89X9m z5l$(oGlVHMv-cf%y6e^9-@=%9w5=SD%inpTDLB0v5RP28%;_qdI7;UK+=N*BzML_f zK)J6BM}_;RSK8v~SfRrV8m3fHp^t}LzdBRHXiDoBq$VCjcbvtf$*1BYjLi%5No@A< zecWOP@K-bg%baZRA;Y?5?QDraEXq(!6`?c&H5Zk(?27=tbnT<_8>;0lfj&JL&|3ld zP+apzdbj5eL)N2_Md;2yI8sYPX!sx=>hkM*!tIhXKUATxLnc)$^QU$@k`V6P$kzCK zUF!Es;lT#gCLU~&D07C?6G(#2F^H1*y*Dmkrtk*XDex?U{Y-p7Mwi&|R^IIDEa)5p zNZ3r`98o^ZQDTnEwwNZ{%6wd}&E22pFqq%F@nGZ_RpH;wx>tuju!^^=QzgEU&0;C! z+|?6VOWjzFAA*Lhh(9_tpyGv~#9BIVQ#k(!UOf3Re_r%Ny{_nalApxD?TCdf36$kG zDLCWCdno_Q1^l0B9LC7pj=92|@;MCRsMlwpgU7clqr5L=we{yrTM&cP{;3K!SGt_2 zaHJlzE#L!M!&_!1q!KIRo3X4$pXG)7w!Bdv$o&?Qz)OzB=YQr=lUkAfU)ibj zSR=c&cua3tagYknRFLZ1=@hsW9&yXKIBc(fU{GX!hh*61;!QybuiC(SQqaozoswbI za#oBwtIU>rUH?G1LVkiYXFtMr;IngDVCFiBKpnr%m@1O-r_#lo&Axe}K~!-63G|gU z?JQ3%TUT+bNY_1_mG4<9lMf}Uf?6w_EbA%dJ&od@QFE_ zaFs2xpBx8XwM%FUp9iY4vi2TEu8%&7YI*pjkjs)3*`!W%_j1qFTna zAbL3yw*WENah7fWf$rPHE$n?tH9F$d(+BfkKKwqMnLn9tlgn8j0&Y`n%aphreH*yN z4Pg!ll?3r~_{$-3r@4`co9jcaqYG%;9%WpUw8i9SIL@!4l$Ew}9!&|$<#0>Inm?z0 zX@j8QcfpSw()f=sRD!kK+Q2_DJi>h|anvXV#OhUQ0Bc#=uMXbQ=xNog%@e$=&!BCQ zoYXxlhR^fQj|hlF>=#vOmDKuSTyqgk_i|4>eIbO!^nvr+ss!yA9+^ zaj!Hfnvve))~ui5F_BY@gPL@IjaN3X#MV6=nqqMc#KRVcFWj3Qe2uRkQY-q@Xdxpg zgm2%&M3oaIJca}&Ao92Gr8@rVcUnLF66te1_Tf&XuHqW2Z5!~#p|=nHLK#+@B9Wv zzSEg<`UnQJo7L05ox1zVElSz@?Z#XF84S4`NW)%H@#<)_?B1PwNvjOy5Do4=EO+wbfgP_YAmz5-p@hD~ z#%KkN-kxK*+w?JFR%Mj`cpJ$tgc5W4AJnV@e=75O3y)M=x1~a$>$*;emUbQIsN>BMxe%eSsJpAU|cBwzC5g0yIqs zj{*;UaMM7t5}AB9g84!r029TXg!Ah(1c;ST)}qH)+`^7=$LuF&Ne7fPW;R4JmHTGq zPGl0qeEu5jDChC#a9hpp24+%Xl356EjTN9HK!8%L7EaY5nm z5}!{@M|#0Y+yfZKcB1)-1seBHC~wPk!Rh#@7rMuttWL86$I8-2Tb5PaL%89&KX6t) z@YSQYu_6P*&(DSTyZ`N=Z>>$XIl4zM0-o<2Ntt`GZGb zfRah+#v;woMrXVTw{W#Rqek??x#ed=k#bL$&pR*}**N%M_&frStIHYI!c9BfI1 z>oM;I2|Co}iWZPlY?l(PUovKcv))^__`f;M4c((Q-1Kg&W{8=N_(P!^EV*B*)r3bz zK^v^sk$txB8lGf9QAu$p5`G>?@hz`8+N?wp!2*Zpfh?n4c@JZ9)nq{1n7rS+jI)uM zyMj~uu<(Hu^Wh7DvVOLS8$dG6Ea|xGL=NzwQ;}cSS`+dMXqVJJu4~6T>kyDx*h;~s zd9K8pslfn_-Gk*?YHO&{9#ouh$6a-AHAf3V6wAX@TZjt@jpl~n{nL0}ZSjSPt__Ei z6yY{?fpgSI`eNu?(t5?ir{P(8D%Y&4SDsd(O__R`GOE)=wtYKqr;mzLo{F1;pRx!& zm35k7%;rx%ncH=cEz1KGiG-B6X-uyB+hP&xR`208DvyDnP`8tT7rg{^;XteybXYh9 zpBIE<%f@cbT&7(Ek86i{-4WHp#7orDl}SN|R~cqzOd6QzF1M7zZQ!xuiaoOU4q8bx zJTUe@DdcQtC~9?>_FQ}53gfU`17`G(kN=b90`C{&Ks<61nwV2;%0)cmugL4Zqm6b2 zdszu3#^#VmAQcXVee6Xl)RR;CV2E%kMl-Tul{;nD^VI#iblw6L1*_U`qHF#wjKi{2 zHSc=YT@KFn0J(F+JAO^!p?K6rYuj1gxrlm+#HRTpS%=xMvH6cT0|q6oKPaI=59W%K z#=opqzhl__W$!WXq0+C5OH+ZUJ_@NHjIQBXzCe`s3D3U~ij3aMdSr_m9SKA4^28&K z#`VZzgk(VS7#Ye`gWMF`7OoGqZ6R8Y`(YD**V5NWXfgdN<65@q>wD(WhqReEclMo% zY6bRXslal?!sl1b7h2c$gNtb_l5yg0%t@3uR-$em9;Sy4e>BrfJaPSXk;{eegW$OJ z_8zc?MmHQ*64A-7qFOojk3px{9i?@1_64)Mf?>}}J^}?=H|*<`>PpSV{A&P`IY0kg z(h=qMHrfdbz(}+65(9%)R!@1zG~eIGbXHb`5k_DwdQ>9X>5nzvLR#w8!i|XrEBAoS z4Z@UY=6e*cC`y?cb0kKxapEF;9Nw&jTeUUOZtcjg#F-LD7vh(xe@LLOlqCHvHImy4 zkANrRY;jWPxGuJz=G?H94sq6)fppm))8)+Lb|ej<#^mR!t~0fOKP$O*1KnjLzK&U% zxiX7IrWA;Y}HS!;i^eo1mhCooEiLk?h2RJ>RM zqGS$LtS2zhGs6l;{fc^>gZkXXrQc?_l!Az4N%D?vWZZaNpFN9d=<=L+n~!tDg3xJc-)tMm{#T5NxvNuj0Y3s;geDh|td&y()AfmC9KZe3n| zy{K#|=Do`pb$)&HN63^+hyzv*$X|#+KF}Hl2qGX@(0%pW8yZ96SV1mbUqDwWfD^vn z5&I9%v9P3Yhy(_EoBm!dG)(5n?O!4(LKYZFZCHyw3pgwvi494~BW*t8idl}Ta3~%FKG+eU+G^fUJvCiOJx)g)J&|c}fJ*H~`Lks(lF*%L3x{<`LBq94& zex8$ZmrOkJ6j47@yTt7en{EhGv6+kxa-zHovMc&-)6ToEXyWot9teq4ii5^>z%T$& zadzS1C4aZVXxWYDGmYEePJq|Wm;zH@O)(ZISetBFt6Z3z8I>}@nH&oL-1W({ec7nFfyP&t0C4{DmM==%Tzgz>H zpKZ)RCt3pk8QZ!eO$f=0yQ2dmcL~m-sz-$%hfh(tnA~DHWOEC0$O|cl@)F3@ZR+?m zo-X1XPh(1;InTTFNXX2qD0AH>ei@Zo^W$PfgIvAG3an{bl8|~h31a{?owYK4Qf4Tx ziq=%GZ=ZDX%z~nk4sm8c7Z^e0U5#$FH-UTb6t_=t(P#v>?LmPsgp)Ji!^edgEO{FDj{FnmG&f zpNWVk_3XC=E-8-zY9?^$v3CEeu&AXYU|tVZigUEHly5mR!-bXk3iETj?w6SdcOvS; zV>}RluL5Upv9b71ya-bH4>g&SN$4}sZ*-O-s};zutPGm%av|VJ>%MWZ4x7qU2hW~w zYmSp&8vW3+nf=f|^vgt{r$^K87q3r8VZ+gT5TQv%B6;$m_=^gfXaHNqDR}yYd{6LI zl}sEK2b;thf22{HJ4r|-Hls-BPjYU8(FBX6`Y{9-Jzp7iAHiajy zK2kS?NCJD4g|04IB{`(1fNK^gXNEP+7b7r3*7Z!v%tcymM~@#vFXVONfS~t4$?b*@ z=a#&d#+vVTG2?59Yko&1Rjh97n`w|3>2+@so$}LPF*lN>Z-N2g;h=Tqk^uyrr%=RK z{L2!(W+vL6;HCDeoYUw)hq$<#TJ^<%9!~q3C?`sUa&q`WN(!lrikg1k$8PCjH#L^s zZw}k@8_urmRG1GIt5k8U3tM(J<}pg=-_mVXExNBmPZoz)s#dtSqP3czQV696eCt7} z#q=Bfj|t2;?b=-x#`JDYU+`m~_WgAGv}&-Ag22~+cbFm4A#zPV{?rfUd8Z9`(Q!vI zA-?kF3(iL!oNss-fI8(Ltp%GSUM`W&o}{V)e>0;Am*30?H{;&4iea>9J!oqK*Ff%g zgf%-;_m!l$WcT$=O)4tw@gK%ziy<|0{u)Su&6OE}3Jyk;Fz5=K-cOm>nvOB_%-w8> z#~ItE;Xn)T(5r2vJe(VN{c$WE?ShK~-arFa)>Er3v4_~=kSKHd^g_-|-SSGOv}NMY zP*@#!$>L25fqh>DHz~ry3p_|bmNA-Ff2)*n&`bN%f7lh5nvfJ_;Y+=zpDRtvX}Z9B zme)PLLdX!i;_wo=!*y{AdB*u-o4s31p|A69t$cAn!OE0aMISua^0){_nJce)`|nC} zf*dn?kX0k^uH19R3kE?KoU4`w#ztxLUu78;suu(wAtq-w`!zcv)}HOL#_pNx!I!So zPm_v$M+HHZ@Y8eDGZv*Hr>O$w>uDDnO#cef6>kZCS`kk7ujy?Z&RKdxg^-eV&#cZ2 zTKUi~S#ib0480sMr`exjdgwmA2wHt$*lH!!xS-Ye+~O&Zif&Ds$EM19#?*{OGDBV3%waQTTLu` z5F0s8E4@`2U?!H$Hm+EB2na83FdL&Y*cPRpCZ3&kxs-Mr#4uMnT~7QMLb{Dmdxa|I z=B+!c5w-sYA*qbbhQ(s@(b1>^1i5u4tXM45$3@Z~n(~u}RXTC^%AMnBL2SydD0c@L z6~$}x8*{qfoYdIbxwjtgz~Bj&3rFT&)K;|{CupA1t1TM%(ke^nr~4nK>ez9Li<1?W zi@Q{0YlfLQTWRa`l=xYUcwv8&ZxqynJM|z>4a!hOhw_n4xQ_lY86H3oMNrjvC9J6} zF7WbY>%kH!HS`6?&U{$R7~l}TqE`x`;_vMVs6EDqz30lxzU`vM!7;kX;&Ap;%+Vv; zbYa+xKxyVy4Uv;4>?wJXT+{190eTtjofx~HtcMsQIC?&(q+}SgG!mLpwDS4Pl2^II zlu7P?B6G!=pXCN(JKpppQpZ};Va;O|JIou(Y0k+!5|A|MPOSRvG01+*>#6DgnEiS5 za+>FJ{p`$@2KtYt?qEaz=+}QoxC$j9rX)Vj^WPL8m>~ecv#DlF3!6eA2u0ORk=n?; zs9qJgr&{Eac84(K;zt^+qr75Iwu#fq!X;ILk~ej9Q(s~FvV0c@L@EIJ(b3r z_T=$Xg8{-@Mg`BNa9cHb7$SM&3W#P+HOh!pB%IsA_am-G>zbHCF$A~mEL;}=5j>5>xTNY_R6cdKddwcaJDSI?^vgG5iW~6RI#adO zo`;c-hNbjkn8ODxLn8H*FJ}ez|aLdhQYpX8eWQD(#U0 zf~1Yjs}aUB@_k4i0k~%VOTFY60K4hy_N5#I@Y{*;Q~)kv=}L@_N5HUn8^?#v<3e?F zvg6QSnbXbY5kV?NIdV%IR$BMRw9j2^t>15<>y84BdMGYsPo>j^APQPQUwLiB9(JYA zZ9V-B&SU43G_+MhYy%Uj7<$G_42E*_&CTXIR)*${;uQ)VDwMJ}jtaK2OIcATz7JHTdVR%ry868uf zTEFLm>?mAed}giRd65a}N2ER7a-w%vyfLc=6A$`>>gX}{*bx5wVDNv1b^lM?A)zYJ z^aZxGGsu1cjL%2jz)9D*A*U8Wu8mKQXjqv|*2r2JA@5cVIJ-h&s z|FO%X313zPZG3d*%<q4-ZF3bB)5UTzI4iOG_;ks1)s|j2nA-n9Z=E6 za<~n+YM3)$N6}Z8zXaaV{q;ULq2J$<0cCDg!UgNNmFiiT8~))M8NjLiTV36ym+9w8 zb;LiBZ*~GiWyrT0pFdaOC4J*|lNNN+tfAy^zfyU>axQ-z3NYB-r%(3w9P$TRpVtw^ z=f!b`J`*HSJtNEWhT$0fRR-tU@KOeMfaV+%wZ(;=R3=PwO*h0!9G@{TxytQxJbPxJ z&F47M{+ne!n_x4@>jM*oQilx&F<0ycZLNLqw%$0?rU8LjJm9fTMV`EGa+6(|8Q&ciZ2keL3ioW4$ zq1db*SErNveRQ2`Bwt}zjOiHT+qxm zEEjo~VA{sgi&_E+GU)>x=#Umlx^j}cj6mejm9F`Tpr7+UoF( zN)Y%WjKz39{%5eR_Mn;+O^xK)5`oN^m-Pk2ItHAB*gP_dGnm38#2|7R7J-RHIFxkg|hk~w}78=IXwgkYX) zZaDXC`!bFHaG)k#bO!+g-TA*-@!muAYy@Fve`3=%^1~ru%@Xc8eE5O76k?>d#ut8} zm2q9Xki;0&_R`$<^!-({6Zbg&xwYp!5j!i#t!FM z8YUZ!Y&*w$h{=E%e)F+8s=2_9e!pz77M}ro8D)B9ArB-O$HyLn#(1$HcW8A|7ma2p z(V(?98Pqy4I@jT9L_^m%&)j<GXHKo19H!sap+y z45P)Eo1yzS1SyUv6LT_Do%-I7rlRmT$ixU@zPL$o8?}Tav5EJ}|AFhhB_3jlc__4*TD`b)wNw0swkhC6% zpZ=Cpw=R&IzkC+GW0!F+fz)%Dai#}~)KKqw*>KLBa7}?tx}2GT<}@G+#rmX4Skex! z9ne4k=&z5CP0jZq3T&ksxS8VgR~{C>5+e7$y++Dz@q4Y>lJHOqJC4mg`aM+<{^;f% z__RGXRXBB!Z_<35ke3I=?urneZ!4~;jOcsv%i$vf4Sn`>%IU!4L%}dg$dn1@0K`Wh z&q3wWoHLiCG&$z!!7r*!n#O%*|6s&c3;&CjInUrIOjXe2a+$Ul$Nnj_(}N~K@*E57 z!4$jfWWOdY%OLkX?s=3-k}&x!mze3G5>rf^451*q106QV2c--zNstG z57#WTsZhFblb=+;+*H3_2CYnU2`cB=jvR31)l?2iqyy7w5#nv(MgP%>UM8kAy~}0S zWj1EOP~6w+^J^r}rbHsuk!4WWD?lr@YJU1{6iXgY^X%p`tX2?lU7$k$aF+W+KW7?D zUZKK2?OZL|@!%1AHDIrV&|Xwl^wNs%-_DGl$mfDQ438dGwb>U1}Wp* z>$nm-<07_y&pSeZ+A*Pch}q*bs!1EcObHI_0(Rg!K79=#4+W;+hb2^fVzPy}?~C0T zRDnbfsGwaEI%Fw~ubad}(Ev&rH^W~Xwy@X3%awOC!IJiw;%|3!;z}O1qmk!kUEAn6 z;@`4z%3ox4R2dyfSJ{7Sm!oP^7;K+Imuq8_nrk~^tb*HG%cYdh*eO{V z?KhgNq$0kbS^w2H@j)WMR>9-JPhR2Mg|k4r>ub*SiDL0l6%TKwbV6bt#*b%JFsAV2 zw($jva)9UyCftNh?)tM^A^_10Yhq|nt@6uqfu>2Nx242qVb7yisN8V3I_x+3wj)>v z$JWeBg)yKgzzOzy^;u&3m7+AsDTLXTy9~C&EOz|`_0;APN588z!KKpKMsQJ4t@G?N zlJF){VKjc1Gi@T7_1FDSSig3!XygYgR(F$Fzb+?Mb^4!fLO!$f$syRgWSTy4pX20A zEQINqyy&XyG{<+|i~*Uw`3+VgB1Kn9&aEVp`&5twLxU+l7DkY%1#7ahtdWmQfo~64 zb7ooYV_pEx+xGE-inwM6J!dfH8S|kI>Ly$ z8Ob{-@X^D=X)d(SSXIwqaV6bIdM$|+9Du>?WYe_6>>(yd$DyRg8!d=1<5-gz6>Aj< zsFDtEDaD;#;VS)G4;2XQv_Ivp&@A137n5m8wea(xv5kIo>8A*r+KM{nYV0H-%hfaO z5jxV_t=Ccc?_}P3z+Uupb*Am3S;RTrZ#80FBCFeC1q?q1s|t?b`&a+l}t1h)T1e0ZYSF0yCgO$46wADVh1 zOK{XMDf(|THOTzA5|&eNPqo5!A02)9Vo-$nTOhe0OkoGul@!6XfHTHEr+YNz2j0&> zq5bVC?fm9jSu@Ax>%y0^9gxa48b=)BAD7p;k7r=||Er2ksy!M8+>S_)0>11Koo;Hs z1MG4(8~bjox2la`U_HvA4Aos3qQ$o`?&k^la625*U+KRu9)slQa=REaT|j{y7iWyX z%H4q~JLyy#t1Xjg0A7fwEiWD!XCujpfpn&F0F|4{Z*m1bD;X;UoB#!%8Ofk(8O_`QWDlB4u9FF zi92pP6B{wa*fhmUCcTZtA-k2SQ)cc*X#d_xraG>sl|B>*{ZC0eI?0&{vflljBqG4S z&6<$T==@hN8+pqf7yvZTd~RPMlS)I#t{_MRLzf^fSEGqxc~Cim%nbe`Za8Y*A^Pac zkX;ljwP)h{r>Bn7@Qv=W0telU`wbxF8CFp&Arq;Bqb{tvy#@V~c zjaXs-_Y=}M;e=&5*+d1U`(vy6dBHaW$sZdL*bJ%%T-)k{%bhXQv_?A?gvJ8Bu8vA9 z*NufP$KSQjXl`%(Q)Uydhkv&c)p=jqqUWy;z9GhjiE8=3ChrbNVEE>Ok8$%}jWfaN zrg_zZKR|FZ{UBeR3ffc8=+j$>w6{kCjUf%A>vr2IfZcJY*YSKyEr^Co`h=t)mQCq; z064OX^XlIZDT&^x|46G9#sR!m!=@H+ad9YF5ygr0&da<|hXl4`uKny=Y;Mr0+NYAB z2WadyJ0WlJ1Z*&uAcez7i*bSsE`nb>-jP_!yqK&nU3K)rZl_|`s$hz<94(el5ds%i z&I$+kuN4*5g}p+C5d?j|lVG)5YPt_5pXv8M-mrE}KGwPahi>22(4z6@z51OrJbO9C?bTo*$YCxo~ z%S}ozzL@7Tt#bT&1F^~mubjm+H!q-{l+!8)-0@!UVhS-WTi#f12Eu+#)NRy@)*n#} zo!#Sc!SF6}tT(=U7k(eoZPA?(x;z!sx+-f7NVw4NN^)9((mGjvdmVBT^^x29fr`+U zRLigdKS;!37mVhm6Pv;xfw07d>t^x9PVuqD>}$p!LT5(qWoX>bw*g0Ap7FL8P9~FTu%=gMFCXl}y%nI! zh=rA}l5cOHqPf}i`ddA?=D**`%huWZ?`yo?F|T#UQWYXvbe$A$gU-Q(Ie%K(_9kQ5 zmM0vifOB|M#2;%ffovPD;4e(>kR!E^^T6fHoD4a@+akR_(Av8=>XLO|b6psS&}awy z!7w)KXe5rm&II=6VuU`@9Fu5#D{kCGyYPdn_!H2`R^uv@ad!ykZvcLKb&&C!e$wri zS{~Ha{(_;>;~>_+`Q33h`=e`eKX;8efaeY;8}}>OFS>4d@L*^e+%irA_FNxl2d^W) z8M8=2sC^6c-&-D=MEF*w=7t&#n3Lj#Wjsz9Ns~)Np*MjnXd$y0iEKz#{e*#C)H*YV zkw<^DYndzsxR8mMD|gR)r7(3%mvPQ~F>!a1JiDZi*5nS!Vk5C`*HUyM$Rk z`GOcLk~eO&z{>2NyBA!;ep*(v08eYw417Hh{ei1VG<@>02w@`aCRSw%SOO?h-A_4p zvjvpav}3=)PkG=us3LL?KY7+9Eh2Amo9L)E3Mw789XUfE8u+r-Sz96#Y*i?;>Ea2Kbg%mS`&sbO@_toXt9#v3+l zI7rXFfM2a|j%?x92rvu#vpRKR+LrrjM{ugx7b(QylfzB!vgM8;f80f1l|Kc&~Ao6mC+H7cS5(ZcTRR zHxpzgW17QAvMB$&?f5-$nhQpzs{o$k4(EIn?Kh=J>sV<%IRjslQ*Rfnyj;9j`Qvw994SV?RI4p$2ZjJ?k+v5 zU1@zPF@*N3ha1rqp4o7uF3URbMzGN($tDW-x+dEWw5?7T=xl6&Y8XS?pi+T#9U7!k zyH;x;)VyK$-yx@UkCrLlotgK;n8x6?^G5F%lKoZQ#xP}KPa^Z-BHE9)oeRt4@#_q& z;(23eh!aFL{p<5-zgSCd)I=v>-wOG@2XkO~YYHq^aztJ?B!jw8Cf&6LJ{Z;Spwq|3 zPWmM|AX`m`KjQz+SGxOV-Hs}2{mA^r3w8_!RpiOd%&)p4W0%6+sGa3{^M}Oq=u^g& znzyaSY2@T%RP(`GwM+t&55a^BZrp4@rh)uQIl5Gv?6F2!R!npdJ5#6Tn^gLBhJm`-jwY=%!nL-wY7j{gs#7vsP@eD-8qclBI*znc8G8N0pF~Jzg(>aJMg-r>`7fDA{!2!zW!`iTtb*D<3$NXc)5w!0uk5oHPHz7V?^>b zxxEH<|7a>9)$w(=jJfDvX{Pl)UN0^*c+*xcRXI5_{~MK@{*wv*KbdC4*z=EX_Z37- z&+2wnJw1GH@rACs_hnYn&d{wXDgHQ3{tF>Jye-%b?xmH#kxv%`HBG+ZY{>M_8Lr`QfWYNP(1i;$`J! zy+Ip#k!XzTVU%+Xto`0N?20lWc8!C$8J5Cwqwb=fe#-gVEcDKbMYtMJ{l??#PW?(d zeO&j*>vJGOuCoU=|9f$PCBQ z2vPRBP3(ya)K8HA5DmkS47C|GB{OUO>Kb1v>()g)xg97e6#jp?d{r>X->aYW75M(@k*~JYL6w& z3cdsWXh3dV^{f-;a+H0jHr+DOB%HmoB4sK;y(BH|J4~FgEpif)q=9dtk^9~~8?LdF z9$w@%(3@_<>i{D+0|hG{)#t|7w))$v>f>CCta0f=z>w5vint<#BZ#f@PT}DZF)V5w z8<9ZqW8_Q1qOSD8rjNcTtn|~*r>~Z^RE@?=)0WNo>8GlVgXITp(X=UA zQ*siMj}XR@Y`ui}gJQuzvG_2P?5*a}yWS_s_*#o=Gd*Ae)RT#=2jLI_KI26&6i(Tj zzn0rq&zdGvZpW~IbTzuUn5b+*&f5`dajH*pnEE~+{O-Qn+yXM+;uE*;cg{u-3b(4< zjF##`^;j@tvVrNg;xmdq)r<>v3d_Pbja;@IZQ0Tn@0#e1Tibm_HF22f%)^h&D}#eT zK&2Gcp2vUtkQ8ujX^Y{-&ZZrY#s1tJ?wpvqnt^K=tzW^qg1gg(jL6%qHu0oWUo353 z{EZ^PB3XL9`HMzlT$Mr^meCC&z>$CLu@+7e8y_Vi3XLS8yXTv$C&+OKVi&D4wJr7N zzpx0pTD@h#wTHC(Y1G*n3EL!gMu~JMbokerZ0UaVo-qZf3Ulb}S3~kC&&R$%yQ#{Q z5CI#n`m9^^`q@n>F9ndIHw%Eoz~`Q#7!X+uWp`c3=zzxpWHsEu4Q{&c7U_xgZXly4 z(UNZD$SOts>lTmHa*@gUJ7np1mT%_AO-Y!#!~pXI%LdXjsu;dqAJ#-Pg7~e8$T0Z4 zwy3nHcFf3jKT#P|Lp;doKU|3#;!K+Jll-ozrkH>fHEudIWrOk@29>&Jq3)5Rtuw4G z=kN}gRQ%+T^N@%qF!66Dmpyw7+UrZTE8hUcS3#+LiIdI_TVr+*?qq%|GRsoatd+3q zkw<~l_d|tm*{4uOt_`IC6GW{3e0sM6-s~&=KR)v}_M$}^8c%#as)*9p%=kqMFwV)# zj&ub&dIs7NCQH8;n)pFssZ?MDuSq75a8tuh`RRLu3X&=9xzD$!l*3gzC7}lG8}q54 ziCmg9ZB0C>xhsoxG8}~ml|7wik&G|xX+f!Tg5U_|3R*4)`f8T|O3jq<9`K89R-e0b91 zYr;1AZ;7;?#3-~X0hq>XMsXrWU$nXCHvhXW{9C|$UU3Fm-7(hIwSd|lTEvbPQH5EW z`G(U_XaYT3UNWWazPieEg?KQ;2vkWL!v5+mRH<^A@)2Cm96&ryCKoc?6 znS{l;#{8_fmmCRGYnE`r@ULALr%#L9&JnhcJde3;8G2DlwnOJNW##~cSbaHdaDv$! z%eG(rP_zjy3jW@LoBodQfJ#!J&-qS~EI;&*X_9|8edA;5T5sH2i1!ph zDN)~saEPoMK}|ZWg|cNb^vT9>q)6;BfR0R^8ul#O}iY^G$u-D z>E0a0xWzayZRg$rSU&RUlQIfNIQX;d1~y1Aw3pS4v))f$a|^ z0*>>aVcA_!@wE!3l<8oUcXU<-B4%~LUFDyrTayzdln-D@s<^t37N<6;HnL)cEW>DLc7pziXAn&NlYJ>3(}g zjF>-O-X69&ah#afvYVmtq~4h$a(rj?>|&Sw*wYie_l9fCd{}s5U(Z3rNW5!A^3R^Z zMIS#?{&~pz6>HMt2p(sr237w_NmE0PET|M#GkeUPOe}-8P2tHGy)D(Ts1kc^oN#0u z)kI4iCs*~xqVteBQuGWa%*7WSNspXxBvd!K=tg4{9vs4qe$Gaaw=8ir9fk0!{2yV| z^v4E#fk?e)Hz?2CPQroEX1}Ji##Z z%(o0F9#E3}WKEfVy@a1UaWbcj^_m;E@$9281K*M{IrnE*_53H5TyO434+hOEUrWK& zoO0&1$r7)t8QC_4hA|?mp=|S^me;@9Dz>s`A~s$vY8TK`LygqN>W9n_Ruf=5ucRS} zd(4q($;xd^Cogo$B>a{)+mLN2drjoPxKYh{g3G-4P_1bx^s!gjHenFQd#bqRV5-FY zXl6Uh!)8Cdc*g~#?puzo4oN)zQU6@`>S|8sx+#zQ+te4O%bqmP4N3i!cpmD+ss;4r zdEgb;ul-f!6zm&lBTKL=`JHi+{qK@NUi z)#^}N9;Pl|f17O}sS1B;5pd0HDbcjI1Cj-QHcD3rnBm%z&IQxA>3dtV(j65{C*9u( zF8wKwYWmQ-*LU%w^w$f>pG-|+Q@tciyc z@8!7kDlW3nVLad@!7O)vhv&ldGWbj@oWU^Utg6YAyXO`l~Pa z><+6<(-V2f1!2-Dx00+3*WbFSBY!J3X9&q?p_!Fq0j}HYdyC5|N8hgaY=(X~m|%ik z;CbB367Dk$pl*Ghx59b~k~P{H0dLMe@qijGeKB3x{qZQ+7}`#GxZ*nt-%Q(2mwem* zu%~%Y_kiKN%E$_smK-$tG$lFBjrI5uw-3qCxG{t&nQ3zo#~5zAv|}{VCbKpL%BQAH zqC9=751XWd--bF-H!I1aNb<0Ex&|3H4;u@sD%iB-ud}-HwE2|2m6DW{tijIsqZLL8 zdl`A6)WLos`9~2hJqq?JVCcoh!os@bLeTy@98mg70X8}~gKHcO8@pSN zfsO83{r~yT283`+SNi7(rktz*QPz^51ZreT6turxtt5Toe`O9Hthg+@MH8EnPOPFY zSCa6Pc)Dgh(qv?e)E!wP4q+e-CAz-|^mj~pO$UoO{mSOvO#5^w!9gICWFfFsQZ6$w z&ON88E6suM5Il8xf(6dxSbXZeRA!46b7AMRGPE@B&K7GBYH)3Rh5Y z#_2l7h^-E>stELirX4_i{EF{71N>%DUH37WY@pY27t( z4E>I&bH1hOBpSLROE@*UFx6)U(yYq;F?9+=$x=V;C+&8RKA2F~~iqZOgkQ2Z;94`#B}=kvNoirT1{Q;LY;NC9%hO5opvx#1uHUVAvc zbf+ygZAwesd^kGxK7iX0T^Bh2!uC{V#+C18%r;8m z?$3_Bl>=ftCw5~HbTZEpJsUB{%{!A|wBM)oh0I8rD!V|g7=1Zb;Pcp?&W;ked6z2@ z+0^Bp+d17^XJu=x}>TuXT^%101^I3_V4^)2~@(D{IR)4 z;HX>z?R`BqMr4_>e-kiAJ2T#6NBd?%)hw2&@gx zM5{{7G?BqTp4I%ZvAyX)BS;ZB9xO^+gJZ-O-yCmP3k-nUe#(BAuoFJO;_ZV~^$m)2x^8WPC@d90JG(E1z*%5Ub# z&zXsgh#;RmP?rj#${7$UeOl5?&{G>d>DI;%;LEno!Esi@CsjIagfB6s;9^TH0WI9- zA0g2Vr9QdS`m-iW-1tA;Eok~5D@bgv$ZEQB66O$P2{J8@tX^;|-~32gIJO=q3X0$? zEK&zrIY$UsMP6*}{0T5A-{aC2eYn23yIU{46?E>157XL^sZEtO6P(_LCg; z-CM4B=U^=6ORw_jGt_*0H6eAc?){bAb!Xk6_ZdpT?B@t7JQ!onTa>(?K`D0$wo_r4 z(EzgRg3n{XbP?I#wl-aj!{PGMU*V`~1EcVzG7`6ak$AT_{AESwkCJskhcI`d))*TNHT6;S`EMb26 zoWRpc0;3%s&jCcp1Kd4!$(O;}%!qUcVvNNPNvRJR*Yulbx6?cnh?tREtBYy1EW`|q z3}v^+`*5j(x`rQ@pw+nNE^w?Znu|La`BNAE-P=I=z;SsU3-p_s#4dLL;SxcD`TjXNT56#aUsQA~ zNF4_>EHPC#deE@n-Pk7i5S-?cb_YgYKZGlI`T04ir*%H%m+@h` zTw8q%O)bsB$9|PQWBKEI^263c{!nsBpatQLZ=D|iQAOfqX4vBilFlBNDeE^q3)PDO z)}Ory{qVZ@PQQ!F6eX-L^HgK7%=>=Z9x;lV85a$Uke|fgDbEcaTo;DPi5OLVYTVo9B z_L?K}#`5(e^>{QK=xz?W5(huIkY2xgv?g16VaHk*5(Gzlgfu^prH*2y{d)naA*XfvLBhV#CKL z8y%kTti_F&gb$O)JI)dw1qYFd-chLWuOOE*n-4AnE1QAn;408nzJrk8#Er3E?Fcb) z*3aQtK<9MIA!n71ND-=X!psbH89li|IS6-E_Mrm?*YbrG2OMi>JjoBs=@RPnIz@^5 z+E3N>NL$#dOQtJX3Qm6gHu5Eq(62@0oD>l9d%-xmL7mC~-^J~(GyBfVJ=mhkNn)8` z4BKpdRItWKmix@bLQ+(j9z!wV-JN`kt`);X-%AuxmLCr&RTtdum%mD}^LCMX4P`Tw z+z(yB6|4IipLIrmA&Q*sX5B`j!zX9_LL;`gE>ws{KqwC#!eHkiT`?jEMztL+Ga|bw9aUH;Za_5WGE~KkK zDwb02`N%XS!-<*-mRFCu^TjmRr2Nsd`wb;$-61vSePVd=%eOcmEs#H_-21LQ7k$M= zgCEy(kO+P&Hf#1n6cN!sE&0RE3R$hLSUb{mcrb?vdVR8-js1t9PkAlSl^}BeI0Pm!DLczx!KnRcp4fowf;`h&jZ2} zjxP51YBCa)B^8AIll6ukmgd8tknio$!Qd96pX^5ivj1M#!d8rZWS9l$IVN0}c3g5m z_;H1+Q8~S#(3e`Wfe`I{v zSUVywBm){$Z8D652 zy?5R-WCrc=Nn`iW<^}rDV^c{_DoiT!cv(Na>6N>LoL)}Gjb!6QB2yjWtq3=IQ!UZJ z(^!5mb)h^tSsBcFrH5$N4?Gz25THpi4-WIj32^1(CSU6g9p%j@U$)w;b&XlVa0FErB`i z)S3$|OBCG!`EvzR&v#2xs#4QmO;`1`7G&R9dTJxruUpDP{M1n#tDLa1&+D}_EPZsx;EjY|Q&&J##8=)(`!4Y=J6h_mz?T)B{nw-M!^W)AlF*1QBiDL+hv(6RiI%D3?>|APB z^#b<$TYS+SM{afL@%!C5-)ACC);_r*0B^nPgf$c|dzgvEb@CPjk5Vp~h=;tR6+Iki zpk;dg3_K-bEow{Lp*;7VBx^Ba?Br#_TjQVoFNT~m%+>3P(pDn0g`meaSo%s{mev4S0>4`xRUb342YBTZbWYzpiGx`?VsCAaRZ7&Zu#G zE;+GglgC7~!xkRSxwXc}b0>e^aNm*o-webF4#cwg0ZDV@2Mu_}f61QUC-8Xb@p~zA z?d)=a?O;2!Bk6=zt-1+3#mUqmTwz>+k0>{|=I=j~5-3?T$xCp%?uO?iK6B zyFcYLMf2DLcV+QLqo)yu{Rs>eQHD9VYA??RZfSAG$XxTIYx?66JgNrjrj=JZ{z&{# zEq!oqw7!d?-L=v9Mv8P@4Q2YizN@B;Nw)PX z72=vx%7$va`yXB5Qs!RxDOawt2Fo|<0W-+&7kkEsJCv+q^|Kaw0`KYng~61tV*WpI zuZ}t9nISMe?VjtA1R@OU!250@bY**J48gFKaG55&mZrf!36`(X;o1vi)>7YONE60g zoIn2=g>@9?Y+JYyY~pvlFe-dl;yDRHFaV?JbAIPA|6_+A-55_064XL#JAqn{ule|l z(H0APgV5rrzH~u`SmlG50Lxm2W%e0z9o7tICk^yE!SeV)kq#v?xM$Cz33XI@V-I8a z35MYF(GMM~H|stSH>Ga0Zb~>!v?Zf`Y`)F0r4W5Xy9hZ|8*}P|tI0O^lxw!bYK3m5 zKaTE@aKXR9Fg3oxp?wq_A{8)KmSkR4>AsP}xzB{eUDu1`(yAiLYd!6o{ay&wS0=q* zAr{5qjuvK(&@xNx!-AIwy9p|UeY(i`wyvNmEm)C%PclWN{)b8GVSm2JD@*b)-uT# z-0F*GB3pTC;GlBaK=6QSco%h!S8Z!Rf!KP1cs7!O&i4!Sme;P}>dtoV^W!fH%zG8b?>}1eIq5x@SBynkXBZz5=fF`Tww188vt9ld ztL670B28k_;F!6qKJSspGo88c2vxA^$OM$+)X_jw7l5VV#lpmRA}97Rur}Ka`WFb} zz;5h@XyjNpi0XBeagyNiRxttDexX*|yDX0xY&Ke=NPWSu*AZbES1g?pU!0d4D0T8H zCWsu6wuHP@fVL3NPcs4j&eWJ?ucF$^3En`b+l{7$)qt)7q%e4`D0$Q!a^*m=WtWq` z=T;L3ujoCgU>4f-qcy;_pe1i>6P_+vb9sX`LLmQDuJhjTr*nzJ?gBPBTF+9b@1IxL zI&KA7EgS8{hw18#tr6K7s5%)25nTKc`_GSU>k*Z)-ZuL^$BVbsF!K2Lr{Y84LHn1~ z{F?syDf!7DWc1BVKQl^>vgNZ*W?CTNk-m!URzyWDKs9{8vqp@^YQkNM~G zW1O6{#3G0%G{h}_E@PksVBQcm}B4VH(;VdQJA$9cP zWS->9GN0w|E-=S$9-Gmey-mQ0M#bvT4jvTBWXMG zLIC}Ws1qHS5tojrw0~8H^`P*{PQ`RXb%mYmH`q(KuFh)0D=JnP8%Ym2cye9_SWAFFW& zlcVc;+4K(qoMVY;JKT~fDFwmp-AN1k8m=(;;h#Vc{)qlGo!xoS)ah6&a_LRs-jeE4 zqhUf?T)5S8yJ*(&u{p|_M3i2J1m)+fypO+A7u#IapUmAM&TTGIqTY6v^KA@iVQCfn z7r~hxnw*qDT^PBJR6FTl1$i}UOZdz~<}=aYBKAmc=WZQd`v>sW`A9VK#Ks1lmNt%SzbDWd0l^_clP@KJC$qDO2Rm5mN837TaP@PyK==Jn;kR1u%>ZU zf+J4xyZB&Q_sk#Oo==3*lOcbqj`wDZ^CLivDPp;laxPVUtRB-}815L>neCZZ5f%Fv zMk4l$Aj;k&aQVQ1!KIZ;s4YI!_aVNO*WR)RRng7VpLv3YQ^Nqp>0dNZ8n;*|JuF^V zycoiB5NeZi3?wj~uJ=toTS5%fCxJsjkJXHbL`9@Oy6*c^(qt_4-!{*9Hb3=?Dbb1^ z5po{EEo?h?aAY5PkQY!>I`IUZ=m!#cC=lcW?0jc>+)(6Twc{=j%UH6H^KHOCsjFVH z`_~U$8&Zno0{rNzQPt?Z1S?ct+3K^uH=L$AqoY~CNP-mKWDSNd+}y5tP`H>2Z(d7T zWQy9F@Gi*|@?ZA|P|L`qc79JtIw0Jy`mNjtB(Eu5#Fe@2q3ewmFgV&EN8*QcUW;iI zaN0ko)>#f=sTnOViv9=J7Ss;&c$>3g?ijn^kBt%H@+c1p&)S859|uxJhJp9?VN|#S z(8TvYA>(cwfH)yDuX(c$FfO3Ce^ zsXaR_;fd*robt5;1JxzEMkYg$;)0UDTn}85{IF2qs{0RM64@X2w`S52s~mKQq4BwNg1$DeV((?ZoPI{VZTTtBp4#6f=H9^7`t?+THFxcr{)Z=yFk8zR z@$Qv*`m}A~A1eb1aE(Zbu=+*6b^H9;M;Q8JOuT~qEf972MKmmYhE&`nVkL6MyG~70 zN5bDJyh&9rUFLp`Ro@q-zGnr|!DHEV!lffuxx~ydhgCah5U!Si{cBByL*Z7Pxo;eeja;^dW z{8x*T7hRGGFgj+qECRj;rAg(h!)QYnh0vr)Omo+inY6f07`7;-cTjJDXdR~0HsvhuC#5aXYpD{yJ&K>EmUteo}I=4Y2I=c|nioFcB zkR&?wz8nePc#?t3YVkn>Fq<`lqQb1UEawVX$}9`qdVMvVa*ccE?*xGzj~DSh%q4zX zsY{54YZLMG9w#A^Ex+FHK2BQ&FSGED)=r^_DRZNvPyqt41xQ3ztEG8(81Ej7@Q zdT@K^mmaWVbtF9=H|?vsulrT6jsL`Jr1LIsrkQ3{-+nv1g7Gz#;{f?aP_j-Po)uRc z6tCgssjp`gZKE_8M$6b1a5|~e(9g6?Y%0H(*F8?Csl8T})`DERLYRg++Eb)y-5jlr zqwG>t4z51~y-7=a_1iXCiwZa6O;*oqZrSZ$@MW&lxRlneJEpm`&`V~({T8_sd0Jj! zJP%M~9;ffOKSPE21M#G%-wB}Rwt%rh|HI;L0RLif(n%4R@1K(o?$^aCp`^tuj@W#k zv;sJi?{bSEd>Viv5V_T_-^2%m_d`}u{`$^j!d}xp)w4Un7?@VlnT=%rPL`s8(C)L$ z$Yq)9x@Q@Xf+vs*DLC|kA*Jp!yQKNH^Y6TE56{-=2EM%F+Lk0qT&zRm+*|afu)}v* zZ+PbWIXY}Bjzujc${>~#qI8(FdfIp>R`{7aKT!?(p5Hd**$TW!K(1hg#S9MYerz6i ze0NocnxOh|#mN#plXb#Ak85t}P5>_ZPjB`GLPvJz4M$?&W}?}N5nVsHnt(j2KO}W7 zV?B=8_V<}QpQM4Lat_)*#5?~oZQ_pU`co}Cmk}xKt~aZu%mg`h6{{P8RtD0rhE6r9bWd{AWr^loj2f*ez$6EPhz_F4&J|Eufl3C@8s(PUo4dy z;ngpfN~BU1N|f@xGs{D;(dtch8wPARQYOOH+PQxaI2Y`?`98g!RwU2K$>%{YuoOcpnQ?nW^}cxqDYo(HGfL z|6vMR^y0SyV^$COnK@jVrjZApG5ZN;L8l zsI6{pA6$oj<)ehRQ>ILP(UyI{94`ml z{3$nkE75H83XPzK;}sd&9_^l8n_!iKtFXv|Q%CA=>>jZDf|aBGGob}G!9s6xnTb&uq_ zX^cR*<%*+s7Jn6OXYwYYP$K?)S}PV%(R`dLkXI%TTf$3u;J=00frdzkWn0 zKXRFMeceA9`>>8=j&{SS>V|N`N_FTYW8%|8CiJQC&(yJN|j8d33xda$V>WdICcmYI*r zJ1IRZxh7xxcyLByk7{|l8(?;YRjFhA8YBdirgJOmU7SNvpJcuY$dxQ`E6-g{?ByJRQ$lRz}uIAOkN3)Cg~UnR}noriMRC8@)%?x~Z=imR6&Euae<{qKJ9 z@?Z5$#O%aV!5%uLg0sbdhqR+lj6hYiOp(Y#WIg>tw@rD@UH_ z_u_o7h@b*^h2rNo7do5raU2^porYmwj6|vqnsCeKl50$xHJNFZ_3FPFn@0gVA*rnf zj{T)!J3eKFl~1q>G#al7imTcg53J_c!^acyTy`3?y~i5~6-?fipTp+;U>J1v_)bxk zGnLlt<*3lxL5qSzTSzvx_q-;&py>z}eGiKhAaU`DhQgTbg#Su0>u>qmhLBaWjiWJP zu0t9Idu{NH(M4MQ*#)nnLWiScD>^@EOIk`s-W#^D@BDQ6<>9K5LnTR%l9Y!nw)QiR zb}5^mc8gYuH+X!E2yW8A7~ebYG55&NQBOaT~v^%&Wj#kOO?}h3w_wU5IREI z?fOz0qMH~VmYXli8rt~01ITHVz5%YT8c+AGdY zP8%oG!VrxC;Y1Rq$#O(+=ofG*K95MgYi7)g%jH)Lvbns-r0vgIYJr<`ZM=ux{B#S* zVW!pry*n|;H<{2=P)B{Ek{#@w-d>rY+~gYJT(~r1mSsgDS{5{@Bk|R%jh8h0lXPKL zx|TxSb7xpKFKQ(xFz`gnvTWFa^7+La+v4>eDT6k-l9^3dU#p%I>#Y}T4{{jlE{om{ zudw%m1wd#Em$Vt4Ae3sB2_Nz%wo^pLy4(XnYz?nW2AaHN;|r}9(c{DXVrLUBZQ$RI`-WI_oZ+FisWF}G?5nR zgVNZze0R&AF4_^i>%bcmkUcgRjl|$PxBAFb+CT@qN>A64oW-epyP_q5)AvT@-0*vQ zh{2<uw)7-7<_+8G^u~8~#^Rzq|O80u%^&VLx5k*#lbT0YY zpWnpywvz+ys@l=c66FQ#btSr|f-fq(Xcse5QuTO4kc(`qRB+XB2ow^gf(O!P=8|;f z3&zjLl!!|c8*~_!XUESM^kV`{EWV7|Esz9n+tJ3?v4;|Os1VY=0IRhNs)LV7>@kp9 z%F?kLdT{N)y=LLylymUazF8w)gO4rMG>#z?nw}va9?c1uR!B zUa1~>%7Hn_DL71hGXk$5lTls=Y6d#O*s^pMsl}iA%HY_WH*CsucHvm%)JK<}Y-@Dn zVMi|_mQ^vW<{BW_D|`Eq^}!2ctLKYYmYlc+Q=>YcF<^N#(3#VtB>ZbP#0)z_uD8i% zUXV@dlZ&@!)psGeJI5W1_M;&>7^AAiG&{<;9NTbJo^dhElK;N-I#dWPko#&Ex>;I_RTf@ zCzHKuFBZr(irVQ{+jM+at z+SMEcDhq1aU6+g~w@fRa3B(PhX@{5^fSYi4o(xL`MuZzGUDb5}`IpnyxXBRQNjd`! z(JqspMwl^CJ^Cw8YeQ}3>i=|O@iNY70WjS)xfbRBU55qA!&jljkiv{qBiNC5{%Z_j^ON9;?Uu#e>%f5Nxi@^*gxzm#&C=wSIaj&)ua&+3uwGfgRlu0J z8>&4626Itdu0f#6-e|Ltq8Rtp#opEV`;AD9DOg0AD7p*MF!k*Y1#EXP?vjS6ouQ{i z=Uf3F5iC^?hU~o+YDlZvH?KO#5E9E6g8}~sgHHUXFDMLh$dU#viU$0FZ#LgwB%6|8 zjNHGhxV2;Jb~{P$>EBosXsMC!h$Sm%CTIGHDX;;gS^Gz4s->6}lqm$BZO`Ru@B{db z(vM7g`VkGKpTvteS3@`mzPSX#@fan@*gQ@RH(B!Zvg1$ZXSOLh#(#>)8%)O ziK;<@zP3Rh@R}c#_b)adx4T>Rk|DZh({#`13SmLmw15V~0wpQn2B9ByuyEo*Q7gFP z%2e4imoH?#q+XH(YPM^ul|=hPPU-mc5d>)G92Kfh~+Ui$Aj z3~$H%g2vThk{P+UWbhv5%T`_J;iNZj-q)EJ*a)_=b4SF*d;a(VOk8PkbD-W6KuR#@ zGNv7?1V09}cl?zuB7U zf_}?e3v230L0qcqbmX{i7Ft7#-+rL1fN-G4L)P$7=A+JSP(?1BU?BzDT3%lnBI5He zp-cBtgr~wMe?v?yU35<<_DhnAPc2Z#M*oDB4@V10H_nF7i2PbWB_hhL^!@ejdqeUq zkM)R^qoL9>x0TMbk;lvobL{!Dz<(;Vwzadt6c4z2tCT-ThRnT6vS6{hgqP{T$@?Qw zf@p|R&p#=5;Fl~=E9Ut;qTfn4IYf{0$GH@9j`?v9;$@;Y6e4nTuzj@j3snBS>D_R~qy)85J==J=7In{7~v*C76 zo^@{_Ss?J6o9$?en=&lsGV(wWM-Z(85y9qNzj1Ik2zkZyUtr)>09{*hIWSH`I7{&g zic&$7g4B~S2jsTwO2buP5^__Q3*gU)v9o-~D;e`Yoyy$J*t~x$l}dkI*8fPAHbIuA z_S;w#Jol5{Ih3rTgEuq_A3Njb6#p%j2LE@SY7^y4(5W*zIbnXn z(~Mgq_rXMo;-8YFC3MV4))rux+hSdaL}F83{9{w8?*4XDnwT#TG2&47hi53qm^@g* z&Bj|GcK_JC!Ltdwy)+12uKS=a(xp$|OCYhT;Ol3hWkFOk4scUSZm#l9K$PP2wy(Ux7y{DUAsPy-zt${2Z{l zC_pla3=5R=%|M?a+U?TUly6F7S3`7+ui?9*eWNgHV3TPnWbNo>dM@s=bi@OHhTzrZT2*T?q zm^=3{$G0C^K?D1Tp9hk|;tpM!SO0_ifrCCISZtvU7#K)ydeYTMEPg!%ll(G=n9@=V zqdoGbHV;J;zfWtIvfo&XF;NCRrT!AK1FIj&;1C;V-oA zLXE>@rkeXa6D!7AJd98yf?&RS%CTM_w;1Zrbkp*G!Rl|WC(~*dc6p}{rVn0mNXihn z^n8)x`CsPi(ssYp)C`yRPEhb@+oC99uz@h_c%SVBU@4ZN-Fq{QPF!d5w$AOR43$*_ z4w>WU{I94gaf;r81uUnO#|!+jIy|{hO*_&dfufNfVv`nU6nxwuwYDBCOWi0^ak9v1 z34qX=o}{vl$TVzLo{h^r5&J0izOBd&k-cxTrKe{#Jp&^OznaRa5ZLbP{8mX8ALu}_ zzE^x(QnT9b5(1~PcXoa%T0T}cJgl`CFlPY9Hbdh*K5j@ZOmVeM#Lyjw`z=!DSBYc< z6_V}FImb1(G=B0%XYFmS4iN;fH@?5y3;01X*Y&NzoBUch=EugHnOK?%Kzros)Cq34 zzQhGZE2h6ZLoJWj+k&1SzV17omPo*I7IOAZQ<(Sb+`G9pJnrVOkpD|}??3jlXLIm{ zreOf;B&wT@S9dDPG+k=u1OTYc73qv?U$5#Cy!U0FlhQX5|~gpDJMlSZI3;gRc+ zaoX`bkL_C=2?80^>8+dJ-~W};4yUlKZnV@YMp_d{VnZZH&CI=fT2TbMCv@XeKM5H_ z4K99D7+16DdAz9=fy2J{nx3 zP=#^1nJvFb#&D3#NM@rOROiI<%})}Fa=X@3d&55WQxKqX5~1qhNUB8SY3lNIRb8`>+?Z9bU|vn4Wvp0?UrVI~=QS2!_9^41S?s`!Z7! z{3(PZhi=D_dcVreXjIqVrEc||gRj=4pLIFf>9f_CT+2)T2*m77UV%6xCAL-N2e=Jy z(y8`@7dE)rmdzwJBn|#yH&Vyzkl~2ey=|#WjA@w@CyPc0aj)pI%8%^+Tc)G5Pj7Zqg6 zI{AdY@9XRrOadu~4yFC+2*-m`TY7w%iJ1LWwa5Am3EyA0e7f8^DtC5KR*{xcl`}|A87t5lAdHvZpq`iBxjz%)~3L@@$2SSr^VuGP4C4x z_3lly>^ub}4VkP_pJfv&vk0)Qt*l>jy5tNxevP6uAb!2TobUaWghIx(L#9o-o$w+L z$BH-YBTJRMzYd76uw$i9>eydUvXA$f^*7nLz)t;EW>1E(QgEE7Bgn z?}=O9p^5uM^G`?ZL!2lc{Ok%ieZv2zUcPO}cG#EoZb$nq4A|mvB3eZ|MsuaVe>JH&84m1%I&&0ZKBf`ez&N>yMJ4uy%8{g_yb{k z?m9k-tF4Ba$n6mwOA^+|Oj26x;^LVE!S}=Z%^e;ZL$%T)l&MqiR(DQM_aG}ZKsu{OY1xHffD>NcUgcYqPmYT4w z=`zD<)L6}sWWn#ShR!hgSEtR1Ep0!Bx4s-VYZ6_3w6oZc+$b_ky(!t&s}O8y(VS3B zm--HV$ntc9^U;@tp^&c>qP`sV8F0rR?>NIY2T{sb0)LCC7S}Cs6jjG)Xh>LemMfCx zwzl~>Uz75*hY8&nGS6Dj_U(2a?{``ys^NTXSz|?9 z)$E?umNyN!@nn+`Rt(8j>ksD9%n>Hc%#1Wet%@Cqm)NQ2GRO59^9Ux9{V?axB&}awt#iW&K}V{6(M%9r{kp+D+!M{836*{^n*_Mselk1uf1BYk~wC zT*q_Wwih0MuGl>SYtCw75 zxsbaqY2cPRecT+CsruSC|90sd+iKxK+ zZr)fKib@%7j=Aa@e;4beQXc;^B`TJ9d1jskEoPr8M0j5Jm4br>qx)YO=kjdd`l(92 z&Ulk}fu_8Pv+-wk{t8R(yMf>W-YkmIeCH5hr#~|32mZdKms;8vRQC2?O;Qi@)#0Kv za9Gzym*&56Rx8XV>sq_B^sC1U)Vbis?PUERkr8MiNfP4S&xRO_;)swq{eqa zk0mc>O?fm2n^gth8=s``AA1$Wl_U6y)5cI&0xv|wAA)TBplUlux)`C`@_Jm)v&;M2 zyUIT(zQa|ex*1)pMsy;BohZDN*Vb>YIpba5HRZy)BCY>~#%pxgMEVBiUa(o#iHj|+`^7oN$jvFZ0$LvCws+^J`yAVGC(6tpLtyz8V z<-6aluC^x6o1$=5AkyTi^-iKCdonDX#HTXqG_)gsIXC@~JVSSBw#%GD_wnf@yGi#S zHYxC!le)+Ylyy+21RFYGf}{qZ+-AI7gE}SXwJkg%dTdRo6|Gd5OR;Lu(9PJe?)rIu zYPMpf(>%ja7KouPtjDk3tJCMJy5I)pC-4UP>OZU`cMVGt^s%NKSfn2EOKj~>I;YZGHCP&v*c!r zYg5b2engz9?#CqDcvi|S0#(U}pAxXBX=uwWpXjxBiIhFqxi$ZwwMpCU9p%^C1tM!V z#JQ7ZImiu5E)|zv@g1hKBAF165DjnJAHK0c-Q1I9D_Ckr=Xosg82+w(ATMK1R9~)> zPocF;s9=$Wr(f$aQl-p|Hi%fzfnzTDR_1zg)<10mR3gi#^BvOJ3`dHsQsS8IRCI)y zidtA$6_ub^a(dE8O}^ZA?~+rKVlG`Yw({W)NG7hau!Ig78LjC8IhZ3omI1M1rQ>xq zr{$)6e%lcdXJ;tGJ=Wk1Qb1%?9r@w~ZCE+TT$NG`-!klC8ckLNW)_Q{@I(>Mw)oJ! zow~q>P$CQsX^DHS|9FFy?f-2e&}4-k`G-?_t!-=`lKQvktNpQr$__em3la(YQU49*~N59}z!SVQHm zU7od1%t+g#)#M@~D|sm?>OR#zroY1)FhM2ybnV0j3{Fbs;gwrwJ;Y6h{!M3ifbA%( zo*ILb__sMM@Q32bI_EnBLPC7hbrVT*etCRXSbHV}ni;4M5%-3Wtm~JGl2@fu*c{`p zsq6J>o(r3c`~qO;qx${(pN78yF|X0ZA#QVG!u!Ujt|fWY&M8aTlX)>|eva@{a<<9? zt#OC6v;8#_sK|IjG#2Q1NzITn%|CZjRdu~Cbx@4e?D3io*pGnCzXi{Jx3$??Brb9$ zhazmk>Pl2s>bi?T&Mi|W<{;zX_Y{orF*OD~qwgoj=FfI|L{bJyL^0hiYN6@}mq6{` zMA0Ab-1A^S=`$TOTbme=c#3;9s*j@+q4usx_+a`ZsL z-;92Cl1IzSja&$59OBP^OmE%DiEQ%BHDNY)x-M>dP@bZ#@gXm_QfXc&w@(ImGNbBbCqv&sAj+X!&t$Q1bVMv6^A#zD-hpHEkwo{59$bFnz{tT-Rb24IYN_id~)5|(`C90KjZ;s(! z^T_+){Z=Vi7Bx-UM`M*#*9acx!#jQc`F!>iy3;Q?zF=%TCFqx#rXo%9N$HZh00RRz zLprVDeHHWN9OgFo0H5^yzjT`uA~*h50?xX=8{_XI%dd)t!(G~C-ac;oDkkiSo8YL5 ze)s-++pQHn!++G^>7D#_B-$;+S8zB%=kY#0Ida|ehI$oYD!IbbP3M0cPPW%fRm^Es z+An%uO5;2WD}JAorar^}GkT5Bx$$F)rouN+&-iYUOm&<}(Nu9z-?uF*n<~2`tgUAM z&>jDmZCX%>nRQewke0S481>eB?zA5K^wdY#Fqnz)D$1g2fdSYu$mbuMilj~%!I1+R% z9F$|nQ0f1DpSciFT!I$UEPH9xC^h2}fnX%wkjWQEPFXG0PM2?BH2!tVxTBCyZxAPu z$^4Ye@vV-6$I97!VXL)92Azv&t(r|bJExfPLR1X@h_Xknw*o~|Ot96KU&YQw1J!;b zsaxovg~j$Q>aPngEN(l+IP8g~Wr+#~Blq!EG*V&qOs0=+%T8`TuBaff(N5^{hHjGR zR@k1c{4dIdtosAylH^(^!$FL0vuGnMF?e5G5%;zis;A0lZp?OgajxE!IQ!9b2M!OF zSHS(w1_xHq3ue7%Y;NOs4VM0|CgsQQf-t?%7)`gX^~Q5|6_g{f!PwjwyqU;===*_jRU?cRl$Cxi}R=MrFs+pZAc_Sgb~>u}-Wv)`*gy z99J|r2;!m?N(5tQnRX_akYhwh0~@a=AmG~XWnGLc{bhd9V&ids*Q0R6ByY9gn-1BS z_{yxX@B#Md>p9O-FPf+-)_Q*U6(B$xlAUogGhXY)^!~Km4sLvwQWNO76-l5o{X20i zY+Nn6qm~esl!?M0XA^jJYpd9At<2{#(tkQo)(e)oZ_25*=rK-A_UExp62$wm5yocv z4#gZ)B;^kl(hjG@gOk$8kSB~AHT*sa)RDn}kGkiZ7?~gcSV%GX+Y`Z+`x0u|%vZU|sI)$(#1>_3Yc4sRt z>)!KX9T?k?FM{Zxx$4%s?b}Tp6_@)+MiQ~MUfstB;*kxByL8WWM5pR~4cU$`T4Ied zN>s2ClGR-+Tmp&F0B&cvzEI;abTsa2beyueyy!4!R5c=BA4&WLrN7={G!m;_n0x8< z0sl(7bL%Ey-GjSnh%Zc-xNgziAf_EVc@O1s$I21Io{I+suq1g;q0cZgMy`wJjlR-# zOWim)@<*h10zI4+7M|KE(PhEa`QnVux?gCz4E*7suqu`(Bz}L_^KsrYUJs11^J&tC+-}yX$N*^R^6fBTl!7B`$&n&<4rm(zyJd`&orXg{4@tH`v^&tk;v0 zdf=Zr_n?QdPjOBWR6a|Q`b^=aW!(d9qmDt>rydjZt(*ahFcxdp&WSJ3o_Pyi?~RY= zt)ui3-Oo?U`Mmn;^xVI{YyRKSMC!kyeO1v1g=o1aS0yAQaPjf4UA^ka8V!-n<^JK} z*5M)xD+h53noZql!E@o?^9O4IT~7VQ1nbe99;i--V! zaa*GfgdOL!7jj}(telGqr-^f+UunuK23gg+OoG8zjBgpXD&tzXQcFLTOC6WJ2X5>9 zzG6nU01HQ%ig7BE!dqH58vM4(qc?6eU+&4%!*>=AucqU|mJ_!zUIu#ctgA+6<8FnBo2oIN3 zwvJfBJWZ+s*}#%hMr9l0W)~F&aXDyQ<+Qq$LVRs$&2sa&`iz9i?gJw%6#2%;vGWCP z@^P_EItTJ$uY{$Gqzy6l!-uO*!s2HI;@OjAH8=}k2S_;XsBtQOaPi^9 z#pPabmv%kWjl16l(iy$cYolkwJSLa9Oy9)Wa%^cf|By5e?k1doz`88=^blfk8pCE-3IB1LMHJ z0ROj>+HZfhro;b_^_0sy##$ZFahMmGJi1&q;kEk5s!{^?tNv__-Eaq=*=zHP(8#~# z9TJ2i6Zt~-R|j_|yf?QSud5`Ni83$*o@~~pMMPYl@I{SFpj>*D1PYz!AOHCA!(%pF zK;}-Qk&#hNS_;fJQT%LK#TMksEk3}zTjIKI(i&dMs;*An+1W7~%+Ad<>;I;no){|*6Da}Syf2~3@49D!`HtA+=s?%i9QvM_GDI0 zmhvOI7d^Cw(pNtoDVi+y8V-yoh(a`sNrIf@Ejf}=;OTya5MyuL`YqlwT zcHDh3qoCjq_X*G7?b+ttAlg&^I|{L!<{cE5sf8QoT4Ss%Eh!wCI!8fl8mfH31}2DY z@~}yud_Go3&~XZji;GKi@0X-v8Z71D!0ljjvWurnv;lk>D_+F?!4>gSu;bMQGLDY{ zpFS~6crH?-cQ`Q7wCJti>XVJCjwFFda9fwPAufH7X}p=)*^2!E9S{~RP#fc-XM4Rw zOW84pj-C~d=drlFI*@B}dbq6!tRXCngjq4ZZZ=XJ3H^X8)TKwl+TO9MVNr;< zu?vhjUJyTORcUE&Z!qNCio_g7Qo(%U;fKL+!{!KTcHQznhK7bhzsZ6~P$6jW4Qn-A zU}CD_inu8_jx3?P`8@K8SX!jx>;^E{iX#ycT7cTt~`j#?%DyS#0~x%K_m9q!h$6w zWpSd@_h3y+SVUxbX=zI+5)6s^81alz6YIr`7p`t@+zu1f6W(bs^x>>R^g*hacSvaH ztO0u7;1E2QpxfHeAGLH;VEM4Iv4b95AtGYn4QeYBXPSr`a;Yq0of0Pwo!dAhA& z4cdS&ckax$3wdnOIaF`s^YQT&7Z+cprR_RB+Irx8A5< zfx}~(XQwr4kNf>s5oZUZKF7;heS?DrTYFQGX~dcO~1uZ0Ixo?uN?0!r@H@qQTL(?u-A^?Na}94=WMJ6!n2F zFfi~UprLehQ%{+oP|yRoO$_kQo{0%%Z|{>YSpuN@>60f{O`0R_0Iw>JfqKDGK6&^M z3y|TO3u5Wirs2mojJjIhL(F2HjDkY$$*(1qsc=AIry!b}#}voFPq~k!fYOE5^8MV? zM&{$2zte;N+<^au1q?`EynMOIkW+WV7P{$8^F4zmmng)3t^+9*)!1SY2jVdB>;yII zRwY|qOUYw?m6X(JVo^4N>SJD>z0^r12&Y!Iwx9FzBEUcFK}rxgToyW}yWnUZIC?it zHrW6Q@5i|M;8jcm-HKDjvL4#n+J{>WS0GdavCRaa8iWg_rTidfft$pzs5tK}sg8_} zYLz?EZz4_}l$4bCoNhNW`V}2SG0KAAPUBiNHDw6JO$2&Bzjt7uDS-zRG>9@Hi@-x1 z4q6&S<{y&gbctiAmcE5RD=;u1V`IYxqR`w%)h079?>m2gtXsEkfzn}3cCvH$1u>&3 z{C2Y}t#2bTMa0U+W_3IgM1kEd@vQXpAhj&z?vatlhK6*is;V7n;%QtaO}|D)+A!!% zj5BcSo}QkiwY4?h3-0RbH{r*x$I#=^+ks$1cgom3og;9cZr>Cz^%FOHzj|GxR zSU8dJIy2~b%nLi+k_17+O({M3MPAR+dUy zCZI=g`d)WR@CKjvR(#Rh=jx0GTC4x7cbJ?z54|v0w0ZvOuM z6TbL(c%&e)oG1l@W1*unY_S7`Ljk}em_KvCV6dvu$E)O;Pa)GB(Jf)r1omtMXsBYU zXaYc72-u)S!OE*edoin|b_^m8_#kitsO2+a0E}RIdRj4&_l=Hy#j8khbUfrFA3o4! zDkeOVle-A63e6?>@RR#!W+tbKxIi;-zHPNmlFwSdjMh>(Cwo!GF!2N|J z2WP;GcTzTef&pnhSRZXBp!KN(GfpXBPxa=_n=opT*w?S|FVn(A*U$jy+a_wfA+G8^ z=_?=`Nn+Q>2R{B_L*LJ6aY0Wn$Y5@A3n8ATfID77k z>KPX6mi(^a*Z9UUelfP*kdBk{4*+lmzk=iloYzYRXSO|#$K21e@ceF?`bz7~`db~A zq6sC7Mp@u042WNUiW>h$)XGat8jFdEIZCQMx(!VqWJ3_nZeq|5=)-35?+|JCJDi>% z=RuWObkU@G%$&22krJCX5fKX`Hu)JfH7P*^S9Zo+x>PkZngtNsm%bMjef{=rhP?I= z@7F%VBJJ4{Xc)!|B^FqmE(?l&r?a%Xz;01~5C-UVbvar^f-hEX(9_e8d9CJR zK^Y4YsSL6lUh8>)#8KIUAS3GpVFpC9)XK_A*d}Ha5a9=Lp<7#yO_8+XYkrVr1JmpB z9`AI3pXi_$9MG@H^*yltQ4`*gHu*KN`M&EmV<7Q^?g056lw)WWnPWpS4WudcAf%t3 zyW-LJ@|g=cHhC4VffK{()g!xLCt)DYXR4&Z*8D(lCa44KF<3XhurLaQZ}ZN?pMc># zc68)~vLMJ27CI8n@8sI61ObN6;dC(h^Si(wkDgZD+o->F$!0Kb-T`yq;Ee&e0q@LI zO7hxls00BpAdjV?7L54i>(|bMdhSl6c4bcB-GPN5w`za?0*Vtq^tKr_K|uOcROC`; zRR^sXh?Z$G(Owfmt{~K56_yGX{w`u?fh>`#z(SXQibwgH~U)xxP%L%R)$iFOB`o03ze_7CX zXtHV(p&%r1Xh22l!W3B$O-{oVhQ>l^D1Xuk7<=sQ@g%di$Q;Rrvb%gKaHPant(_9Z2u%b z?VLQl)ZN`(bLG}}xf3HmZ`kHyEqb-q($4Me`PL2J*JU;QJL12I%J0Rg*} z)ihwG)w@WMnlzCVW#u$j?m6vwedGMq0Nsjof`T5r?i}FpxQ)e7=8>^%e&>SToOSwt ze8O2_1C`n|0l{U`%*O1wHs1^ zk^)o2#l_Wq%LKi@!0UyZn={BE0R;oH1MU7E&{TX{U3IT_SaC-_p>;6%e1T7z1ETNObFruv+y>0XRp6_wA5W?VlwE|M#cL zcm6^St6HYxVv0MrS=+nzl$%h5h`v}kMo9ssMMqG`hfoI##5IVv41thWi#glm@WKF) zvzY$z$`z#WU^YT0XzPBwBCY^A;xN#GKnU7zhk+TKTXFhHpeY7PJ0P2n=Q6=Jz#I>1 zeK#enudj!YnY?7u_vD}u_+ITC$eiDRx!*fldwtt}3h>S5+3mwSm!OH-(c{B@9@!9l z#pQ6z2nPqJK$BfCMHy%XVt~5A_ch?Jei|EraX<;`v**uu7R50MfI9rKuwV}Ed#*Nt zn8x9Kur>|Ejxo>0lxOPdh3=dB5SjUI)*dhB&2Q2aHeJ*On}v{~Q(zL84#;=j8x_G& zqTbzI59k5}Gih+N5OC=+zmxUik`g0M3e6fXK9IFk@AW90E~~&R!%863jO$VNzA^xi z8stH9Aa}=^O7-5Tx8>i37^eYxCk`avj$L9Xh!;js3H<?Y*ELPZMfdmK|5K?|s%QEIq0t(&~BsYO)gSamg;eaD| z4-JI?GQ-YJ5>#IYXqptbH;Cd!X}%LayPXg^LYkn!ghvnrq>w5J%{YXOK-d8>HKbws zLq^7{8gCRJ`iYcA-QB@HB`5g0XTYC_=0I^5sj%sk(sO10KIq4`2yEJn`7H2oDbj0D)szs9C;I zxq7(aUOQ@0m3n&^Na|LZ4+DoUH`*fF$wg-ZEh_KJoXw(kY_68P*>F@tr_Ofylmcim13km>B|m==Fz#6(D$Z?9 zH^eJ4Zy-+WA3b`c30x7K#A^GUhk2^Woz}h&n(Di-&w{+{(d8Gxx6#y}P zGeHpkTtfrp`O6Y;NMMCh($a;dt=FJgJJ=Wx?On#5f*i0Vic!~ly(9=r7SEhEma-Is0;iSaWej2-4*=njW}eyBZN#WERlceL*Jcdn^ec)=O?OV`S8FK zMdefcN2}5xIQpM>)y}_>)cpG|`C(sjwgC1oh0tw6H!$O5>1>IjR9veKh#&s?RLi)8 zzyxK@5xDZM(SxQ{6zIZu{{k)FQnA&X=}?2bJBG)06g_X3JW!E7+dj;$Zr2WT`@@u4 z=0ew1TuP6R@?1Mq7c(Y@M*b4FKqAh;)3y*e@2>0~qtN(}> z;=&z|ky#{ofvbz*8!;HhvZxH?)Q;>O_DV*|_hMfZJX$uRF4@WShTfvoxG4;C>+U{e zmT>ot!q-i&f~G5ey3QZO414Y747(z_}fZ)n22K?(Mu8Z zo6D8gAgMjGsHkWbykQAi*5na)hs@nx^ClmcdH;5RiYuo>(iZJeuzIzJONuz;ces z2>~^M4ZbeF`2tRpnZxE;SHS@}u3~v=U9IM$scLUQ9n3xIU{tT?OjognxJq3(Gfp~9 zdE*_51R(W|V*@db7mt8IK!2m-d~B3}7`%!+0=o#h$9Eu2vgPOM+8VT3;uMYzwoz)U zBSp@c*0rTKUz@O?Oo25{Km@E1gGOy?{JjqrUXD=NtQQTW{kFVX27e4$|y z-P8H@Xz26)PLbFZ>iSBMNjB>NVvxn51MTli9ti)cAK>1T;P2EmwmHv$O^TGDF&Tc;|DK37thY>X_$SPM! z5#F~oH2knY1o3HnMgX|FO~CI6iP`+0eTYm*oj8a*(G7=p;^EaIhEo$KdRQUgUX4h{7Rr2DW{53 zqFY(g%3o_c3}F#h(me74I!tPxm=>7d9rYK`2ms3Y{CL8sA?8CA51_o42N2dvefh)? zW6f>>Pe3i^#rrF6ipYnmeuv+x=d&3ApUID`l3;?4zOODVO#!X3oxtlx!xI@QLy5ub zlg*K|2Hf^z<-s!5h0x-tPXXzvD-GuBrGyC%Ii3l`$)N$N$yqrF<956hBv~vjj4{WMn3*AtF=l3FW?FxL{d=>uRp;XD z#a5j&Rb3os}g-NTkF?IheUQIOtgzm;pdCGFQc2Gx-5aVB^6ISzToD zvNqi|5Fi6p!3?8l{Xj_qj6zV*ga$(+;M6fNQFA2K27>0`By%IS3#eNxgrg#~qt$e| z&WTsc8hyPk&dy#IUOEnP4qhibCy@hC(I^x5e^>+D%AtHbi^TB?k^_G`1HdsQHz7$- z_DB0Hpaucp62jBN&*it^E7&gw032|fV<1QSv-J_&rbwv)kR$?|WM~KQUr3K$?349KLGAO{y}2MpB$ zmFd8H`hbEeAuF&)61IN%7Nid*iG<7^(W||eFCG{Vk`}O7KHtBYsZLNqA_E!{h*j=)m^lT8i9RGm* zcI~#Q&;gd!0n3diJ=PW20&Tc|UQf}UT(3ezd4-C@e1rsW@bG68#)X|sJJA9ktXmG7 z(SG-zPv2L(E`je^LYdV)MZs_1jqKfOl;aLYAw`(&9}Yn8e?tA=;=WF8v(@U-VMbkP zMz$!D$^G$cRZbKm{hPXtcI3};MV$5qo9vDVRD~(@$TvYZj@tJ4or8f#BWX^%0YZK^ z^f7qwi2#ZP8;j=y03>%m#S77b0psb#I{?seh@)Gcf-O4)0{~(Lfi%B`p%Hp9s(O%e zdI>vvu)&SKhzTK(_wb>JeAx{kfGfmF6UJ5T;aP-i{0)IufSR?1($LGyg?O-urW<(T zi2cP7C$A5uBMcT&co+uVh?FM+)i`dEYyt*5GY*o>NRllnpqS(+0z-*xaM-CByh6k} zQBM+dP49%k9jhz!oUk%X!V>iw)gi&05?H4MF$?Z0)LKrA7d2GiJmtcMtQU~>!)6Mv zfrvk#=||a=%%Q6b;2RZZ5-`P#WFE6*q#F}1ZWyVFNBIZKWKWY_J)Yf!P?OUQ6Tc_V z=mLfuJURgV6h=P;rO!4fAwfb@R#UW`;uO??)&`>i8yv#iM@5!guFyc?h4wVyVa!vT zp!uze@|={2G><}`3JKjHS&|&-Z_=(j94SQtixj++jpT|XeX&Zhz%+_gjx6O#g5BTs zU6{InDw6cX<^<#fg`}6i{kzQ=JcAM2vamnMm5Wo*q}j~`_aIC1Swu1mekzSu6G5C+9F*U9Yt~YKOPBSiJrfezHOnT<9D$@cBex^kRa)yJNCe1yK4`$*B zS}ISfda7UQ!B15+XEnh&&!6zBm1@Y9`s%)=-_+_AcPe0Ku`1^(2-VsvIBF)8-Ie1N zCzT9}x(wceo=p(z!a~0lgh>{unlumL#*$(Uv6x+Hip)EyL!8jrWXKuo)IFWbx>2E&nnYs<`Kaygb@a%hub)iQM%vQ>^cYT1@JU|BRJRYcW7c^+=D z!|s7CU|!`wU|hwvZnkQ}XJl-}vQj)yJhVa8=_Y?yAfYO(6?X&m=l zj!m9X*HXvIYiwwVc+Q}7G)MHGwBH)WGyA6zOjQFq9U++}xmzhsrPvK6pPQRRS-y7Xi+}qtx5{?m| z5zh0v@cKBpxQ@BmI(D2CZ?W}P3{>;k=HVLzV;^$EMBzrX01QW3- zVH?pxY=xVN{p5@Bm4@Xf!?99>ipzJG2fF#HU9A_3&bX`iikMd373(G^H;XE3TV-35 z)9jXjmY*$w9^sGTSLtXj=;W|xk-Gzn1N@OY&fo{OS*$s`S*mF+CN+DjKU%T(k0wU* z-1)TFwGw=|LNn0P>jd|LNkkMGaTNB-?Bxj2uS2p!6~w!K#QspKZElg~OWMy>Prc__ z(t zPD`7Nxa+xZH-euWKJu^&dUC#5!L$vPohoWju2Dq7l4UHX_0Yl5@o16kl+>PO-7tSY zegeS{8nuFUBB4kp>?%M)-B$S1&UU|c4uJLQ*JZ$u)3V~9km>-OM6q?)>Kxz zw)R}Tj#CbvE-4YE4bosstw{MhYF~4!-D-kqlxnJWVVypwqwY_GooZ0!SXEy0`1@PS z{qBZo#MWC_S5Gu-_-)<$erqif?h1o>@!log(D&jOC)O?wwyVFoyX#=>lsRIF zBzYY7vI88Jv(K{J8y&T2kotu8dZ{)_eY|%R4+|&ySNq2|GX0zHU``}GX{UL*U90Ge z>M1o30{0f9z5=5Nhgsg%@Ec`^we1weLgtV-L zl(dB8)C8pDpt4i)pO@r6FUTt{$ji?_#Rs6GeezFc*$H{+F?q=m=*J$YU>j7hMgC)# zyl@+ozX{6U0OhQLa#qMP=E(Eb$aB{~*~_5pB~a!(D18=`HUmnXBL6I>OoEap$>N7W ziKAq(gJd!NWYK+~xFJy7AX!uoS!5S!Xe(KG2U%D|8G+N zU!>kY$$YEHd@4!3%1J#+NZpIcJj+PkevrEUAa%|oam*rfEhKfyA$80ou}vYdNhY;U zAhC=kF^?iKiy|=%Cov8sHVh&$3L!BHCf4^S(f23T^C8ypCf4;P(eowN_8`&mBGGgs zR&ydzcP3V`Cswv4Rhuq2W-C6+ZImNp_1*Czg^LnNj_B&tpn) zJ~j^4*4Ea>#>U#(+RDnx($dnx!ouwA?99x}zkmNGCML$l#zsd+hlhuUhK2?P2KxH? zdU|@gy1F_#I@;RWT3cJ2nwlCK8tUuoYinzN{rXi^RaIG8Sy53@R#sM0Qc_e@R8UZm zpP!$TlarmDosp4|mX?;9nwpf9l#q}R7Z(>36B88`6%i2;9v&VN5)u>?6c`xj=jZ3+ z~3^=H}+&;^O4wCQ% zqot*#uCA`Cs;aE4tf;6cFE1}ACnqf}EhQx-At50yE-or6DkLN%ARxfU$H&Xd%gxQr z#>U3V%F4*dNKa2sOG`^hNeKdhNJvQV@$qqSaj~$lFfcGa?OA&sMgG&WuU(YoRX(f# z-@1SVHh-&6?O`1ywVVL}9{oQA24v;n005c1l$fxpN6uNUouZDF+rbAK8rlU8Y)cC4MZ5?Qc%kKN<=gpP>%DXi+wb4jjlyb7OHa2F< zHK8e|F4qi5xhu4-%q-fqXJec6Xu_Bnp}>#}kpy}`cV2{7R&tUD3L}F*JoyT*dgnP$ zc0QkY=Di%|fu`WLw?0n%@rAyx>OrCoAx&+(#&N9^DG``GK}gSoq5i@^nv(y1rl{US zT+kKep>;}DOYJ37Rp&FShx*c=hsrX#@!_J!dhTxScz~^Z@pkjidcxDV%F66 zSN_2xWJahJWYqNfT7GS`ByC(YLPqY4yV45HBv)#)O8#o zg?P+ewvmF7EYBs7=))yq7zfNtMyjfuhU37T+DoD^N&vW9M0L16OCa+|P}19Bqd5fGqKS<4{USqoI#gks(RjOp`#YqIVLAwtS#{2WzJ zgIcL_X3*Sy$u&KD^TAw_vKrltw>&p(apS@fZ;4`BP3JWMs%VRNwQ+N&YewP1Pkysu zR>ND2yfX2v%QEE+fdvNLU+pygC%OLw$UvOeQF3$7udBUFVBwnatqQF918u zmA)MI8_-M}m`3kr#ZPFu-~l!72*)-0c=b~VJ?zMipPrfS?I_UHj7-}<}dlA8Cv=R)@GTh-Cm%{l{Z zzZ!y`D!*yWVby$E=(gwjpF>TRPKZFlN-3h}lSe7DN%^e-gph{6dUhJIr7bD%%H_Z} z5*1%{h<;JdfS4N%vQw9^@XYjYSRH7tHrzdvuPl|5-{fEWeAU~FdIekQ8TdAE?7X~x zity35Ikx&9HBv-N=z4S=R{RA`*jPgapmwg&PR!^5z{(7)#(zGH4j1a4tTa0v5l2|& z<};NIHvH52K%GhtU>oo+4zC?e8ZHuY_ug~31Wi2qnAJfl zZ8=<|<8iPi?K8P&u9;wfds`w%kAtWB_>ok>{;v~fI#Ff=b7{T4=_A;Oiog2)ys~?# z3gwFS5xzCJSC`wUeAO?Pk7vT3d3K74NzFXRET2Nfa~BY!^W6Km;!KSTz$*%M)0ewbt?5tZ#-GIob|>A*?u96LdiL=-c@cK%V1_RZi+)W9#vu; zpB?8^KL;ETbXS5e=udcXAj=tRmrhyz!#cy<#R%^{6qTo4<)MXJBTY<9ih zr54UR6gs7SD}dGQhAFCde+5bEJz>y4bKdxMAma<3iy1)T--#N$c9L|nz;tgoj*xNQ zPRp3S6?Zk*!>ws<`m5QneALQI2S$8@!tacR?t#!`Dr2rUrPJ_Qw}Tt8x@3)={&HH<)4t~_orZ2$?e zdAbc63jl)z`&Ws5RQH)%U-L6!^JyLELwA1fWi@-+6~XNhWMIUMtOJuAxP;*RQ8jp_ zyR=Xz53W0ZtotfTXJ?iME@X9H2NK30!v!L_HisS;a7zHigj!}L!UgrA{3NP*RT#Ok zi*#q87zT(75P=Br=BnpY%cNcNVOhm-)ux9H;+f|H1L6hsz;+dV;ee|Xl0OAmGwrv| zi`l>3w1OSJ-1gtY8U(3@*OF%Q28bmA0bdoY=oN4Nigi%X13G?MSU~v-MAsu$;*h<; zqy*rFx_AZ_+nLpt01qTY`0BF>dYIR~ZVwzqMbzi|ag6`J#k0jkG9Yvh~5bSA{B zu)V>tk-i`ng37%-{`z9YjvUKJ{;t4ol6HX1P5hl+>^M51irdqD7K+y5=c}IW^ za023A2tkE!CUL8rHUV)O(psd_?9TB|8a2CNM!O7#_6jnUk*TTia=buKF3HTC3uEvO zu<{GtJO4)G0|mHTYJmdssdiyQ&d+7kW-C8i~>2XO13| z4Y0x)*dhHYC^6346UrDfTKF~*@Z-xb?KSw!f+A^I*zEgxr%z)J`?oFXgtmb{@BE%f z1nh6wc}lGUtiI0m8E4T%MXciX^_O0Yq_9H;Np#3NAucm;8Tp=lZdpz-NC0JUnJSd2 z^lP?IgC3-AsK>Jokelz@wPJNPrP!vxQloopTG^N@o2n=1hyjsiaVTfv`(o!K~6 zr>#&Lwmq6vmHyT#%%z85_lWi+Q>^LZtdl44rWf??2Ga8dzvBt2nfmTm@9qI&ZUP$9 z%I;@)Kb*l1T0t&c^*wG2rH}aivJd?HOFgB3HGWp-E8QcHtJxcUU{Ibn@qSt#>M45U zuA}?!AF1_IR9_eGI_@ohp5bQHy`2pu9NuyZHCuMyXiO{cIoVOCWMww;b|c(Miq%9O zm#gqjScd#^7C?**g6)oo3^B0Zyg+fkXiTcINbUA)9qz89Q1bt<_6e>`ck;DAT9U33 z_hDT0&_0Gm%2$^uXi4&~U>K~ztj4<#KQ{%e8ft()BI+0+fy3c|t_gMTfj> zJ-yns%RF^Lf}Qo>Uh9$!h*lKC51kzV0Nw`pJT)`}&UPGtzB2e_A+Kr%NkUy$ z_UuG8Pr4pUdC!l2a7nEVtwp{c1YqNvup9|>pIc=AKm9+9!TkvI<7TkSAIBV&>KsXT z7>Ru3&K2T!%azuzS=JN78s-PDY-?bI5IMzFYfp^p-%pN*@PJw;FvSD4)Ym=bwqTy) zggQ6zt#)QJXQ+=vFThk%e)h!YgF9E4&^L!|QhFlXdEOOly;2zYqnwq5W`-s=T*7wi=B!Bt0C7y4_vB*ocfw-1mdvD}{ z!I7~0tB-_MsDj;)`_T>d#q_PCb|xY!nW_Yb_D$_Oe}j>gxY#J^uUK`?Eoc`Py$5=&}D;yOVGP_6DcQIu9mJpG7)MxKB^DEv*gRT~Q2Er8bm-W}?Uqo7b}3v!4DX+` zN@=4Pl9Nv$&-VvOS)B}AsEf1pZ@wwiKxR1l_|>P}fP8b1LKf};b2yX#vkB`f)TiX{ z!RpfJm%T1VZbz2Wg%kBz&0mz&KbI!#`4cuItc5^@Zxd$o zzA%Y!$Nifbf!^x=B2X-`wIe#&i*8#Ws~&_}Oc#Xse+x;JSU;Q%a-7XH;??jUsDnqB z*|4wXt+~oJS$$@v`7e_P0}X6zm2Fm8HUB7W?GA0*>JCIp3iOPLwx;#R+P>&0`)md$ zQ<^R132tCv=Q+eITjtTmIjpfSI#FD$o5t`d=BD2dTD3CeY;txjU%ihKn`}GN8^QC^SL?njwy2_X+Cfg{km}+D6G)UscmK2Z=E3XZ$GZl0 zntwYRO#JdZ@W91)^wW1`+Tw0RhhgX5IQ#ySn)ppj^t|@J*fW}(c#(GB++!$up8OZz zdF3m&Gepz66g5ZG6Y_I#qMg*9c#X`jylbpZ{oq20lk2`a4*Gx^KVqkqyrCvtLDzbl z-+Loxb9DGW8JrGrS0~1ot!Kc)!tG7r+p+ z9uu$3JKIb%txH{x80Wov=g`0ltc|;M5IbSPVKZ&;e}`8fI;w#^5Lk37u{r#Sbml=U zENJDS1NvTCHmQA|;v^Kw0Igt`u5gb-_FOagd`vaei&bUS5EZyNami*}`0fqC`0ZGt z^jJQY?mGL~t8y~yRy^R3r%B@n_t=B!0tfV?uLeS{oQ5NB-JXldo#Sr`b&L!CeCq!C z@X%k}x5~ig27a)^dtI>wPTI>4|Jvt_2eVfEmdJDY(dBYPlZUxuM*8%q=Vw`ME*m!P zhc(VAe#3g`6(h%I+r6FlKv@5mfC7eG;UuijPww6>7dks3idfwP-X_Z1G98c(u>GY1dtz|m% zQc;PF{uny;+G=)!)32;SsWznir|Hga9miN>H}#c+vUCBzfqqX09boEQ`rKV>;MOy+ zY^L{6ql_+9^ImQTQo6juZg#)oP2}CeoyoEu+h88lk2M|r*uP%Dm}!EUTpq$YU4)It|6!E)ZGsUsqSM zw~!rBz@W5|JPf^4_9B=SYTB8ma)^*z zovky6OiJ@}|7ZNESYKv;QIDOh!p(0KfXb_+agM5AQwII^c0TU~)mgCx8(qo-L`J7g z-?t+u29$_JOhCOUsGF+W!~(@`-pwOmDP=BAV%ZdW9{8B}1_Tg1Wg*rQ!FT9fRqu6) z8>R`WHaXxf3UM6Yj1eJSBS0Hw@12dgbK1YhnG29d*+!x>wL3ZO%H!K+UuQ6Ur%%8% z8*&(5)&@7XV>x0mY!wXAw3rf5U73MmpE@AwP!<-1W$4*TBr=;*T^7h^!`W3!;CX(@ zmpC@OKM(2EndruH+mGa+`mvm^%Mv5Ff`HdbnTPl-hLdV<=)gA_7HO;^J&Xz=dkS9- zhuICzLA+_j64fomLU19Qr#lZUAj-DrYRSy1@7SY-jOmC_WdzFr+{~&0h(DD!BU!4lQJKh`RSorzODz?jmtnd-PEyZ ziki#VwHm|o>J4~lWSkkvA#^D(h-nQsaPM3wD7rU|ZM=R$T0bTVGu_6q>qRGWgpgT) znlh|7mj2bo$Rg2+>zP%v`~EajWT?AF$Me35i}6&L_4ZK4bIU6TV&NAeGCVyLp4XS| z;c@th%(4O2rR^es8#R=_(eEEG2ymkyX1f!+#rgri1hOMjutvi}& zMrh#fuo*t}$v%(EaNZRX{Wm$Cfgxj}Y#UR7rVHOhFuB3m@HEQgW#G*!eqLq0X@VWy z_@Jr$@iwVMA$Ot$ZMMi)etMT)Je<81-EJ`yE?+i`-|Nhms@DEot+76vL*~#14H8X) z(~Igr<@@8zMjpp^Yts$+ne9O0|DU4itacL*&GwCyUPv>n_OO~`pvRb|LQpbtkZ_RF z;AfgyM)6Z9mWq^bUoNz83!IdUc^xI;2n(MJ#CFnr)|wnT%X!{US>+$!T}|z}^W+{d zn%%InS2gHI$s2aw(h;4S6{3!a@^SK3D9KZ(=rTQVipwUa*K%@kPaEhiHu@RNu<-9M zaD{S=lLh$-`aKBPhH)BDjtkwOnu{Av&D*~9v+%Jk?gAo%$ibng-yd2fw99sEd#z)E zE)IRWfj6`#W|1wMj5t~3~H8Z>!XR$hgPMa2Iw_-=_n5C`n|f14}W-7)Yx>|y-1V{|rt zh?gPbli6KsPbIILvQGP&?!o1|J4XFo&ogTJtF{ z?Yvb;-Ufcfot4OHG7&3^7%mj`#jBAgn3*_Or16^&EY+z!wEElN(97Tj!z~_AcnJci zsWhqx8KBj7;cR{vn^w64;z^i`dPpnNZWKIsv1$t8x8p)$wL`J23t@6k3$mY+^Qh1% zaa-eid6jug^%-!%4gnu_ZNjAt{CX3s%AgnBOVZ8n273~d8Eq-OkN!Q_)sSS?hUTl6 zFybc9|B0FZN;TQ62(?OjX`L!Rvk&RVO?-=*BP!<_X(Gfwt>vg1TSwceKhDAGDcLXP zWfGdaMR&x^QBfHDs4>2|M9YkvOqzyPvV)6j$#6(ajijh3r`&W#?;SW{7wLWVm66HI z5npjKDp6EcCi&6uqsb68uH%e?Dy0c_?=ET?ffq%sN=aiag&%)QLtDd}MzFh9d(Zt# zGeT^7Zj`OAvSs~}l64dfJS;F>dgJ5S@Tmmv4z{(S{TzF4C7eAxR+*}|t$CR;dIuR0 zaM=s*GPfKPg{3#;m7{Hss?ke-&@i6G-$_wH!S9>41@g(9`N>YA`>IGCGaT#Mm~B0Z zSZEy^@LVtca$j*JZV`wT-~KMqgQ`NKMCxoWrP?%jHlipc6*xQ{HMkSjh^ONNx?A3;}5ffi^ZF+g8<7uXQVD$cfl9?P>2pK@PbmmtK1U?`zCa!q&z z&(4%cypBaDZ*yx~N(% z$91RQpw%}LuY|y>V;wL&R8o}YN^h~)Y4@AKZ1vF{g-*WW$y6yvi$*umP&|2Y*nKC- z%%`+{_NQN~`d5-O+7gi~(LyHUe5Jdh69FC7#&0DaGzL1>R~W{6sybFHN(RcUO~urS z@-jCj?qaFxbV+ifQCu(DV5XTe>OF8IrJ6;qDoy?38D;2HzsPJHxZRs&*{w9v6#p|nE#^46u8+k^ zb9nH(+0q#8ESST$axnr}3@YVfJFwl773x*gZhlNq7eK*;frjItABL!2EeqGojW{EU zR@L{X8Z%41@pQKs?25sJB?K|{FD-VhET^L7hb<6=3})2t!#`4if+XB<=n%XNk?$jD znPprq;t5pCIS~RaD4I!LiAhVsoo2ApAz#KMUpfo_t52F2cvCX$uAhBsWt@*zB4?}; zJ6StG4;2^aaz;+Iq#H?`6Jf5FJS7AQa%jIPF+>G;<$ss;iXx^G9mLzi68#pN#(0Lq zIK7AYP)1DJOi%TLS=x*M`<_(QWmUMGp#;U>bV?fdhrs_Vt=}@xjf7Puwnrs^R7k5_ zOwc4Q&X7>0Pi}GCc`<2KA!FHt@H3yn(c46DkNnfe4`?YYL)RN48ECHj=!&iNO}$K? ziR1gzkhQDSyFGhQR$96@Kv*(3uYpHVg*~}oqq5CMpHPPsboYI}WOntR#slJea6~ji z!D_pvE4d%5q5nTiNv6P`QfM(GW~jFRD?QfGR=(Z$E@z)pJywh!7M(tOh_b~KozZ51 z2HhP?*luSwDoBjbMC*!f5$z-7b*q9)9F`d!B5xX=TfD9R;8{uc3qW=Sk4S%gkut_P zR&A<&Kw9MLKNWYqP}!OI7480)m>Y{g&kXm!;mUu(8Z#(ooPV4x3^kVY*er;TrXf7H z2=+2G3@Km{@&ZhE43JI??64Im1pn-XB>s$Vx&b)eB;BR37REjQ1=mErV(X^sGh-`u z)v&wd{Z8Frxu+NLtTq2F@s@XGw60V7>9DVmhKVV1D2-o6{6*i2Uu5wEpk{5ZLS6fp zm3(kT;cpgEU3u3zUDC#F5A>}?yeH^g5Sua(4i8&!y_Y32iYn&@3UcmvQv>Sn-k+|)^1 z1RLskWi18QKndUo0W=k}q?e%#r?t3$Lkw|Zg?#*NBF~G6OnQ+C;tR&M!PjXxffqVz zx8MjN_)@8<;nzBZJ;DY<3Gx7sf~#hDl(iDO4m>;6LBz8+;m&|Vy{5N?zVxVWq+~&d zXynu$U- zgRllxAQ&ytrziIAy>%l27p7Ds#0P#Quw-1tqKK85<-c)DnQnwwfYUr;BIM#2HRQEe z_CJ@qgFN9r2ylP{G%c0_bw4;aBSOVG+doLT{-;zH(wX&#Er+rE3$=4dDiko4KQc@Z zW>_lBge#2rHoN;+MA%4F_0YWHLRch1Gca)`bT%+kuat=cag$CX8_XOYBb|Q<4S0YD za&cp##sv<44*NQMrQ{*oA0ktOP2dr96LT+iYt^m0(!U229|ru!+Ppax;YQHXq3tP> zC!GdJ+rLc2xX1&Bro4ar1Iu<{_ z6Gos8cf?5X1u-oza8B_*+8K>nW;C|DcS)c{&i%6j52`=`0{)Y{d+^R8+Vp=as4U_C z;Vk&MKpX`*?=qvG{_xV)#SU5YFJXF|+tt(|Q3@EQS-P^&$Q5@Pc4l0#(QnurOaqGP z%id$(q_nO_6HJJ4h#szxOcZ#v?8V!-KLNMq^43>d*!`UlC!FUmANty!>cecCA*LbF zAd~H63-`xz0H*pP1#Gu{uGRe zr{5+DD;ea?WNQR#7ShRSd0w9SpJxjtTTy;05vHu*I}q=w1!3Ii{rxSeM>F8ZOph?{ z7arnTEYYu;)TSEo#$u|TD{ljYk53UIMw%d+X`=jZUE%-g8vozcQTp@FQ6enN`x#c)UUhU5Pg!8JEVysVnUKy zY(!{Zce>vgfC%-+kB-5km{5<<-#6VYpqOB`IWlEYMNLw(NXyO?NvNRTea`O8Qqf#o zq(9IBPA_LPEtU+Mz?^9)ToV45fFiav~+if^k^vo0f~`Ay1S*n zmydIHIBYN5``qW=U)<*|TuVcdpvS= zby*OoDw^op5*Gw|plmBAr{xA!e8sG+ASWUyE+WGBoL>+GQVLJk@i2@#rY+(Ltjh)ioybYd#cEW zEz<;gqZb6_#cuxz$|{q2!v9};1y-O@-E&3kz!qGPJJK+QOy&(J@Y^fi-ymi&Y>*t| zqd^>IHJq$xqiqYt7S16JN?4%wfrP;%)zKG}4GDi}J&q0*mROd4I#j)~1apzUCtP`w zmXyl+NNF8MZHO2IqP-aDxD}I*SzlXOTzkLnuz%8>b8CO#^hAo~aQ?PA68k?8xyJDR zjK%8e1dekr)^|jkeX$8>Mj!NH{=)3VEMb=MgFoIE5nf`~GK?A7TE8S1=pPUf?Xp{C zw>(>pkh*)Z=(G^we+ImIJL`P_a!HZ-R^FZ)eB)vXbFY9R*9ZQ4B?voTXT9wW3AjOW zjVuXOnetM7IW!D!(0ZcY>(vPTD$mlFutc#L@cfV|@rE$Yg9KC-s@bklhhID3(C)v7 zg&jd^IO-0P@jvE^q$T|@<_$kcze-*ITO9;Pgk z*K`Pqk*qIJmM1$XISnNuwh38KH4EL|3v=N;bTmUqM@3hToL;YYYhd8z<@CLli*Lz zCvioZHSFFL7hRrKkg6EN*JT`gEQ~A}?B<-Lk1XPp*hu?gS2P~5u*W=CCQ`OnnpNV< z)5((>CAUpe{VaqN=#FV)w6iUdf4eu96(dWo){o1QeaxvjJ zbt$@-L9eM;q+$r_0gcug*0#uPwYd3sX-)DYG~{(ws8X)3b$!p{C>H9T=kE>;Uro5` z<81TVC#zeT{J7XrbH}}+a~JSY(~)y4d0+Pa>-+RImq9Gjo~nfd@#!>`A(kPMAs;>* zK54$H3A^(5<^JWOGu|_;GgiBus-1!qfg!(#^RM#Z$+V+*9z@{>1(Wljt)c0+E=<4uWFV2ft&iH|&#b8%7R3Efzf- z_<;J_^&xjeIhV76V=v5RS=bXS^}fuB-l|OK?>mgWgr$}3^Vz(vyzZ4&uEBWwl=rBS znlbqA-@kYvZ$%byP^cLRz;r@9eBe-+D^I#`s z2X9yW%<_!=%<*iP;Ws@6!^BJYOJ7$w;oe<=+?PE$ zI5%8!`MsQ8m|FPrIA&eI+}+&B{HldfvpS6g_Z%IHxMbwcb7w(I8r@Q z*qPp$VXXzNPv#|CS^X#czxm@|XI#C!9Jq=8*S6?&M181%`v$86YZa>{h&HGi#~z32 zziRzWjE|~hm;p- zD=E5(aO;XyOim+sZF6WK!$ZPI*eJ$VEF_sC>4)@cF!L)d0UFKKLYO+kqoYr$A({%U zIZ-*8Nczt~w+sJyXt}j3Fw`_RYWtvYs zJ}o{i7fNEASi>{1}Xw_6viL?5YrTVlRzHGHq0YVb5VTzJx^&_vA|p#%2d7c_&S z-g?>|jQS0H82FKRqqSrR)jP8D!W@ogXjq_qPrV#xX8x+w_wQ~iZRc&n%Fd12DpgwSWYK>@ z4@k=C4x?7-EF4w-!n3{YPLYE(gZ+az!j{6jCco>-W{~Gt_og6TgZEZgY9ibvoj7kva)zm~^(PjtD&L>aKt!CNl zRm&wSBo+OgZx)p*O`Nvhoe%69NL0X2)VE)b?2iRp+O?zICjU+Hn`L^n?Dy=@)x$XE z=o{Ekr~ll~cEKJG_S7(D-qD_w*R3-*Tlkk`TLCq2=^M7B>#7?yDx ze>WDkII8$t5zYFVH7YutiHVMcw&m#UP~2kSkHSa8{r>uQUfXV^>+?5DSE|E?*HZ^X z7ZJm46<)>1uKT6Mm5^T6UZ&++#lzx{@N)O>FX)3>MBGPhdCBbU~_g?}{9 zrPh6KuLaIO9bfS&iVXuIli6Acsty8qbAUjBpFp5X;Hkh35D4)M1loE70zr~NAh2^9 zN?QVWfzVmW$PEM{dUXH50;QzUfUu5;l;vf0J=4nkJUpq;6SprpS)wS#2+6UPKd}54 zBu}U=Q~WWIfctY+(x0vhg2ds0mid;tQgHK|4!j2+4MB9+AG5MPKWQ;T$-ELEjERZ) zwDNmD`dMcNVq)gS;nnVXU)+lqK9#M1YYWoEU+>P;`r+c@Vu22>ZdyNn{1`aDf4;b- zRo`em0kLJO-#z;_3VoOBx;0U3FGl-^IH48EsW=}Ig?U?W$4&<|DqWUKXB;)%4v?4s z?@L64%i)rOkC~}y+VD)BR8!y1dooqVG0@OD_&+;N^kKTRi-=Z}EW`Z8vJDPMtI0CG zq?Y5&E>7SKV#)7y{^oRjEkxwV%rJmWtJEN6|7Qsf7U;Xwq{ip-$5>-=7;~g*>*_|) z*}oTFU5Bd`Pra~;q^YYH!Z|3f;!aBq5oEnaK)p5k)K6AcR;K&I0-}KLQ$D_T zH=NTN{p_$7RGrrV9XW#m(UrU7&k+o3(Dv;7#w==a5(GMF@oRAEoLS8oGPg#P3^)Ha zK&+m)c0Pk;L{lt&(pq;8<$L;+z%T%EP<2-9rsoYOwV{c~KGSF|ew$2`EKf%K%uili`_Jru+ zW7m2MAE!$vF=g3{Ltt6z>lZhVTT}5#52+|A|MNcEu^o7sWx%QEdv)WV+2&`FSwdEu zTRT^61Fq44?-{JBupX8AGh%W{=uAk(uOGKgn0Z_MieW*Tq7Fmh;}Nbitf!%=e$%3F zr)oa%mNfHS7K>tbzO&f4nGKCyFCFD)WikIjscN3=Of%HK<-}WcMx*4zES~!5xpP0l zpE0Yr>^6?`lf#Ss8NAE8nY)#Dv^(0O(wZ^Xw7iBaANj?^u|_$F56es%)_93KV8f8~ zEF&(O$8hT|%a{CFQd~lHF{}mF7Vm*E=6+d^?36I(K^!Rsk1DCC{HRK~A~Wqo=uvBk zYDjN^HP+swaD0h)fCW0;D$LPaBas3b-VSokTo=|Y!q?VR>sGh2XbVeV6H-**vf)YL zk|_C6CrThjv}TixIQ#TV+tbyY-tPOPRn>8^I?wY+>Dlq2!CGM@v(OmV*XtA+LxrST zt1&C_9~#*cXQh9{Is7(LsU2is=K>EKm96{In|JW>-$%>k{9_gszNbQVS3hH`Z1=&DJ?8 zsSX^xL+tDkbqW0k%Ih{;CIrca5}(Xrj*MY{u+iDWem{@UeT;58T}I7Xdw>WynjFs+DoddyYMKGVT|S`l=zU( zv_Y$j9$85_9|7hky{>6enXehb@pY-Sx1LnCd6;$4VQGuL)3tEV1cX2+@fqK(m1UNm zaf;IL4VX4Na&9gF4}=fpb=^G zu~Op|@yNU+B!@>Cs`XF#X}`&+I$FDazBCPXl}GdaQi=<&WknXTrq5X*--x*A@2a)a z32#khKy0I}VTM0P7C&sfo#^X7TwPiE=M~<)MJyne$GH4&)EeCvP%n18^iqQ?Jkdns z)?!&H-DVJt=1F&ed=nQhYaTtsA{UCLLwUx%J-2!}=WXj*{@C2F0G3)1F2f8O6Vle! z4*CN8{P^*sX*f;%IA0++jpBNS#KbzYtqOv$HW@232LZD~irrPGTUR-j41LVSm z#%}vk?bS{w|M-*ly9cfu?F>56VPRoR<$9(1P(wo+;!bj+V$!$pH!>uqbDyd|Y_><^ zJxWN$%Z0U))`&Cg)b^m+Mh+5A7T4Ucj2QwKcLdn;O$65+hA(~*$+$sAmlH8|60RbD zx?}k}S|}xkM8+$4lT+VJx}e;e#~p_zxc&MS`^^MoI4^hXOig;HrZ{K0`MCHSFB1Lr zv$Ii3&BbQ2sz+d6aE9~qPEFylR2CelagXLQEpQI!+m!~`QgKd6$%>o zwBx@pG1!Vp-%H%!a)=_mYh6Fx6bk2}8n&2~m81tnv}&d@%6-gOjS4!G5-xQ)?tj=n zAa=5F6wCydz6dHtk0$zN`LDrac9hnYd*KpuzM7kxGA(wm4*V|_Qz^`XvY)#_RIk`% zRvdI0QlXju39A#FHS)jSFjeN{GPgyd7IE^sFH)CTEWfffQ657~^Fs8~6;D^rTx!M} zA-rqX?A?V!NL|eeEUiuFhF^yZM|x4B^?093RY#wZM>fXp?s(NaWbNH_9%N;~LkOqd z8f+FH=S#%f5l9XlqAx6R%7@6v$P~+sYh#UTK3Mj2b?ILEw*E)3Y#a^821j67yd{2q z?a!oP^NrZ&m6qhsWM#oXEu7`_TN8G3p=|Kqe6}JF(8*`eriT_<7Sc4=MS4Q zE5A;ZiOgLl;4Vdu$cV1KP$C>}SXjdD7E)LIsf~iVu}*)!>`--Cq@|r(a45DX&Fe~d zPzwIO^dLzn+MalP*c1cxYxyVft5WT2d|>1Nr?-TXru~D5MqgwROg28;sc%JLCi#8c zae0qK^Tf1Pb?<7rBUfVLHODz1lI&iM!)b$T0jk$rW@b9CyxgX?6_|{(8pH~Z*%=`h zY~GN^`BOwcA!B`+j#)yj=T-uQU^WPZ&e%)MO$!W`75eP2i-#!JtrnkdG>u7p0l1=T+?F(r74W!olL)AT3}_l0VN&@1 zl!hBId&R09Px}hD_P@VFBkVP!A{8?6VrLhylkrO4`cx=u*}B>z+S}R7b;}yPP+f^9 ztMIQ5*(RFNE{DH&-8O80j_{83gpEx{q&JgtB_cwC`dJ27kCY6^pZaNJZV5@7lB(3L z?(}cA4;nZLE7!&CWW{OILM+@+8{bFxCNZG~;)Z-PMVXGH4P327m1SS*EUp+ly8EEq)gM4d~y+hmnlg_qTmKL-|3uqKwx_#W z@ABpr#=^o<7c-t*CrD2JL04AUh}JYV2ow8}wD=eF@p$|Cgfbx)dq^k0Jd=0YGRyR< zbj6FN%}N}R?Z+>WOlG+_^4zH!Qhn4=L65QMiZ};F#Co0HB}`;(qN!Z4qbv2XS_F;_ z!h|-qvJvkUT6;9OI=J(1LF>0VDK{6MZc2mrVv-`>QGW)Io3{`5Wj~rw<2}P`mt!_H zc!VD*4`Q3WX#|nBhvMi`MXDS%Sr*b!D@6svJiEdCB5Y+}x2V8QP^2?z1n)-j&L@o#hW(OTkf`|_z6 zt{}iaI9l8hU7x02c2z=E>ggU)P;B+|_8PpFmlq@AO6w@utNs#R!iMjghJ|S7WY08k z9uwCQ4a?=ht<%+gDm^CT=+h{S1Iv2tr9$$EQ-^%Y%TuZ+DkIf*G$t^i;&jQWIDAal z9m=bf0WlM|fLe&HgjGK?exraDjpL7sUJIMVetMDeFq*B-l}b`4LwIHd$ko0$y-3R3Dn3^o;mY&N6j^LP=NwaAucGy$sLD!{LmmfNkt_m#-pS?C0z*GY5%i@y%BcD9&MmKl?Z?)Cw(_$=ffE z*Z0ec4*b%`3nbmkjT|g$RCdRQCtEGW9*|y#mV2#&N-nu`0m@p6=pMhhS4`Lbci;)1 zhN7=E9XTLV1W~HcEic2IsX$b;$@1IH3Y^!Ka(3o$tTLS8tsw-_D+aT4Mhrcyf;6W2 z7cW)Jooe;6o`FDCMK0@R0crie70*VjuV5+j31|DcCH?f=>dMLM+KDD%E?D4pZQxAi zw{>&43*V+5109Nru8$*@-rSeHul$&-Firz3>Q(Hp<(m*a>APSxZ*RRE8V$>Z2+I{_ zPQEUJxNfeDbVnNeHMe&{_~2EFE$NZ^F!s5O2|Y9@Tug7PG&^Mnv0dJ@y6T?l+-u`f zlU!n=wj#S<&woHJc6uUwIxEq>7XVwe%?Ch)gvPT!O}4MDZGq|`Z`Spl#ivVjPUa|5 zMr&6aE|*l({V{)p@y#8(Hy8BNHU#a!qJEpE^>|@)Xe<|4rb*<@H*JPlqggb1nifV9 zLh6h8uyHcVaE>jH`eP-W+}2>L9VL|MK%oy<&YcQt>$gN`eQ|T4Cas zHs9MIx09>beRm9iMA^vF zv9yg85{-wb#22)R^W{pkq1Sv0^<8@BjeWB3mO!SQ_>vsca=meJlcgjre-?H+M<%ft z3dzz-RgJkG!(|Tda%DWxoqQd|Nb?72E1g@EI`uisn*ytKbWvL((6oJoIGbgJ9e(<7 zqba)F!+|dFbkKU1^Cyvscx3F6+`56cX+KLk5>+d-FK#?4g=k{~7v?7UuCFY=o!opf zi0)2%E3HAMY+j5UX`18Kgs`>3u9}xfMB5;)N{z1(&(+tv2?;YXuAjD;t?tlxNn2f( zp8-I=cr2A`5-Gw>iLzQs)zHFysuxVKXXye+AD0G z#3J=jI|3(_!I{?gOGpYeplY+*>K+RS9%4-0iVY#Jcuf2kd>ec-?ShkW1UG}8fti|C zs`D(pG?z!7VPQ>U4tMtt{=Y!lEai1RVjy+y72na@A0uut0% z0aur_u{Q%s*cUD?S1&G^e~J+duf)+bh36e3PPKmIe!79rs{Yn$Z1AmZW!R4iGiQHq zC7;Affpn=eXyem3?ujrLVd$uwxc&1-OUQ2g*NXp`?hWtlcODQM2S)|+%NHx$Y+PmT z?{I$3GE}*Lgoc^wxiYi*y98pr^o3;#14nYl8*=&;%)%Ecuslpb-b?^DfuDv;KtN=% zy`$VyIfPkBAbCcZ{-2h3Bf|8F&IQ_W4rh{EGBt5g&D-p`n>v+&p%?>Pofxe%*Gr{p z=8{u`3H&l6XP^ZKmGCf+{^{eLHnuCun@YOQ8PHB|O>4&Zha*2lr3J9v8TVqI8O`kr zx1uj%quAQKzILS82jI0KOBG?_1rd5*ZXhk2f_0A`pvtR~<3^5f?k9uGb<=M}vGy_=G#KmGvLK+6YMeq&#pt*>J_5{!$`2o-WhxrD zH?ViKv8|(~denXH=J<3t{n1*bzHnbIJ^@tGamgc`QK|H1hBh?P!4yd+La60WDRM^^^}+v5dfarxa6@g{ z5M-KNy!l@90fmsKAN#iG4fF{6cPgTn_Rzdt9?&;G50ZitTzG%xoQ3{ zq3{&#fPG8cUVQ$yWkzXI?au`a;yEWk!u7cM*HSG(sStgyBqg2zb2d-x=pSJ|SYStA zL7dTObhRpCNfWGSj)hidw&{fnxR;bLf@Emv3?fOPvED49fNkU-AyuwLw=n!f# zpR|*~O*}QmDHBi@6lADM$Z0u5Y$ZQ?F*M0D=2^&?MemsXm;xexCF>$=z&v= z_h}nMS5nVtd>FH^q?f}(MS%4GW6DYcFLH3edN$|RyZVi^i(O_#N>w2#&M3Y2B4|Lt z?giI=K7N$AKRkmwm^2lxAS+oBp=g$-J&jH)hibR7|D2B4YG3Tq`+2#HEt!5Di!2ck zQyW?3N~)(k-nsko8H!X%mamHJX+pM{$R^o^@ZXB3I?EYYh#9dr$4>Z!du@uDRW2_k zmY&rO%Bh4pnm{p$R`urj0SHQN#zutaQC z5!8x>BKG6Z7!{cRF(O=fb@^>x6lo#Q%P0)ft${yx4N<=o2ztQ){i*uHC96LuDi5c= zz^;(@QPPy%bLC5Ag&7{DVmO1nkj1~OBk{AZHnMp+4k;YJbRBn;A=_u#DJKo_xeye} z5PGvZaUymmXh78XZ%xKvZq0M^y7FjzRVux7wzY&j^~!uw)vd9h12Up}`sZ-&8Y%cN_7)&u@kTR7_Eb9}ynNATUlnZVr*0dpm)C zU9q-3AufNuz>|=enu%!Kod79|WS_vr`Oy>=98jaO8=%GuvDU3k|IYvi20%lZ`T6rv zmZv0aW^bTaVufxGwjX@dHP!Xnpha?e{B|s_=~2a+#46L^R0)d^ zJyxT}a-m%f|2FLwhO5opjQaT6v9C`R9~~j00M({=^p_wbckC-{dxuN-nW?|ah05yJ z2cJ^YO!ssy)0)%Ngh<(5s^Th*hV-*hJ?77_{jCadbHye=sdJQ%O@H00n33TYLM;g# zR>H76OiC-3eFuB8ymqVkEp(^ztRa|HXStkx+$6+Vwmo`S_7*RbZo(AVHfvLfm}bGZ z_SsVXA`J7Ao!od9KA>#oJNoh|RDJPiemm=e22S50`0(*#!n3)eEj-Y8rNx~Xu83J~ z9+AAaDR)r&i%PikVmpK_QQ-CWFsYO-Q#rU8-I!8}_qSOl)ldnhExN zVuu?L}yz51!zX$+&|Amby|6~k{Ca1qLy><0phJB+A4Z6|3Yp^ zlJMQH@n{SggZj3Cp9TZpZ+7>cMS>Yl0psPm;^a1IE`BP^(;G_s>WK(w`TQnVm znUu;gdj_h}{O!av3gYrqm<`&zfo$x_r;wv(D9#vNHvi|jP!n4W|=M@3(j z)znae1FY$A4J&e7FCoW&Amo1C>Q`>Z&(SX zLRT$kc0lV5t0#(@H*Dr;xeIG{3o>Wvy4?@nImh2AD%o9dbVhowVS zXRJsg;YX?^mR>B8HmG|G=R=6taBAT9AjeD_#v^^;sUE}e&6j{$^FBJ51$4jz0kq( zQ+!n|!~%WWb&Lz?8|)98e-+@6A;HdrNd4n{HXPDz>`o)%a9;$o%$~O#bzTVHC@1M3U*qNZtv% z$(EIYS>>L=JM?t&Q2sSsxB(Rv2X6KlOE6(QjH^Rj>SUO2efuU-L%q2@%W^p8ziZ7QLQ)TTOS;E#^7voai ztjnv0OsaEKDk%S`8hTGZx78I#*Cvhg1ohwQnvzhSpRR|1%lbRU)lw7I=~4S2{qv?l zkFnGU;YwWH!Xy)4U7K=~NY%@ttH#DrDbb8A2(muU1mU>dGaDr!IvJs6emJ8lxN4=U zXko^AxaPUB;vT_nmR<^@--I80j54=x0Hk*Gmi>9^_1+G&74tOa=~9!`M3LHlDGbARM9l+?aO6c_GLdr@G!%Elpc+BY|47+ zNWpFsR^t{J zDVl)6kgLY-EEp4KDU8HMn(KAL)KM&%F$7ODtkZ5tN{FLKpoj^x#hgB{NQ+i3iRP9r ziI{_%-4rv2S#i5ue#4RAdIMK5#5U?{j2E!i9R)!@>avA-;yX!-e?~h%9K|u zJL{ygPA9^2(wiym88Y0W5`@??RW5oDp*OwVfo!D}fv8P?N7>T@-O&+sj_}tB1RcWA zoWW_Ogx-i2jFs8C03>>?PRJtc>x3y+=m*jgmkwc7Q}t{KL`!gm6hN)jHp4S*+C67#j_DPz|xyf;3;8J9Nd7;MA2UX?X=^&n?w7;e?szF)QatM-1&y9~E)vO5$ z+ys;{;&je(M-2E`@r((nMBWVQqz<~NqsujK8vEqmEv&ipHu~vG_ubNe_T-{8UDRHU zSxUqauMkrWltWb4XAheUwbsxSp*BE<+{BP3iR**TWI<~@D=Vv-7W z6>nuEB!ql>VPm!NC;Lng&4^A2TE!7Z7FBSQnYB9Ku3jq&CRBqNpo3Z0MSC#!kyVL& zx#Q`&ur=HkS;)uUClw+;JbSLWvC#e zM80luCdipp9+z^zFtFY*zVe_&n;^$Wbh*O6+oohmkC!mUrEv$bs=L||^ck8iB6!3w z#%M-eGMJ|=1=~0FgP4^(H}N^K?_b-$VRPXVaFg`mnPR(EF>iK^EMK!S#K~m#1I}x% zw|jeMy1L5y)J`o;Y($tPf0=RvWAzb-&dd~bku;j+#-+(6m3OT(srAyck4Z%_W&3sK zkfdq#@rz-J;ErH?)+bM}!^6WJf}RoM*qI&MITb&hstb~Oz1f-~Z2trTp|$Rb?ARE| zH6qq~mvdG8JCTpxmA07_Cb-$HS>j8v<|ZuHPgqR4xe{N;Rk!s(vYd;aKed2xlYJsh zazoFk{yIw}W^Id>xEQ&0ppc`u?&_I6lyIgk-axyZk1Rg&Mn??Ts|1_a53c)WN0@Ba zS24`njS~(Ela4E$iTFvAY4-@J6Ei?4fus6YfW3JL^mqFERZJV4vFJn`+JU9Jto?qh z9bUo!YI((m`(=eKMMkJfEKJKE%IqS?tua9(=FH6Kxqt1j~l(UEI>LPEzg;;!!QY^V9w zhTF?+LBy7>!%R)MaEAKur<-zu`d2(b!TgPLDFSGHV6LVs5DUk7<=4ZNm6iV<&qX*w z+;=|z`lXay5Kaecz>1*eqgZdEvhlv|)0PE(K?JE)jy9h1k-D zV*j4d^hQ^UgHPbnimH4dCrL)RI99#$}$5p*UhLOY$fC^Gu3J z`u|r&dZVwhvT_H52L=oDZ>zNOyVvpiE6=;z8<)GAb5;=%>V$-Z2H%SxW?3nsPDtfA z4we@$9#ytpmVCxxfWT!;P3eL2XW*P61PC4_HT8@{e}8|Cpnr_$co@}V?w>v*f1o}{kP6XbE-E0 zjDDD{UWy>nAAkE4yVLn*CQo0wXUfu>#Be|f4VpF;vrWUU3dO};?`Rz z%Mxh=8hCqQwbAR>c(lTHpMaXC2@N_qf!8?BQm2YIx}tmeJ~uX+l0#|HSX!u_a`F;a zbB_jk!fo z9o_L2%q4DY(oi2m&S$8G$)KpN`5$qVswnK0o{0Y5&9a`E^7_AD^77cO4||ymQSf3v zUUqg|VE9GW!GV{QQQ~v4PNCI&s~?a%RaI4G6QZKKfFY90LRtS`Hq!UtQTG7%*r&c5gwiRRsReSPj={CQm-Y__&wM$ z>uWAjv9!V_q+o~TGZ!h_ksMsW)qa|qGVDzOvZ%%m{h+`$M>UE6DKBpq&;*Lf%&e^C zS>4uuiFsyTG6Lrtv=vnw>B~Br5bjHBHurR!MQl&h3b@)bRV3jliankFR{s|mE&2Uh z4ZzHu*?O`UHbd~h&dRpiFTjHqC?F*bjn}|o{l}FfXv2ofgMM9qV|npLw0KEe-Ua1J zqZjwxQ2NVUz=Mjp@6a3_9g)+~MU<75WnIKFh{?E&h0nCp(raxy6!9FS-J(Jj@avHO zja>g_DP&zIvgM1ifsrz#X;A#a-+c9eJn5Q38e(u1jS97Q{xHT)Wrx@R#p4L6C>m^R zLWbH*F)Gh{ySlRcuFin{0T@BX(lT3e{c^ifvBmpT%h>qK{XW1@#GSwkEPuVX`R;d_ zfQ+oZa$F|^;1fAuy8zi5-Qsip1Ykm7YQvop&K<}9eiLw=XKyAe%8-CH3`#GXt+fOZiE77B7ePk z_L}yk6vx;XWq^F@K{7L7v5up83OJAW)j9x4nTIbDp zegzEjSX&H{*6xX4-uPPpi!odjw^q6;0@*p!g$8YGd;rP?0NDv694mE4_ecX0^MD*A9SRKBHdE6OFA_qKntpQN!^x1C- z!(dFq1h>$oVI#SUxjKa!pOHvBEyPRPtFzrRU;B#gI{+XI+x#VAtrzdRdU}@5=L34D zt8GTw0s>6t$H#Tc%v)&JhtfL%?uxu0dpXPNuoTV%PMTf;V>D1G^iPG^OMrWMerZe? z9fc5q4@Ol5EP)Rf;oGi5TGIR*4!8{lvJK-t>sUudD+-Hb3b=jK{DT4&>^Svm-M;uuIj{_;)wOc=r1RCNj5)$;QtE*FWj&$#TCkJ6L zm@Jy=45~7nLef{SUTHz0ALsqANbdguh;s1KcgW#y_No+TsZ6rFW9d8V-Wc1I`!tuj z++e-_w;YWW4z(1^us^x*tt;DayOX*H&Ea$ig8N5xb=A88YF%C31t2}cY*Ug&ofy{! zQ^SBzvWkm829$|>?(3)+K%D}tcYoQx!{y|c9{j+OY>3NpPqy3o(C5fVdBA6BWJ*N= z9MbRauEiia2YY?H==f zy+=6)xab~*$q&G>>(oF84jwS57+huDYqj?K+ppnavL9_KFM$8{yFBUmLdCPwm?k}r zNGsQ4KzJ*kb{pGEF2B2b8*`fHH;~8|{An!x)Ki=_r^D@JLt6-jp#c`J0I)`tRriCi zC6jtsx5e!dI5iPME8_4OpUIaTfKf5GHDWt-Nyz8V1ow0T=m=o8G1!vk{`s8TThPVZhrn^I=jCRh<723f&f$669fNv`kgSo>V^=q~do>I&7kq1+5V1AhK_t)|*xW3E ze>YQWpKHFaigv1Dhw%*yX?FYez-3%EDn2r&oPccoSU z0wj)qzxeg*m&|=~rb9w_3@WD1K42OI&3$%-9WV_K6%-WiB?hpnuW4xxqIAIO&)Dts zq)yx5iEl2XfgFvNx_tVCmDOeOCoUiySpee!%)xg8Tz0UhC zhYTpOML^}dmx0;=Ob)i{K4Jx>N%_&{=jRuh)bpo^IDX;G@Wd9j8*c|P(|K#0i(bN$ zd}L(Aoi`J>3|K1C)Ge(gMQ>2v+ZzH9+x;bF7HxkY;)jtk{9GMKdI+Qfqons6fCyxx z#hL+io-E}Dp{sxV`0)pH2^FA1@0F~>-{!{v2FUpOO3c(bnxH+uIh(2&^@9Sdk1bCtA7Rn-P4^ zxOxyf3mT_0jtyuLN*c8p!sk$neEb3rSTW5@cU}O+LjZKFin4h13VRQ=%@1rBGmumJ ztydLOS=zI%brZtxwqn-46>K&vtuC9h%7z!srdh&m_)4?^EU2INw-;5{l)7L8Hs>P{ zmG7iW@y1Q=e0!}|Fk&VtdH^g$MV$Ud0;lMv9&-pW?~|vDeka-(w_(YK-MZO>oAdeH zA3rF7!O?bg(Yd!{4Oe?jBSqS*fP|oq4D@SpemM>KbOg1uA>0 zEkK$v;MxVgJpqhXX>>Jv9Y1s5F?5*v-tnCOUM&=6c=!7oYA0k?y8uSuAp=7cz|fY< zJyC|h4c0RRh#Qu#Ozb$Wv*d{Q-4v4P3`DnuyTlzXD!%=G;jgoA(o4Tk$<1SXob z3N;w6)W(F=F+h!N{B3#3&BNnpba#~0_NG6c>%KmLQm~W$S2EMNe!SSU-`e>nTW+Mu z!wGqVXa}Ex?^p_rWUcf>v+XJ!g z?*TAZ&))y00aA}T7@w?8^NaV%Mi8*_xjH!j%|}Y~c;e&Z7otS}wgdI)IBU`D`4C_& z`Tf5wRhFH25|6HjPXJ(NtFU$F9&BP7UjPQ_S-`DtB#j_8VDx(cWQzoP z3-=bW9^*;`dUJby0pxO-U;aAK)Nrpbsw%r25K?cV0g9XeNE9)TJ=2(gfZGe3GLw2z zAg+L>_z5g?H#Cl##+VpLW1vS64FLZ#pn9f$yd%0N`MD+!R$gA9eFASliO~@0S*W@c z-p<$BgMrwRQ&Zzz?fsVq8B0c!?7fYe)m$(7;g+0h|AnnJ){NfE=9lm*J8JffG)z1P zTH6M!65aD;Zg>QK4DROdrR@}V0-6o7Zj)t3IGsh8XM5GdQV}+pe$@B31=R0-$Ks`D zcURYa`$JZuIN&LAYv%()VeivA=*{>|_4~nB+S*_JOoUiiIx=j^!=s~n%{=BpR#x5u zE_eyptdX94%4l6Oft#;gowO4Q{Jk&Cxv?0SH&O^SKIkP!(TQ4Jo^1Xa7?At$;VB@k z4(r=)Nap>{845JgzX0wdLgDaUwKjWJ-T1x+q`yB9xcJ1xoaScwQvLEDSX0l40c(T@ z@Di}=W!NL7%l!#FVy-I${(#NqCU3he{{=V)YMbE<1>gggmY0K18k`rg?^T||e5>SB zt&bFBu0KtHFBt%Ghg=lnjH5Qz1$-_5iUeaqReo3Qy88N0BqbTRxVT7Z>D5y(v1GQ# zm>dP7w>CCFvRa}`^zQowJB@Uru&%;P|1W^szn4f1FWrIm<-sVt^tqdj^h7K0v@f88 z{{ivb6}O)#lLZz7@TB8Qno!I6SF<&?kEE^-t@jC~V)^W(vRvx$DhEx;k)U6x-1cJJ-GcevAb zKsNsagofjMXx`cEcS{`oN^{5KJD_>x^6$VB5Zi!Z@V&iQKTEmahF&BCK+Ax55orP1 zP>=6*2;i7^W~-^A0br%OyV(zT$jAtr_Xm~y<$E{RFYSF&ZdRl2;`vs(@o~g=PhnaV5SE;Loo!2(1WO|Jo_I@*By^#`@YF4t5V2T8KF>_kNqk$Gb@{vgvWzN*+i&>?6Na5BYR{; z2-zzkdsB91e&_Z5{Qh`XFVDE|>pIWlJg(z7?p*!diiC)jYr^+wJ~%plT`x7`{-Tz0 zC-1&cceYl#_)-~6P%z4@Ef>^H4H`Mz25zT2@7~Q@3JRAbB$)2qyZ58Q@iJmYpygm5 z5uYG5khx*gQw^L{jaj0@1yZ;qt9wvj%DjQx>|5d#rU1$c>OK*R9nOZoC8s*S?w>nc zPckU^KR#Lbd)dM0Rm zjb|K4O-(H=xa(mc$h{BPK<tMjDKJOn-&)L}B1XBB{Y?yh!oMpOulK>yj;@F3I<{jA~7-;pj0K>8se zWS!mwpIp~1ubNhiDT(HlAGXCH?fsYPr>;naAmM{}8VewN&uoztwsjO2FECk|CO z-p{ntpO@L2iIfwmbX`v!>&8*p0-UfnPBA;<*lwSzT*hyw6cRc^|MCd{XzASvB80Ti z+PwMjwbuzrt8ZtZry$o2Tv@TBWD=o7 z795!5OKWc>_wly&7T#9`nbRaN=x8C$5ILuN$1KR+UvP5Px!3=8dQj5{xRqPCZm~m; zyx$Vq1Qmj%%IFwOK?i<1`DYeh)kNLt*OSAWSG7I%+S&6AjU)<{CY!>G;mu7?%gqnu z-8&~c5Y3J?2yp=a&>7I$q%7rFB6wF|CS&#R;Uzvk3cxxIFjIbdJizPuckF4X1C@5= z6;`yL;J?_=RNkMXOR?n8tP=1|_3RKW*-I=I%MV?QoQPCg`z{WCDh&`ateE-=$7#)b z_g(_3`MHUzH-PU+;0S(Hx#X%Le&k2B+f7EHN9J{Xxmj6C9yASmF-vZ6Isyzjw1pJc zj3-ZmPnZ9A!gsiYDW7W&3B1)#Az+f0k=E7G*F@PO&&WJivAF!?bz0MK;AixHJx zBf#Vivci{h+7R02?l)z;k?6DV#tv(;2Rsck4Ag?yQ874-`BGc+&15mcDRerMb!c%} z!M_b#*V10KMM7}Rs0@E6hPjJ}L)ft7l=e7%dMMOwi{YXNh)NZ7T{ADS8>WG-hae=| zSB?FGNUbAD^Lhd_vjM)pa^*_>uNse4YZ<^cLA$%V>=O}j4CQloVsTJVc7~PV2*vww zU6D=<&Z`3R@~$2*y}&SJNoqgaI8jtZsvdGWe?8{WGC6g|AJP z`bI=Vq?AVFMcA3v---_YH*b}(L3jRqN=b=MyJClGbkO!a`wKPS{?IXv8V3(b{HgYb zl}rsyO#+;DZ{EIr0caPsY(x++IaF(HUy~=k|C~2?`*tkUTbPY69Q^%LzS{rELLLf` z3bC1RZKe1C;kT4&(l@cOK|aR^;{Z^ub7>^K1_Cz)>Zn1P9fNPqyt!eS-6`nPfQAl? z;-a2F0m4m(h+;n7DH7+-#BjrWE2~?rMD=PYFtGTU&`N28M?SvD!$z#)zoy0cfSCR> z3#7BHJ%f`~R%Fzo1LDTT-`0EfUQFTc+@a(<{c#BPO(XC>Z^FYZd^QvFb9HlLt^{L} zHyL7mN(v_C?uv^aTu>NT?Exa`ry1oZQ}P2qDzB=R?Jf-uEL%tCm)QH{YI#+ddLmLuv+iTVYd@p7hVYK-Ig$Qt^2c{y_e_4)*>&0f zOsj469$WrA1yxost>(ii;#{pO_3}GN>DYi5|ny{KF%RX8( zR;D)zDf|aRO%GOtDs4$zVEdkWfym~Pj4pRJ{iJ2j`p0GnB#?z<0pJu2{QA2S*0ZjQ zDH7E~g(DzvbaYhG&?ukJu}w=$J4;7Lm;Ty6b|d#Ure1TlOxpUv18DPv`uWC%b(~fo zCp*uJjivjR&A;)Oyz2Xo%di|90`oNtzJx~ye-#16FdsYAZ0j8Y>_o06ppy-Jp^8(p zg0eHzNSIE5$M-LcI6#}SV=zCx&7v5i5P$#%004yJ%(rlMDY2UV-b?AcGp3P$6 zp;8$UQd{{|!$i+-EDNj;wqla>N&Y^&wBoq!-G!L9#^IV_tMI?)yOI@e-FkkFFC-Hf zCPjXwCf{Hwa!Vc2d|Y-5ph;hVE{Hn5e7R8$kypT)z()gIdUfXy094oIkL926i`p%G zh6IR(-En?J#VB;f{zF@GT-+-ln3%<#h~q^}J*50>;zEB+{u0Po;wH>);+BzN1NNBp zYbon1X?QU>8h#TIVd--!s5vNriZ@xr{wF&eQXmTFlSL!*o~@TD)DLj4!br117Og8T zUYGSGH5sOaQ;mYSjhWVzJ?s_%an0GppXzP5UnUvdY-pX&aJ`EW3e+7QxG-+L8F{JqX>+X2P+bfe&k{-{z(UNDVuaq2QHo+_7mJiD`BZ;5eP zZXarsaTWd)8lYajbo3n2%4oM&^#+GMBfl;-Q7SSbBHt^w5M+tm>Io-Z&uWWiIcJ@a zIX=7z$BYzE+q{qVEr2Hrokpw)jD8QyZj8cU`sc!0sLs4{N36?1wn7NwqrMMROM@RN z8U-~0ZQcP&8UPZIqpAkB>OilZct!vVvHMb5z+fPp3P1%#q$47)?zd3)gaYJ4VCqO+ zVB)7o5CUz~ESMk9x?u{VWEKNQ;aRy_UVeTBU@hTgQvPwx|F6^B1&%ZTq!A;B#YIJ^ zLjjhas{co{vq>PU>l7Osdl_6p{0|4w@B-*r3znv>Sti+=q$F&+BH55EuEm#Ea2H6S zvnPphjZ~uMqZN*!(`e`iBqiv!VI@+@S)XzJvheqGT>X#cY?0l|?~0>(w< z`#F>Q=2CMPJHjm!nX3Hw7Mp3WWxsJOTHL6=He^$58TVnD!!$acPO~k3iJR6k`?{|E zte$h_f1Sf$B95n;g5`G74nNYrGUWD(kQ(^Q&ZscZ`l``8{ZtI&e^naWg+}M(rzx}c zKWqY#u;}{176^Udol27Tit^Q$1J7${`{&x`WR2>~!6dw&Cm=>5Wqg`_L0XzK@85i= z7D@ln&4=GVnGK2q6E_OvpP0`-N!W{s^aeIP6&AH>%+<4IXR=7sqGq&(S@wd6 z-Tw;8_e6;yZ`U909xh5sFOIey`A>;?oranLI)wgQ{nJQ7f`}=A!^3~n3+Ll>Cx)tA zZ2JLBW@;oi0|CXnpPf8ZU=}ul{Y(YR{VO7N*t~ZM;k=~@G!p1IZPaZ(abfJUDD)1d&DYtP1N7B28n_Z|MSy@?4ge$Usd2>-7#;9kouF+{1;{He7oLOXc zKf`eiA}N71Yykl22tC09P6S*5Z1~L}VTErFUt*xekB!CpcR&3@VN|{h%?s$HdNgpu z7Ji?SLf~D;29^JcfIxY{CnxqCY8#pIz|FIwW>O+RcX4oYr@`T@;k`Zz;kVBxU0#pVAay zXoYPD@tzVc$IomFCB63Xiuu|9^p3XREdcYKhwp)4C>+{h+si%ooc@Q=N1aat*h4q# zfBOj7TR4_j6^KTBI~-G9zf-)VxB|Xy$HiXGgOEMr9y6e25#0%A0~aMEM%E?5s@~3& z-MxDk9niue%84zaF>x{Aq_x<7n?Iqdc4FuPj^u5c{C%wf|Fq0TlK8oS@snkPjvW{6 z^DDGl*87Kje`@qTn{M=aQv9+%1=yE?T>WVQpS@raR#W#y&TD4Nn*hB^ohuZgBH!6s ziVvqND9@f_V{Kw+8Mhz*LJ4t>Dia z|JjS#=2MYA9Eh*|Sg=`U(?<$i8Jyz7-m7m8VbZ#H&xXErx+UyBFrzz0)w&YscolBB zFudRyR@UDYiSdA<5$6cIQBF(iEHJx>+yPLCK!YnVROs9>=wtA!S8KDeW>fH4Pf}4) z^_F8cdLGKnODr3}FC}^W7WV{EfSA|55X>`ZI=!W+I1TKs>(Tbmb)cGpzE>$IyyDCc ze_xb!Tse(SD&W8sak)l=fb)??4s~QWV?#f!Wu0xK9j08QWS)ub0zGU&^#(aY3;-w5 z5$*Ns*Tr0x&j0c`_BsUq{K;B-dwV0CBLu;+W98)J1j$TE9Y>Mv5P$Vr1I12xv|8US zVEk$iH&j6i4~^&ieU!08VY@O0f!Qw|bMd3_Q!2E0H&^y1a4{|&bQg;SMiPyQzT%5A;)%*8eQQ34RGbA{0vd`=&7ru|wrzL$a$_Gxsu}OWpawKzC zfu=aVDS0E>GIWX4A~$waOQMFVyazv7`M|;>AP}fKEXwMFlcM~ZM%8oE15H*1 zuJSfSy5&a&pMPV}*`e9-eyoxJplx6^Zo|+AJ2(*fn0}tyKnSfme^qk(I$ZAn5E&2< zc-EIbRmHvLG@qE$l6yqcSGnZa`lIUk%!0F%qxyWraB3@wFV>*sP9Jd%nfe2qGqa)* znE^gY{b8#J_1@&zd{oS133YWIg?^c;z$N+l9Ov|};WLw=%1#E(*){z#ys;d?Ble!U zbS8PdZ>JcERnj0iuKmA%!UCz7EJ0hhPHE8O{2=HI;RSXIiKDU zt?-Omi~89`5xVT;*Q>MWbSP$;5K>u+-C|BYyF#?R3#@*infKo9sKj7fm*3F;S4M^t zq>b-rjfr{j_!I~aGV(*3y_H)!7iS5e%1tT9d_{t}NK$KL2C(+2hB zZIeKoxSc;_53H=-(rJ~~f9SkRqJMnXqB8W&P1X$65ZOgskyEw!9*69kK_%Z7Ds}75 z(1?EtrYmKq3R9NtQb0VU#jxPcD`AqE|FpO!zg|UT-Y!Mrw+Mc@>BRh$fQ!^q<9Yw3 zZ_Bs!lojnmZ*;l4zn6QE>g-Y>H@~>n(4MkE1sE1^x(qov`5GL5bkw?JMe0W+_sKmK zzRO))6|2>_3yg4<{XdZneD8qhRthfnaQQaT@@jme8OC@yfW`+SHH!OrjXUdV z)Q8_<)=Z&6M%7h&8l?=}5{d#KfLI+Ve|jjAOI>`G1?I zP(JZ}u{w05gZc(4bs!D8EG(?M^)e@3U8co{>vqP4)XLUb9ANl0XfJ8dwhfA`lr{UB zsQBr!>+Bfn=Y5|m_lqQiGS4~J&M#nGWt(rCd@uI4z~gh-5~`QHvuhUvscpr68YlF4 zPFao#HXGda zwgEa!kOXVgIozOB=g?04@-y4mx+{+559=?M^sXwCJK`6;d2A{!iZ}CVm=NGX-+Q_c zHi!t`xqOlKsrzdpc^*wX;kRJ}Yoc6sgVUudC8Kd1*!;HG;*0Ydq73Qc5ur=o1=r=j z+&)2}>z8Ivmv;%~n=1Ewy$b0!3FoIx4=>T)>Ew?Pz!$dBT2ga-bStRYVT9?X z2e4_+hZ38~m7LPb_U>V`3?JasfmN{pP;F51C_IMVHYm3uuTMVY^pd`c{zmz89=Ss2 zZC*P1FlGg%CsQ+Pej203r7u)HS$6y+@|UgBCGp~2t$eLqJ%%7ELkde6rQd@z7G#A_ z4xbN{*^dzDjJ?vgzqp>dqkCVWod4u$B|8;cm)%Ad{Z+-)YwJB0i~}KU3fpNn3~WCJ zEgyN;b!C}+F6(++4OiaA{Tdz3eSr>2>+{Ojt4y89ht?M6K2s&ixp-FYCD@tb@y=z$ zdZUIWg@d%WLc`MO6aPH;cuV8NjZ@~Mc6pl~E|zbN=DC8(Dg_CDZ+H120qFUgpY+J@s`tLur7KH*Qs+|oEDe-M)sGeO|A`0p5p#vY|Id-GiqWoNnND z!VhoHPFX%{Yi=smiMnE@PFJ3gaZoD#P}a!$w0nXjQKCQ-aS*KzEB{knRyil$^|qd9 z?CD;675(x+={$`gRT8}h?Y_J!nnyj+g7WlFb<{f*@t-QX4r8+WT`OAUhZd~XQ*S;U z6;kV#n8NPz?hUUqd?`(c)cjX zuD=v3@+R&Ce{Ho8b%MHfVG7=5y?$7YcW^YtJhQ!+{U29xSYEiVY;REHaK}*SG-=_* zlO|u7v1$Qr@jvz31b7@uHqJ>eiG&EZmK!%V_UOB;zl!8Ntt{|L3-hk#$A7(A4GyB8 zan<5Sr9zYr;=C3T!8RwcG&amim2JcR`$`Re*elPzW;;K>C@os2^5fbpgQ)&v(5ix! zL0~qWI@fc;NpZ8Yf#ces;f;uSHW%@hRt0Ay#Ufm05IC50-@fS4`Nfp! z62I)DHC4GBB3WfeUT*6P1fe%6ssbtS`HSSd+9ydd>LR6bX-y*^*EG*eS~>G2MTBJ4 z;MKw&E1n`qcMZ4epgPqrmYm}`x=^(qj(_9X)|9Oh3*NG%U;)N+=U2qMBUmaVXJoa8 zzGZbp8ikX;x=;1iXUAU zTaLj#E=mT?i&?>h%4RMs^GtEYQL+5ek3zKPaqIQ7>_>P10>8YMhB3%)%Z>k|a5saq zDLjgvdFRn4>mfbKsHNUm*J6EBv`w6xn&iO9vzn7JBUCbvLiEZ%T37q#{#ief!~blN zCgy~s{+$Xrq6&-6&gjRk z1hWBgjHY8Z-Q#E@iztHs^hfi$XsB#(wvQql4=e4rW%TW`t0C>jEjv-SPE&fTSjC@C z1uM!-gk9V4_YQhYrHk;3G{sH* zpmy*JZay<2nj$%{tLh$U6nvc$d5*d`!}ZN>j%^0xx7LOM-Rv^4Eun%Vt1|?<00d49 zdMRRhKC!JGjd+O}f)pk>U(>K)aZlz47d`8Hoa5(3%u>G$C#K!DpI3TvchE?* zg7o&R;)C?%D^w=Zs?wWNX5~MoowRtTnAZ%CYjy|D%Hu@NB#ib;o37jmuGD#i-%!y% zs5wuWLu5_1{gzr^+%RF4b42SGZ@!ktg4NwUcN5jUs>R!jxb0qdt|E+EThFvrq+s&W zt^0~ccifaZVvUL$nn)Fg_L-yTXJ)>3kZo1ykH%Pz=O$qf`_HP3Pv53eNxVg}MczDm z(urfQnNruU4FXBeNBc(iflhi#QeDO*MP-?Tfs;Kp!f;AXE}4a^t4q_=GKR!-iwFrsKaEtL@Y@VS~6kcQipZmK`k0 zCnF}aL{2yRhbz*G;$`xT?UK7L-)-d7GlDRx#FA$NME)>E~$_>j4yWzj#A1doQEF@)$XVwUxTKNp~f! zGA5Z?W8>x@?r^&=oS)SB6V*l;C6!HWOkED^>F9b(77mw53ABk)HzEU>&M4Q$4&qv8 zNO2gcfw{q4>Vy@nx0cJ)jI-^2#rDq50IA+9hfe;e!1Q3F1g9c*UaqgdX#!n4Od>S) z@7O?2!B*Nq&3D53+Wq=YwKW2@?HF>I5Vf43^T|47Mo%VHxJoN^G{xU#xaH(&zwh_b zd*Il}WPj(BtyuD6+y&|&&bWy0*ng=-OO~3w?-~UPgf-}oK{ui}_DN2E-bHvI_|B+g z=T1Z@wz8|0vnTb&A%h{-rPJLj_ULu&{*F5q_wpjc_g{Loh4q}=V}rtuquxtG|MouW&w49m(&AY9d0xepBGmHV&X$MJd{Kx29-Q@nHKF*<8%9Z zR^KwQZHo(u{=&E|oY`#Qq{BP%tv(GF7x3<$t%hM-e5$#nBlc&4;G|e#ZYck#r!KYV zk=UM?6n-PCkxvA7Foxg!uP?ZEfWefUjxT=gPFB2hmDsvpE2^6_Ox0qHfv z(^={Xb&JfRNpi>U<~eHy-9)SZYHQT&4|SCJ(8=SMKXxHV8?9Apq4!nHEakD94m~j~ zoxgyH&J~sR{gtLdJd3XSe%|kCKjk4!+=g~#UMVJ}mqh01D$CvSYw=pYAZm!@6>n}o zJSd^!xIdQJOq6{BulpCiX6K|`MsX=dL&~}L!FdLRdN^bhXV4x!Fk1-+==Ozou4yxB372YRL;1_$H_r3f5Gv#5;yVF z1fumEs@m4c1!t1(n2ts%<4&7ijH;b?@W&|o){v)jF%*$34^ zemdR|Wp(L$Qw?!?ff|vSX)lEzCCUEl(uh!;$q7*^kn_9rc-LOjYsoy9<3zm zioA79X4$_ry%yf=I94dk=N5AH3{5^iFPe8}o9T;LW^LCxtH8^%`rq*KPH{Tv741=j znDU^7{^JO{(VK>4h3sSuUJ4-_=@X65&L*~e49BMh+#+YRxuzJr#6wv7Rh6DdJ3)v0 z&A@%rSC`5=S})U*FEK@PDQe71n7p6ZEoN^X-HCd|*3uFznP7)?oO!4^<{lc#A5$T2 zEM4(2I?#$fQM&3d5*thZ--Pd5I~)+BF%*kyg&R<@lsd)&%Lj<1y{+j|$7a@V8KgA(Gzds$-o`>=RZiDV?PW^3hra z>bRmxhRVlIYu*F*sJ)`tI?_uxlQ8Dtfz)sMXEbN0$EWnaOG$z?zsar+`6O1bVAuq8H-I3+Zj-L56-eg3A4us>6RiCFcG4qLOC$xp9)Q_7;} z#HDS$JHu>qIK;&mN4-ylU2*6POArue5ce^ZVVH3k?6Vd%9gG&}R2mg|kjnUu$e5a# zVlOrXXvGHaBl$42Ot2Qrz~2S&C`8WGd0 z^O%m-OI;C>G#!pw;aZ=bb85-hrg_mVK1#2Cg^HicWoPr!O~v@Mrq!ou0_?_o3#|sU#x7GE?=*Vp!Ies|k7shy3 z?P?F{asOF#@8rfhX4o2V9Erv0=!<#|3KL+TM8$D*>W@y?sZFa5y{@nb<0v=MU~j!T zK-w7?L1{cTx%MnFAlM;4e9AJSHwT+&;hbyE$6U_f((#J?7xRQ_gR(47$NLfvYY$Sh zi|@qKoF~``iOFJUTo(vBZNE&Nc*9l5#I9+Wv%-AM1$=TV*Nh(XmCu|%YCXIf{#d`$ zI;T^pOlRNpOZ;>$5gY$Qo9_#8q{}iSsyrY3?YO?BcE)b_7Sq$mCRrK^evI<#vH)f2|N>I!OC8xWu|^1X5?BZGw&0cPmRO>%fZUcIF01~;Y`9-@>`sg;$OT( zE2k2p!C!Eh@!N%fe$_S;>4_-I`z8*f~l3gK5WLYe8g}z0^FnD0A9!hB$UV zd&7A>%l146CL#}@F;yTwVWWFbA!rCIuClFt^i}w}wwL2FOS*lzVZkp=TyCtT#-&5; z0D8j&lRUS6ei604Y-_NZdMQMf5k%2vX$PJ^6n~c0HyX#f_*Q>nfR>Hr8vpfltDQ^b z!W>3F4gy4DmKU6xzUX*AA5(Q1zH>do>iyQE1jn-Qsg4hXzvg>=@_n^F->sfFoG!gi*-I9fTPH4~ zT(oT2LuJe1VLMy!p&Glb6vRaO*Hg&V$Lac_@CTRc>h^h&0c{p>TniEh>Dv&4>A-T0 zu|S7DRPRjL8Xe=9u2I~n8soSA)7UY z=+A`T%q%QYyvh9&N+seRl}$HW;z`VIiVantKi@e&+TB$5RsMu4e=P?ivy6bP7|Vbg zwk+lFihv7M21CCqyO`+$F8DorjoLpe-OS6|AyG`21X#eKOA}WT{|F1%y)!n~d_`zN zbuONQo~3dcBZc3I8LSw%EFitk8~;4S^l=SGaK905y{U*t9f>>Rb1Wg>Gj48rlFR$|=x^b@&#;fkP;)KYE)z%(Uw27XiOr9uOtB>%WxQw;${R_KBb^|SE$8|Yyqecl@&f0L z>x(3%QR67*m?2M(r>wGnnTZ+oO`yA~*x>*7Z27gO%n|QXyAU`^MJpP+#9`kj95Jdx z4c(2;30q5@2q5-*tNNtvVc-OwKI| z-VXA@*FGOG&MS1$-OLbx$D1h9lxZ6+1b!W29Hdx=I482Zjv|D{>*0*-<^N^=&UAj{ zv{-*%GSFz=k*1jqpDj%k{ltLB60jEFx_Wb$cci?Q# zHFv}~^z(ohPjiM?^zPE*+Z|1zj&CJbi%@NHi%t(gQ z;c>woR_;~(ccs;~>w3b0SZ%^@p?eE#(YJ@#aqbHd$=-vDk2*F_{9^Wb85CbL8u7g> zaqTrrW&t%Z-nrtEhJD(v&=fc%eMmv!OsDE5K67IHWR_6HL&c%<-9KWdZu9C@S=JA8 zDoHOmhuac!9IJ|7Zn~L~`7?M#(MEOa(Tb*tm~lo4C#B|4-%*ucd#iZ?{Vy-BR*iY5 zo#+K+|Fn!oiqKjs)$QVZb8R;5suDTPu;qQ-iv^{cssfAW?OvZOC#>tfb*AWp5WYZMFz(oz1uUr)50kPMHQlWqz*DqfZ6c! zm6KXcx{Pww0smzZJ;I2wVmqSJDPje6L>TL_J24TO`ZL+z6RvSceCCW2s+h{)bEh*A zHCnEhpORbU<7DPGI%5#`MFdAg$^XvZ_0%|j#)NgBuW?>Bbwa)l){uR{Q(b@GE}$(k zUU7TMs(UZ6P1e43@2#zDPTz#mzTOO&JG^M|FQD2^yL=Ip3p|F>*(1?hW;p)ws z=fWg6PTshAbM^j`kGjYy%0!fTVsLyusV{3j^aW>W4UrdQIsWG3rAU-2ldZ~KbuzMV+Tr1A&o?iYh6U6aU%YVL z@;Qslj^j^BzA4mZ>(g^Llg7;ILo2(PMu|EFjJkxPie6 ztDNX2=%&^DU99pM{A?&v08TjrkUR$-#nb%!)H%rKV7m3Uaz3dMTx8e5+~HxSu1>9& zZ~PLpMiT!ZPRJkrXQOEMGJo|6$IvEqGPD;-v zIJ-gg`2s|4sqlVs&#XJ*A;IxsS5imsRDB|aVWMZ%)VKBZ8;^(}9&8$nkR>i@w{^WD zD^!*nhYuNpYi~aViG-n9&ZfAB=C7Cn&`i)0yAK{ikU*Nx@xMnssR3RId_oCc8Lrb- zT4Bz1Bpz?5XLB&&t6>wVdw4!}~`be<$ZzoBD6cF7l3>C0}`jLv@ zt&j~3=<@=TYTQYO2Vz2O&g=fd{$qoLDKH5BFWf0w-_&CDq6M?pE#F35+ua;m=3m*# zpdv-ypU={SvS8swu^C|K;dxQZZ+7h`s8-QCjMvG4X6;-*rM5VSU2LGc^|QY4Msi(| zHpRzEkM7`vHD^DzGkui)k5X$ai~syI$5Hx^lr(sH*O{DOkJE2uYxWD8%IcdWzkBx* zB}IUk4XMyUz!_@fP(us;1UWD{(}GTe_S`uQ$!BO) zz{#)Sn`hU4A?Gf{>rt-|M=N20>` zS+kGu0kKAzM9#`j2u@O*M#{)f;8bn|ah zCNA4?dR0)zx$UX%yRJ-WmY zm68gsJ~;mexg%0afv7D!DJc{*$mU@F%A3+IwtQ9(mFqfwLG!A?Cjv;}I14UP*pRsP zYvmAEOcN_$)(mlDg3fbyz!po|)B64USO3$}=)nBT{N_cFi-VE20qmPU2v7zStJ}<3 z5L|*Ol^I;5D1F5HXj>miBf-nu42HGtq6eyQ=Vw5$3Np`%<^0cQ*x15~if-f?6rKQK zs=tNK*d;e8ec`Z@K7A5IQ6G~H0Vt^i48|Zh12ZoX0ZOd`;alL@%L*w_qX~TL{%se0 zYinz;EvR|eJ_5B41j2-%wIn7$y9@%#%3VOCCQc_{`Wga?(A@#;Kv^xgd(e*hS?&-9 z>Nxbrzeg*PBL+5nfFi5g*PUfBF<-ao<@lZ?#RSFyc#=uE%OM0QO#VIS0W%mfyNZg^ z|G!|6kU;d8>t%&7))2VRL2O2WlpB;Tlhk? zdvi=*A$=$%rM~Q)P24xQJ4gryM*BuVO87q0aIa86gCK;^K)ObQAm!n9fzNGlz5Fss z3IEfVu3e)Fv_K-vT@YjZz3a+X!hcnljg8hkpmy9urrnZ_nc<5#358b4wOA8l>`a@ z!E(?5rYAyzw~%dvd=$_aARy;HtfHN3e8NF+X)o`$3aP;j8YzNf7pdKmpj4Vc$FcWSAs9HoG{mVI^FwUy z+vTu{+=AK$_-q6ONTh;dQBa}~)Yi8*t*POemnahfq+($99&J9$C1`jn7>Z_=bL(D(yNa4}Z4_eUe5rkZcQ3!ha*3oerBJ$4A&{z%yf)cU*&)+m? z10aU3VLA7h@@q?r#9C|bG*larhm!wxr%|jOU=&TzSwg7SPe)$Ew0nJHnpo0pg9AjNZ{EGL;dTZo zZF3k~7~Im&i`Oat7j7pfM+jS&bS)D=IxklH!43c(L$>N-h+f?uGl z{->@U>LoG2`x_-~p!$y1FgG_DdiAt0=~ob7lLB|FV(~*f0Rj4TxJ;;^BA+Sv^IA|0 z6!;c~50_yHURDe>henL7;^00BfE9<*q6BKTBBp6X3Gxg}$>CGBz+FR5c*p@9$KC*) zEOG}!@ZGuN{h>Mt+xT5EP3^IY-W$16**w<>Q9dD3*!t|Xn?b-62|SyIk?|FLP^1Ng z>Y8-zzP5J6(Q5^;e*VACafK4&s{;V{QH61ZUU~QEbK#MIY#AwYoP}P zfxuXrwi#aCGL$R~2Jqob^R3RLPDDIbF8-dbD$$aoXoeWad;``m(1 zK3t<;jz*>=0q~*OP?}ZkzkOR2tpdJQJ_ybULN*A3wR)n{u%^jcV;}hi`ww>Lm8_st9tAL8i%Rh?V)DBN%FWd5ttLFFDwkcjsk}UxG#8^!oaG1^jb$ zv=D!jk_g<`2(POrNxp=Tll!2MM)@}iq0Ebt4At(X4^gxr6vs8NDUMw42JD8)=?9yi zAn0=;AxRPQXiX%HUz_5!q&!WV7cXA;+QjXSjQ;+ zXa7^K-t3eplVsL(^q!s5)6p?UnIo`92noqm6l(InPXLvhpdR8gADeN4t5O2s6N=>od&87^{>Za#19L4Fg$vM>_S+6(k8EgbJMpL(L}r8ZyhE&Je?1M$te&ia8lu zw%#(1$8@4c#S)-|1nA6iP|(BU`LHM5OZB)|K;`9grFXVg+TB~05-qG#nl{xp4UrYPCp3_B8d;+ z-86G4Z6Zbwt0+YrQ;-M5#{>7~Rid$w~%4`@@6Q_D`-rL7>!F6bJ#{90CGYh?ZTiJ3gpefGj&y zEn67#Tj$i>Z9ENH;5#^&O(MGnb;a$o>i?pA~GQ&`9A; zn&$#uwUq4UU~WTmfy|4mB2OTV4&}IjxeysCA@GI;19}ex-Hp|raGUyi3bhe% zk3t5oEDSLKBC_ozvyMS=Y@at?;~`r0WR;xYt*|W}ze&||cxezi5kcHcbC=BV_g+Zz zMRx$!d?7ext{Rq{1pK`4vy=~3limIL2u@Wr>6AR`Inf>~P#!Pv+wzspboSAJ{bEj- zn*ZSnt@Y_8hw>vD0fB>xAHC}tLRnJjjFiST11EJ{EFiJKisJSDy`eqFv5O{~aE~k@ zt>A*tqcdpx1NLTu_x=L7wh&BW2X+ujhwL;2IgBB|4WP&eu%Qm{cJXBYhV}wCO&)f@ z8i4hwvJq?}h?|iY>5_nez_$hX`j8EL1K}oEfH0VSezBWxDwytMH&#W9fQBNQ*K=6T z=G#jH1{JvX@}1yqBKYL+@r17aam9xPSey{bL5Vu=^MC*dtOi2|J9ty)jI^{SuTWO` zzB!?L1W6~1R>82%k)I8==B)x#c)pZAd~;q6cu*9?oxC}H;+e0S%$bwK!d*G9p*4F$zGfYHX|tpGH!R4{s3wsfP32)-T6YV|^D#*jq{F7T@kyq_8C0xeKU0pyBWVGkrt975<& zQ_+L=l|VTbaN~hL_eZ63W{m@M+8xo8RE@G==9QNwfC~HMo8=xQ0aUsdetst)6LbW7 z_|ZrLD(((udoW}m#jpi6bZ}{}t*x<}fRmA|4)%p(Dd{!{NKp#~?E49#ML$p0g5w|M zX+n^-kp0ho>=l2(?*w_{+*f&di&jyP&Ik};pA$qFLpN#7D>KU@_y2gVzdyl}M0uix|+DRi2KNc=m}j zwwO)?p22w;++*js?$pE3zak6+1y$QQ;p6mkmmibaqI^Z*51@9G%qL5_(Fi^RG(@)e z=FJ;lpc}+2yBboVV~~-;R0z!x7!!Ihc0M2fV{LzFnET90MF-ipUcQurQ4ihHpg%ezPNhcMAB7d;TDA$6m=!g?c%Jp`yqW*cTYx*P z|B9nX>gErHH;XRmdswL#9Lb>35@*f-dvH_>eoQ^3pzBfm(f3!BPMixJ@f!RG2~jBR z4u~aWUqXloY}62t$PBj##$@=EEx;x)zXA@br90|SM?+F-l` zNt56HN6Aou%w+uuN-C<-cuoRjiDiD`z0u0e^5g>^1rv-ybS~;ED82(6uES<>L&MM7 zRAuho%RU4blPh>yk>Oe2)Qtqq%IMPooMS)0v47Yo+7N_rD-?N&MXv-e4)i2Z6=b@g z?$)OvLlqJ4m#NYhp%JnU-TpnyFLLHqAy9qA2@X)29T>tsLG%3%VSlhue2$jQ?lI^{ zRVXz7^g*7zX}05fz(&k13&y96;Yq=_m~d{YtDl@^RR*bZR*WsCNE01~u>c1|=5S6$ zTU27KG_kI#O&S>f)xX;5)s&5bc6KW;A;Ku+4~v@y3Vm8)F_bIU)zYCg_q*@ya%$P zHVv5T5ctP@-lcg*GD3EX>tAjbk~IpfyDK^j=Iu zLb+HO$hS*d=*v6}d^Rjz+xE=niYR&DB_I;I8I~rY&TK1UXaRXb^)I?kUf(0e&B^&i zFeksNdspDf6`%$aydY}BJ@h&XWdSyMoeYp`IHXwA$@9X#h1H7QS@&+I87~XOdVx6= zWnFsLc17rKM^=UPX8gmz7e}7qc^atrh|_?~OQ`Z5Nl|r}gPu|y(rpbX1(*3<6o?9r z_Z?cMvF_P`Dzm2MAybcz&S4l)0Wkq>NpX^bqS!$LvdkeNO6(Vf!lG)GvKl=Bn#AEs zg^o3JaMO^~Hu!0O9;tUU%F0e~_>gmBHpcU1j1;lTjq zR0Jp$6~<|V4O`?l=}2MT`q{;ILYJPd;=M?TP^^vD`^|LHz>yf!2dPT}!f&e*!kw!B zkFD>H$GU&tz9f{4yCP-Yg{-W~4waRJ?Ch0fyDobdcPWZ8D=RDeva)4{kWI+m*(-ZJ z=iBf1yk5^A&wqXI`f^>L&wHHbaUADySW5j;%Ojei`L4B|xM8`k9qjz#M6b8xtEklL zE`&r*jL9VGL$T;2K!(o9d@fTP2$O1KT#&}=!FDD1g$S{=YxC#o62!GQ=) z26O${bYX(#OQSfAxg26MeVtF|}ZbCv1lQ#_D^6(OTM39><0HT+n%xZFO zy-*22D>KxX@Rg*%isHZ~5(=k*^>W9~U&w72EUl0a20(nU>O^gs?1{ifrHmc=Z7{vM z1Wk&|L%vu^A4VA2m4hFFUFhTgWzg*p*5r4f>_z^j2;T;;St&43TY-I+Z_cP^??7~C zDj$%uzYG26Q8@=gmk44&{XVQpG_eQJtF{;zZVVL3!z^_$SRLyY!Fz~@Z31=$B&ak5 z6N1YkBK@n;jQXZSoN5yRt?_<>CSa5cMU0%LW(0$jeyJn2uYt1$St)=^|I7-tITNfr zpa4L1tW-QQ*_iB?}cG^1ugAk=o_0V-OCR_#lu2b&IHG284`W z)bSKDNCBJj#nA#d-5ntS#AOgmf_NOLACM<0@M=}?|1ux(g+Y1fwGpR)9k3}f{`rA_ z8>~(dd4~)sM?BZxdgUejR^12}K*Ra4VgTwUMoy#9(vJRc`kej@YC$r@3)BVDbY$rY z$?@nEDMKGCf+=wV z{5sg)PVL<1<#}nfE_n5T1h2$(CdLJWf&T8+;bu`~Jm9Wix5fkU5jif)2B=$P4Q_FGlyjhza9g~@hel|Q9FqLvfKDnp9xGmD*)3Xo3WBBAwc>N}_W`!NX41Hak z&!Gr17ex^gh;LzVF8oyqNFstlP<`tO)!|bhH{jlX1~AeEgf0U$bC#O=#nDdO5%#+) zcp55Bz|OgN%aspVKqMt6BPYB~sS>y;`4Ra8=j-^g&yg5lx%Ob)*RZY&2m^ZXl8395 z2zo@!&{T464%F=EnV=U6mGlZ^D0F#0se=KrxJRGBf!KR{j06EE+kX;++mjV&&#{WX8tknp5s!u(_kcqpgVRRwVdf*84*!#>DOfS9kUsF)xc23T>t>_~4Bz{Hzvdi8 zHpgbWNk$O3xLg6!4xphbSgyffLAxg+%S!}^UlvTe=B&!K^do0HLBUoiZqet2gL-v3 z$_@b~$RQifv4>^OHrMpN{sWiE+Rw<}#;_EeD~27r6u~!^0jNbkIJ8Wmc6I#|?{5#E zAK_r|3~@&64`0aqBAh+ipONGTM-wU<8nKF>C)J0@i^pElA(RTG5Fnc!8beQl&vR~B zfCV7CHiUX2N>{~pcJ7QzolMUS=x>o@1?FbEz3D&y;3 zon_8}y#ZVt%a2&W;mg}62*Mk)IwJR3dd!6?bq3T8erG&<5MQbyk|e^(&EvvuSUUX4 z>RUte^48uPsABgrn+>7)&-ls~VqPL_CNlBx`7Ad5gRVcucTaHam#c`a-5FjR$1ZuHbZ)}@thvR);WLUc)+VhS zS})6gJZ4C7n1Lq-I4!tqHEPJ}8_XZVwDemi?3ct8-3cP8q#c9DVbgzbQ*G04lqBWb zpkhPzJ3nV{@EMN-npl+0t}`;()foR<&Rl_4Bt7G-=#j#nu$X-zEk4A^|L<-67#r^&L zcSyv7`S&K6p25?W*qD0_(P@05N20-t}cRaEA9 z!hPUWdQY-%F?mIMPvm2_)0{c4=ruDm+Q@`Q%|;Z2@wAk_Mb?%4ZAQS#}L%%bmt;RpJ9`V5N)Ybv|{;W77V<)R22)% ziN-8B@-WzXPizxtQ1(LAts$=GGyeVcZSX-;iWa^sVZ_Ut%Yq@%F(3`uFvJB>IjXDB zCeecy%e>Wi&^xGtGqE(QbL>~zi~r`D`;|_(@D!&mMN{Zu?i0Q0#1V4n>tSCg%E^%g z(Yrd-kv>~@4j=m~{#H(CXwWg?y6EYX4Uu6aL0+;sqR43BB9n*jms~^qoboe&ne>x-jC~~8yh@UcZf6* zgF%cN!Z9pl@t)=1v$cG%*GqdudEM@Bb!{hErT$d4#yUeyg?CpBzK>=j)rmAE9b|56 zMAPb#QZR@Y3D6ssDwi&v{P$01HW8^Mfy0Uk54D2g>Bxs66{TLHy6yjkRq0qN6;C>Z z#)WDLK@{}bN}enU&*rW3<3;l6T#{`0QDol`UwQo*^yGP>=WAD$(^J+ZdT}@Bi@+lz92qy6y ze~b$?^w$sJE(WtLDR9N9jO|wuAn{hPjtt2O;fB4fp zCKgI-erQZRQRweB`UB=48k(6KWSuRXl9xx5Pieo&{4eGHeIq%dr2CH?vYh4$ODUfv z&Z^GnX|Vq|JIBS#Znul!4R0HtXRXSz5z@W?B4DnOyhpIK#MV(g_LpaA>|9c7%G&*X z7Nbs9){PWXy4DXd4Z7meG`|OmKyq_XPOOkw36Yn_41&CB#a3uZ_N76jzfb4% zaOB+QzXe$K(LnVEkpXOJhln~)(?AxnLazM!`?-|0_kX`K;)4SN*LQdf$}i@8;-zR$ zZI3pw4#k+~v#_$HkWqi4ea>l_)g?fx$3Hrq@%)}s$mHkioui@jEkx&wlp`^!&nF*` z@3YP-%-Ub?c#I`fIZf~A2_`9q58q$kzN$fUez1^*E|QR*L5S(zEn)jFwbLo|A*AJ{ zil)!j{coy^&zf`kH8r0#yt;6dDe)#&lQ}Dp+ahb}183Z%f_|>*tngZwV{1GNXR=Ji zDK?xcxRU4=BFm{)OkqouAbwlX*S`?`>Rj& zC)d*Gtc|Y^(*48R@}KVWv0oY2|7>SiePg*2lhOI*bZw)|jD2Hm$7jc{(stJIvF z!Cw_ko3d6^a&A-!5vTv$)cJ5nV#fOJls(}S7}z@Soph-tYhALfn3{HYmev(GY@pHG zt``=NNtJ)i`AXj*{q#LQlQ2#?8nqDap#br4Lw{adsU{x^=AO1_1JxjG@pQf6ICuZc zrk2Lcj2n*k4slkN#jaCp+Tji4F7$sdH1E*dm;>QTV`_x%Iy-I=fdUjEo6RT?4s_CzY)NTKQ{!4+;JlN;54eu#vfu?><; zo*_EPZdYp9sjv0n>DVagEBvVpPZ4pjmb}^i_m}*;;4yEHnlrZ_mUb5niVBrlns^kX z=&JqO$UQqoT&Q|O#`IADlm2Raxl?XyziN||k|Bk3)=_tT;e(%T@9gymHc=Mm(V#0h^1G}9l>@a~}IaEe+dGG`8F+ISzCFclG5ddE$*wDiQhCQVXx zzjTN$jj3o94yP)nFwZ(ThPxf2@4t@u+9qE%EA4QY z;YZ6Wd2y?GpLi(Pokka*uxQzvd0XphgseTbaAki|IxRCaO`6VAC9;!r<+8|&P;JhW z(My9ikK|ouZxlSgFQxT{`_LfZ23HxyV|~UC)1>c}gcx8}r;&j8X1{z;G4)9e170GT zNUbo*WJQ}hygmUpvo66~*FlpQxEz8D_5&Y8X=f zYKuH5vG{!5{%{-@QtJ_;%&Lnm9JPNaR9rkhcd7tl8RP%54rDZRyZ;yyD~MO;)!4l= z-(oa4ZOv&+pTfMQUBGNEiLaQo|3SX)H~mzv&A4#VbVHLl`ErkKtF!7NKUtzqTloDC zJmg@^1hrHJU@37_UsV(TeNHoj(YUPfH0Xkm;~h4laHj_}eWK#*nEQ@SYYNp|)HD>^ zUvIPTmp;RP7}zx0k0+F;@MQO~yb-<(gi;CId zPjcLo)hGNXsq#vKrC6!biMJ@C0VG?W1m)NnDC`;5s~QA^gvAtELZv8&2o;(p|IGrGV+bgd|Y&LDP? zAl;iQ#Mk$+hvtP)$=1fqCh#ryJ$4VzH6cRcY!s#e?GBhPXXk~llu}wSMfTC#wSM_x zVFI!c6#Rk1;e3aw%@5*`g87=ZFVeqza__1R-&-T1wiEUm)3pEN&GYMzM7aO`W(7Sp z@jp460720L35ftb?Mv*fkH}j<-+4hgbWYB~{zYWq84k_vg0GG5Z-4l$9PSrocl=5c zWCHojn!=1K`M$C)Jap48j1qN9|6!OHDfMJ}?M3SoP+TLAbND!qN?5j;Vo+io|8u7kDwm5BzTmqA9yrFi zZJ5)rV-%|D1q{Q4%-MH8(y97gp%y5T4Im^^64Z~6_`8}A=%}|w2Y8L-F4;34m7YZ; zMT2JW`%Iiq5HJhz5c+`pUax}AoyNeG0ARydn`@1&tbgLy z!l*s!5wP8!dYMmUE)22QHTUHv65XB`(0D@vR@0Y2srzy?an`j`uEk>~T{Lt$)o861 z1$>oVg{?K*R6gDAQNAmWwc9y2s$8Wms%H~nB2#mr+2C)lu%jEZvGhBMtHEv|l!GR( z_vn+(n64ueT+py!F!7pQ$WIA$B>(sgD}4Uro2iv;fB-Wjo{G_KoU>jjs8~) zi9i+%vghYFttSIqg9P3$!ZrZ30dsi*&|`qK1JQmEWdh<|gcxaNK6(!92SMs?btjxb z$etYv2lv}CUVZ$s%N@YrO*c07ntJODvr5R{VVMU2>-0-JpR zYQ_P2hE3c6=m9#|5HE<(XMplgf}rP5`(*?SLY#}~L%lz=S8Dm%A&v@pIRi>kWCQXl ziJMQ`F`(qX3+B*>1_Nu|L7$@o=LH15B6<{*&CS2lmE$C~n+*?rfw+Y@7^Im)0u<1y z`GIik*4htdq?H7B?C0<*P-X=J=JLdiuCiVcL<|I(`rUxBekYHRqEvFN&b@n!Xze%|BL5b+6;Wx%BX1_#`iJi*0v%*;^=11%)Ev^+Vi)X!x#;%3M3>0p1Qx}7euszM)fmb zb+86ex~%!YWk7GxXagi)KArgqaGAB3y~sM~OF^{&OKJlCBnuoHW)vu$V`&p8LPh7` z;a))AywSqg6Nqlb80~MbA`uQ^bKxOT0mAYfT&PWZ10eF#7_L7E1M`DbQOAK$#DCe? z8qRiUmZxFudI-6Az(p4(U{Edv5(Qya0K%StSsmmWj5&TsN=iYAPvB4|H*by3_4@oP z1#)UlKn#iqZ{*<)f;JLo#gEsCz*-%_%ehYTny6lYyFdnnAfW6bz<~a-0hH+hU{Z_d z5=fxE1IEA@3|tOiPbdhC%45ma%L32zx|$e@7{?xZPzF4AY?pY^DMZHnXPlhPkJJCF zvP&l`gO=Q#J9j|sx(Dr=ekSv0v8ajCX4nM@F#YuZXnKvNn!=G}9bO0-EEkA78V6$J zHYnZUC^GGWU^D4||EYqQiB$ls6Aiw(|A6T?Fpu>GSrHW^Ysqdc4IzO=zZH<=&VcJ8 z(Oc!=E*CJcZ{nr=Phe2w3eY>_Q0ZNi^u7hayFak!{gK85|AMGiMMVW24LOe+&ml=E z@IN0!9Im3!M0(J?0jqTvTs6_`15(^0k_PIz|3I5=1fnR41B>=YaS$De&~cO#hCG}t zEjiH{fSAul2ziI`0wj4E*!-+$Gg9G&+{wpaji7GVHq!ncY!0l#Bm0aVCWLxGh9W-*osqU1wkGU-1nR=i z=d&BWg;cai-VA{kXy=!MCG)Gmz++iC3=(XTh17@AN&sAVzUb|MIzbNTS%`I{qw>eRi-}oW*+YtTlx+o<9Z-58ik1Ua+baCa30VEr z@HZWhC4{1p(WneMQ;~!Uwv)=kE+Tb-E;N?{YM;1PH5F_6}UfUeF+E4T>Q{0%}krT6;sAfNIBB_c;!D zp;fWo7{N`xo}5G0!d5Adfu?YyY-!YlMiV5wh+NeX_r@x^R` zukePr)eHY06}IswjUpUMSb?SmDW8SC_l4nxS}gt|ECep`NXQB*sf-X6w*&uVWIdgg zva$CHF-G`SpkR22Wr+95x7(0x7&gE#I4 z#m?do6`EdfHC#j{=@7KZfMS1PScnhuE>YAb5|YFJ{2s^x%MntU8$c;{ z%yYt}h&WQ1O9Rn5%0z$>FG>`YjNRYH!qO|9!`FX!%rzv#prMHu8net0h@7q%pjNbH zO6G2Cn&q5bDDL^Aaq71=E|M~>J^BP}e%LOrAczvlUO{S&B&{F;z5}Eo;>0mkXrH0d z5Omqwz|mQ;jUcOhD3Z{YLXl-4UPeM-C|{vQMJdu~76Q)gcc4>-CyJ>8UJRYoz;V)p ztnWL7ouUs8n(l$?hyVw%OARdCGZ@q(p+QrmwFZ?WRF20KFeGO~DdXR&I&q)y2On8f z8wfxN)ML0=Sy(_{Dp3V`5Z{Gd2-EiG&d<+B6)Ixq;I*KfO!(9&Jr=pagAgC?By@J8 zBx5vh27+!>?Sgt6QeRMXDeRqdXgxklzmWlD`rftYVrM|ATZO7xxJ?GkRY5z021KF% zBn0UUtQm9+!xP+e@Y3_N$EreohpuT9djd?@TewC{Kr8^`eYyJop-@#QI0iJ4WEjbc zi)?Kgf<&PaA%@2T3b9exP)J;dgcqRafjr;Oa5*2#xILD|;Q^x`jl`^3It9L-jdfq= z9NZ%cVI~AI3-H9?5QHpkDY#HT=-CcM3B82pMWlMh7&V5lKJCwAMKR>C-$@qXf&2uY ze&GM}&Y=7kq=kSg6x}qKXHaAqZTjaM*GNNwkEVE#iUL&WVEv6$LorO?(TgSsps}FG zLRnxaI^Maq41^tl`t!d){-OB_&%YgVz1yv^5dVZQ`cbQk#7LY9a!e9<3wN9eeb=qw z%rE4IF6W}7EJRepb9f1YT~DB7h3!W%XlFnv4<9O*-@K9E>en@>b4`H9MmP!V(M8W? z*a&d{*!)COnCLWwXa)=hG0cd9zYX6F_J-uY|D#PV;O78I1oSRnr;>$>ka)wQt3Id~ zswk1xrk+QtW+2|JLz6j9m6^4?+eDyR#5ZYRkemaUW|(>dlL{|dNFb1gtmy@=L`!Sc zsMO7C92~h`XbhsPI?r*S!b=Puuub`tXTZdStyzVPVbeCThocAcyypxTZl>+s5;K#+#Q zt{oV%LjgnAN-X+v-XsFZQ8f*`o|9C(q5J?tVmL+CA0)>SUzDDHI36?Mu*LqcPnP z`|7OSnwyF!!=n%8P;#`5DZAn3Mz93>MHB|n6reUltRv`& zI$%oA15%@~;cfV}%*;&b9_I7Js3Zk(VcGMq3)WLeYJi3Jj0EKpV2J~lEWOq%6ek9~ zw=Qgzpm-2f!-^~kS1#CpfJD zlB@>dSC5vV)6d417IK}$>DU+O>?pLR|G~z>^6#x%gB{mEqe=tdp)`zCeFCRmct0TO z?k%+V_aFQA`;Zt4K@PE=Yw~E7YNlyG&W|QxnWxYSPXQffJ0cW7k;AG=mmY4fB8#e7#^Tf z{B-jU3^f7h@E>x2SZkeG?`IQ&+oSgXP9wm{1HJHAfoRe#wgTvb&?qrvF`{U5_^ZVX zRfvs2Hy>PY3k9G_KlxBYuJ}BGDBuF1Eo9hIjVZ@X1=L=}SpUohd1Z|Ac1v~PkV_Rzah2i}+@e65Bg4V;w#=AiC?ffPcMx`&xK0LYzF3TT)7FW1!z)IF_&cdP7R)Y;=0LD8X;eL?ALkJW2M$ z+@Ct8UpeR0NOd$Wqy?r}3GV>bbhi7}pODuINl+7@W*TZuX;j>S91E4=ND@d^dn$k- z+!`xSh|QZoB8) zJVdVD+8*@-L18s&Goi3JuD?KB*ynUx!=Y7RG5ywGHDt+FN-T9;0lLk@xV{#IIC8lL?5{dJRoto{VO)qEpBxnjlcGZNm+C97W;H zt{c9AQqn;p)DC&N$2mWv zsdnt-3~zeqfVtHWq)A&ZBse;5$angQ$vP#7wwulg(n!%O^HdaH7BJoPZkd0_GHl|| zaMQ5+YNxj*@dT}+)gj1?76SDo8hUt6()lVdY7m59jx7FKs~${P1+D!*Ac=*iGSC1k zE6)4h={o>}5_#N^>aYp11c7FcT z?>4+oSxsr1zU7Z|%2_({k}Ri`^fq5Eh&6BJFXNx-_b`tG7YUGlYJWvC6lcIP>j!94 zVw`Cu{>D>|b;vQ*!9g`&^fe%%3VJF&n8QNh=V<=GHLYGnJSboTz=~>^PtrZ|dzLr2 zc3)aM!WF8Ismg9uc|CCn7#PRG0K_;5sWJ`#dArnGv;U?;pKm)cCu+n=C$=^4rj>KIMP!THM+}Hq#7&-i%-oy=^ z*@?e;8>FjVTSNZHvcycgNvpo|!y$+WSwn$Ev1*w7w9qft;8TaA<);3*thK8jp>+YdDyt$WW-MTG=H4PZ>`!CO<9 zTos1y8VeIsPYycLKbmm05$c+(H!6Fd1BG=>awJfTt-^$`fG;3jMGt>GR&;V{2%9>s?|mFlmi@1dz4% z`MajpS|e>WHDMuNpXvMITeIN#K^Y}ZA+c@rhE9kA&Ghc>?%H>&$e}EdKA0-7?~tJx z3pCun0x(L?yOAlOc*{i)5hGO`I)?zYH-=T2E@@}qtM4)fhedta@CMHU^gP0Fu&ut} z^&~i2p*%8b{DX$v=MA<*qg}^e3qwzih2J9JJ>avD+kggN79e>QLCwznW;6(0f_4`} zA-2GWQ0Tbb?vxy`HWA#fK8=MQ#{>j)2$%}GwRR6qVu@k^Tj>t(VIlO65;-(@46idH zZ8a!8MU})BZ!VP1;~zna8*F}mX!jV*W8JO-WE1GnDnbG!Kpm`6wvluV@97WxwwUcY zUT>A7j2vjcF8VV#Uknmk68^ifF$TltCHtzS(D^Vh?e#TGz7vPMuwxtyM&wZmU%vX* z?z`B?juJgcH6P_aA@|-Y8|f*z0VpxOQ@4uKlIPeN!_*Ptdy6F?w?z2PSW>7`4FDg8 z4F{D6MMp2;{5OFq2iFO@kypWQq`Xw}QXn}-XL34LpqzJStx;`@2!@A7Oh|0M>A zrh7-7C0YSK!~{mbfn|H7Z>}L8=h!LV2dY~8_B$lUOYCsKXU~pXKj6QvI8NCfb7c_X z+LgmR(J?>}Pw90E0Vj6e>vWm=WfxGi88md*U)tuz&<56{caGLj!5c*BWN2mM-E5n} zIGpsgqTk^Il%e#03B_->6u$ORgzhjcBa8@9NbcL0Z)Dlk!O_`1FfcHh3Ihd34Gb>F zT@ffug}g62)>_<@4l~nkSb%qxUc&Dc^?v>dptR7JoE_e-7aE84hi2?o(C3q5aH0YU zJPUtPFLai#Wm~|gCmsKBt6~cTH^{^QGO}iUGtGpcKzpT&7#D%?53U;l6vr&(XyxX*{(f)@SXB( zFXDWh3X=sI{NiYFw3TG>;C+=cv^CQjB>l0()(Y( zM-i;}l4;s#kEIfH;v(4O7T`m`P1+BI^#WxM$jv_u^bTZ)Ak4mm);=1Ug)B zavcE2IoJtVG+hAiyW%;lZ*h6Cg&e}+)gYHq=t&v?(O%7uJ0Kg`X?0n6_OllOlhJUK zIQ@);>BV0qbAm9I5%p^t|JbgjpZ$bKoQ;?pfR9N`4okI3c-8Aj1mZp(z3y+(dJ zBQRG4?+0J8YiV7!ScsO;O6*oS32cdaO1M9F38#@s9L%v`fATjNfq0_^9!!7J9(sWO zjPC3bE6*v70F>PJ_u2O68?K75n;nML#Rr6N`J#sjgf!Cnid zPBGjD<;D1XsBua%(X8bM)1YcNtKB?j6%1<_Tn0N*XKv!tjyji5-^Uh@xU1;_5F&tf zS~R?z?RI|&)D%tZjT}iFu1g-Bmr+3;AAM$#`u6a6M&YNWNuE*5f`NgF^bCBuLqYmH zh$afBOKwKOyrb^AyqP&F!XT~9W&tk74f`#0sRN3`wm& zk8u*I8g&Z*;RvHz3NhWVl%&L^#bGUHm6q(zW#qO94jk|Iw3Jw#UY(6#I-F?YC%9x}7px-I6`dZlt&Xw)1xI=XxTRz(To^MUIt<8o9b6OWg9iQ5q-?|ob?2u4PA=Z76 z<}S8@9w(r+_HfQN68Mn>P%Rhq8U#YCa0%i0Xl5Qa)wHXDE3BS+OB=DkCEvH(s`72Q zr)Y(C?`I&ZC5M-7v8s9WgA}12jc+}Dy}d;x7bQGhl?6P3hcjShdiCFz&kN5YK2G+0 zZ}KRm)f2Cr3{l{N9AeKuQcQWZmnEGyWw*i}ME=zOE4x+JH)_AGU0A)SWlk%sa1ar- z_x7^6>3{Ko+}9=2UHzuk1oGr&gG}O@7C(8Mvog^wO<$%i9rZo)I7=TaYx*owVrWNL zVlx~H<)>y~U$6YU(bnzvVm&If;gKp+Ze_G#*|ai^M>JW3cIL>cjP&-|VCh1>h*v_p ziM+piWnS)nG+9s$ZEF3|7UZB#u4x4fbMbfpR}O#yyBQ`r?tjiml>JS?-)i>goU-Q{ zHov6rb*?U5R0s(9UBkc&GRRJAVj1RJuyDTjj%aDT*^weUOvA;%nVmu$(&_(@^O)P^^qHX0JDYhn7~+-D73>bbSKvJW>?miS-t zl(dKc7tNHoEh9oTRUoD&zH~Qa&LBxL-DW13r{&9;s|6_v6mn}r0doU89OGTlc8}NZ z7s?f6w%#Dt@K7O37f1X~)X4sm1!y6Pp{&>0D?IUr&W7{GqG13{*>{!3hvvA0D_YAP zZH`CU<}okO{Wo&3(!1Nw+al;YVlC;^lD(m;wC>8gv_7PIa|`%9NZ<@6JpRN(EIA< zw%&irV~%+aQu^v6XD_6bDP+mzmF~*FcT}I?anZY>)u!)>dz;8LBE72~o$$P2EbHi| z;zOwtW41L7PPy5m6@SS$sN8`QfExhH`iQvnZ!xQ{gU3?KA4 z#KsI7Bi^`QT1}myI?QTsnO3FVHssj6r=PHP@aiXhEYDWy59jYqn|WTX^@9bcB$tn@ ztQ%t-?EIZJ-gbua^mdp2Y2N9IF+1RSxs>&YbwuDNPs#Wshv$8B3aPK9ql)~$KhPR} zizwf=n!i=lMy_UUMN?9KrG%5R^mpQ48Ie!}5AGJpn%_<`I6bMh#@Bec+K1o#y{qq> zUeVH)?z`s0g2NSxMU{5nQ_m=geV5Oik|Md^!^CiHz2NWNsr+c=Lekq46W89v>p(5D z@C+_zw`Q8uJY#*o_roqTn8$S4XC8A(5h;t@7g1~+{nkXHQfpe7Go=6Hx!25!OZ9># z&87F0F}KVp<1*rCFqJW%NrR_K_AU`&zBP(3R-b=d=UPPMNm1z7DsM?e}_}pxt;F;2zBX~C*~1}DDe zLBr#4{Yvax&y2|&N?Zx_V27&Fy85~WmKq1Sc*d=)4V86lkUAkL=~v%+oT{lFbHJO{ z_cW9WO^h#IIN>^-SRY56Wqs`4I^^)ZZ~L?&`$Dj6=5_bGC#3e`lEmUt)`X{398@lL zmqhSB7%aV@>=-vOkCPL4_SZZ~Y$I`9Sv%sbj)MQtsmRXoY2txi_cx>5R5#ZZ$28py zoA0l#Ns0tJ;?(4iX53ZWl0Rim-1KJ-Z^Z5B;N>+zL?+!#&XZo4Fq4+eB6k71m|LDx za;`0TRqNZeIf692?9cYilA5rtJLa;=4K4CUa@zL}daE34>CYVHbvD$f18`5X z#N7I|StHh;*LKBuEXc`Y%yc7a!mkzwmQz6qA^Pkjw|U({G(lY=U|~5g%tk~z$bQIcYbxz}C28D^l66m|nB^uR{Wb1h zSL&BM0#lPB6%YIRzTC^E4|uS+;=TENEh9hpKv(h$E$#!ZWDZxmQ@488v87RSi>__f zQ2V-V%8l`^S2GT|Eiubiw1yno7-mZH(;K95%$fT0ucvxP^eC+lRI}AzIeg|MmKmJ$ z#{Xd8=Pa?PzP|Za;=FHXL+b-4|K10k>jrYs@8iOw*^AS@m9S{yRDE#FV|V1pF{BO! zLRa4XAi>{zHC(V-lxYz_Yqc+2Co_@KO=-;*A z&iAo1zLQ7PDja3n=B2{b;fm=-UE8u1ntOW2>0`IWgEh5n58i4v-pft(Y$CV0=BH)* z&$w4X%gR<5g$A6?`md!IUt4RqhL`g^UY-kj9eWfW#oX=agMZBxrCr!WUZ=3+_w~Lr zPh?{9I1~AWCJC1cE*_1Bw|h@3!Hm2$)i9MlU@hbz+j6M?oPblLgT{iP!UKnm#FeRC z1^1VG(no@OhwXjx$eFu zjaW|EE4|svvqh~vv&LcLjhSnUOvY`~pTEngAkcEkkmhb?tE~66S<+O@x8uTTH2C}k92DhT$f#=iCp-(_y9#7mJ5#Ac{S zgi2_Me2iWEokN{1N>SMO!_cj zQ@VvcAVnwrP6llYIS(nF5LZfEoQuUY=LqiY?=ekLZDssfm+F)`%_;TC>^W;5+iSc5 zF?kBJ6Lrz*`V;wcC3(8o+-AAq$a(F9w_4r3po%r?-<6|oWgPAbFH#?z_xc;$oG|?&?KFeqsK~fb!e^; zTgNHCr#}gqvv==_ecJwtQEX?{2`^GaKU>I0dbZ?~iNx5AYySQxrYVOf)&#@b1X?!b z<8J%N-kY_=(*NG28sQkw75+gpXC3LTm2bI|NN{^F^yJim&14qt6>q+(b@Z$83-?M?Q1g@yne^-qEO?NiGX6faLUs=Qik@lnzT6jvlr$Oo! zGrQbN%U3G97ZdYzcY6D^=c$iOuGirgEN^}``tiynYM}SkofQFMo{{JSPGwaSEaCmg zFX`6m8066vy9q)43$Zb>OLMMFJQf1Imy<_+it(w=4;xK z4GO4~&v3j~)+r*GN>ZTLl3qbmSKsq-{Pd<`D4l`NeS`3YWw$mDzQ;fQK~9F0w^lIt;&D>cz7_vT2wZ`oYJ`hRJwIXYGihDE z9<%%%)LX#_yg|4p>idmgw7&sab|QTXYKE~r8d_sc*&g~?=3seTypY$7!2o!M#G22M zKn@6(ZONmVqtQ=R_&X|8#nYdxR>0lCbbDnCshUwUh+Hz@T?vmqBRr;Rz-|`xW&EI8 zi)x>35)hi{`t!hNQ+TAzi!?vL&NTe#x9lZSPN7WUOj6yO)=3@=7Yx$xaK_0wj*#6HGsS_`{{V+sU~xMq|dI zB+c4XyGN*(Fx$Jh!o7}*I+~P|^ng=QdpD9GoF5L*87uelBnNb z8Q!X(L85tpA#&l1BXRa^0)T=UUBM}Z50KSpn7v1SrpR2>bCi&&O5bJZ1p+St0=tHA zkwFk~B3ahql;I%}LQOCjrrmb`l^~aLLS$oyejW0WTlSn0gBjkRw7uXb`V#GSkoR9k zqID1=(E>t3C$avqZ4|__^&s!C2P*)f1tpsY2UOI$%&-1t3vnLh#btPK5|;np4F^-h5{>xWVd-@H#Rye(<4kC7QFAuz^U)}VR7a92RDZC zdwNn_ew2b`>rIk_Gzk~E=nROmLZtp=o5Hj6LWqg;`W- zWCpL$JM}jGrToJ|zVK$Vo|gd$sRs+&-5V11)YytS?Xw0I5;;XhMT`}XPH{cBBhxl? z2*w(W2r*|mTp>X^agd@j|1Y+fzz#yfV;~5@@@Rnq3{4IuG7X@i#*RKG83(y7QmVc| zw56Qn?%7}2hQV;)Xy!EMA)((&kA>5HAge9A-Q+{QIU}Kt)~q_ zdCWz<;+l^xF=gM3y^rU#*C@Arq*f>+UH3i4iqTd$(0=kUCmqymoE>IgruQkOb>Qk@T2)Gtisg!`_?}J~Qd~I=K1#bV@353~!Rsjlpz>rnVA2qXDWf^e)pl)=0^N#{5@%VvXDnkJh@Y z{PR{j!Um0!Ej4;dYHEA?iaaaaU9kVQ?=$kUJ_@J4ki)Em{Un~mZf}(+pZeq zXC8yd@BlzscmSXv$HJonbB@g_07a2k53=ILJVR2dMLQp{R*@Ncdhn7#68FOi$wNXQ z0KyvAUka5Iz-UG_Vp{gW@xt2Z5}(m+LQ^Mi;Gj0v*&9m*&ur@dGSsN~Z0*JV>%En< zx8SOu;Zx3WvmGU&k>Hn?>n{o9&sBi1z@waArTCzn*OKZ!iQ&8*^IO`lmX2?3Sn}!1 zx9gkg>02*|Qcdkw@9xAlDN@freHbuTSotAAXOnIP-oEVc!g2jDb0jw z2U5q(%pF^IE*BZr2{x>%c!@B{c+T$k2pC@S%j;QOuIVLJ!z(fG^*C&d`_<6d?2=^Mr^nbK+|OYR9eE(Z#E>1)2}$n-M(+0RAx2iV*4}u@Jp4uRrGY5y|nc7%MKme z{^8}9LQ|Ae=YMGonR;x$2~hCaXMF#rX4q`ol{+a`V)-16LC3-2xo=`Ar!?zi1T3g~ z2USIS+TVXTUsqfApht7*flt)AX3fE#_P|1Vt;kkGOG7T=apiwY?y#B%Hd#$+ZGO|S zeYo(c*4F2{#cY7docn9Bkvc<8n(%}7_*czt*#TxtqihPRfzo8b0t*b%V}mLn zU-`gCm{xA1uRqE9DXUS(gPd-XpvU@HS#lX_)meY2M)G(4p3`jA79=a5JWbMGHLvT&Xt?VFBMIodT! zk2&1l(zAQwH&R5L8`&U^*rr1}a;AWO^+JT@Rh3w|j3C{38y6?4sk9809_3D}{SVm5 zwdSd>q3_F5a=!GIt(k9p5V;&_-n11h*P241z!P+qH+dI#Yp}+m;snRRPtET$J&Xb$ zrgOxGHAVYJ(%I~&%QuWCqUN7xL7#h9JIC=9s~gW}bajuvVm`jvfIdHiTm;dO5a?_Q zGQr9H*!&l~jlkr$Q|H*08TR)vNybyQ;48k_M+odUIT&y~)3Uj(9f8J#(0Pd{lwueX z$_4&&9PrBTh-?Bvi4t9T_P;GsUMS6I7=w0?UScCn8Nhy}r>vTeg1;lJ%3HVYE78zh zJbzxiq2sK6fY^guRnw|L*E&6d9w)y1A;_WsUO9m{eLsa%b?iR>!I{KJlE`wqo@ydq-y1~OdfG&C-o482co^08ZY7Ww9PeVXGn z&l9)6<1$Ne-|}RPn>VuZ26vl7#@k;VWy}OgG4nVSCJW|_n0c{QQh_AY(qY4FgK)=h z1J4&TR^IlU=$X&amQTY7uqQ@9Rc}K*;ScohgiGZf1Mozj+}q%mI+l>5h3QLl#!>`o zfa-Ubr4YM4hWIqZV<6v~5ilnrz@TCTcum3MS_2N57nNYg+kmic5IwWduI!8B!<-;+0{fS=yu!Dep_$YDbBN94FNy&u1K$riU)iRr(nAg636VH zD#H0j#Q;mFez8Bem5D=%H7+~mA^X)^M7l>C3 z+aBsQ2)cXngNJtM4dN@UT@>k>ySEL{Ds zk>?O1DDCH$wnxERdytAi-GHpD7SW!3re*kOHYV3vMBrd8%)#`2c` zyV6PFQNxa=I+ev4kNe&Q1_}n7F2PoOm^sR7K|GPjLNSX!N2KAw(1?bXkCkilliOZ= zeT^=XUCDDI-+Flm>dXrgsC)41R<;-2xo7sesvK(H{hXo@`9a+ ztH@{-i{{M9?Aj5onOsmUeWV-^w~<~mMnCqS`sulVR#vYd_FXBKrP#cFfzrPZq_-`V z!}5bB-3-kLh16!WUhvj1^jKzGC5_3qO}?&&=UOOn70a~Xp2mG#E)gy%$F~tn(acX< zS%j*3>KflWNZ}KyJn_`)>Vqk3ODpY&P09`-&npsv6T;H{Nt%{)d#@G)U0E6=fA-#2 z4&b1bctpH+XO)+?M-1n=^jMBi*|uFp!njQ1jfhM4gNKa|#hH5t9NcQxy0ohnht{O^ z2z83Dv^qG{f4{ z9ll#+Q|tO?>m`U>X%*0dR!rJRtCOPPx%S zUeB}>ws^ALo#<+0{`+9+({du^8=s3GFZ`h$NaZO?y{7A4)=jqT^jMX9N>b=&GLAv0 znEs(_SgW2V^@YN0>^JENYNv)X`s34<4~?-UsknF9B~MbvllB)@gup$KelRa9O{&gN zd`X9-!`qB`Pku_8roX&W0iS+DMX78?-?TTq_FTWqtL@C1#fYXBDjzT2?V81>PcWZHCk z#RkH;3rjRBU0Iwt$ShYc)$8q@vhgUJ8@-q-L2VdmZ}GGAT5{9|A70Zo;ho55M~!UX zWbbi$=iV!286NM4Nsolr6M>_cPU_vJlL*L%I!`$vCNml^Xs=ef_h&z$e)v!<6X zm*aQvcQ&z0QPy-v(2m%g(56IrXsbXkk?+!+`GjaC!#`9#Htr$lvfF5Lkk=BdzT4<4 zz(5{zHHTlaIjSb#NZl2ER&3k*%iQ(v+ae*9{fNR&&IF^0X3?WOPqXB9Zdlwer_GYY zb4g)T_~K)_$2!Kr7CjTg;gS(n#~&ONvH4DU)Wl?eN3(liU?XM|@Pn?{< z-WnBiwFps?B=7&Or1YzIvIJj=vD4>MQ`!}J8!f8O)6Ax@>o92+ zT*;$&{xxlK2-%LJl)-MOi?!?2w%cq~ZJwLx8q10h;xb|AqF~B#HWT`jU6h_aRZ&%u zs%L`i95{h}>&Jp2PSV`=!c_@BHl$dv&2b|rbmC+u8eQFUVW?=Ce~y)3j2BhC441 zJatxF{1`?fFhle#p9{`W1$GwM_8LrdPtMOft5(!)Gx6n*BTO7NmS&$&G78M&V%PkL z+57c1W3=ys5m%zEcI)jePAu-bw;Tc{`I9mK!&Ulh!L!z4NSr+{+NYI7ZTC4Y*!B*TeHFi%G!kAdA ziSLP92uXZgch`$6lq0s++AykY__b;?+sa?5X<@7)on>J`5H0&nyz-l4y!(nX1Ezye zR0lh;0?)A7ur8t`A)w$zUcLWg0r|r7AQzs`N&? zL?uVBPIlSjKd4n+Zwx$T5I@5D)T!EiZm^)+s$JYa_8@I4U*CrE=89l!YOEYP=82D= zP?Vj3hPMW>|D{Fd7lA(~4DD(_Yk#E6yFAO?rmHwj=g++sj1Tmsdy$u2L??)|raN&u zXp0b##-{edj^!wqnc2s-&NAO{Q)qu)C{USEu;_~NILit*z2;M8^MB&&v;(0DRhu!d*2@lm^@%G0^yeOJ{! z7S2s6YxkPzN>cl{$J58YrQnIBhgsmNFOzLP@kz1oG`At`JQQo{XJYG;J_pc)fS`5UdpfTfK{(;9&zd7kMnj@ukbb9QD2o#Vml9u#F6>?_a(X1WJDJa;z%2UPMeRLk3U z*-Qc+%S32GoD?p4a*9YczvFPW-W{De)&ie5ToxR{oV_wmd4W-8Hr5#?_!iYoEjq%J z)euC9s20C$9%+P{Qh10aUtO@fmbfC}`=;dE&{g@JNGF}XkI!Ro4Y^7+N19K08ec!k zSEhEYD1Hhlate#A?z@4A-G}vQe>8+YCFka!?W;n23(4gtV8wcxB=7?HNp@pzDtH@O zrTSqT@hdSu_mYZ|VE8phpYI-2byNhQK0H-O7U{ZE(lE9f?3I;*xIZ&nc>`ST;8kd! zaj||HSLvB7Uk;Y~MOyICSm&p!j!y9C<94kA*L+A3%W$_}8~DbD@^2U)O4ihL-SBOk z+f?wU{wFdMk^4(fCDpm3t95JE-RU;& z#4S>?i+5X-kjzl|S;r1$7vcP)!`;<#%r(+MQLAcP>?8@cvEVp^DYCMislqEWIJTMe z7U;5g>+Z&K`S-1iSW&ihXqUPMhEhE{$a#&4(%q>wsD7e-rc}4n<`Ww&0?~D4!rctG zh?aMa?}f98&Bk(Eb3WETnx0N7{#CwRef@z?kUF!IPx$>iE~BV6;0wH7f$v1j#~*OsbUMdRc$0>Ex-(o->!Hm!wtRY{j@Ifr%= z(uxL(;jj9iy35s=3di^9QfvC=&MSn1qIEaiu}3R!hY@x-mku8b<9Fqx_6d4NN9sc* z?z#6EB6C6M!7Tfv^*oC>JSn`6_!qN>?$Wrj*9d16=PNkJU|!eF;y`pi1g)0!0oU#4 zb9>?Bo2U7eit|Mj9-8P;Q_#owA9KER!V`kWDY0d@4{GAd61zl3 zvpA;goyRo^?bBS8Zl-RlHj8Gajb0sFSau{sbBnQWgPfrkLNwSTX}4fS#;EYNx^Vf2 zd-XPx2=p14W^87US7l&wtkly3JD^Z1%2FO^f1ms0*{S!ZkM-uTF_>w#%`R6>_utU_ zbI=nI6rWMXkXdTjR4Me*DTPHM>+-<+BxfS>Sk4xY^o{IC5@O!4$&WkXm9UCjprbah z49w?4`*p~ zgz?1==bk1KzBP%Li%uvUhL+78%bOpRo8FswQ|OaYk^}mlm4%Ic@l&@cKRDv?FFx>^ zvAkJ#yD`N4ig?@0A11uXlxnrADH_@;`8C_Ku`vO(F;Bg|{@}}2mV_NIHP6mFb;ODw;E^d#o*x#&*c9%me3I%hYswU?WV-1eIl7}!esLg%Jv2sjAq+s? z^aXiWeNL!<5J!s=ykiHWNiDBhHFo2VkX#5U=EE~5mO`vU+zTeNdz#T)P#9s~)Doa^tug(CKg=CzOo+#^&Z`m1*#kfYiX|ptAEYr_bGU$GIZ|qjCBA??LqmN_sy%f!tB#{AhOees zW84x+slytKl&mPKV_zSsaI2qz{2{+Z*m|N;D(+W1{r3|d&k9WKaM{fC((Sm{Hu*B} z+9yjZAI3KcGqc1stgVLDOzds9iw>?0dqs!!&MgA7?z`%%dg_*!%wCQP8rypp9BWv4 zHtCkf?h(E=tdX`q?wM8Wl*e0%zM$cpyB~(n@^-$zIMLiEcwbZQd7|Nn{o>S=(?=hk z#iv`B6@DE?KYXxa=t+XVR+DE(^;L+V$-IH5}Scs7PN;Hnk@|+u?S6C$E(`S)39*47R zU8)>jzDU7MjOf;ChQ$DAtem&hxZ)`ZRBv$SiXA_4Etp za?Ru=ts}#=C=mpKmEN|!FVB-)%$l=m;X`|+_A=NGe{7^19Jcnw>XQ0WHQJN|)=3!r zW2$?Id`iBgRjsoq+6#-Q{{Cy_(U&diamZx6Ec ztx!@^Zn*Fuy0s$L!`A2wp|L7Q%rm`EsG&uy)%&F_zLrp1RAh%$H#Q!fp2ZrSSAz36 zJ+VFG@P3@*``rOJbq&_n?<<(AnT9+rav1b8hFia01>LAn-v-9jyiHT!p@M3#`Z!Xx zQLIci@}tipI_^e(rHfviRjy@~27QYOrKP6o!7$fT+36EC!A#5p1S#sHPlRi?r6v<@ z$t_->Vi$>7v*lRaC-({5eM0 zGj3~YH3mn*#^MgzmiW~NXyfYr-mXS~^MZ;4wv@mOeoM8f`&xvy_-D2GX0kc<)24$H zuH!5-C;E!WMzLkq=O&}vmA|%7BqlY)74K)fSjm^2JVkTDZA_^?@14WDN!kdsR@F_m z!k}C6*0G*_HL;<+GWP=0@7-a1z3gP}1A`}bC`AYku8WEjva^EP0Y#|C61HBZ{DZx= zzIPOlvB0YSq6At#Nlztjna3u`lgnF;EKn6^<6bcmNJmOJ4IgA(h{+KgCB&BrySzO0 zvb2% zH$r&Uh^0CPJcjoWgf2YKOuK}c#EQGQrQbVyx*swe8 z2!$8)=M}0~b5=CkpX{?+l*(&EE97xzK*y%f4*A;Po8D z&fvIxDH>C1hvwD7=#-(_l2e?fu8D-m$&sAEe`}oU`;@%S?q{;Z)VblzEf84P;%l^)VA;s8n zbX>Q8OUBE^&dPNbZ=Vds@`8{6nB3J7D_8X7WZ<;M?XtNVr!q_e9r{XMm47-jw zm>!C)9v#hrC9e;!_z_tuspin=$jb4c9OGKNeMFmGr-*ttUiY7|ny!zN)1M`_!=r@_ zW0S5U1CIS8p_F^oP`Up(CG?jGpI68E#Y3C z&hv~>`OG;Oq37cv+jvre_g+}>vfX!lNl6mO4b1xQ-lv}9&8^-#J8WKhWetQvy?eAvBJBESHUypOU9`3W~>wa?FnWmrj zKNKizTCnU{9>o=pa*U)ND?aB#vFs!(;_fe9kU*6GwO1@@-}!uQbk(w{Ey+u`Kn~u+ zof5j~)TTY89u+(Hm|EL+7wy=#1Cyx`k~P0%jNK&O|KV`kEC^F?qUo_0ok~3fayAC& z>v#q%R z10ms$U6ABPT|1}36!|0UyF^DL30UHeH4zryyH!1s5l5f}ar%|Uz zl(!I8Ai9)WtB`Rh^&WN{oF>^qVyqRC?|-75P2gU79c*>^&V{@9Gx;s;Y7Fn_l$iMA zmYyh!%>~#r^QmCe!g)?8gwvsL8TQWxTwZsZjmnGI*COScy*mezWS1V@tV#93p!*Lq z`lWvG8|X!Z*g2^l6unqs^-sk{m<`gc{;Pnw$`bT8{0_NU=gvfSg3c@0A_7o!vnJDTr(Mi?4qN9?r`}|0f zK^?rX%0_m4CKqX|V0hw+LkGMTDXV$^mpS*4VVt?&9uMp(;ft@T=SpexuN$ec7udJn zE_F^ZZ`=;oB_7B-cYIo z`eJeYMw`A?wLgvsS+hn1ZA50{9FoH$mXzl*w}fVVWO0*Mvr@T*@H)=Y?7XsV1LQ0@ zilyp?H@QfwT5^-h)!RmhHhpiJ2oo!KlGtR-ui=)v+B_~y0^8olYNT8Vm2Am(#HyBO z)yt-iyPR#IC441Z!z&~OU5bi+--cj*dhhz+jQRcLHjnJio;Tm5b}FwTT5FQJm?MRb zJINs*Yab%}ke9IiShp);e_C&w>ut_O{E)^VpLJV>{~UgBF;deqI^Sj_n)lDJm_h~U zjz8dAbw?)eRdr`=CUm`*mS;>;NJ-3<@D~Qt|OA#`dt;^No!(5)Y)Jw zg%xF=DS7l2s@ejH!bQ?hZtI{#H6+Y^rK*e3IE@Q6Bbodc%q$(2%sC4 zaYmNs>pHcJkT;jpgW`!MpLW{5W%xl2Bn5BEOl|Ki!Rto0{z*l6sv+q0;@3Q}#=@I^& zV>*|WxNkadI3E$F3$P!~1S|(&6l{7C@9_X_=GMhMOV9b}EpyiZ&YUNFR)0G>q zh;_F}y{E5h=Trsc*~|AOB^%yxzA+7UB19{GwiiZleiV?)o69z;-Cl-Y4ZFj2qtdT50tx!Ep6vdlTL*)j-f29FF7?&}Gg!Yz?v2 z>chr&|C^P<>VCy1?(^_oaSXrCJ`*zSMawb%)WK<>#hE`5nUP<%FWJN>R{a-jc!q7o zV^tMzd>-N+NPkjzIJzHlMZxqliaEb=jP}k`4o+Iw%_@GrKW;O|H+b3fXAcziWG|eg zi(W0W3TJea7h@KK2Wsy;qE1Toz>)V3zBx?9SNp#2lDfa>=KhCKVlUghQ!E-EB&U;9 z6|nnw*Hew2#Wtd`xdO5IsTNxyA~)@s=2(GgdyWio%Twmvwq7l1MQITDOItMqpgJf9XscSvr#Zh-n0VnNyJ3t2lxGL2m@q<;mKc7$B(+L>C#OLDqg|Ut z7`)D0?k%4ChFoCN#)3X=0%3OEyk=hdImsZ*%gZZqZ+BO`ugDqd-yNSGqF1Zl6r990 z(hMq)K20TYwM9e)<9J_ETyb|e(9KQaS*1+Z1P5mAd@1v<7lej9upMdq>jlr}CtGQq z;+&=?Qc~25a#B06o7iEH9AUgLsm?uvQ5tBZ&&Xn_{pDO(*?Hn=UZ9D;Dm8-kdaQJ; zS507v05_4t*)45qW~yTUv9iNSW7H0stSjEqCW}-46}%~dQJ>PY^kZ9;ynyxAPKq~D zUztvD^Ws$PbS%-}UdZK=-lwzDDKIphTwilTA?OoB)#4JpY0Gx0%ZU4h>jbohi~?ET zR&3uK^Mx#-aD@jpBc;~Gp9-I+mhXDhWZc&1IHk4V!qCcGB$VBHwHALSoZ^5XT(_?f z$_Uw?vPR7$srXBHk>A+p$`{_j{S7ZK;k#DeDuEyWVt=wjlWNEK4bO$n$$cgRnTe0$|c05}>`N>{edT)(QoY76Ss#`=Ut>ML0VG64e+ULig(2r~K^#3tbx0^5r0Q)p1&N|3IaN>F!|N%zfNapuv)RQjUz1VT#KfO@5+&knbN96j z;(EvSvmDatxFSnY3cc9H@(E#Y4;#wa!4-!MTze~{Y~LHpw;S-4p{jy%CgqJBoX(=O zN54gR(OtJOHm7^nr}o=6`BSqF!|xEUT_xYxqZ@R4xudb2QNjKx^CAJ(eq$Ad3b!0f z>(9L(yx%a~;GlaVVD4J!$ng=iNv;*^KGtt5a(%_^>VabI5xqzCsCSamvvRB_(++tD z@R4N?Y&F8)G25TMW9b*fVrMiEd3TEgsZ7RM4sVGYRHWGxq#Z- z2&`h?>nI6&h17DnkxS>t8;0i9?RdQNgxNd4PN;L;Q@)unoo+up8+e-M<{f_|bzD@i z>$jKTLAvFznC}xK-2KG{cBQ!Bue?s?7Vn}HUlLmkm!dqvrM2B^Bv#YeGIZ2LHqRI^ zoY>0!!rCkKt0%dA@<7;-`jfUJooo>zxz+6z=|di4BD1DGEy^1Vw^V3*%F?1v30+Sd z*LG4JNG6R3osL;(S7UzM%rc-EXKwaW_fS(@w*Z{pH+)pADHvJxEynlYYj&Jfwx@ce zm(H1_P_#VROrcmXjnU0%pdd=j!os~v#${7U>)MWrBdk~WyueQSq%u+AQXQ{aZ{fUd z#{%;C>iwa}H9nH=iwR7t2~IYhyy0%kEzy|!I(H^~T@SbFAHDD-B~j{ViOsj;*s224 zD843()~}VD%IaiHo!*ULvtju`-gtE}WHsnu(J_}eA63MN5a0hS8$sJ)fLzn5q5bCF zT)g75%ql=(FV`8tE-K1Wyy)4FkAz~nO(HBGZ)n6$D*W;;51OO~T<*Q(K)dBd;ii$Y zcxP1GyeYX)%^$UNv=f~Uil|?M$I^JpioHqxNZYay5%hqb-m>5RneVinXgf5cPIcmK4=lg(XQcfKf_A*HIB(D-fryJryzA$01q z*ziD}r~b;qe59TClev~p#&w($C+JUen!UB!&o_}T(Tc}4w5M;OL``OKAj*=d*cLxJ151nGa+3(RbBSIV>jdAHmC`7d9c9Y(7HxWV zHRB$G{Jrpkq_EG88y!8m&7%!xjjP+eo0~**<9Xx+K5BBiBt7fn^$O6~sn&-kF%OVY zFfh)@Ki=Fd_-OK^3G1ycc)6_cD-}xz)4SohXC*o3RVqTF`^8YbsqH!swK~)W>Pvb4 z5a#Ana~+7LN=~`&EQ}RSl0wcsrc%BVvVUypV4p6Psob;(&lEJJSXA;v>3rus3(IXe z!=7rQA~>BtP9)~BW$+42v+#w8NKWX?aBT6f>WU3yr4v2FCdfZeb%-RnMB2cmW9_uK zkmWI?qFg-rVme0C1a)XRpxNx4>)5Pb?gw~Hcpc@?Qr7-(H;^BcipZ9qO!h8mvRfH+0O4t9ADd+RTw+i(A z&7dUYHhxNY>ZE(BiK_Fd*WCOZyDB&Qw@ybYW%|rhMg?4hCJ}kUDeTG4Llf-Q0QYC( z1<)22BklA)d2qn1pRK@c7cCX&^~W_fMPvh|Ez@R2%+$S_gxb^Bt-l{rav?@61W$Ye zUw|>%_9ElVCv5_Qv^nYozJ^;F4i9X?LK1OS!Fu6BLywV8GKQVK_^=@c;h(h@YCoD3 z>91Pa<#!LH(Uz>c()Y;sC*%}>;*$ZtaUX*5Mh_p zRKg#;YbOTQCY$TlKp9C>aY$;;;2*2a8)nr?o_t>lwlY6(JjIMv-@6jWE%wAo9QWCl z!d5BtyrNI68vR_`Ao;eKeScYd9NP1->Er6Bh*I`Dsti)evkq#NQHCs+zdp*0ifk~b2E11c6gq#p&2%0?T-86RFyA(;<=a>I83Vw zThrhX<8$TG-19`@gp#>)p$rQ3%Lo%6if#q5Hz_z@vkhg^_iA3o@@11`@$ag}Tk zKKGNInBcNuQbT~e-$l~ccjPmjGSkM4Yfsxy49;h%Z1Fsw(NTh*4}L{A;$SQ%6k;L` z^EIc~#O7e6(ucJw`N7-6W;b7Fi3Wb$Q2ajIdM#KJCq)fL%BbUbo|M-$zgxWi*^;1K z_-RvGE4u$K{S&drV=mszcsa0q&OW|< zye2r{dS4$oh2JM*Sf^+LaaZpMIx;2);5`PQr39)HPUq`Pr*6xTwzMB%9w*-YBE-Nv z1v{B*U-ed}?HeXO7yY5Cq#(P^zk?*!5+L)6@7~-KAL5*nbI(Q4h0&gs9j!9{;#1%f zb~EZD?<(4_ow3{s&4Hb9*%+ogyq=?J#lOI6tFQJlCggKvNJMuH=KGJNacf78DAV}{ zc28qUO{q+&u#2qaSf1UE!_*AF3zNi(SnQ-4ZlY#AsX*C9ZD7ti4^Q@nRj*WaXIX3S z_IzltklgI7YnMO2v5WHf(h)2P)kQO=bp5f zLcCkqR|1x*=ieljgg#W1Z?@2hb|%I@79_x<@Ru)%&6(E>-@(W}H(V51Q^E~9Zgpp| zd4tZ2z=UIy+&+3z)IN!RtY-e{E`{~Gg{1Al%Gnv=IR^qw+R8cKRqcv!C2P);Z;M{q zi{*KK^I&2T9MZ9KLVw=Ru-5I;Gnz6Zhp=+W1F>2HIWXb%1ZQndirrUlSIQLcFdMd&DmD+Z>eM_1GkyLbEB`f z{AfSysL<`DIgM<;TR{^0)hvwhw#=}&p;Ig~TH&qFB6v+~^QqXrVmKexEp}44LSaC2 zj!CRBB9@_-5=T-GRHl|;@}>v$D(+1MM-;i5z*G+{hly)$nfIttpU3lEq3f$K$#HEgLx*$0+~|a zN*q+_syKWi&b@e-3I1OwZP?*?+4f&*0m^rrX)g%hGB63VFX_zPk$i+2J&|c&9NwDK z>2k}D^6u)}Y z(j?aBh%CIfZ)_c_XUw-~nM|$9pzT1XtUgE0z0iX!9}~8hxKEd55IZUpwOD#O4&NANJV>?@XXR?VwjKrSGpx z*6dcv$!@5sp+*x28y~Ys>m2Ajr#NlD2n~u2L%v95BabqR@sU-Myx{YM2SSeu@CZMy zy%wtd6=nv+7;FW=_@bLTgptLzZ*rIF>l$#T4vq9n%1Z@vwuGAtQ$nRWdqNEp@wv_4 zw}}1Ot8dNiqfx4cVpN^WeXzX_Mp!i8sP^s^oIxiF7MC9oo2t|C$yw4`L9^9Yu6$sC zk)EX;6{l&kct5=4{AriNf*^&e zb=VZ(fQROV9V}#-9LAni4=K8U5vz9=NExDgFf_GWl0TVSYdMfLbK3iKhHv6Ba)v#1 zCHRfvJ63v51K1ZIKO5GU>uX}M8n>$F(k9O&MQ?+XOHDU$=--k>bg}2Yi17!Rl4#NW z30AiRE(eP8oKeu7Md!CLo?TLPA}D?&1v*SNkGBgIeFup#O*i9#Lfu90;8^oDFNHzE zH3|eo0~{zMM0E?30T~YDDF&1vF7e!6tpj&$C-W}__&g>R1zYUA9M~nst?*f}QYWPV z!`PuMkB~jOYBb>`Td-FvK4fdijipbm| z?H#nsJcpF*?@SueIo%@oC$lsE@RKRW3PaH>%cSsWlP*akZ27SN7DSK(rhzhZKWP$t zs!DQ#W%^{ejeD(oy4CaDA~rtNgcp9K26UryhpgCg61KA*&>6!(@+0$>GGq*Zm?tZ1 zsYk=KL(GZ9X*SRDPPrpb4v+bg|130_AR{yE>-z7w?u_R4hp$g5*UYy&%hTVA6$9|c zB)930lJmulcK~zVdZdOGV$L5Pj2>}H4BJ2E=i8tHhK)wt4Ou|Qs#si82g}>G9e-@A zSSWxRf*_^PE#&rCYXL9Kq$5G>-zx!VVdtP6z`E0X6KUb- zE*33!_}}pBtSBG=N;63Py$S)lwxO407SaVdE?Fn~+dM3Mx7*J$=b$Gi$?a>U$uq)l zGv|~l3aIEcQ`Yv&5?|zNkzLiTD2|+}@GX81vRfdZ`@C!7QgyuaerF|q+=;VdSTFE> zSMEWmUQ_2v`7Y~5kxmYK43nZ!xhK;=SK_oMew|{O+$EHZ=SFyA?jk!;dkpuN9{`{; zEx_fmfP^F9wh#YotZ?bDs$JsB*-3w)3!(rtCkoDfh9VCD(n5bt^OC)PjFiTW4nEnH zv(Wtlh}>(obqA1Dnh8YDc%TSg4L}c8foM+YcMkf18Wem90zI8BfS`{#NfVh$$=T&9tnNy6Iqn*@$8RxdTvOAJHW3o}4p--eJfKtnPP zQN1L;FHhz=?$%d5;t!GEwx=cP-h=R^KD;sTLd(O`obP}HrOww2 z%2NW%c`?w#xGw>`U<4FpIZfjU7-KCEF7ojO873g9xF2wy^(}yig>3)E)#_YOeT@gH z%bfHr047w>WZM3ATK4B)qqjcbCVB%H;B%llbDfbMeJ@m0Q`-HFJu-FmBd?@_Jj@vV zIE?LOfzeNn+8BnMGC3hOE}m*FX?^$^VP4!`4e@)g*v9Z0Y*XQc)uh|mWrNp-0Sf-c zJ31z)nxE!Z?43WkXGjG*5{EUM+V_}sSwapsr6kgphm7(s=eKlvkC2=RyO@aNqZm7m zF5psj0<1q%5SoTEsSvgjK+gm|I`BY4;1)24rXI{&lmOc{%>#fm1;U*B5S|gbi4fup8n2LuYI7K?exB{#8DN!a z0F>u2sec)5pl`Sjm}gaoyK^gmr^Te8IR=$NK!Xzj&nq<6Kq~8d zm-nV9+IxqR%q~|<5jup-%s{OLLAc7#KPL&P1GKa(B*_9I`}Crs3jp26X zoh zG^Xta^w_@yCMZtl{$t>C0qELyKxYew((cepLfL9aT@GMr0KQ)}n_mFR&>=PT@T|Jb z1`|}@m=t_{4*GnW0D08`AVf_7G7+M5LhwUC!Q4LXy*D&Ek;#3)4`nw2YR?p05GdyM zj~H6yb1>(y(!??eIb+;rl26lY10-DVB{j)oKy4rcmB6o>OpgN@nhZu(FGCDY$ZvH^ z0Mt?Ce<~jcu?Uo4(50Yx4gk8}fikBAP`a8Ac_Z%s2O&=nH;j*%CH^>Gq8aNouxo-q ztq`CJ#QM1b+0a+2r68-dkrF{FgJ_ZvRuH<1!1!VcQp?WIOa}<*f|mMp%I-#DG5ALL zeDz56DQ*R4I+R*KL%wz1R?NzS0oNhp6xQ#~ zoR$Z0e!XOBiZlLlv}t#AnPzsJ)Fix$3_qQ07ds!xi`dlRE-o@&$T!-_KWtJw$>a6; z=4+RiP(gqK*?_ji1zhNbO9ucT*8*<4KQt6Uh@PMH9~3?Wm|+BT_pA0-Ns#a+$)TAQ zaBk1jj16PEnt(EB1w`L2rGgOu%6hjtRH}d-L_f?N2tne3q5dCT4vYtA%3AKNtR)Xd z59-KxLo`z`r#xYWqPjpa5&&Z1K8<{GJ5Tqi>OKwNm-1HmcsDCf zl;U0RwAOt)%a`<~%WDzKAAI~Yr{hJC5!`|X;`Z2Sxu7CXiqI8+23M0iaQtVA+w3)xrn356R>%5TH0-7Qq;~n^H&jAvy1zch) zIiSs31DDe~Bn`S);HE-jZliqNBH=HvZQg;DFhI9Q-eQ1HU4+uP;FsZM+k+q!lK`09 z1aiv*KewP;4R#(lfzGhM3V-Ha2QTv7udy=V$AFNLU@}8wdP`0VUSK+lgy)<|F|S@@ ztf#OcKHc{EyqK32^t+8j{~{dSU1@<)eQRFj=~}pBGKs3#QtXdmXtMF_rFT?v>5NnS z8sF7cxkjRa1hVKf2Hu-#oFALmSAbA}4dmfZL)CL1%#8Jb=5WVm@E&xX zLCo_um}F%kvSb4=TH4PQR+@s#)08-}p3O#rfCeDLJP8zW4InBC(Ki4-xMHJ7cp!Xz z7nF>*LinoVb$fr7ELj2G-0Jx@09phA==cOwJD@QKn5=!PTy9g0`&~yna{m~`V37nK zE6AA!kp@Aj;33hfKM zDJ0qhuXuDC?2o#F34AnsvOq)*JYdD$YSQC5at8v?_+huLItB$;0Wf1ZTjJ~4i2JK& zAe8FaOE;ebaU6uHhkRM{gqalZ#Iiv%3WN%!fdo)p$F-tYsb-mF-Sn$y&(T z+0-_&cTY~{v+R;CdhqpkvV~`oG5V6`d`y__sH()DcaSm;kXt0DKKzRfmv8)H@t01) zI#C1kkyg}lh^})&@8Y>H$DVkfo4o!a$K@2R{rOMGa0(cX(3}hr2Ow4~gn?ldec59H zNyi{kXDqCl5@K+5|L4e98 z;)V~nKm4T9Lux_*Z#J~XT=E#*v#egKJMu;Y;TJUDL5mz{4fA(lp>P3|#uoO~-`n$F zy44`LDKw>nxPPB(irX|RBzb@e%0HeBDB=A8gAJk6v$(P$c?XC+n}SohpmsuYIs{07 zv{!&Fk++}^-sk@@D!|V|^kUizT%jQ6UT)9a1ok^aGQI(6jt%xU=|2D%0fBZ%n^wG3 zyJvcIu!XR*yY2kP2{jDz@DK;G;o(zCQHX(0P@Cb>+*~6di$ij(r@@vQnuiVryEDVK z4x(lr8ONF`z}`_FW7V+9SAGUk8+ zJTX80_8$^`;|;kzWaNor%9=e5)a%h09GXvnssWWRwBs*@jauM7m;h653sj3hZv6@* zi38bmD;s<-@G;nf1>uB5-HvKOe*gn}Fqmfr+J=JF8aIUg2Bj?+OhxRV$nZcO zDnQA{yL>$d!E_+Undh9rXan#@LaVMmcgQCUnFFC2m2|rf>P1Wr_jgJqC2R(-d(E`Y z1Cbpx;W+>ypIPgNYmoj0k_6U(3W?u3f=mK{0bBwI+Gb#Ju~A?It`VTbH@rD^Vi`R0 zPwpEAy%Wjq$Ev^7gTq+B6JL^b zbSJy6;GEvj%#E>Hl5(44hm7SefUM~&IB3A9_8T4Wj>~oUxutKBR zi3@ox0Tx&&X{7UZ))2kADENk#)0LeWdzvSD6v;pDgyI}UnvHfTOk7-gzAP05oG)Yi?K9*I>>biaGbZG zo1MWAO(!Pcgd?;cp^HbqPZ8$kv+gf{UVr=_03=(b9*~;mx(c&4L1zLEhGy^wHw(Ee zR>pvVw3a&R3))aqU;u(Vtk7Gytxc2dICb}+w_e}egc=Up?MYQT_e&5+2*8ww-l9(B zPAr=0f$#~KjX?K9F8VR3XoeBvR77%SH`WYBw@-Uo%;WV8cKn--BAver&EFQ&$h&fn zHuQPku*DCgnI?U3;DgO_{rp-h50J%^_Ce4Gy{|(Gtt;f@$xfeSo$2Lvv~GW4prr>p zBHkN}i}0(nm`m{J!S!5CTOHguj=wOSgFeqrU;AoFvkGn2MM;|rq~jjKZ_4ktR0z$7 zyTqs%4GRgef?~=C^irqN;Er(a81sQEQ+kyXTuw3#JC?T#<&qW`7uBa4SN7G%K*J81bo&+aQb^~4xDB&bY!dr zssvLo&QBFnuuq|;3epck4R#&)IH29K1b4pIPBhM3ug=SLdpvNm(9TN;TuI~wwOCci zj0cPwv)4MCW`KUK1#Bz4U7u4!M`k}Z6uSov_`+wP1Ax>akl7I0DCJX{a;Y9oCVJwUU=)twyyY{E+OPn+^555d&58Bm zmfLy4YL|_%hpMU4Lkr8on-t5dGMTjDj&)XpR6;APyl(^lN-}u+y*WYD@h@&b#~72p$|0DqbRoG6el)$>1SUvaMJ%r<~Lg@QSK66%fK z^Q8meXt|-9oQBC`L5=Kog2wuxkc`f5mHY4uQBEy z=wHH)3)(B)@0L5b1O}}X0I!YKqiR=~lfa0n0^}5cr9a$mkuwG8@@=qVa`HK8xdx~| zz%FUcp==>!XEAFItp}@dtIrUWr35V7%>dj*a`$iM3M7vJRP}!F@vZ70f^oA#dSM2dfs*jwl6i*1i`h-0S7%D^oy+r1 zjoLU-!^En!yooy!Oj3`+?}+*Ua(B(_yfUR~TxbSMKCSPawT?OQWXgCN-OFtf?m3QW zG11xi>^NP;f51lH`;k;V81P@1T;yePIooG8*>~J*{kAg(v3_tldH84h+%n;>6MJ`E zfQ<$cU?4!g;AfU+Pg8`Z;hNAaTsD6_%cr2^i3F%KdGqQHx_xjs$2QYD-h>Q(d+0#} zD``n+)9zkvk4~G06!Q40Sw)e#_H&=`lh5Kd4Bn*A%?6XHb8j=19TZB3FG>LtBHE1- z`|PW`)4`wT;V}?j59+I-9Y#GL9LrMM&h@4-x+O4b8Utn5uhD#iw#2~38>MK~*P|Bf z+e~sFj&miT$jmr|pgT@pl4G>AoV`w1fv36_1k$Zex@62vN9*KPOC%~r)eZt^5- z8@vB;cEQe?sZ*^+i7+<>2Gb?TZ2)fETEfKNU2`9+NGHHr2a=bU-=^CP4zG=TTQ6*$ zEsU@<%de^B@_bNcC8}ZJYU#MOL&QF?QljTzz2RRfY`HmGdrn^-&wwkvwt9^(ddkhP+um-hdUL}}GJR*~tmy00 zT&`E4HIY@(8iGx`Jm*%W=gtx&DS@O#U?=WK;HhwF2ct+!$^z0^5*%W5l$+D)$GYzj zheoSLOyUJen|2%Yf7a8j16#xja;bT2V=|iBg`1S^n4PDXFIHa^5-;?yP6xv}a_8>E znWFBu?!;K77zW(KBT$XDSA*`z%YdVf?ip+W7IC1@fQ?#BOw>RI=WvC znqSt`njRg%3HTH7q^FciH*h6Fa&|_mG`lJKL;?u^&hwuwp0rbet!R}#Np<hXw7^I z^uVtuXZ9Y@BN^R@Y)V~HhQmL1+}EE`U;LS83A|yMMqTUO$$0&k_rhPSLB8#PmemM3BDMHDk!|K~hm;rEOmN6=t3XlQ+DvZMyTvBdcTGrU67zKW zZAoFX)-KBx9!_Dn<88K^!}b%jv2_7h9s~5}D(8M$X7y>4z6gEqTL~4_vN5wA9mfrK zU-i^!C5P58SP(+RntpEe{O+L90Q<7{y_)8jM>xcsj9_^>6U499@w_c@>724h4dI%1 z-MB|d1DpeRYmBUkG-t(`$36>=6)Rk^yoO$s7o>PvhaUr%4d-}5x}*}ZBD3l+WKrw- zoXUp_o_`_{F%gz~zT+wamKT+Q(~1XY7to z&yg2Gbk}t3{G_}o5Osky^c#2naPV1lgZbuQR&%O)rXa}~zN-@1g(%nWk1l#N_N+J+sg~ET>ya_25x*Upzoerm-;Y0= zyZ1vZa)B;_WP;CuRSn!u%84}2(4X8D2rjJi#ME$bugW-Or)zt@yUMxVZ$~ZHsSWmB z^DB@*rIyS6?3vqsvLZBz?21*-texEE-?~3r%^}p6h?d376K?9v$BV|`?_D7F5=+|# z7Ts&g_*mA~iIcFLA%|C*Oob8R^Xr@4?qEefXeUPe-gZM(9uu5awcW2-BS-h>Ytb8- zoHIWnBY1hXM7@S%(~$oa_z0)pk>>tXk)PqPnbj`|=&ZRd^m|I9Cy^q88ThV@Kg(Hm z9a|q)Oh;qP_X;%;be}WX>~%B?vZHo0l-HI@`zznP4!2g;V4)LDRWHH{&#V`8T+1O$(mi(vV<9CELjE-vS(C`B_hk1?2PQ&bKM=k?{PfO^IxK) z+uYZEUgvq;@7Mc1c7e}QW`tB_e0Y8fKK|IBKD;vVfX_B#h1I$ga z$*{jXJ-w1r+URh}pQ#pzEts6QppS^kr7S(l@Ft7nRWYhZYtVjMOtY7Qsw|R7RR|T@ zAVe7q1z)4Eu?5*XYS*-ul--b@FC8wsf6?MYrNGpw(5?}?B|+8xh1F2}v*W;!W}8J_ zMp3_4EYjXrDYAf*J(PFu!-uCq^E^)>!qc~gsJ@COW8Nlnpa zWZk3%ef!hyJ3odpf6;p_r>~plRcpC;{Y~`1-X-pIk(WE;`Tc-jFO9{)F%op@GuPz& zH9o=3wJr{?{$**CMb+<5q(+VEQ#%ultqKK)9L+ndtA^S16`E=kE|2z|;OCF!Z_FAt z`j)uQ>?0LKduFkoi+P=*wuO-%&NOe}y4CB~3dIuo{E{DVhHrwHG_eHPLcAAIz3Bz;pdsz!gJSFkbQxXa4mJBGl zpCVOqX)tZCN{A||#N)}D*VQw-=>~hd3OP$7Lo{Oz?O76y6Mnp5Vfe4xV%$!LZQuK&Kri7Ryl#~J=ankOE7S`&u7DqXFL8K zljtYogg(JrzQ&(ES#)Mjy=P=P`}SKwCr!1w;i1xdqWdlyk%k5V6+4`~gvq&~xKm)tgZNh)=5sH*LkM}pFn zD#p1d zd=QNK-XRjBnD#UY&tX<2o!ndvi#B(AgTk`A_M;w#irLm z`@r4SAsJ+^Ea@+o#hs^NA*#rYXtb2PKbT`}z^sx*NgKwVI-lf$`F(N)-Gcu93|?xj7J8jMZo zchoXg^Papp)f6Q%{dMPZGTAq{`{6a6CCA6-@tsa1V_z;YoM9G|LY7DW{salQmrPW2Q{7oth z7LxQH0Nlt+GeYNXV=1r|&NiX_5Buks2|^ml2u7jNBD%||N>0keKGBoAA&9Kk`xi%g zs@*3~c}!TjSA91MpSqVCj=Njfm)HK*@qBl?XpAa4bGuiGH0@9JRY^@Zq>v)1uz<{2 zt<=m1c-7Y67K*QGK&e0L1&N_GtOqVAKW?PGD7)@2Z52zOjP~nY(H?zI70<$ujQQqR zom_U+jrncS`P-t5pt31VpMZ>xZ12#^oWOCLKcq%}L%$&D)3Huf%53ngoyga8fr)7@ zJ}sWjyhzJdTp^_XqZrpMHT2((KW^<- z=N0XMlnq4;NQc14wrX|h`574)NoI5}aVinJGF@c6>IQfyMjt0gwJrU;imr{%x6kva zKgnsG-jtDdVDB8sBT8+i_TI+yVPp+`Sv&(v=D(uq&Gf|F4x+azdcVuZcb6y*lD3g- zi8aU0$>LX=+Tgm{HL%%S!8d^#HC5s7P_*?b1~MkGe6A_)k0EkG868;4{t7fXVa^vw zKDg%hDAdHEo>Nxn==;`knTttyOq@xyB{|*U4(7N1o}rc6noUO1;h*S=_o~NK^yiTQ zYKt-HRzn|%6#k$}VcU##v1y~;EHb(5*2VF{ZT@8DIlZcUqO21#!+~HIKW;!JXzvg( z3I)>Py*v2ijALAuQSQLUVR!#wouLIG`%AUZKG^(#e!CnI=n5||PBppV#LYF>g>pTJ zC1dK)0l`~FpRRO^EkDO>vf2v;NuXQ&Pvhqc;1g;lBeU*e9jaM2md0Xe(hB^8izIbQ zrHA#A$|+(o@xI=9!ly|0tm$nO?(y4K=qCm}f**TDzcItrAAZYvK5|*{6=zl-b67&F zpLrnQs>B|mRd?yJqil2To5g&TbX{-GzVAFz!h`pigkvK(g7Ugv2lH(pe9f0^eTqxL zk(&Rvf5d@EQlTR(LLWqbauCTVjWH~reaaoQa#ij-XSIQ*xM^(;$G{K_@=c|dz5F-k&=F_ z7w5>F$sJM~)=!4>{n?ulF8H{O_4%zmJmjizK zqT<3ncJsz3=gYqxi5;2sI4QTW=52Ee@Hh{df6ZlwIeQ^_Hy8s;?6Q$DZi~jHkBox+ zR1|{OnjD^l#$LT-3ZuHIkfUQ>CRy|`+u?$1?_h2_>rNHWw@gO6-Uuju!1$5>yM|8P z#5GRosCi#)XT#6LSW$R$Mco^xfd;gv3j88-c_d=GlIQ7Q47I+$PvypG_0O>p-$zC{ z86V(^D8&t(A0oc#6TV(?a7pLIHRjbJymv|*iP1to#87k+@7>j8F@Aa?LJfKixoSC% zcFz|1vJogFW!i-&M?#FAV{BO{8L%BV@K4#ND#3V&sf=trt|pgK1bJD;MHQ4sep3_4 zMI8Le7uN1bunA@sH~jS-$mp-6vWltSB9J>J=ymB+E{O9j%?tF*Ou5UDY^tw3axS~S zp%$9K&oS6~YgmnPd#Ka2T6Q{&KZR?ula*^>nyhiB>ubEKZ!dGKY#vo|E3Leqwj{;z z@bIyrIxPEvOr)++%nUW+`GYQz=TqMUE&Op>pDM&a5?@!OHICDd$aqaooeEwZA=3wSMh#FpOAbPmZ2fF5|duzyX>#qBOE+`LJEP9*C?RW&+ysk38~K$)k}B9U0OY! z)s`4A+%6INWRVthxfNCP#O)#W;QQ)_S|KXFH6c5SGb*t(JsEqZgI`1`@2#KSw1466kqI=Dbaw(f}0{2AmaDVc1C7o<4r^% zI^fPysV!^oCuRZKF18{}pD*D9Io-wZD`8S_D2V`9iPhPt^cO&Mp`Mhw7x`trYnbSI zy{J2EE-@7a=JzlOWmBjsC&9b6Z^^R%55UpEte+;>R8!l>!A&N;=k_t z%$k7&>UW9#8FIWkL{v-$+{AgHk58#KbDHFf)jR$AynG%0Gi}*KeRJQ4xOuvHB(`og zz2T?OTI7{?XJ~KHg(7xtvVG>Bldb2wH*NPm?6|r~HyiF9f0OFfc+u38dd))T-8NN* z4DZU-8$5_TH8B0O7AU{IpTvzgOW$-2@VY|gj;~ah%hs?QNP}DQkGnFYvQeHdG0iNf zbfJ0}9yN5VJw%wHLzFpJa64}-ygXHV5tF~y<3;96dN|rM6DeV=n}}Cc;N|9MU-b8l z-b&x|cfQ$8epeBGK%@+;i~2XG&EK}I7xU~A>L}LKe#CF?sJSCGguv`= zgfCz-zK1WqrAdA)^%C)V*ors|a^b=pd7_h7ioc+O_3m~t+jxv0^zHHK>ks$)J;-iA z{!r`6kt;l(VXG1op30Y;^nyL4rC=G}Ys~3x^UKJj? zBG>Zx_*?(Hz2$Pp&e&Syw|+knzVku;#_?F{h3We%H}>Jyp83nhKc9~2q%^-;ij~6k$YZ zgdZ{xYiDN3?-9AB--OwJNLH2au&gNYE-BA(El@Ha%afmR)3foE?LT6*yhWF4_LX(f zGPHk9r><|Ov`3O64eVut6cc}DOs9eoP8dG!p%7nQ|DJCsgTriKnnkfdXJn|L&}gNx zIB;oRx`k~mD)QMu?*=e@qDC1ei)UUQqcJfBLIet{UpO+ z%O#_>Kk4*6^rv}xt>Yhx-&U#GHJ~5~C^~H4+5XCfs8O&u@mruYdw|}P0-;Qt_k>pmL{SFV! zK&c6Bcw80eMr>A5feYOxTSo-KsMkn|l}XiO${j_l`;fM)L5}Z9HEg23uj|;8p%^Z! z2X=38hrWKU*Ju=wy`nDEJ$ljpzk{}o(s_$ls;H7^+jvUHsgu(vdk*{QGlr&V=yTG9 z0S#LjOPdj9na~dAJMQh*ZuID?A_OwF_rt-vlT)GFLppkST4c4}VOHT4uh2GcJ}$S@ zrYTDZG46Qug;|rL$uA>#Cp3JHnzrU~?cQ~LFR>qOjS;5?m~$&76(&Mw&?*-sucVsc z%H`n9*VTXfPqaw~ET&g-9!m-!q?JE6w|=*%0}ds!f-=QS!W=*Q<5_F&G2Nu+A5EBF zsL(Izc8OGC6&RqLkQkD1Ye@JxCG&V8cv#vTRkR08#eAzw4<2lxaW{Dj_mY6M$Vq-K zH~0!s{NfP*jzu~Xt}(aHQ~m-(SbgZo1ZKVIFc0@)wSt*+*El- z1W|X{Fs(_B=NQBJHEd7z^tMUcJ7#qR_i?x|X7o%WZ^!!#py1Xqz#B&_TuFTxi8o)B zn4z0n@-LynsOrS_zZPt88crlTLjZGNTdKBY(B@3KC+Jg7_D|-o(LWFKGSXJ(<{_EB zuGrA>X7MV}nse?%45{K&Kas#x3C5wFW{u4UEWc}pd_20kA{_b6_yRrClE+2iOin&+ zSl_Wq5Xo(Lgb44?Nq%g+nDA~_=|lnNr3tOAN@ow9(%fNA4f55E=FZ%wL9?(bOL*BC zJ=HyaGsAD@54KTtL*|vJkfEOKZIJ>|h9L${^MKBZf%VQ!Q5kb&68=*5M=ACYeh;HX zCxo)gd)tD&tT+?(U3rJ8Wq&pWm>8(A1jn0Sh0GlU!yk^|=+1CxHQ&&-%+C>biO*iMnFTd_ z?0l6fBR>6XZN}PBoGrG;oL)f~PhOr!vSo~+H!f}9G<4VkbQsp69q?WO>q5Ww8GTsYU$J(Jq0)iGo@$@jFgNj z4E=2}>sKn^z9nJjNpjvVTz%^XQXVZTk+zatE?qQgWe8$*&(1j$5F5qan3AK_TaL68 z-GcXd$NlIDM6YR;7j3&adv+nnJ;<2Z+T?@D#nQo8x#i*hN}vJQl!t!#oS;LWof8;6 zWxeCEnrH1_>Fqx1ujzNJ}U*9zdE_Q)8m4eiknNuJeE|IK=g- zGMGpva!c3NNb&`~PA{tHSi76AwWE;AUlKxg<=%dDRUnxX7B)7Lbir`?iNZ7Fr3tvA zP7byJUF^spyN6NeRx*E5JX~)v873Iswv>F_Fzfh3_lu$nXKWs#!~t zMzwC9yNA+2WK7j!tp8DfQx}g`yqZ04NXW5XXb~d(G%PP!5!e`z6cuhIyNHVA2D0pg zk;Uy&x*FbaA)O9rjXy-DEV9LfJ?jMA~Go4I9<1 zON5@bD=j$RQ)OgUyfq}q8;t=Hb%#3!z$PR1YMugtbdUcNM zX|samb@wKz0 zf7ftBtl17C77evU<5ne>YZxiSQQ_kr{icbW<%hut%@JnUWRf-|Ul4APrA=q4we{)Z zly**Gb>Wsi9A;Jj*ytALn#LjS;%JU_q?2YJ>W82(w_laF%gl6vJ-Ib5_JVHl+}^U1K}E@>8C@-a-!VfW67By%f&EOM}|=|7SnXfEa^79C3f*K zuR44-?7T;;hnsnEyTP=uhiGA=VIzJMb_r*hFKcKn7_c#|Ke}AoC4xiEK4i-%vIB5a zVR`J~Y99chC4Iap}2G)-i#kX|=CkyxMLwzz>a0upQsOPC97K z8CDPiy-5?(M!*B;zivAh`uIdXa|iZ_%*64PTk~Yd}!%3*DFQ$PF$Xl z!@K9-=U`xNK$g!$XpmnBHAXz<9#Lno1eLq&wp9_gr#Pa{AhY{OFAxZ#S_~WYWB467 zW3LXYgt!I{&?$b0n1 zf}Vn`wvpnbBkV9TJl9_rZ6OHNpQ=ME`5$}LHWW`1Q2qXdXOQuqSF zYR#<*si)${f84EywW$u@dCTF&!F_9cgHJUBDwBUD@6J!Y*X%ZS``u^!hIq01Ke?6H zovo~~{6+3Mn%bCh*gi=?mD4#**PgTyAq-dIlv)ixq4{wWuJ2jy8k(K$^5>peW6Q*w za8E_IuPlv4D_#4ydr|lh>7C3EWhwL2lp=)s~ndN!KS`b znQbxZ9K{JCF~#JhrF=uGk&WFYy}Z7W_kRmtd-X~V{RTq+a^<_C1Poni=c_er``1kZ zodt{8MC!^98lzS;L-bayOr>R-FliiVpi`b=`?}nwW2(pU@Rs84_c%K@++;|_bLCW% zI^<-gafqwRlUQ!7BP0<%I-EG-0+KW|m;FX{#rAj=pqEWOq<*#b#ym&#k|f>L{e?5u zU+#P%J$eOCU7$~$I>xG0WvHk!mg}Uuwevh%GbKS_-Duy@HdHQwEYl%r-PoR%eAwBS zDGis`PMMMhF9<<1J~b~h%Jx!5#oI?`qhad!ICd%?U0Iov+I@epf68JlS(WIyyc3y` zC0xA*l3ICV6?dP$<)v3%wpd7Ad{SI1m|-#tAf6_l-vE57;uxk%Zi1LLEiKpo5?JLD zrZ>L3DMkXBwe_EG?;(9{8Jeu=4Z&jPfjmJpi_DjiPkKF|jwk1%gYZ zHJXGh&E0w>Xm4By$w`yu~0TANXpdexlZG~RX-8)f0*7$ zn!g}n4j`1aU!O+>Hb^GB~ z@xHNm=Muu6|ERWZY7dT!GLF1L71;~%D89`Y@!o z29Zko_n$N{Q`Ds7oSf>hsq-inGwG)T2bJF_!=9lelz!b72r2#YKkmiWbP7lxtM|Bu z|Kq7b?-E?psdN~nSo70fuP(+{$r&sutTe2nQWl!U$R&b$FAnJP=539;3Xo16`X1n zu)hxB?p))mbpC*m`wRruW{t8SgjL*eP#6MjAPlb@=p!2z%vV~T6=u=Vn1$hUp*Yah z0-~Js^`}^e?kho|9X{Wp)M%axAKM7#(_a94JZU*eS8#Xm? zqdTu`{AOitz-I97|2_$+wPO`FP4;((n%aS)-RW0BpWDp)&X|Cag3~DfVMS>gcTTnL zO_y|LsnqcONH@&wgVfJ@3bla0FdX*(wprsK7;C^k@hRo{2WspVWj|q>TvpEdG+?%C zq`>>`PE(I=yyKVX2`Z9y=M14`zAS%_Uzn(5PFEo@VZNB#obo&>rA`0i3fo_S5Ee0b zP}vfV>;L#(aA}AC5J%VbUg?G1LdyK3RQWEFVo&jGX)w(g_S;N1)$x8dac3=-c zLj%#X|CSU4?T-U^ZW{P6v;)Ko2zD=uSw!OlH?WW@S@B>_XS%pBy9LTk2l>GGqg^}@ zle->h65H4;qrvmUWVzkK2HnGdY%f%TAy^)&K@fiDzZPZr1zPVA)Zz`q#wegkrlolPlYS(TWP#efxpydlCk8YZ- zOH}#nC}o*Iri0rmr&?W=e&IgZ#F^o3+d(#Wh@uu#J)Ga6L!!TM&V;sdb)iC@X#?p> zIKD^G_Kansau)k}H=o`iG<)776gslvjSEFp61!;(_XTS`4@5R>!y=CXIN6}{^gN*z zKbs?J?;~lkg#KLnj&yQ|?-)LN1^8n?Ko?KowpEFFeYK(+7-xP%w+56#2q6NL1`vQ> zlv}Rl7bVP`hX{1`n~!NAVJ|=%FVeUIOu-u9sBKLLqfJ(T?S}-}ub4pkW!fzd&6sEZ zhx9^pJhZyPFL|<$?+(^H;zKlQ*g|lDOVHlv!n0k#wzPpfZ-=vxHwDo4hl2Ythz2VC z5J+$xDTMnBB8=&1hQY^Cq6oGhIFE+zPpG zK=4wW(xD8r03pVV9mJy^F0ULi0zMupldf&7h{u3jXoI$B2(p1bIS{N01=jA8vH-XT zDXXhJfZ%)^ge^ybRMB5DpdzFG2k-&{Sb4F2r4iZ+p|tW6lk#JrVwf#qr5o2`Ak*}+ z$IUN|EtbH~fD@&wJf@o(LMxaJz;NXW(sTQ-HV@mTn(MNhfan`&V~28dAEC&UVtjw_ zdqH}qv07#*mmB;-qn)WA(9JA)#AaAjFoCJZ{i>MJ$yKwl(%{_?k}c zuEB0v9Lq)9D6a7Vj!mtkRN;O`+0a;KH#5<=e(p~o9;nQ9@?ME)B^^Y$K$2hd%k_s5 zW@B!sQmPx2r3jGLj9`T2+Efi!<>^pQ>Y7SMH)fduAkCnRfr8>hYYqSv2oHiFUr@`S zIuZyV*de@Y3~(O+2BED!FCjU3ygE-a;23TK1{X4s$dGnv zhoWksgkaE$=75YXR|t~_y$-}HL&ZFtH<10`w*U~t?K2Y(xi`=N443Egm)92{>IYg* zU?s{yTqoEeYp4$U+iOrg$Ua=}FXhGt60_vkAlHR8fbFw_2jdb1pa39=QoA#ou~0Kd zGl2hQ?!VTyvBTxkzzisxw^ylKpH>3Ad(6gPLhKj71HC~LGdygN^%iX1zP`TykZ0(* z0>6YSxR@z$=5&hdBe$VNDIU5Hpeq6y#~-=RU0Nfjd(?GU>BXp$s_Ig^BUg*UCJ4+a z4>`VL`ULP}f4ypL9yrKMgDK~nPfKK!$|JHwlBVR97R z?D^85(|=as9OmK7tV5h9-S?B7pPjVQ2}n4Mo>2?2&hH~fZTN(f{b#7--D2OpuYDz_ zOueZPUhq-_`*Y6VXsli-)&4u5A79fAZHp=glHHDRo*PR@)Ojg-jMhc63Jur?-2$8e zkuL+tE9hSQzYt%&CX6nFB{?o*wdF?p;E!LWBHf>ZFG5iObRGs>92Dga^&z+I zz7}mY0mlk(AMTX8fKBN=9&==1hOIP@_#48ze;KeeAAjNiM?k}dbftxE25x+!LgwM!8;Um}Rg{hx&B zzUS2=9}aLmyWhtPUZ(Q860Ay5PYat`*PMGmso zt9DCK7SMbIXi^Y#t@9f!5*iw)`Guf1anQ|z`J>`WPAb8!^ZPn2Q>q+x$)SSZYCFptwDe8*#C(z#DWh zu7KgS0SgrRk%CXp+$?~D4~Q7QwLgz&a`;VKeU=>@g33d^6GW^+w4s*Rr&rMQ0x@-< z)R-_?2xPgRhbDtDk0JAIP!mFdHPLi#lqhCH1nm9L*4iG&8wZiiTvAS-_jlH9&jPZ& zU{F#T30BG}z;oO)O_2e26g6mS4~3FLW&&aXUn^dS;M=o;4tHe!ep43r+jIh^1DKTJ zCk13cu?hgOhHf@|lM@ZS(R*+VJLMD`Y6XNix%g#IDar_7c(KI%^We&>%n zB|Tv8O*@9pYrNGvODxwearxo%9#W8dmUzA|OB3+(J!9&;>+sUCHa_-mW@S!aee*T@ zaO$%sgD#@l zU@LdDVh2n)h-Vu~czDC z)&_9qO|NAn)Y*U^5E-TP<-~!zW7C~31<$Tnn&QQFxFk zQ{ET|HKPD)JOj32X&=#zatHS&;7H^O$baacLmTF!T1*wF1~%ZFWP3(H>EAg?IFGL& z)Ivh05^3^2=rdTp%8#@9JHVR=Zz*ZiE3Iy5o`Pjizue%RQ>jC`v+JWrLe`yC8e_?& zy^}TVh-4%2biCe|c|-`-nTwE-Y|HA7TP@>eGiz^Eyzdcy{6*O>RCeU01%#B4pSRfF z&7nmzf?mK~k)O&DSPsdj#e3a!9%9|ki2=ldznpv8ZE0)N#;3!ETSkEeairfZ-g)x) z<%gLv%n<9PmvZeNCk{AOk)Rv$1V;jgM%F`A95=k8(AU2F&h935Oyi{ttArU{_D zkZrFFIAjn9?i^#lk_noPA?v2RSXoRU>k?$?1Q|MjatK)+{6n`|&WJ(oQ2!ceu+*d3 z#gBo7i(P^UFvz*3l}U-)#cy=&n{JVw^N?u}7ipS$fpB18N3Fisi`0*;{_-C=D42=;N5Eju?cQ! zl3MD9PI+9HF@DacbSXROeCk?tSKApLcL#@*V^%)7|XYBTPyLETadl9+Zo##b!*e7flMu&C^DO-*f6U?dVVDmfjaM8&I$h zS*UNwrG>S19yjqd-{~cN?DjHBB}$v4FUy+!%8+=bKbdJ5ObfA_z=Ofy0G6KtL(>SF1sl*d$jqkMtP;H%FMtx&2E0!UgWB7#=|t*TtyI9GA@m#?1-S$M zewPdbc7hyWIhe`ZQAO{RQ@13CkRjl{O+$D#@CXF9xWSOe;F*iUpP_Gog92dmbt+Fi zu3c_?jqzWpPt;Katg`np$aM-r)S*8^t6W=Q!_a@XKc^i!3V=pT$Xm=6v}V&_aJ&PM z-G99`Fw|=WL(_@JJxw3Ypsf_3s=k+j4btHYU}!afHvtC$&_9M8O`s(J+#4@I8#Z** z133-&K~opGC)EbDWM+qwdE4q-pHrCL0>X(N)Ovd%9BJAmV8IMJ#u*16)W1VfsG|id z`ZsXOkpz7Up42!78hKaXZ8jOSA9tj;=K)F?bYiy#pt+CxpFm^(&xHsa7@~gM7a+9) zZ98N`1WxY;x6cKHE8)u$Tcp%BD@2EGqg#bK(l=D%vhe7g z0w+Ch{e9eKa#P+|$slM#Ujat?lC9~Wa%uv0A2~fny@E>-l|CicbQ5vxsef%FyQ050 zlWtHub}^mhY+CB=*ciRjw}6x0Kl@3YK6X`M}u4Jjn18TF8HZ0Sq`_I@i7$eVh0hSOdhlfaB4a73%y{p~Hp%uNzh? z+B*o-x1*Ho0gdtCYz<<|7ixZ+t$kqnKD+-d$7G}MuFxgiY0u_lM{sIgswh9+;=I4{ z$V0Y1bMay}qer#s>Ce0=o)7o&m+ZK4q6+VXPEt0h1ktJyOYJ``h26`CuD9^q_H_sI zPN~_+{bc{-sF)Uxr6m{qADyEILr}Lx!Xk!p=Fpk=CpA-Ql0%n)MNuV4Rf9{(J)-x2hfI@2XHm!G)0+R6ky z?dS1YhobN|j_jgM_2)hEqXIVGGXzn*$-U9wTRfJ_Z}O{&Z?3nowDi!PW+UY z7?*ZDjvRId?rucwuBAhldmKLd^NxZqxLdQ>{oA+>?YG0NWUTl&(`esYY{Jd{l3km0 ztQk_C?-MMAI}XYFJHQE{*RVTpx*Fa=P~>ZXNhcF=`-R@hRzf|cm3Ku8b9r)kD6T4p z_BrFgaNxYz6E2EQDO0acQSj@jkw2DKYkZLh*!K4Dg@qdKsM6O(^!MP`IWMEzZ;Uw6 z^kB2}|7`Vf?L}8+DTv;^6q_jmUe!e%|KJqYsnzww3~QGtw{TPSGteRv)y%kl@LMnT3@mly;yitPe$vJ?*9~W;C)vXsFA^8W49AMu_o9PwS3z-`6S_P zXnAE2C-+rBVChd~y^h!-E~{CzQnYoLM`Wi&-uCLOju-JPSaXjRu}x<>D==qr5-@t{&LiW#^>n zgb$sv9woGK3sN*La zK#2s8_$V@{5evnzUX?dzrp;HXmw1x$HXG>NDdNV1h%Utacg7y=pFVbaexMp$Iv=#k zmEB*kdV|~Sgv6H7rdp$}BKg&Q+nN1BYh81LL;YLBM0z%xnF4h0TqOFnhY**kT59`e zDYY2WE<~-V-NzasaW%1ceJ~#g$*#r=(y6oq8IpK%hZ_7Sy$1On@7_5nowJ7Chw%H4<+pUW)AhFP4C+2 zju2an1;$;lsnddGyFh89i;_H%tdx1kUAPV>BaBwM>0)iFFkb8nE6KVbeylT{72Y8v zP`1YWwrJ*ljF(EwcK`aK1i5yeJW#YIelfgBQDB_OxFR<{I$bwXF1djCp*C52yoX{i zLC8m($P^M*ePSLp*e0O9ueZ33N{XY`kuWT3Sky7F{RC@guKt_-;}>rbwGqblJir!x zPco}$5_U;105f|*gq3&b>kj9%VbzzYgD+b!Q;d~_e|Ys#UYeZw=A%11v1YlGcd~Rm zJ_L4=nLA{rmXOZUwdZ{!?EI2r;LDiJkJQqyTU;IQ=`C$TTFI^;saZSjraG06V7`|! zZ&0PUelKE5Em?g|q1uQ{3uUfm4N znF&SLyFJ$^Nyx-M6^4tL_3jmOwCTtQwQ8qg;Av4Y4rPG;lCeCQ?!HIT?;TrH#!3BX z+eRwNIvc_J*l9carX_AB%N~(#UbZ>bi@-6OH_9Hsh>?ADQX4D=I8S7dX2AS*u!kyD zKR}A9&FfCZL)>?*qc@!4BVDs~k?zIAH)1IrbCSu&MZ+Td_2uT--#xFKR!$+i-dJqF z;1i1`91MSt47126QE~`&^BOvm4GuYpS^V14PAd23Io~JrdCo*wh)AC$v`_lyNAxlL z7q1Y1$>EKjH@`UzCtmoLaZ16A>VNP2e@`tFK3|0x_ z#t#kx;f0*pSJnPxmM%#gRe;PtX-l$}Y69@kx4m!VA8bwT*TA>poeT+BbTE8S(v9?= ziG^0`4k9O>!5JTUrb96~*;>nHarg9s*2Zj^SMS!T@TmrkY%DXY0NX^THkr4&T#L!g zC(m6~T)*K*fm-fimgusOW_UuMOut)f9^eYa^fy)4+W;HA;Kk;*ef>;CXEUoMu37y_ z8?lI37LB8{&gMMJ6%{9x8LhcrV+wDz(%%|*r#IIVAJICsN(uX~DpQ+e(jP!vE_&+7 zF@ypwOA4t->?-mQUDubE7F6ZK=n2pZGk>V78?YAQ)vaEfIWl!lrM*hMmc#TM zzj^gIOVgt?MY~@lA^45P0cTP^JkUWk=ALA+q0W~YOfyUYbNF^4t%F@|+F&DLn#rSA z5|+Y6%BfJ^5M|5DWDG60I++fiap)x1R|rf=hl0@{KL=m3Jj!D{F9SmYg9pyri1lLm zp&ApZ1uwT#dwrAU<2JNzOm!s?H%t z`QCWmpLCe^P+4+N?v<7NvRY5te+4nzm{_n7^t+d5UkQFNF7#Qd_uC_fywnz1;R#={la16c9|Mhz88oCH`yv z_{lpTV{~X$W{ee#U!`Xws7cu$*OJMD6aE~x4YQA>I zSE?p_>3(@VW!{LZJtN!3k~$K>k|a;8+=aXAm1h?bW>YwMek@4xd<^Q*+`?>=BPW&& z(7EhIl!IrQ#8kLXBrlBPS&UlMq?7I~hmxm*9(l1}Mxz+aZTn|MH&}L>v<}M@8p6YA zhoU)}D&pWEBw~fk`RLVac-@&p!DU7*2ZU%4_r)$|+p!FN!dRjHTRp}u`5V)>{f#%u zC329ZHxH3b(R4S$v823*OY2ja9vkFc2_>Bat_?$Sq3GC`TR#vDoIzLRV0Y~Q3&9fW z{#%%OJid@__^R*`NeG2YGk;m&DVS?W`9b>v_}l0F?5Z14O99zcd6%=q*c18R36AXe z?+%`sev*-NWN0&0$-On;tZGLu*YRe_TG&Y|KVPSkUv zy)!|k(1b5wKkd0L_oPG9!tdX!Y6`jKI{OQ#l($%>jsQ&Y(9oH_L~ZTeG)FaZacjx` zAK!?C;^j)5XjSrZj6*@x*~7Q%&mV>8SzwKJ8rOO^NWJrW*S7Cm`xb8w8@{IZ-vde8 zDoYOhOH$j~fhqi0yBhg9UCkp76)kKu|gYC$lZqTi(iG(zS24+u4iEVa9Z|CK_7~&GPX17 zVHe>}dKUHViQQi1owr=C` zz1l&s1{_*5h$^(goel6pauDyXn%Ng;eo)aHdmAk@6*|y9wwky7bLYX5oLXTtt_j|I&72mPsQ~C|1pHIVK zrx^Js5OtUiJtvdK{F>wBgm|( zS1gS*5Hr#>sldspSgkqE)R~`Y4$C|+N^&ln^hO_IA8BsY-M!O(@nMhyxN6h!T#y%4Y zsm-L9t;WB+#J%c&a}(idbPo-OI-|tFe~gr3@!iDPAA?T{lJLD z*PJfCpS0FIOqqE?dl*FK6a;!CRjZ`=M&|=r_(|LK7YSdU9kQ@YKN+os3n_FjbAOl@ zPrNVrd4}_OWFINNrArEy;U^8bSGaUUI6WzCg9Ow7r{Zx>F1G6RT-F$jZ z?YnZ!xlgPG%ACmF2=0bBTlN1kreZmBv;so~`0Bze)(f(o-#l(0s&m(5{+jbaeAR1F zRrwwHlR+=Ku#S?R$7e^;vd!T3PW84G!q3MI zk(Mw8k`C@lM*(aZ7hI`L*%CQ9s^}`Ec91Xm_`S%)Mz>+j^zF9W7&h_iq49kno{yci zhC-L%{wZZ&q-Tb0w4(RZ4UjPCvEN}=b$D+GzV23)OSt^8U~HE|>@r8g52b|O(3T&j_^qZMp6Qt9Qa$J6Gz5DxT=2zgri&IyWx3llK8PQ@a;CgV`&N?hMNG zUv6xjCC+vlF~g-9Lvs-B=8}B22F?*rB|K~lR6I59UrjLIGt3!_M!Nmx_EJvaw@I}x zX`Qpns$M%a9`XOsbe2(3eQz5^5rYs>X?}o`(jc7*(jcP5&@qHCbV(y3UD6FIO2g14 zCEXx3G!g^SFyv6a`?%i!2iH=TFwC5N_I~z0&vjoLU%yn)mk&+GO#c_-;HJQS*ree* z_QDDH<4)BxW_~h#>Z!#x^u}>XboOY9$1aNpPL1Clrm>)cwHrCSqA)O&hiNQqw&IO< ziMvzSj8nV#shT?(I%d}TqF00^2RanS%{XyW=_urF-{mCI?1Y>+muYx`Y$m~zZ$=D1 z2Sa0ZNrPUOw7k2M{3F-p8!UZGPI|NkmP_$p4BH3e>)|}_RLk=y-#s#wTrNF0s%-Jd zsu&V@g-RY>%fHwt6GLj%b5}iIw7=w+dBo~CN&mpuBQl8G`%b2^r=`poVFqV$(ns78jlgY%&(lMZ zxq+OQvPP!{J^ds+aBzyflpVy9%bh?%8qKgHca8F;BV%&K6sPukk*)FNJl?#Yk^5yY zSr(>Z5W4trZ9fHAwz)3o@9isrtQgv3mW560Gz`(Ewi~NNNss}dhy9s2mBxrRQ94c1 z!MeoiXHN1NQT9ypwAX&6DjjW4wB=|X?uUz|?U?&2ZX5e--D>KJp7W{7I*;yMcvunF zu~tjI>U|&WRkACa-p%LiGl32%@Cbggv7avyCMFWCaltUCih^RAfZ2>#2p8!at$k!$ zAks3JH};v8UMWY}o%S}>oq%J?ny*XY_k}Kd7)g41V&JkLN~~VsZKejW)62T)*;Sr6 znTQeM!{fapg3a?j80%^7i!|?J^Y-gta=r?V7&XhMyHEP~&bc2a*mIEWlhV>WV5Zj8 zF4Dt(Dtl75j-{!S8|W6GXVUA)FfUc}$ii^$s!KW_KQUsZd)mBW{mDFUE#|Vs(3vQG zU#=lz$IaEGyF_H9=lpn_E)DltTZB>C;*1E4RB5r=5x$~`1nNJQcZ-;$`lvmViJX4>+ zG{gE{_u^J8@w$W%&bJ|h0DJ0Bb(~u@9w}qEn_AdZ#KeU#mC|F%U&G5AAwz70Y`#a8Ow<>9o?Fxq*8exx4RlHzGE2Nb6gBB)#6=*H>754R3Y$;3=f{lvqFjioC5$mrB~>8}Q*mfCUGW5vF|rpY1R#Z=#0m%632 z1Hz0R<{x<2C2s}f`LEcpg&h~}m-IZ5Qk?%hv@MH8rhZv^+O@kbL3(QqQO#t1JoxfE zcUK@u=fk+(JMSje*BWhtG0qwRTQaCzr>D>O^J)*&ZFIf9Y{u>9Us(LdwM$AaS2b}O zk)&s}H!A%~aadeo)I2}Z^s#zq@69K-a>^M#LObtGwcl&;>ZWH~@ zqzBenBXOo0WqsJm%Qt>HL+vd2Ll2pgxG((7!Yd*Vniln_n6u^jkMm|%DxL&++V2Tm z@0FY=v@uh$6IRsLNpz(PIR>&tKQ^gxB@cz|VodvX-xT_kw_ILA`@C+*DI@<{cHi%| zBl3Mc{y()eWilbB_nV2gdjIZV%uMnGBiid?+^23+_x}KfIkLw$PC?o06CJ^{I$d zqt?Sd@cAj^!6s{I|+WB%Cm!s>IL-}cL>%;8Ks+mSo0n8(jPvPP^FLR7g5_Uck`1t-tdw|ENTKl+H)p<}345`9OOy!Es5M7RBTF zKe}qqo)VXP8Q}`OE4F+`x92#txS>Y-vGMbZLaoOQAhhRE--C}>&0@>tdPo~y3*xS(P4crwRSj%Hujak3@gxSroP)SWJ8+B>xQCx*$q=lSRAKhfQTCMsDKMd_S7B1X>pe|G76ZX_$a$g-vP z50V>r_hQMmZH<+&iZNPFgCs_k1vN~Wn=^6So%`nZm6tgVig)Gtz- zT;W%NOb;F{gHY7;cwvR+-d~Y4jJnjJ-EWvU!~O!WxRs8JZ+JVwj3(4mIqteM5Rmw{ZqJy$wDe1HAjS@{i?tLbOB zMrRdcj9X#+)>?L~O9yt*6}XmT+5XG7CujRjp zEJVI)mc6$8CKWlG=jbpm>rsLkTGSrOUR}B9IQy}p!_Ogn?sVnCYfS4V6VSS1(}(u+ zbC16j+AVh^yg_6Y@zvx>!VI4M(Npwwq1)g2vNKb9HL$yTvPX-;A&wQ|32lQlIE_mX zk2ddA6iIAYpvjJpFJ?&M!p49~#(I2IXL=PDezGSiPewC#=s8GBYdtpqwl?Jc!1g=E zY_s|sJ5B~nqPu-0qHCGfRF{t;J2T6-i8bR5z&ZH1iE(R4>hrPLul^#9JGbRxLK&2D zXGl!zzxt1I<|e_vtrVm#2aPAR!J;uq^*Nbhvm_~fx__SS)CDDHE?$ZZsyYYo7SL#4 zfhSir1@`97B_uiL8kaw6foV95rHeJJ4zZMPKSxhK)lzSeS$HZkuK{|A#)MMzwv075 z{9zpv=&$7rn0IXKmIzOSBadn*5>*ttTK3`<00@4C3L zc?@!OBEvM+HI5IC@`OQ*tg{olkLGAhPU|k_ak<`hsDc(qG;#bk%Qg`ulTpE99hTl_ zN3Y;KiO$xIdNr20q1mQk@nT24L|!JUTy|>5W^g~CkY&w_JUhh9Bdf4*V|!e~aO~m@ zHR-zZK)y+PF-`7DYFO*Y_e4_EQ5X4!;oWy7J^3`6;u*cpP84Zql1He0gRuwkmz*5m z2XASfm)O_Q4xT2c9O+{PmsFcFF=ZRvCB$-`qlk3ksF`%SbmFg)DI0~fP*=+>9N*Kn zEW2uZ0a4kK=cOaFpMO)uJbqG>T4< zZ}i+?f}w$+FdNU@!ZXyJDy{~r0*~i zN!fL}ZsNN)h0N{w$ybEKk`B}sd}?fVwzs$QZQF6nfn zp<;D|(KhlQpJHTPm^VHf&saWU4YC%=3|$fRbw=rLjUOF~po~h? zFtnZb21JN#z6KSL*HIa<{JhiuGwi+!(?@GJ{L07p6Py|5lBfNcl!sq`zQ1fqK9%fS zd-mc*Z12jNy38u`uMvg~nT`*(5%}Sk2@JLA!~kJuZA5l!#^knjSdPN#KCI1Z)V`jJ zYS%pule*{=_-)$W6t=aRHAwEAv$?E=E2HQ8(7q{=;Z@V1z3K~(xHI|=6k6r1V)5^F zF#XnHxPwcsp;OBW{L?wiv%$l4L^2umwf&rXnhI4MJF|I?f6rZYfoDO+8Ml7zxc$a}w0@ z8v9JXt}d$aG$aur&gJ)THP7O|FT7gXalORelBm5&ZB9D+5?9h_7uYK&P00dXs|${q zldlbK$E;~}FGoCgr&$xy^QN9^HCd)H`OpgZ6ba|D;_vCT#UMw&Z-|zs+`87;<87)N!uk|hR~m(rox^U3>Gn?kd{>i0V?vg^5B-rUn-RJs_6w$S;IYlnKe?^* z#Iw3lR*gGr=}WO}*B8*;nWnmgT~|&kUu4p z91^^&x)fV_A%I}Dxo7~KSnj@7_Tz(xd5_)jqL2I2MVcg{Z-cw(bds&R((t6Oe0({6Xo{O8lk2I> znB_xzl)Zq~t{9ta5r=hkiB(RNdQ^W*K7+F>r>eNDO8z|PGpj&Mt0Lh1RfR4hFjdM z$+C57Bgbg~%t8m~$UvLacDLucllw9w zES#(UHfX0mO4SB)p5ux-jcAg6uDlU<1-_065r}{pNTRbY&rg z#Bs_zNoUC&pwm%06&5A{hy1g?{u8wBJN9wb{H9(wm9ssEpcTkQwE(*78~_xF;wzxI z1msQ;Tw`4rw2On5P>z4VGDKKH1R>O8{|^U+n&Aw6vHuXXh0{(Uh%o~FhELw)@NaM% z0538wAffN-15|~A*YsnDdMb0fv2vxq){2)j zcm>GjSE_HYQ+K`o>)|VSj-qhtBXZ|pv3@yg?C~mgHf}FI_P>l}uZtv#$tIU)&P1?v z%OA6pk~3AmWGNg#PL~5xpV`e81as29BRFpEm-KSOhP z<=AC9@oRm^o&r(2D!_+_;{ZOA$y6=(#}ToGA9UW^0zebmDtG{5;u;R_DDk+SY3~Kv z7EkF3asssEz9-3pEfr(NBTKTCU~`Cvm_K|5MgMcP?n! z|HlXcPYy^ef-oJxk{fI`0yqHlFhg1$!7bs-!%;oi&bzh`X{Q+nj}y$Tg1P3Yv15x) zP3Es(9u=6b$F6WT;$eSlj$D5a(f(e_z8d^d-qZm)>i@KV&@JDeB}Mlp_hrYA`6DhG zz$UwcH{l3Ls(=U)NJLe9pdMVPo84`}=NrdC=nP!~!`dE;}qVIDw4wM zh$1xNAi89F5VU(j(wm<^`Unwui9^CykeLIz6#+>jf%6`6BX>bAGaxyF=x+$1bO8PB zP*3dXVXjmp0Jz2|3eVsT;LYK^_x#;2Fb6@VkP`Qng1hXo7+C8#(IN-!eh;g7u-uNZUP>%5EhpD%}5OA~S7CsB)fG{q1g`NGa&L3^75LGu-$Tab_;l`AQ%|3 zDFYtoA8?2Kd(>PCW*M9o!1RPdw>+?CLo^EypwS=#8dhaZ3h^gfpaC5W@P9}u7^M*a zqJ}UNh)DyZkpMsjkiXCu+@6bbyS_r6US4I;RJvs;)dsnTfhrMbYSLXNbpDZ7;O_u@ zC4-3Nk_JpK)xp+O#Xmg}B%*>E{=nwqVaF9I1GpU^LFNUosSlzBaNa`^4-jn! z+Uz4$0S9s)tv0lwzw@Vn)j%jN_T=>Im+Sr%|y zdI6;?ytT4=!dM;g1>zTgFbbF|$=~*ymyJ$Mh0^^!?Q-9)LgC;bo3BK7Adw~bWMHMn z*8r?S@Oe{nw!U7}d*e-3Df0>4a4?Lic-0nd zlI^~2su;9fEApBXFEQtH-4D0ruj1}qVdOEoG#!XTv#&l?CS(7^OJv{gfuuaV_h~Mp=xCFd~bS$IC0Ul&_V?Qy$ z*N$q+xim&6S3l8XK&~w;CGh0wuVcFZclE5Dd6L2F3f3kQ)-d&(xJ$sq(~;D4$OOSj z5E2FrWq#HDlp*h%+WL-oS8IM(w;&fBB+@+^*Iov*=Jnz68>&Dkh-^Hjg0K*v>HZHm z%SB3>&a7uaLE(P*OWtws_wx%No*r7D`T=I1Uz3v|fcUvBH1Ey;r8R6Kn`xmH0MrHH zKubw@iRA#?NL&Kz}arY9S4dj|qbCFzAX zC=g6`X6uCA_vo*Hpo!Oy{VvX+_<;)GX2fbi839dI(Ao=%P%E9$?*xntSt<(vg&A)2 zdI$O50SzMu#uqdzL9a+HNcap@2;=1Bguu_HlHy__0OZQS;S3v_o6cB3Qay8aetNt! zr+kN7^$lcXCn6$(vJ&R~{$BDmOdUYr9He|c-kv$|V~%BfuB=P|v7FP>y6K)9`E|2k z6gL3K7DCkR>ercdDas+nhEWm4t__9&;9R6NHA%sD{QADeVA%vdxXB;yxT&b9RAOsF z3?7Iq2li(YQ440F0lbf;^4p&3-0lsfHsD1v4ryv4ft=z$t`jP zV9v~!v6p7|j2p(^`i~3_f2bn-&zSH6ptg~qDE^|swg@gaJB1yITx!mrKYyM@(@MP3 z)(+;>tFFC)U}^;)+CESen*bpjg|GuXSHoUw!&EZA)%}9P!hgbNfCF*O``OSimk0JBRzmg0TN|4f;6l&)p4&o2@npF< z0i?$Tm*e8R`L7tbypB-u3)Kg|3PFmch|>*7GX`Qc$p3*Iz>hmTJF8#H?j;EpI*7=I zsK;1FXc2>$b#UR~`%4{me7G&ZXa|;pyZrq8^osW(srp-M>sE+tfEEa#=9K~8)$Hq| z(O?D#3eHoM-=#K~P`@iDEdiE<Po&e1bgKA>G z8CBD6IUgU1D5)!VegT1$vW0AU;<2%@&)M18zr(@jp`od{2$E{p^qzt@I)*|m1BYun zxLxf8sh}GcyiA$VcYgS${OhF=+!@ZOk%&lOPay zD7p)jeSiU9ITN&>Pybu-(;EY_43>ahP6qrQ;BvhnAU%kwRP2h7Z%?$T9;8_zO@9UN zTIKs{4ubhN-jP(=miK&e&o9EasiC@27;1bReA>o#F+XFZo2aQQO+=<=*;C_UzSelg zU~3T>Kh6X5EwBA(VtG(B84@OI39GXd@cANKW;5o_(O~ZJd^N`k5|&=W85CKgfwm4F zGM!XxNYt<(Y{0g=n(>Zzbkf>0+w>~O{x4Gu3|G()0S;JpFU3!!hC#|hJw3bGH)`(l z=z#?-MM!ljePhF#QSk#{oG4vgT_NNzmv08BbK^I~yia+m?dEiEw}Ubt2j_TqAu9_3 z7DpTXWBd_ll=%Ji1)pQ#Z94D0YtSHqihKxDTj`10#}4_?qV%1KAipZ`YKu182jl&1 z@;Qf0^~+G%Vwg-!CmE5NO7(Q=aNZu6r=g9uBq&+_ky$`U9010fDM()UgF@2j>gho{ zhx+P%)Y7P$4#Q*DiLtTx?Tm;d@FQ34d#<;9kqIsZkf%7XFDk^|(5trj35Fi8``&MF zDIqW)LvIh-2SQmN^Nn6>s!BPz_4WU9alk(zmFRDPcy${o0$a4X&hq!=Re%BGyuaA~ zv(-SFv_-?P2++rs6O}oqK2CQ)h7urH)G{M{FV7aivd4Acnn&M*IFrHkY&e?GTa_@3 z$OG9N{;%^tW=eRC(2U-oYGpyWW2q6`%Up$@lzn(R)VdnGd~7&>l$e*9(!Jq1(nrw2 zVg}_{Z<hr)jo*!?Y0?sa= z3IQBzIk@R0)*&`4j^HlEtU*_ry!liXQr1KCWezxl=;Qi92my{@v!cz+9rsTy4+u)g zr@9ZKgzjXWwzEKiV(}(rTiu-yvjUz%XFvSCx7OC2&JZ`f`kk{LQpq=ya!6?GH+~b zv_>8&#{LGTXm?O4K*cjOuYeo}enH#mk2yKaP+d4MFo36GHGQO1IPZLkujJ(=c9%{v zU8oVGYirA?^!+}#km_8xEnsr+SI?F+0f8JI2V5v4P!_&LAo_MGaqClm@vDMK7g^qD z17_)yow+jbizV$fthOQ;zj0KbJW4TKH1)_q^XVNne&AT+m?@q;4Es0+CnBpkOLgTM zGcNnUjDAa9Y`Wd?>`(f5KdkJ2cEF;=8mIR(UA@lUMc)#$*WrYRBfNN&j@bmm;rCR^ za#PyTd|N_{&icYM?QUYT%|}ms+vq5<&zoA&P(k?&#ZperrB5;Nw8+g%xX9-dg?~v6 zRfX5aK^8$T6%TW~2z}0@zALu%+uyw>TFg+~V8;XIN;s(b%a;#9=EB^*TZKt@ z9sh=aco034(nkeWHERSSp#6^k+3N*RC}ODDH~;j4a-bH2sG%UN z&1(f|?VgJ4O1)iQ9t0v62A zWo0B_HHAdqRUW^-$_hKJ+%9Q6)`hBjSgnJ}RN)7Xd}3Ph+c;q8oT&GQNkF+uU`qo< zUtyK!z zd+{=1aJ4cfFEEQ#x{*txoA1)Ir0TDz5Ty}$>ie-Zx{QSp7G4CbarmGzx<-oelD8{m;%kp{xyg zXn_PMcw!We zp&gVZmb3attWWIS&Wn5v>4hdAUeCinG&Qr?#d(lJQRy?UEL7?-QIxu070v0*Ka z@y?+=WBDV3*s+emzD?c$^d0u}#9fM8ffs$ky71Cej=H8-hwA-4eTK*ugg0-KSyOD6 zN^>B0IB6rDE;-_ZboEm%vw^t4K0RbvxlheT656?tmk1qREr0MVHRtf%Yl#Lh!cY4x zB?I_Xp(qdV=1Pt2bt*nCHR-4(cJ3T0YDvZW`4_x%mT)dvHPD(4O!Q?%|e3$Rs+WrX76pwp}62|fD`D6cSHi3tj*Q3D4Ul3nF*>>o1Z z+d%Ou(5D3j0Dz<5yXPmn|K#J)*KyuM0TCcSOFTy{D~j@|#b+>hAU!LznZ#)U$GFhI zfX>^XSS#qP^?!%ajv$h18KlTS$pFxH3~W-tru6>Ag2T>RlznC)F*FN<*nABIz=&#c@ew=S#ZmN+_z{0dEOPU9RCC-%@uqzOqq;|dmwb=E<~$?T}s9frI;HBI0}sO6PDh% z0Ox#Je*PPv8v6y#F2D-D1TqNvsm+R`|EoY4!oipcBBqNXmb$nH#yUs;s59sF_~Xqg z!t8{D31heJBi@S+9&2g}W{bjc4cKNS(HH40Y{x+s`qpM@;7?Dk%VKH{=DvzPM3W1N zn&cT5-++{1+j@sjl7rJ=8qz%H^O^v;C-3t#Mm(k!WrF4==1bBzzasx%<(wRK+Zzb2 z^3HPOr|+TJJyFl@c`T!WJ$F-C>F}Rm@AZz_`DmEmu&~l7zh9vvm}0G1-@BBYXegDKQ|aj^bCsb%_?Ni=UXDW>acjysPpfJ9X3Kn$?SE<4H17W;4EE(h z8bPTD6_#pKGvSH)%0cOAjEaUvrYG&OKS0F6#^B&fFbO5y7cl|H305|^^zz^D^hQKI zT6pzUdpe}lxh#~V{O3Uo>d@W)-kW<@0{Katp{Q#miel(T3cre6U^5O}+AoEilBmxV;0!Ys*wO?o|$s)A23cSFQ za@TNBn{I_z;ZK&D4lRjA6G&sJ#f+j}mB2RPXdMKHACFY&^KIq@x1m+NHEFlbJ!7WX z|3obv-`8j%iT5>_8XP$4evi8lAah6Of*+dee|CkP!EG_C?jAb><6if(EAGEEs<2SK z?lQNg*}<%UXJtJ*lt5-AX}0TSxmko${l2g2HL|mFMIpoRO>9rBQ~6Quw4nlrrj z)XigCGxILZCuJI5S?$1Jl!=oPkv$I(M&NWr>3~m5&8Gij6dYcA|A)kmv1Hj`86vaM zM#Ea~kNy@`Y3WOBLsY)~6nOlh8tYe3R##USe`y{!I(9`B!ni1;uvs-VyuubN|91Lb zHCJ`Ac`{{nc3fCFS~RGuVjwk3>YcBG-dW~U9&h=9ZXz1DQELa;jzlSl@Sby`c?@)s zous1nF{zbCMkhA0k`*O=;wHzX0@Ey>l6@lVteD!4N20gHN5=75smWM$d5yQz(DnwCeG|Qxu9z8iY0*_zm5S#CFYksn>z161 z^%e8gUAvrULd?LOwKpf4h#g&3=07gY*eJY;dJ>=8FT}BnpWNasYg)qi@`D6komD3K zn)Yko?v3ie@ByOn_gkqG`P*hqJ(A-vCMj^(6xXWrGm_|Q|IZ0=kt~u(*%FJrdrRQu(`wvmf8f*hF@t1wmaHV#{SN3rpY$-jHB(@Br*?CVLQf-sMjjv{@Alh)-E zimX!aUyk44hNi6H@0J7PX|)8kXAFOHPu6%VP@dJj%WzB@Tk9Ei)L1?FCfwC0-`Je# z-KqUrUWI7*%F51;Yus#Mk;#u$k687#)_uKidV<{7%1>tDf^n3qF(ou)f;qu4T6w?q zgw4xggEfaRvuxW^Gu}FtOxY1KLoF3)ue@6fh8D<|O8E=y7!|Izp;%6`<|-pk&I5At z-te#5FJuH^``?@=XKG%}mOmzM&B*Dfk!*CYaM2Z6TZFF_Ute9XbO@?dFR8k)-oAA- zB=1Y;_&GNK6ZeQ~?2miBsrTZ*{?b^%^DoJJRAKk**AN-I$>o@cj_{7+#DRGaq@d^i zU~Td`eg{w6y$!nLENQ1=Lz$Vc(K;7i2b3>|=nOOd14((BmX<8Q#yY?(suP-RTVXSP6?4p+VWTj`fph5V5gA?`h?hXh zK$z6|$hB z)v2^Rwt#wc8_f}CKV?cPwwb^GjTr_el-;QfHk(I|i9KgfF=4o0vV#O^66#cPUcDa>y6gP1_-c zidWmijoAIFR6R>1tCzy2zIY7xnoD#Q-0}3eihJJwyO}pF|}lVf;;w@*45M^OOam!DFYsIL-MF@l#ADDp6UMl>K7Y;XO-7dVP<}f4gHjwv(d9L@aGS^;$NanWVXcA z6TTjsGF=$`6#GysGT&o;(~8)ZiXnDil_aKcB7!*iAkiF-^6@pw^|GbHqqpK(KWoAS z8GbS<;!9&2jTamWbdL@l|#N5|+7o)+6=bqw@;A9~jUNU07HM4h4Rb zaO+Gl!&Y{gvR-~0E{&)OV_>HsyOX$1vVd<}Z25ebr07_!+T^VHNl+gqx_Sm5M({?^ z)aRk~C({etjwnktLPNpeo&%TmsOC;$!@KU)I?l;=ImJG5^TS^1co+nsP75A#`-yv( z@tPQ@#g_Q(FftDly?A@cOZ|FJ?Ydc(1)OgM zdkW=1y7hPxkegJM7wN46Je(tgTUj=e@AEX|u?%gqdy;kiJYL{&IS_rx-!V zvV2P!DO8T-Xg)f4HEM=c8X_ijv&ij1_TF-8G^B1@nAofBic`OH;vqSDP?UNvZYC31 z+|<^#I$l6QJ0UFY^XPms{bG}GGM7@~1HOQX&eGd}l&CE#_EcT@u*%ZsDJ97()ec^; z6_Mx&(+~Q6W91SlBZD>>@}d)l>*ux}z;J z;Xc!zv#ccd58C|Oyh7%Xim9eOrzw>-I)Z!yaZR^iu~N7txLv7jZyl8JS^^~%UGNp?Xi zBApAOZcN-dWj0uq-eFM+$-XYFfml8rNu5(pkwk$LPscB$?&{x!%lIJ+0(&c$Cf31xW(1TRki7CYuA$EM&%x?q35(k50|Df zsmU4~aCsbbD1`X`TCpfT2+LmM2)~VXO;pjX_6_V}yHcsH4q&=hp&plKDWCE({ucK^ z%u!fE=U}YZinBy8DUZ@ix%WC}FcqGnsbR$dg zf8Qp-OY7t5b~aCrpk69=E^=SrOw35=%rm5$A%x*Q8-!nU1tqra@z|LJ%9%#>xDspc z`2DIaz4;z1JAby=Q*&WF9t_)YvF$Gv$toBZJ?g%?hH>v%em>_=>Z-zAp?IL+_Hz0` zfMQ9-p2kvkc3ishprq$1nx-++@f#B=oNn%hr)Q;2^eE@f26$>*|86&`;P}`L#~>qF z)BU@nZi*fMinkWM&adNCikq1t^6&$}A!SC1qPqDY1qmCj@w8K-F9IA=ZyUAf%UhD1 zIX+kh2J|W|U-a`Jrp{>EzHV3ER)_DqAhO(V(3Fpkm=DC&7hl|7oTx)%Q+tjTQWkIA zyWl-;^=8PboL9PoicdQ`YpfDwk;aoH);ewQ9$lOAwWe;k+1uKU>0syMIN{F<4ik*~ zxrLJHt+A;&?7;?3sxG_BOj}I-T>6A<4c~l4WP+E8&5ESBYr6k@jq#Y)Oy9VJWt556 z6KguvcARo_?-892ij(%xGw~YkO%2soDj*gKnJ6oX;Uos0Mc zXg8@C$Z}BD_%iKQQ+ck|I*Bi%FK`|BHe$jfY`M^pJy05)PO8~wK7kynF66{hpSad_*d01Cmx|B- zA%zs-Wi}x{O-Pw8^)wY@<4JB42>(Z+WdawbjQXJRiT)+lFPzHKF5oy+ora&eQQl?< ze!KtvytH? z*2ZSfCUdC+PIepf+7^*#aShtZlug%#{Eas6TaZgS6ZI~O>n)pb&X9i%-Yy%&O=NJA z==(3IL$u2*s5X5ZZ$N^UoVUS{Xkyd#!oAF7JG-+@IM4tkT6|IEF3!PP(4GXC6Ryt&q0)0@v@6lQp})yfX`a2Z1*1JvW_6j*EM4vTE|A{ zH?&$!8TSxG7Jat5?B^y_&ePf3vXt3OtS+z8Dq5#Yi29k80mzWU@#yPBV$fPXo>Ur1=@_RaZ-}K z>)cCBU+W3C>Ti(V?I@z17g+6sU3{4&HmSxWEyBBaB}cr!9dM6%ThUo%!RYQEOq3>9 z9J+g~jN{n`M`^E;`^?WgaNF9>7Kk2*3}p$wB_kk8vf$i#I5s}6sVM-?M9QVu>G}PY zJ9yjKeg>xs*yB==<)ro85x8k%7nO2ZNHd&!i$IB+r`%cVx`(KgkHfAYsWf2G_keh6symEqU7XAhC@ zi3BPL*3i;cO9H~x*z4P!EYc?;DE5#?guES0dhC}y$C*>R9g4TJCbXG@`A-e+mM2#o zdh)G!vy&A`#0iTx8t(ASW!`lgT=u7+74T4691$;p3$~kZ_zB2yBDW~yN^;PiMI;5| zgg+`n(dAY1GipzUDkKkdCH@{5Br>k~4o@CNYAfVb>ewkvX>8|kikrm>U*A6># z=p8z*I@|Bcoc~_jqhgcDLRWq_TCT9R)X8i+U7P+a&F#k7R7FKOQL7-LwO!%B>ubWx zdQ`f`n_h@`>&E3($orQTSzljYu=OS~(P8e>$FN{hZ}7Ozh{8Fn)3^tJpyGA{DI5DW zjOVeRX9)#MtwL5awKK?38%8?|Q(pD>95IrcP#7 zWJ_sKE1Qz2Hh=Zk4^A^F5{cfS>KkKg#TR8;N8ZcR(hhtxMW(Nr#Uw-*9=jMgH0YbC z##D=R3UZgoh!?EsIq5k63`>dNorwR4|B}$YOmxxF_0iu4v=w9)1j;t>ueR0R17=U_ zBH}1+xpx>6W7%r8hZ?3SF$8T(=#Pm*n4Ts3RpM&YG?^AVM;hGvUd6)lBla~8yH8y} zsnFeA+O$QWQB@umhIb(s6#1d8du~+Jzj)1wp_p#B@_yo!6-k5qhL(fYYZvJbuCC9% z*O^|t@bv37i(w=rWh`zZ5SK6w%QRP0VR*3YmDtaJEv~jxLd=ph=#?Z7pIP92;Zzn; zIi1;c9~1jDy{m$6Jl#JX+}-*_QoYJSA!U`iI^dS4;6B-Fs-IsO79s#z6NE{teuqn~EZxQ8KqUZ^un z;UuO`XjX+;_s2U4RWd4@o{OT`8?n zjIFJEM8{6qPaNUUc%2)~Q$8vz;8txkLCaf26}&wm>65ZU7J4Nt!hHv~%4{kp%tX=YtE;Q86Rz%Z7k<)t-{1QAoG%uq z><$MizL9DrfKKS~rByPPeB#8R1NU+U+|2mDd$m9v);( z(cTcJ?3d07L`$rshVe?Lt5<}kA<6G>SvCg+X@h8(!uXP zc-v2zKF%HZi|=A%r}8hq+L|Bhc-;ED=l?ML4Vo(4%e5_gJ33!SYX@<@5|XbxG5u*M z*@lXtGq^{bE55EjsNXzXzF0RGX)c6H0&uZ`fq`bnK!Q-L=?9OV-=x}4d6y14RNmWE zNC(CUJboDa%iD_PwG=~yQn&$Y_mShvKdjRT5(B0cYpfzUM`C}3aFw{6lS-0#b7q@& zp6wW43XKIpg^`ryL8BQ8Z>sH2%)J@=@NS7BT0=#L)LF`P>!!UB)lK64AaaCCwfDF( ze7G7GXyi=TmhEh`7GzmNjy&{u-88}035LPN2pw5c@W%V`w>)-m1f|4bJfmlcz>kAl_Oj17QQ4p9pzkVbjhSC7>;-l+}K-Z2aNd=Air9O>-@&Bpw_?fmCxjZ;-wIQ(2kd~4M3N4T6-6FBm(_%sLnHApc$1QzK6M3bL z;MOXssJ-JDIcU&#bHMS@e`lh7V#!5d-6Ldc_#-xdvlrPS7)MBxsTjjy`YNNQE3CkEZ>IoWTa<7O}qx^4Ckw(S} zp0{)#yQK|B&lgDo6WdZr*rTUtCiHdON@-3o(uUDf%E<1`I^J?vkrz=68Z0dys>}F} z^a!O&uGM#alJ>>z?Ka(OpK7$xtdnsVM5bSHa+PTkX=CzIzWMqkys74*bt!ZIzqY=h z?_&HIk4 zyg#2Y??N+5m+xinuIg*WF&avPE4Ox^JvYS|EtH+2BzM=&_IE_7)oiQRu455fEh~Lz z`b1e%Luxmt2XZNEJ zT?6-Lo;T4Cr|;4ony(Pe_KK45=$y?v-AzATKHX*T7ZcbV<86z7mlJ2y#GPg|oXoZp zAXj1aMr1H?ImGEDaEIocw_4R7q=_Tx(n>0WWniiI8cYF8uC_a|6rJ)_*c?|)oZQjtg-t-pCHX;z@I ztHM}vZGTzUh{=pB?(rkLN0!c-?RUOwXuo1Mx7x$!Xik*AFQ9pC?S8~ICk1<2sP08$ zpljs)gX%?-Rx@@?>l>=VQqa$u7n> zgJsbK=hZNJY!>W>_)GW{z|wELrpX9QQwUe6thzx~)XH5PL>Y0HL*fl5DO#AshR zswF6v!J>1(ge4oVd79!t%v17LiWKu1JtY&Fn#b4~T}BWeJ2xa%7udItP&(LUwCD^s z?D0MJ!8RMM+ixjYKVwnfYNj*BIlEs^K47+?%}$Es4G?3n8R|dp-B5VFw=BX%R6SWc z{lKa>ZB=s5L{ovAq<7R@r&W9(XXAS2GyMd9B9?`=`+#Im=nb_BQKj&^CLJk-KBcv` z-a`pOtEQ`0vihr!{Se}han)N(yabBS8yka~=ZOAdQV;Fn#RYup{f z^!Umd_Q)GIV;wO)S6xiS&Q#GR`<@4Forl9`6)LUGQmT|Mhlz66t(R<% zk2^OUAS}d{VpPq2OzWTicEs>ooc2-wQWj|6BJgx%FK*j@`W@?%e{qV)wOHodP0W_w zmg;PQ$xAiLt?AGdS;_5|h||r^rhcl$c}g)+efbXZc584@Ex~7SA^AKe>|{ zJMGyv!oq^vwc=o!YT78?tWmFV_P7R1gVgQiA8(UeW2SAkdZx~qPvI*Vxx=*X%(1PK zzRTaR&*$5j@!#Mhvz#nh3=YEA7`V<6x{NgfpjC%a{Fa zVzwQ7X`=t-^ti33YU31>VkEsfT~1q50;>_mel4D!=-fC%t4JbS2zH&?kIhctzZ=qj z9cN1s$T*zbtW$OS@=M~X-<#%P_posC3);OR!`DJyW3Xd$byaqa6M}B9+8mxa*E`mv z(qGF;p}IDlf8~EyX!WMwO6p?8tO>lj_T-<7`l7M{^!l#U#2(wN-nGzaEm7I;K~%f2 z)a=S4~E1!FasiLrh6C;bOxtEc27yh7Y&IWDb!n@(0<|&$FrD zs?y*zE3WVDc-$IxY?P7;M}OWfrret*t2}D z>T>J>g|}%7q9kVy6ktm1X~P3Q6VJ&zvN=O8d$fPE?|h#4pVHl3#pW^uziz>3q3i4CbMS)q2u?$o z2PG|o(N9gvi=>CGSB4@qZ-vhq6%qMlMUr(_v?GE^1f1kF=2*tIT4f81ZT_HH0c}pp zF*QAw9P=->O!WG_!e|wghlP|; znV!8ljje|ZnIEwjLiDQ22MDkTg|rF-zoW4?6uWjaw?0pj0vQy9(NQ3RaupHtM%E-96qQ$_Ow%V{)d?GB7ui9ouca!fZsl1c_r|NMnc;q=z?C=_ToN&ptOkcS~t7z$yN{z~H2SlIZRM$=sk9 zJJf;dBFfFNn}2E!l}xCO^w_nIa1a@jxs})&@Gp+J(4;mfSzdF#sazIK$x?2Ar?u7J z-;++$wcqF2;`{bjR?4L>Zj)yS-B#6ooYT{^{R7e`uJ12vY_cB^pRum|kLDkUhtjR0 zje1gi%-Z|Ta^TY=5&|NdO_w@9<1@ff)qeb0&1nK)PqEtL{_d4cR znI4Bhwp9WO`C14~L;B}WeSHr=K#bxe+v52&P@dm|U43x|e9vb~-r@1$5o}MF6smvp1iu9{Qjc~v;z-pbXx7eFx{?#RIs+l^ zvTBz43gmk`e;RgV_2j3IUpFzyt*Bt8&Jx@8LpF_QK0vJMU6h=8iI}C-wKjwF+@KnS zK&ZYi^*S?%F9U{gdTkDg{g43vpZ*y5Enw$`+Fui-snvoq^4YDlBi3Bt>zC1NXNHS! zcG0p+=Skjiq#ix~b3Cet6(@J@0mH_}gg1+25|09z=J~>;TPm_Rqn{Ny6Q^?p1`}r? z0@dCnZ1z=ZW=XH+iRA1dWq&EJ?OW!G0T1Q(JPq2sM(n|#lyv{){P!T`-rB!ZM94OU z5QM{kfr0+>vj#USVV0!qQkdgMMmc%|#rgTyzCItnv7x_zx|cVj`Doh@JEc~D23t`w z4b2D8;0FDRq3sfHh!QD&w>x|5%Pb8IN;CtERI58nDdU31q)qd@oKoTN*~pt^-X!U- zi`q&nhQr1%S)NMkjEl5Dm zH`D|Bf&LN&^2UOwcXn;=4W zT-m==C8Ql?HnE7G4?@v%{a9QNw(aIkfgei4qP=CpdXRe+ow>R zBw|!(mXQGk%G$F0?<>Fp|A09F4B8+Bm!ALX9pI_Hp}1=+SfF1_$iujxMQp09yVcKd z-c_|_RPi8oe&-1k-v?hI!22?&z$w6%J#Br$U55>@hbM;kVjz|7LUIUef_V(Ac4nmL zM&WbFVg#Xpi0%Vk&<27jkR+Jby45Zgqp+pih}NO4iO@>e#eQp8Td z`a?J>JWKHoN^kE*)3E?&wzhr$(?uQ5&tSGRb3rp-~ zc(>`RujR{U%%in*5^wf6QdQT*xJ1>bM;fyf_5VxH-&p!(>O*KhuS**sWS>_eGp&eo z`&3#e*jw>%>gm7-e9-Ty))w<_o4}b?wn*D#z6(T@%m*RHIZ)vdsf2KNsT0OJ_-(wz z_0pKlHvu72IG?#E`(jDsPdAcEHBNc9m0uP!hle z1Lw4@n_xAB(u&s}L3x3&PVBe)RsI|UfCnf7!%B7cQ+z3|NJ9O6q)HqjnU}_QwzGF0 z!pDpfybw`%yWx)y>=*~YHsAva2mAs|EX_UfU`ixBDLTYr1_F@`Rxk<@kkW;6sbPTu zEZ>4wDRLx%U~d)%Z%$PpfIOove3+k~|McZc6#TA9w%f4C?t?zv256M4{l!%!2$%cE z6MywOh8{Okwj(?dd?2J1*M{tYEougyI1IhL8|gq5AWZljP{H`X4G)WuKdZ=L0(gRO zK#x9tq@jUWD>+o@&k+@mT{Oux0ki@1=6qKoz$t+iaeYtF6yZH4zE2!@~SFu4zkq-MbEd>>3czzS$Kyewg@U#IJ0@v^SS{maS zsdQ_G(9z`Vu^MlYzfdRafImfcDmq8Y*%KEwAh#`T{Ecr$6Z?XBsXgjK@%%V;_qMX(*10 z>2LdW;CDC0b1ifHP;2Onic^4(?&bt4bcojg^vS}5AkY|4Kjlx(G^I};uz%$L%JsT* zi~`j1(p~-gC6C%Q5@ht)S5USf?Ei9$<^BJ{d|q^mC#thg7WzW%KVnj zB$0nb4q*wU4u??PQry|5BGz|vZ83xOx>KRxB3%GrX%HcIcYADSD+CY0ZBYI8Lnz@T zAY*`>bADRXGX*Hbeq=&38c5Yem=!WFfuox|GCTNJT>%|M^fx5vet-o|i&9XjGek9W zbMsv$DZ8ITOx^c|g%(&Q-Ou$C_5u~wjNS654WT^)<9AY$?zz?Dc zoz@V)0d7E-K={bc!GQ;UePN`E5r7^HO!^}(lPMm2MFfqV?Up_+z<2CrYX{|{kkQB(*^jC<}C;Of~? zW+8HTfO5u!9&LqK84k#Ms1ECsvEq^Qxwc7mK#tsWb8Cg4<(3Y?lVz9L%M+&@>PGt- zJI4BLnS+e?R#+6^dx}k?bvh0t%%&(N?fv~r6=y_j25%*yuZHZc*fbek}s*pXQJU#9W~RmESb81!Vt1*=s>eIe=#~JYGQPw9hL4mPbg=z`y`a+}2z8 zMG!#$RM)(9giRCIQy5_M7iC-&F1v6~%;Jb`7v_NuPA6kcR?{=s-j zB9pFcfZws*wQgW<7<(iendGnU6D5`$IM0cFon{d$q`HN<)$6xCwXysA<~_qTMk=(1 zFIGmouvmtGVv3;VgZ%vbqaVGoCQ05QCOHb14GkDnr5%iqI@9<4vmqF1_``va#O*(0 zJ4!U)Hs^7#+NAutdASdn>1ihHg&uMp{5E32L1mk&vU5%l_-c92nN~hM|**H6lKUh zb{q=MzL1Wc2pC(4;A#`>pGo;07Sv6Dgfh#)y8^5o)SyO4z%weZs2C3ccoHFLtel9o4UV;-7K4w z!;{t>$8+AqY|Z71U;O>(F8|0VTUzO|YfFZ^#=VWzcyWHUuH(z;#&tY7e%H90#bTwc ze>C=nOZZl^ZT4ftl=?<74W?=`ax0m6Mzg&AxXG4@9D}o_iXUe7SXSYYxf{(rc&y)| zWk*ljI0O&Md~egw`t2HrDWxL4!&c$8CMj_4V#UGX-Ay!(o(%vGPJmCmnrUMoSg+x| z=Z!)6lT;TmE-xQ;5k@Q)U(zyxo_nF6t?UcPF=d-d1fbLE(8@u3m=?%+eLn)uE59t< zVb|Obeo*|HB{1#)z7-{LZlo_T3v<0DQ-IowJfLmcj`T`cs!ih-R=IqR<~>2UA- zQ_HaF7IjXB_EmEFT~1h?dV$sW>BndJ*>SC7q^teMuliE$m`}32Pil#&b?R~4VoG== zIp-Vo!~{pCc(l9pO?h*1xayPf0@m4s_?zVK8(*9F1~K$WwQa@ccuML%Rr3$d$N!&M zM2I`QvH+zwa=6zTroLMIymjOUSFL@Q-nU%$V)xr;nQs>lww=4!7f;GB$>1-m@QK^RkM{nf$=1I5G&-7l>9FQZ<4(>n~bkh1(fAj*JY*Ir1-Jb)Oo4W_O zHX*Y*WUB?+{N1!-m~FkhHK?SCj#DV07u9|^ry$dwJCL9|4(RBI*x+A$|8iv!B?|k1 z9rTF*5L)Dn2c|=iwTo#-P&?W`OEYz)LXaW%wsvkIU z41v)=yJpFH14#^9=IJCYgMO2twIW$WRQ5gA_7aL->6Qq$KZsKEHnf z>dhn~`q7#{E{u8)t2+Ojq9QTz1+>$GkA?(vOkYf(#T8v>hBK+5xp^eCNus8uQ@K6D zW3Rwm2_WcPAmlfq=W9M#qTTf7%?|foVjJ?U1MdSg0EBWL>W~S}Yse)FZFA@;UN)=3w;)sG;Qc{k99uxWJ*y|1V2vZ{qpI8f%#0 zWewGMV=u{wJm=Ol-=};&g;`hqdAiB3$JK*xE(T7udDW4y+qI4CCO$FJr(8o;{Z80h z@m8iE#n#WM}TD!H%d&{al~p$ZseAXCk| zU^<8lE;e2XSLo270Yos&N+wZTI`-%580xYS>v+OB^FD0Xr*w^K!IKFMBGA|eA&xACTbLEUTPW@N2&OzHR(|_nX0wk5*2up-8rmE@bh=-}VLxjJ zyEkMd0sb}Mr!@|4B>PZb1qsY!r>QsADy_h(s6J30-rSXKH+7M3COE5SAxCVXWe7dV z1LK#!PqY@x&?k!+U+_m=5x3$3XDhT_!B)fuuq?Fr|7YNj!Y#;whOMtl z%N=~NPkZWS(y_}8e+PHY@TI^6ZUWA?vWqa?2v+BKy|D3^iSHwH(cAq6eS%73?8e7B zX6E(}pL&f+qvK2LVz0^7a{HS6Cpl1f#N6GsuZLkITq`2EtuWFy%5BA>P=%j`RNH#~ zi)##Nn(&D}bnv2}W*_qb%}?A!6OsyRjbWMHsEZDN{&C?XR`zad9q|ljr7An~$uci| zwWKm-+5BDk0=!()?&3~3FTAht_fysjoq?=n;>N{YFx9Zo(o>|gY=@T~pkuppSyqz! zl7x^S{k7ACq3zVB4-z*QjAefi5sRA(@>6)m#{5bHK_kO!GqL!0?du0}C)r_&pkcn`b3j31$!LP5D#ax;bAE{iBoh z-)NNerNb=u{qUPGmc*?OwJV3gyMQYM)k&yq=bea*+9I~t=?8It4a$z5IiK?`=9B3g zrJO%@Y<3fsM??H(MF`UR?fB1o#jRAYC~!zpj&Sf9Uy6QXnnN|qGRcmS=CNNJ=z5=; z8AVW{&0V{6v$z$TgFzD)uuen9vc}gByxi8ps?+I1`vZl2=3dvROSj;>hS-PQw(%K0 zRx(6de{}wy-YJ2;=w_BJ4{_k$`tzWR>(7thTAvk9hTpi28?E-9Y(0Eq8%=P?H?Z(E$zdK z)|#nTigT;S-a)%R-3h6^@VfL^=TSZGedRvsCSrA(B|kM^FwA#47ofY9C7Rc(V8D6C9kf1Qf$fwN1M$wkazqfg3ojr>Ldrx8Y&qkVy$f|~B+x8U8LR$!z z6Ubk2ML)}I@3TpU&t}OcW--%yxN=2zDwMjNbk|r+f2?-@Ox+mHKk_?tt$R{S%o?Yk zkh^p8mMgc=kDMT4pc}L4TKhWRX9=3;zsDr4lb-2!{h44k?E38y^K_BR96MxTdW@f? z;ABeA75wA*wy=%lMU#%op_;`3vq+ILPsa@w{FtDu{5*!v{BHdGN$V>ws4~}C$yS){ zxS*u#Yd`H0@^Afp(xx1=$vO8aSK~;WBJrSsb^l4xjbu?_eHCY^!V2T@(`A~q74~y> zl>t{ils+UlG>^_tJ#0v6s?x5&#yOBbS;t|R0y0QM8&&BYpwj*Ij|PDa3ok{NAW}sQ&+)g zdg`X3@J|dKdn{-BQGGIMxxv!IBg;6M*F-ElW8^ zINdKw91=D4kI5T5RWwwcbBai6kE^VADx#HU^AO|H#eFpCi^9aR|93N5w)O?nsfTf6 z+~(|NP45p#iH(TWWr<>M>l|xz2v1MbT6L0Ai`KsL>{I5g?qqs=>aN~! z_6(BslZM(_$CWb`x>U(YAHgmch7-L8w;`y(Q<6V?E(IeHj8&fGu9I=%sq-V%XnC07 z2B~V^olT3BI|tvlJ~Ru1+vz_;Uo`uV!^Y$ef0n$bI+$oCirSEH9qbu7q+jz>mY6>F zKJ~2oMma3R^@PWZ)Uli*FLvq9Ggy-0ZrhTH*o(y#RzEY=WNDQb&FlUf9d6o~I=vQ` zwNPtPSWB%AUYC;AD-ti0z3y83Aa|ND6v-pMu%ipr#El!DQ1y8dSsVhz5Tw{vgQO1ss&?LaAnqpxI?Ul6blPhzsy_m zZ{?Jq=gXKT>B)x4p>v=>@sx`H$|OWZJLh*bH+z8<7D%&yfDxVzjA{hFXTQinlD!$RMCdTBQy_K0YZ|TgY- zjcUOL!Q09X%4@F4gWlI1v)4aWwl3h5iAOJ_9JtLvRiZwOQFvqhH~co)ovSRfFF1lA z|IEmPyGuA9*PVYpZ_BX5w9_>U&0f_j4ZJli zM~zO}gU?)?Tit$Bt!77Ei29Tp-YRXi7`e#vlBaNk{o(X6`6ZZBZ>*N;OYhcLs&vO< zN6ykeK2`GTX7h)O0b#dFt4O6S&bpx{dm*k{*QP~iPg_EJgb3|&rLFr5g~Ycm zK!Q6Speca{+^95G*+r_amk_PGkvqM&OD;&P{E|oUQ)iYVe1szDYfi6IC1C~Ywm5k{ zu>00=vFhN1je*kpwj_*whv4Uu4rxhz%dY^+hqaG<3}4T;FsQ#YdzIYPVcC@+2izVr zhnr`fWamViNR+W_r>B)p$-6v%8(B5uTHi;OPC^lsrijW>6Thqt>$P96t$&~)73LJ0MQWl!vm`q#b1$?Yby^woeZi-f3 z{|A0e1_*>|$8g6K*8fKTo(q(u+IbL~nW%)yBaB!7ETmVdxISInx6#SzhSiC|R1zjJ zy(PVVGWb4dq3yEhM%#?M+jJ!BWMTwdL03{R-BH!ZA|z z^YoJs*|^A*#)k(!!QIcZKMuCRmU_KoA%$;$3^s)9FuuHJu;<9`?@Si1E1cSQB%-XC z@KY`4MR?zrGWG9$w9P!p7;7MA-Ma9k*6Nm2s6?oG9)T-?X*f5IvvMq_j%BmeIg_Eha5MRMO&&8?VB2h3lgU5&kVqaqCRMjXjF>AE{@) zYRhYMM&P9d_0-zU8md$zv0~XG<)PZndm;u%o+t{5p14xZjZO|3Wpud{f|!~QpJ5q>^=EoeTf~pRNLORwD3hP zF?6D{)Wp8b!}4c_#t?V5X{Nv*8%i;AgGW(?RWIdT3PqV(#clPa_4tKbMmj;uE-|46 z&sCHzywV9jO7Ix&%bWZ-q7kX`lBe9tIAq8Z>s#A&Cz+tP?(fdyRqMaix-$4x<>}U7 zgkN%hcf+Q0&8p;Ie`K5<%X`aK7pY8ljgJ7bGv$ONx6--q8 zs8hh@$@r+VrN+;1_-B!?U8OsosC}$hBkLM&(DkN*(kU?;4W`)7guvu3?M0f^gKcMe zNpS(h60P?|C!L4_+>z(Y>3K_Sf*GaNH`A>+&V20CxRxAxUz&&I(0l$=7LJA{&I2MN zgCG5M14r4###~f~s5;5L`oh}T&YZ1lpZ#yQGB-PJT%EnwOPid*En7CuJeWEolHpEx zaw}C)xqQ7l;#};KDdlCTjJ$uH&Viic1>6z}-mZ_>xeE4oR9MyjkRJ{qm8{=r4~@ab z{#Q|VEvqWlD$5k#>tRZ?Uo~Jf#$uvN-Wm4_CuzY64Oa{oXP|M@Tr;JK0|AZkt`9J4 zUEye;c@Fc|8!*2bGBYue?YMS^D0xxRX7#av+2U#8Sb1V1Pv6}5V|?Vkk{d^ApF4O8 z$M2Q!#VC#x?frudG!E-oPdh@KVN=np!{5C%chI)L>-vMTgl&6IJECc*RQ|I0iU=jP zN`ub%u|_$U2g7?~P44#0*a~SUU7aZZeWQ~x_N``fes1Cai9Ov~DOx?U)BZVUsLpw% zZk2Zoqa0VGI8x48*UK(x(OJ^kQdqy(mF`QQS=I8N^R=4hj-r>@|8zazee@M;^+tF)K z+J5|^yf}wd6y_4Q)?+Q&i&0!wyd~;+0o+C>OPO3w|4L_j;mr2ZHP+ScSIN9&?KftP znPXV1o8qbzmQJOx_EM!v{Oj|hVwuA{`08R@z`n->YJ^C$6QO*e)Gom?a{LKKCr6d@ zZBWwByN$)iXY@-$J>sGe~r)HrDgPYB_o*o*j_R|vw)|fP^V!wZi0}zSzfzP zZQ(5Ns4YwA;fEO}-TJU~KAB6uSAZrovOq&%xY0Zxy zE?M%Yq~x4D_bxMR?Tf_mKKvV^DYiUNL_96fr`zMv;x269kY8PI7f-c*XsV${>TMZC ztN6Re`fL}U#O%v|NH){73R@{jXkx1Ab)jh{miST5%-(D$3nFdS$`@8%B}WaKQk-Xt z(i?kQr!EADQgbK)&y(u|pHW!t9~P#W6&E{827g?qdHOZGbAjp-vli=ZIowpzdgpDj zSdicPvMs0Fo#!?1pl_7Ub*h8L;Vq2gO{Byf+QHOT1V;B*o3+Up_S!3fHF16%OlMrO za!&{*p1i`^ee_FW5pC*rus|i5w=nCOS$6GhW&_=8IUfcq%>%v>64$i1$^TVg*3~e| zM^*6w(J!-J5!;X7Qja=n-E@2VqNKXCCgy^D`neN>#*LNid!G>a0*OL8^*5%E8p>m2~`lRtWzXqay8A7-I#Aik4<9R7+JVQoa@*I zq%aS2dA!HPrt&DZLg^{OzjJ04qBC0G%<^@Ok+>a+X^b!Ha#H&!gkEf&t4d+rnSRY+ z(}(m0x9o@1IJ3|N8i_=@udAz@x6t}_p;dI_fie0(5J@SkS^97u;Dt=-XqlAlB1g0>7Iw5)O{^GTdY$|=PoSZtRtsWB^ z=tagh*{!xML<(xHt$26%h| z_b839g|!!I(d=d*)?<33T60$Mb%&-!Ch5b3kMrr|SDkG!GK&%DnwV`eo6IuG7xGem z6m9GHu5Oq)lJAi)ORhJkIjvpLcaLzYw0_bZ{;vD3EiI1V5UQ=+B?Q*e+go5UitHCR zB28+te}MGq2GB`l&5ei@*Urt=`QJ~VFB=0J>B~%)ZW`5`eK5*-qQAUluY;&CHf_|8 zHifk%>HH-Y%|~IZH+fx4@o}4Hcx|ye<=sba7hqZzBC&iYtPCHEHFX<5X-IIuJ$hWo zqfnwqmWt(vow98!B#XHvU6?VZ6x-)8!l-x?uCId(jEs)m_$=)^ciru>kC@Vy~EO`QENeQQdqS#||zQU>=r7iOlujL?)RYR`D}=Xnr6E@0m7(5dUG zYJB_c1*Cq->l@RhwYNQ%PGP3OY2pJhbEESWI#uEEbH(L&yvG$r@k$Y~b+g!6_t<8! z4ZNqc58yl|Sq3y{X;9SLd^IqHMcUfry`K~ zt)Z9I@alt4Xnfq)C$$VW$6h60R!_ z*PuueqlSa$EMEqKf29DZhiRPixW)fAq}C$-obqk_g-$eF3#5!pAcYkH+F!jjJIe8Xkrx3dRSPaE|&VK%vm1)rZ zwyu@P)?6#x-4csRFLPhE*b*iAnv(hcNH~TRjaGX)_oK@o?V!zHZ#w@v^b6op{x}${ z30RY%&Igzt5W+#Nt2+;t=P58Bmll4%)TM=SsvZ5Dy2>k6F%wSm+q8H1z9fRb+FYhR zPY>=7G58z36|RmC=c*P;???pHrgq>$e(|VZ4arlkx+-v4E~({(fc1RoRNH2Evm@a# z;haymE57%}#5r+6v7>7%O$pS&7F=b&Io7sh`*>eh#IBJHgWDM8IWwB?W9j83?NfKU z+2`e2g20|pck9Vq8L7Qx5FcZ(NP9Qcxyr;`P11$%6k}H{Lt+UNBNY$%Mvm9JVg?ly z6u<)d{$e;I4|m_^&)3=E1U3MrTibZ&sU{u6I5}te&hsbc}5)q&)ISYOSk~$!GdyCz=nS$fy;j=FBIo zw8Khu6498mwdL-??E2f}BSvb6efQ0pBSAbfH-9&<~?Xu3;tUizVWrM zuN0CUn$L$lD?57qf~4TxQWYOD1t*S|k`h6iwO8~O42MN#&3$XmuK1Z5$BZ33?Urld z&g3j``OfjPDdX`)qvCa08#>wjQbW{k<3)|dl+r&Z|CS$SEX&xqv}Ha2xqWkMBDGt} zRr8ZmwDo*na3{=CJSBakER1ajLi`z;32PQVbQ4}K)PFkibHO55S?R6Ol3}4Gd)a4O ziq31zkCcMy&ryf$-rtx?)r(a7H5~U$r`Y=$`9rz_Q9oI!J9gOESR-{YnLE0YEOLF5E4z@-nGriT|$ z*ch0pg%nCV6H0UCesz>`u!%U|t)NsChn_EjSc3m;R&NZ4V zIlE|oR_DRyL@&VU(?u<#&e1BsnI-n|o&IPP0tdCl#99BCe3tP&Whu{x7va3(1(-*W7N z^_ip#P#oMB78bUHfLSs<I>?&&f~4te=ZN8_=Y*fx0;f-FpefFR0)xKlb+4F3%foD*6nB_cm8@my2If${J5PFoCfN@;Y zkJnw9`t>9|DiAD102yfje8!er^iPU8KC) z4UfoRfJ{CSwGTVSuNTXw=2_>9J5S6T)%syA;3F9*y7spGqVUPRdd2aFf8{=n?@7c? z;eFzK9+1*{s1f|{dtFHx3j%64n+24K%8?h-+{+nXa$_gc#%hD24|PMV-i>FVmaG1< zprBnkd(q@vGXEhsEWLJ}<6%+YW|`xQj&0pXjZ|(wcxAqSJ=I;5gI{@(vB-Msa;Jk_ znyzcMX|E<(^mm$t8^_22uB~Aitcvv94>{*xM`fz-6?>b@oim|!sz2;P`7SR+bzvCP z=oe=V0_Ww83nfLlt=evR(2rQl$8LP?v=-D*;ie_}eR;X~(c=V*lIU$4s#lbmxB6x% zVRN~eQ5%lv0wh<022F==-J3UWM1Lxs?c2J&A$Pppqga->($FsAk<}W|ZIv8ZXyxE( z${=CxQf^d8Uw-)1xww6rrw?gh#qyLI-aJxxlKw=daEvC4U~GK;`D?^@-=f{Zt7Q{H z(`9~=!4#P^!l$(#FHUG5O=f&)nH=k#^C`H;0dr*)qzfI1&=Qk3K+|r#3IR5M|KoM* zTboz11zNEzB4XNTE}ZRIcQ&`MM7ICt(1p4bQK4VKZE=T0(yujycO|+r)k~ZFu&=zD zmNRq7Gp<**%PQz=dan1mCPz#~kk$P)JM|yGwq2&nNUhtay#0eqeueekT8Ii!97%N* zA*t4Od0bR+UJacqkqGTIpPiD%h0WR5#geJeYTek?noaX``zxStZy|!`YOfPT$g!-> zETT^V^y?WFujI*(dLw%?B4x>aw?511(<@+@7NArr_ + +![setup.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/setup.png) +![overshoot_protection.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/overshoot_protection.png) +![opentherm-mqtt.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/opentherm-mqtt.png) + +## What is Smart Autotune Thermostat? + +The Smart Autotune Thermostat, or SAT for short, is a custom component for Home Assistant that functions with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) (MQTT or Serial). It can also act as a PID ON/OFF thermostat, offering advanced temperature control functionality based on Outside Temperature compensation and the Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficient. This capability allows it to determine the optimal setpoint for your boiler without any manual intervention. ## Features -OpenTherm ( MQTT/Serial ): +OpenTherm ( MQTT / Serial ): - Multi-room temperature control with support for temperature synchronization for main climates - Overshoot protection value automatic calculation mechanism - Adjustable heating curve coefficients to fine-tune your heating system @@ -127,8 +140,16 @@ setpoint back to your desired temperature. heat the home is less than the minimum boiler capacity. Moreover, using this feature SAT is able to regulate efficiently the room temperature even in mild weather by automatically adjusting the duty cycle. -## Support + -If you want to support this project, you can [**buy me a coffee here**](https://www.buymeacoffee.com/alexwijn). +[hacs-url]: https://github.com/hacs/integration +[hacs-badge]: https://img.shields.io/badge/hacs-default-orange.svg?style=flat-square +[release-badge]: https://img.shields.io/github/v/tag/Alexwijn/SAT?style=flat-square +[downloads-badge]: https://img.shields.io/github/downloads/Alexwijn/SAT/total?style=flat-square +[build-badge]: https://img.shields.io/github/actions/workflow/status/Alexwijn/SAT/pytest.yml?branch=develop&style=flat-square - + + +[hacs]: https://hacs.xyz +[home-assistant]: https://www.home-assistant.io/ +[release-url]: https://github.com/Alexwijn/SAT/releases \ No newline at end of file From 0c04307738123a6a1b5209d528735ee994f44c5a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 16 Dec 2023 22:14:13 +0100 Subject: [PATCH 233/237] Fix image order --- .github/images/setup.png | Bin 130282 -> 62807 bytes README.md | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/images/setup.png b/.github/images/setup.png index d5d2766c509e0178ec4f5302726e7720f5c21f37..d8b4ab2c4f0f0f0ebc03435befc2461372d28225 100644 GIT binary patch literal 62807 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 E0TjS4S^xk5 literal 130282 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 -![setup.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/setup.png) -![overshoot_protection.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/overshoot_protection.png) ![opentherm-mqtt.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/opentherm-mqtt.png) +![overshoot_protection.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/overshoot_protection.png) ## What is Smart Autotune Thermostat? From 454ce3a4575f306ff7eccd8a6fc37bfbe03843c3 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 16 Dec 2023 22:22:51 +0100 Subject: [PATCH 234/237] Fix some grammar --- README.md | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index cd61f0db..c23ad3b8 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ ![opentherm-mqtt.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/opentherm-mqtt.png) ![overshoot_protection.png](https://raw.githubusercontent.com/Alexwijn/SAT/develop/.github/images/overshoot_protection.png) -## What is Smart Autotune Thermostat? -The Smart Autotune Thermostat, or SAT for short, is a custom component for Home Assistant that functions with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) (MQTT or Serial). It can also act as a PID ON/OFF thermostat, offering advanced temperature control functionality based on Outside Temperature compensation and the Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficient. This capability allows it to determine the optimal setpoint for your boiler without any manual intervention. +## What is the Smart Autotune Thermostat? + +The Smart Autotune Thermostat, or SAT for short, is a custom component for Home Assistant that works with an [OpenTherm Gateway (OTGW)](https://otgw.tclcode.com/) (MQTT or Serial). It can also function as a PID ON/OFF thermostat, providing advanced temperature control based on Outside Temperature compensation and the Proportional-Integral-Derivative (PID) algorithm. Unlike other thermostat components, SAT supports automatic gain tuning and heating curve coefficients. This capability allows it to determine the optimal setpoint for your boiler without any manual intervention. ## Features OpenTherm ( MQTT / Serial ): @@ -81,23 +82,23 @@ SAT is configured using a config flow. After installation, go to the Integration If you already know this value, then use the "Manually enter the overshoot protection value" option and fill the value. -Automatic Gains is recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains. +Automatic Gains are recommended for most users as it simplifies the setup process and ensures optimal performance. However, if you're familiar with PID control and prefer to manually set the values, you can choose to skip Automatic Gains. Please note that choosing to skip Automatic Gains requires a good understanding of PID control and may require additional manual adjustments to achieve optimal performance. ## PID ON/OFF -TODO +To be completed # Configure ## General tab: *Maximum Setpoint*: You can choose the max water setpoint for your system. -For radiator installations it is recommended to choose a value between 55-75 °C. -For underfloor installations the recommended max water setpoint is 50 °C. +For radiator installations, it is recommended to choose a value between 55-75 °C. +For underfloor installations, the recommended max water setpoint is 50 °C. -Note for Radiators: Higher Max water setpoint values will cause a more aggressive warm up. +Note for Radiators: Higher Max water setpoint values will cause a more aggressive warm-up. *Heating Curve Coefficient*: The heating curve coefficient is a configurable parameter in SAT that allows you to adjust the relationship between the outdoor temperature and the heating system output. This is useful for optimizing the heating system's performance in different weather conditions, as it allows you to adjust how much heat the system delivers as the outdoor temperature changes. By tweaking this parameter, you can achieve a more efficient and comfortable heating system. @@ -108,36 +109,31 @@ In multi-room mode, SAT monitors the climates in other rooms to determine the er Note that SAT assumes that the climate control systems in the additional rooms are smart and won't exceed their target temperatures, as this can cause inefficiencies in the overall system. Once every climate control system in all rooms is around the target temperature, SAT can operate at its most efficient level. -*Contact Sensor*: You can add contact sensors in order not to waste energy when a door/window is open. When the door/window is closed again SAT restores heating. +*Contact Sensor*: You can add contact sensors to avoid wasting energy when a door/window is open. When the door/window is closed again, SAT restores heating. ## Presets tab: Predefined temperature settings for different scenarios or activities. # Terminology -*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given setpoint based on the outside temperature. When this value is properly tuned then the room temperature should float around the setpoint. +*Heating Curve Coefficient*: By adjusting the heating curve coefficient, you can balance the heating loss of your home with the energy generated from your boiler at a given setpoint based on the outside temperature. When this value is properly tuned, the room temperature should hover around the setpoint. *Gains*: SAT offers two ways of tuning the PID gains - manual and automatic. -- Manual tuning: You can fill the Proportional, Integral and Derivative fields in the General tab with your own values. -- Automatic Gains ( Recommended ): This option is enabled by default when the *Overshoot protection value* is present ( During initial configuration ). Automatic gains dynamically change the kP, kI and kD values based on the heating curve value. So, based on the outside temperature, the gains are changing from mild to aggressive without intervention. +- Manual tuning: You can fill the Proportional, Integral, and Derivative fields in the General tab with your values. +- Automatic Gains ( Recommended ): This option is enabled by default when the Overshoot protection value is present (During initial configuration). Automatic gains dynamically change the kP, kI, and kD values based on the heating curve value. So, based on the outside temperature, the gains change from mild to aggressive without intervention. -*Overshoot Protection*: This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler -overshoots the control setpoint, it may cycle, which can shorten the life of the burner. The solution is to adjust the boiler's on/off times to maintain the temperature at the -setpoint while minimizing cycling. +*Overshoot Protection*: This feature should be enabled when the minimum boiler capacity is greater than the control setpoint calculated by SAT. If the boiler overshoots the control setpoint, it may cycle, shortening the life of the burner. The solution is to adjust the boiler's on/off times to maintain the temperature at the setpoint while minimizing cycling. Overshoot Protection Value (OPV) Calculation: -The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is present ( During initial configuration ). +The OPV is a crucial value that determines the boiler's on/off times when the Overshoot Protection feature is present (During initial configuration). -*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can fill this value during initial configuration. +*Manual Calculation*: If you know the maximum flow water temperature of the boiler at 0% modulation, you can fill in this value during the initial configuration. -*Automatic Calculation*: In order to calculate the OPV automatically, choose the ```Calibrate and determine your overshoot protection value (approx. 20 min)``` option during initial configuration. SAT then will send the MM=0 and CS=75 commands and attempt to find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the boiler's on/off times in the low-load control algorithm. If SAT detects that the boiler doesn't respect the 0 % Max Modulation command, then will automatically change the calibration algorithm to a more sophisticated algorithm in order to perform the calibration of the system. +*Automatic Calculation*: To calculate the OPV automatically, choose the "Calibrate and determine your overshoot protection value (approx. 20 min)" option during the initial configuration. SAT will then send the MM=0 and CS=75 commands, attempting to find the highest flow water temperature the boiler can produce while running at 0% modulation. This process takes at least 20 minutes. Once the OPV calculation is complete, SAT will resume normal operation and send a completion notification. The calculated value will be stored as an attribute in the SAT climate entity and used to determine the boiler's on/off times in the low-load control algorithm. If SAT detects that the boiler doesn't respect the 0% Max Modulation command, it will automatically change the calibration algorithm to a more sophisticated one to perform the calibration of the system. -Note: If you have any TRVs, open all of them (set them to a high setpoint) to ensure accurate calculation of the OPV. Once the calculation is complete, you can lower the -setpoint back to your desired temperature. +Note: If you have any TRVs, open all of them (set them to a high setpoint) to ensure accurate calculation of the OPV. Once the calculation is complete, you can lower the setpoint back to your desired temperature. -*Automatic Duty Cycle*: When this option is enabled, SAT calculates the ON and OFF times of the boiler, in 15-minutes intervals, given that the kW needed to -heat the home is less than the minimum boiler capacity. Moreover, using this feature SAT is able to regulate efficiently the room temperature even in mild weather by -automatically adjusting the duty cycle. +*Automatic Duty Cycle*: When this option is enabled, SAT calculates the ON and OFF times of the boiler in 15-minute intervals, given that the kW needed to heat the home is less than the minimum boiler capacity. Moreover, using this feature, SAT can efficiently regulate the room temperature even in mild weather by automatically adjusting the duty cycle. From e2c4874a6e56183d6a8a4a8244d0cb1f505256c4 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 16 Dec 2023 22:32:30 +0100 Subject: [PATCH 235/237] Add discord badge --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c23ad3b8..38d2e042 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![hacs][hacs-badge]][hacs-url] [![release][release-badge]][release-url] ![build][build-badge] +[![discord][discord-badge]][discord-url] @@ -138,13 +139,15 @@ Note: If you have any TRVs, open all of them (set them to a high setpoint) to en [hacs-url]: https://github.com/hacs/integration -[hacs-badge]: https://img.shields.io/badge/hacs-default-orange.svg?style=flat-square -[release-badge]: https://img.shields.io/github/v/tag/Alexwijn/SAT?style=flat-square -[downloads-badge]: https://img.shields.io/github/downloads/Alexwijn/SAT/total?style=flat-square -[build-badge]: https://img.shields.io/github/actions/workflow/status/Alexwijn/SAT/pytest.yml?branch=develop&style=flat-square +[hacs-badge]: https://img.shields.io/badge/hacs-default-orange.svg?style=for-the-badge +[release-badge]: https://img.shields.io/github/v/tag/Alexwijn/SAT?style=for-the-badge +[downloads-badge]: https://img.shields.io/github/downloads/Alexwijn/SAT/total?style=for-the-badge +[build-badge]: https://img.shields.io/github/actions/workflow/status/Alexwijn/SAT/pytest.yml?branch=develop&style=for-the-badge +[discord-badge]: https://img.shields.io/discord/1184879273991995515?label=Discord&logo=discord&logoColor=white&style=for-the-badge [hacs]: https://hacs.xyz [home-assistant]: https://www.home-assistant.io/ -[release-url]: https://github.com/Alexwijn/SAT/releases \ No newline at end of file +[release-url]: https://github.com/Alexwijn/SAT/releases +[discord-url]: https://discord.gg/jnVXpzqGEq \ No newline at end of file From 9245b4686693c5e362897aa3cbe573fc73b364a2 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 23 Dec 2023 16:28:58 +0100 Subject: [PATCH 236/237] Some cleaning --- custom_components/sat/switch/__init__.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/custom_components/sat/switch/__init__.py b/custom_components/sat/switch/__init__.py index 48031a0c..0f0be47f 100644 --- a/custom_components/sat/switch/__init__.py +++ b/custom_components/sat/switch/__init__.py @@ -9,6 +9,11 @@ from ..coordinator import DeviceState, SatDataUpdateCoordinator +DOMAIN_SERVICE = { + SWITCH_DOMAIN: SWITCH_DOMAIN, + INPUT_BOOLEAN_DOMAIN: INPUT_BOOLEAN_DOMAIN +} + class SatSwitchCoordinator(SatDataUpdateCoordinator): """Class to manage the Switch.""" @@ -36,12 +41,10 @@ def device_active(self) -> bool: async def async_set_heater_state(self, state: DeviceState) -> None: if not self._simulation: - service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF - - if self._entity.domain == SWITCH_DOMAIN: - await self.hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) + domain_service = DOMAIN_SERVICE.get(self._entity.domain) + state_service = SERVICE_TURN_ON if state == DeviceState.ON else SERVICE_TURN_OFF - if self._entity.domain == INPUT_BOOLEAN_DOMAIN: - await self.hass.services.async_call(INPUT_BOOLEAN_DOMAIN, service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) + if domain_service: + await self.hass.services.async_call(domain_service, state_service, {ATTR_ENTITY_ID: self._entity.entity_id}, blocking=True) await super().async_set_heater_state(state) From 0800e7674b065bbcf787712cf178b09d11dcf9fb Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 23 Dec 2023 16:31:47 +0100 Subject: [PATCH 237/237] Also store initial boiler temperature when warming up --- 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 64cccd38..ef02453b 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -72,8 +72,9 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a class SatWarmingUp: - def __init__(self, error: float, started: int = None): + def __init__(self, error: float, boiler_temperature: float = None, started: int = None): self.error = error + self.boiler_temperature = boiler_temperature self.started = started if started is not None else int(time()) @property @@ -284,7 +285,7 @@ async def _restore_previous_state_or_set_defaults(self): 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["started"]) + 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) @@ -778,7 +779,7 @@ async def _async_control_pid(self, reset: bool = False) -> None: # Determine if we are warming up if self.max_error > DEADBAND: - self._warming_up_data = SatWarmingUp(self.max_error) + 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()