Skip to content

Commit

Permalink
Merge pull request #82 from Alexwijn/develop
Browse files Browse the repository at this point in the history
Precision Forge
  • Loading branch information
Alexwijn authored Jan 18, 2025
2 parents 9ce3926 + a74d44d commit 1a11dab
Show file tree
Hide file tree
Showing 44 changed files with 1,061 additions and 476 deletions.
13 changes: 10 additions & 3 deletions custom_components/sat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
# Create a new dictionary for this entry
hass.data[DOMAIN][entry.entry_id] = {}

# Setup error monitoring (if enabled)
if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]):
await hass.async_add_executor_job(initialize_sentry, hass)
try:
# Setup error monitoring (if enabled)
if entry.options.get(CONF_ERROR_MONITORING, OPTIONS_DEFAULTS[CONF_ERROR_MONITORING]):
await hass.async_add_executor_job(initialize_sentry, hass)
except Exception as ex:
_LOGGER.error("Error during Sentry initialization: %s", str(ex))

# Resolve the coordinator by using the factory according to the mode
hass.data[DOMAIN][entry.entry_id][COORDINATOR] = SatDataUpdateCoordinatorFactory().resolve(
Expand Down Expand Up @@ -187,6 +190,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
new_data["mode"] = "mqtt_opentherm"
new_data["device"] = list(device.identifiers)[0][1]

if entry.version < 11:
if entry.data.get("sync_with_thermostat") is not None:
new_data["push_setpoint_to_thermostat"] = entry.data.get("sync_with_thermostat")

hass.config_entries.async_update_entry(entry, version=SatFlowHandler.VERSION, data=new_data, options=new_options)

_LOGGER.info("Migration to version %s successful", entry.version)
Expand Down
34 changes: 20 additions & 14 deletions custom_components/sat/area.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,28 @@
from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, State

from .heating_curve import HeatingCurve
from .helpers import float_value
from .pid import PID
from .pwm import PWM
from .util import (
create_pwm_controller,
create_pid_controller,
create_heating_curve_controller, float_value,
create_heating_curve_controller,
)

SENSOR_TEMPERATURE_ID = "sensor_temperature_id"


class Area:
def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_id: str):
self._hass = None
self._entity_id = entity_id
self._entity_id: str = entity_id
self._hass: HomeAssistant | None = None

# Create controllers with the given configuration options
self.pid = create_pid_controller(config_options)
self.heating_curve = create_heating_curve_controller(config_data, config_options)
self.pwm = create_pwm_controller(self.heating_curve, config_data, config_options)
self.pid: PID = create_pid_controller(config_options)
self.heating_curve: HeatingCurve = create_heating_curve_controller(config_data, config_options)
self.pwm: PWM = create_pwm_controller(self.heating_curve, config_data, config_options)

@property
def id(self) -> str:
Expand Down Expand Up @@ -74,18 +78,20 @@ async def async_added_to_hass(self, hass: HomeAssistant):

async def async_control_heating_loop(self, _time=None) -> None:
"""Asynchronously control the heating loop."""
if (temperature_error := self.error) is not None:
# Control the integral (if exceeded the time limit)
self.pid.update_integral(temperature_error, self.heating_curve.value)
if self.error is None or self.heating_curve.value is None:
return

# Control the integral (if exceeded the time limit)
self.pid.update_integral(self.error, self.heating_curve.value)


class Areas:
def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_ids: list):
def __init__(self, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any], entity_ids: list[str]):
"""Initialize Areas with multiple Area instances using shared config data and options."""
self._entity_ids = entity_ids
self._config_data = config_data
self._config_options = config_options
self._areas = [Area(config_data, config_options, entity_id) for entity_id in entity_ids]
self._entity_ids: list[str] = entity_ids
self._config_data: MappingProxyType[str, Any] = config_data
self._config_options: MappingProxyType[str, Any] = config_options
self._areas: list[Area] = [Area(config_data, config_options, entity_id) for entity_id in entity_ids]

@property
def errors(self) -> List[float]:
Expand Down
12 changes: 4 additions & 8 deletions custom_components/sat/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

import asyncio
import logging
from time import monotonic

from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.components.climate import HVACAction
Expand All @@ -14,6 +14,7 @@
from .climate import SatClimate
from .const import CONF_MODE, MODE_SERIAL, CONF_NAME, DOMAIN, COORDINATOR, CLIMATE, CONF_WINDOW_SENSORS
from .entity import SatClimateEntity
from .helpers import seconds_since
from .serial import binary_sensor as serial_binary_sensor

_LOGGER: logging.Logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -57,18 +58,13 @@ def state_delayed(self, condition: bool) -> bool:
return False

if self._last_mismatch is None:
self._last_mismatch = self._get_current_time()
self._last_mismatch = monotonic()

if self._get_current_time() - self._last_mismatch >= self._delay:
if seconds_since(self._last_mismatch) >= self._delay:
return True

return False

@staticmethod
def _get_current_time() -> float:
"""Get the current time in seconds since epoch."""
return asyncio.get_event_loop().time()


class SatControlSetpointSynchroSensor(SatSynchroSensor, SatClimateEntity, BinarySensorEntity):
def __init__(self, coordinator, _config_entry, climate):
Expand Down
156 changes: 156 additions & 0 deletions custom_components/sat/boiler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import logging

from .const import MINIMUM_SETPOINT

_LOGGER = logging.getLogger(__name__)

STABILIZATION_MARGIN = 5
EXCEED_SETPOINT_MARGIN = 0.1


class BoilerState:
"""
Represents the operational state of a boiler, including activity, flame status, hot water usage, and current temperature.
"""

def __init__(self, device_active: bool, flame_active: bool, hot_water_active: bool, temperature: float):
"""
Initialize with the boiler's state parameters.
:param device_active: Whether the boiler is currently operational.
:param flame_active: Whether the boiler's flame is ignited.
:param hot_water_active: Whether the boiler is heating water.
:param temperature: The current boiler temperature in Celsius.
"""
self._temperature = temperature
self._flame_active = flame_active
self._device_active = device_active
self._hot_water_active = hot_water_active

@property
def device_active(self) -> bool:
"""Indicates whether the boiler is running."""
return self._device_active

@property
def flame_active(self) -> bool:
"""Indicates whether the flame is ignited."""
return self._flame_active

@property
def hot_water_active(self) -> bool:
"""Indicates whether the boiler is heating water."""
return self._hot_water_active

@property
def temperature(self) -> float:
"""The boiler's current temperature."""
return self._temperature


class BoilerTemperatureTracker:
def __init__(self):
"""Initialize the BoilerTemperatureTracker."""
self._active = False
self._warming_up = False
self._adjusting_to_lower_setpoint = False

self._last_setpoint = None
self._last_boiler_temperature = None

def update(self, boiler_temperature: float, boiler_temperature_derivative: float, flame_active: bool, setpoint: float):
"""Update the tracker based on the current boiler temperature, flame status, and setpoint."""
if setpoint == MINIMUM_SETPOINT:
return

if self._last_boiler_temperature is None:
self._last_boiler_temperature = boiler_temperature

if self._last_setpoint is None:
self._last_setpoint = setpoint

if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint:
self._handle_setpoint_decrease()

if not flame_active:
self._handle_flame_inactive()
elif self._adjusting_to_lower_setpoint:
self._handle_adjusting_to_lower_setpoint(boiler_temperature, boiler_temperature_derivative, setpoint)
elif self._active:
self._handle_tracking(boiler_temperature, boiler_temperature_derivative, setpoint)

self._last_setpoint = setpoint
self._last_boiler_temperature = boiler_temperature

def _handle_setpoint_decrease(self):
if self._adjusting_to_lower_setpoint:
return

self._active = True
self._warming_up = True
self._adjusting_to_lower_setpoint = True

_LOGGER.debug("Setpoint decreased. Entering stabilization mode.")

def _handle_flame_inactive(self):
"""Handle the case where the flame is inactive."""
if self._active:
return

self._active = True
self._warming_up = True

_LOGGER.debug("Flame inactive: Starting to track boiler temperature.")

def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float):
"""Handle boiler temperature tracking logic."""
if not self._warming_up and boiler_temperature_derivative == 0:
return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint)

if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN:
return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint)

if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature:
return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint)

def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float):
"""Handle stabilization when adjusting to a lower setpoint."""
if boiler_temperature <= setpoint and boiler_temperature_derivative == 0:
return self._stop_adjusting_to_lower_setpoint("Setpoint stabilization complete.", boiler_temperature, setpoint)

def _stop_adjusting_to_lower_setpoint(self, reason: str, boiler_temperature: float, setpoint: float):
"""Stop the adjustment to a lower setpoint and log the reason."""
self._adjusting_to_lower_setpoint = False

_LOGGER.debug(
f"Adjustment to lower setpoint stopped: {reason} "
f"(Setpoint: {setpoint}, Current: {boiler_temperature})."
)

def _stop_warming_up(self, reason: str, boiler_temperature: float, setpoint: float):
"""Stop the warming-up phase and log the reason."""
self._warming_up = False

_LOGGER.debug(
f"Warming up stopped: {reason} "
f"(Setpoint: {setpoint}, Current: {boiler_temperature}, Last: {self._last_boiler_temperature})."
)

def _stop_tracking(self, reason: str, boiler_temperature: float, setpoint: float):
"""Deactivate tracking and log the reason."""
self._active = False

_LOGGER.debug(
f"Tracking stopped: {reason} "
f"(Setpoint: {setpoint}, Current: {boiler_temperature}, Last: {self._last_boiler_temperature})."
)

@property
def active(self) -> bool:
"""Check if the tracker is currently active."""
return self._active

@property
def inactive(self) -> bool:
"""Check if the tracker is currently inactive."""
return not self._active
52 changes: 0 additions & 52 deletions custom_components/sat/boiler_state.py

This file was deleted.

Loading

0 comments on commit 1a11dab

Please sign in to comment.