From a35a1aa529183e0ec4f276d207952d9930ee9547 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 21 Jan 2025 19:48:46 +0100 Subject: [PATCH 01/20] Apply modulation constraints for Geminox --- custom_components/sat/climate.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 6f74c920..3574d799 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -43,6 +43,7 @@ from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus from .entity import SatEntity from .helpers import convert_time_str_to_seconds, seconds_since +from .manufacturers.geminox import Geminox from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState from .setpoint_adjuster import SetpointAdjuster @@ -862,12 +863,19 @@ async def _async_control_relative_modulation(self) -> None: # Update relative modulation state await self._relative_modulation.update(self.pulse_width_modulation_enabled) + # Retrieve the relative modulation + relative_modulation_value = self.relative_modulation_value + + # Apply some filters based on the manufacturer + if isinstance(self._coordinator.manufacturer, Geminox): + relative_modulation_value = max(10, relative_modulation_value) + # Determine if the value needs to be updated - if self._coordinator.maximum_relative_modulation_value == self.relative_modulation_value: - _LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", self.relative_modulation_value) + if self._coordinator.maximum_relative_modulation_value == relative_modulation_value: + _LOGGER.debug("Relative modulation value unchanged (%d%%). No update necessary.", relative_modulation_value) return - await self._coordinator.async_set_control_max_relative_modulation(self.relative_modulation_value) + await self._coordinator.async_set_control_max_relative_modulation(relative_modulation_value) async def _async_update_rooms_from_climates(self) -> None: """Update the temperature setpoint for each room based on their associated climate entity.""" From 41161bc9bc781b5a0a2e7bb35d9e3a85741ce351 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 21 Jan 2025 19:55:08 +0100 Subject: [PATCH 02/20] Add support for "Other" manufacturer --- custom_components/sat/config_flow.py | 12 +++++++++++- custom_components/sat/manufacturer.py | 2 +- .../sat/manufacturers/{simulator.py => other.py} | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) rename custom_components/sat/manufacturers/{simulator.py => other.py} (58%) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 27c20a21..701ae9ee 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -354,6 +354,9 @@ async def async_step_automatic_gains(self, _user_input: dict[str, Any] | None = if not self.data[CONF_AUTOMATIC_GAINS]: return await self.async_step_pid_controller() + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -440,6 +443,9 @@ async def async_step_overshoot_protection(self, _user_input: dict[str, Any] | No _user_input[CONF_MINIMUM_SETPOINT] ) + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -457,6 +463,10 @@ async def async_step_pid_controller(self, _user_input: dict[str, Any] | None = N if _user_input is not None: self.data.update(_user_input) + + if self.data[CONF_MODE] == MODE_SIMULATOR: + return await self.async_step_finish() + return await self.async_step_manufacturer() return self.async_show_form( @@ -479,7 +489,7 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non try: manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) - default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else None + default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else -1 finally: await coordinator.async_will_remove_from_hass() diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 500c8eb0..b8260fb7 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -3,7 +3,6 @@ from typing import List, Optional MANUFACTURERS = { - "Simulator": {"module": "simulator", "class": "Simulator", "id": -1}, "ATAG": {"module": "atag", "class": "ATAG", "id": 4}, "Baxi": {"module": "baxi", "class": "Baxi", "id": 4}, "Brotge": {"module": "brotge", "class": "Brotge", "id": 4}, @@ -18,6 +17,7 @@ "Radiant": {"module": "radiant", "class": "Radiant", "id": 41}, "Nefit": {"module": "nefit", "class": "Nefit", "id": 131}, "Intergas": {"module": "intergas", "class": "Intergas", "id": 173}, + "Other": {"module": "other", "class": "Other", "id": -1}, } diff --git a/custom_components/sat/manufacturers/simulator.py b/custom_components/sat/manufacturers/other.py similarity index 58% rename from custom_components/sat/manufacturers/simulator.py rename to custom_components/sat/manufacturers/other.py index b4ae0293..e7e18660 100644 --- a/custom_components/sat/manufacturers/simulator.py +++ b/custom_components/sat/manufacturers/other.py @@ -1,7 +1,7 @@ from ..manufacturer import Manufacturer -class Simulator(Manufacturer): +class Other(Manufacturer): @property def name(self) -> str: - return 'Simulator' + return 'Other' From cf295442fb633f0d76612df348ec1b3c5d4ce97d Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Tue, 21 Jan 2025 20:07:17 +0100 Subject: [PATCH 03/20] Enable relative modulation in PWM when we are in the OFF cycle --- custom_components/sat/relative_modulation.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/sat/relative_modulation.py b/custom_components/sat/relative_modulation.py index ad77f1e4..6795a406 100644 --- a/custom_components/sat/relative_modulation.py +++ b/custom_components/sat/relative_modulation.py @@ -34,13 +34,13 @@ def state(self) -> RelativeModulationState: if self._coordinator.hot_water_active: return RelativeModulationState.HOT_WATER - if not self._pulse_width_modulation_enabled: - if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: - return RelativeModulationState.COLD + if self._coordinator.setpoint is None or self._coordinator.setpoint <= MINIMUM_SETPOINT: + return RelativeModulationState.COLD - return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF + if self._pulse_width_modulation_enabled: + return RelativeModulationState.OFF - return RelativeModulationState.OFF + return RelativeModulationState.PULSE_WIDTH_MODULATION_OFF @property def enabled(self) -> bool: From aba9b8982466fea2f9b843e4ab2aca02a22c254b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Sat, 25 Jan 2025 14:01:34 +0100 Subject: [PATCH 04/20] Create FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..88d3c51a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: https://www.buymeacoffee.com/alexwijn From 71e996322bb4710db1dbcc0b97590ea30e019dfc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Sat, 25 Jan 2025 14:02:07 +0100 Subject: [PATCH 05/20] Update FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 88d3c51a..f240cb75 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -buy_me_a_coffee: https://www.buymeacoffee.com/alexwijn +buy_me_a_coffee: alexwijn From 571e1101a07e268dc867ae25ed5578dbf483b240 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 25 Jan 2025 15:19:30 +0100 Subject: [PATCH 06/20] Add some more manufacturers and clean some code --- custom_components/sat/manufacturer.py | 52 +++++++++++-------- custom_components/sat/manufacturers/atag.py | 4 ++ custom_components/sat/manufacturers/baxi.py | 4 ++ custom_components/sat/manufacturers/brotge.py | 4 ++ .../sat/manufacturers/dedietrich.py | 4 ++ .../sat/manufacturers/ferroli.py | 4 ++ .../sat/manufacturers/geminox.py | 4 ++ custom_components/sat/manufacturers/ideal.py | 4 ++ .../sat/manufacturers/immergas.py | 4 ++ .../sat/manufacturers/intergas.py | 4 ++ custom_components/sat/manufacturers/itho.py | 11 ++++ custom_components/sat/manufacturers/nefit.py | 4 ++ custom_components/sat/manufacturers/other.py | 4 ++ .../sat/manufacturers/radiant.py | 4 ++ custom_components/sat/manufacturers/remeha.py | 11 ++++ custom_components/sat/manufacturers/sime.py | 4 ++ .../sat/manufacturers/vaillant.py | 4 ++ .../sat/manufacturers/viessmann.py | 4 ++ .../sat/manufacturers/worcester.py | 11 ++++ 19 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 custom_components/sat/manufacturers/itho.py create mode 100644 custom_components/sat/manufacturers/remeha.py create mode 100644 custom_components/sat/manufacturers/worcester.py diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index b8260fb7..ce1b3c22 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -3,25 +3,33 @@ from typing import List, Optional MANUFACTURERS = { - "ATAG": {"module": "atag", "class": "ATAG", "id": 4}, - "Baxi": {"module": "baxi", "class": "Baxi", "id": 4}, - "Brotge": {"module": "brotge", "class": "Brotge", "id": 4}, - "Geminox": {"module": "geminox", "class": "Geminox", "id": 4}, - "Ideal": {"module": "ideal", "class": "Ideal", "id": 6}, - "Ferroli": {"module": "ferroli", "class": "Ferroli", "id": 9}, - "DeDietrich": {"module": "dedietrich", "class": "DeDietrich", "id": 11}, - "Vaillant": {"module": "vaillant", "class": "Vaillant", "id": 24}, - "Immergas": {"module": "immergas", "class": "Immergas", "id": 27}, - "Sime": {"module": "sime", "class": "Sime", "id": 27}, - "Viessmann": {"module": "viessmann", "class": "Viessmann", "id": 33}, - "Radiant": {"module": "radiant", "class": "Radiant", "id": 41}, - "Nefit": {"module": "nefit", "class": "Nefit", "id": 131}, - "Intergas": {"module": "intergas", "class": "Intergas", "id": 173}, - "Other": {"module": "other", "class": "Other", "id": -1}, + "ATAG": "atag", + "Baxi": "baxi", + "Brotge": "brotge", + "DeDietrich": "dedietrich", + "Ferroli": "ferroli", + "Geminox": "geminox", + "Ideal": "ideal", + "Immergas": "immergas", + "Intergas": "intergas", + "Itho": "itho", + "Nefit": "nefit", + "Radiant": "radiant", + "Remeha": "remeha", + "Sime": "sime", + "Vaillant": "vaillant", + "Viessmann": "viessmann", + "Worcester": "worcester", + "Other": "other", } class Manufacturer: + @property + @abstractmethod + def identifier(self) -> int: + pass + @property @abstractmethod def name(self) -> str: @@ -32,23 +40,21 @@ class ManufacturerFactory: @staticmethod def resolve_by_name(name: str) -> Optional[Manufacturer]: """Resolve a Manufacturer instance by its name.""" - manufacturer = MANUFACTURERS.get(name) - if not manufacturer: + if not (module := MANUFACTURERS.get(name)): return None - return ManufacturerFactory._import_class(manufacturer["module"], manufacturer["class"])() + return ManufacturerFactory._import_class(module, name)() @staticmethod def resolve_by_member_id(member_id: int) -> List[Manufacturer]: """Resolve a list of Manufacturer instances by member ID.""" return [ - ManufacturerFactory._import_class(info["module"], info["class"])() - for name, info in MANUFACTURERS.items() - if info["id"] == member_id + manufacturer + for name, module in MANUFACTURERS.items() + if (manufacturer := ManufacturerFactory._import_class(module, name)()).identifier == member_id ] @staticmethod def _import_class(module_name: str, class_name: str): """Dynamically import and return a Manufacturer class.""" - module = __import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]) - return getattr(module, class_name) + return getattr(__import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]), class_name) diff --git a/custom_components/sat/manufacturers/atag.py b/custom_components/sat/manufacturers/atag.py index 1b8fc21e..03215582 100644 --- a/custom_components/sat/manufacturers/atag.py +++ b/custom_components/sat/manufacturers/atag.py @@ -2,6 +2,10 @@ class ATAG(Manufacturer): + @property + def identifier(self) -> int: + return 4 + @property def name(self) -> str: return 'ATAG' diff --git a/custom_components/sat/manufacturers/baxi.py b/custom_components/sat/manufacturers/baxi.py index a5c66e64..d65a340a 100644 --- a/custom_components/sat/manufacturers/baxi.py +++ b/custom_components/sat/manufacturers/baxi.py @@ -2,6 +2,10 @@ class Baxi(Manufacturer): + @property + def identifier(self) -> int: + return 4 + @property def name(self) -> str: return 'Baxi' diff --git a/custom_components/sat/manufacturers/brotge.py b/custom_components/sat/manufacturers/brotge.py index e20115be..34f905a4 100644 --- a/custom_components/sat/manufacturers/brotge.py +++ b/custom_components/sat/manufacturers/brotge.py @@ -2,6 +2,10 @@ class Brotge(Manufacturer): + @property + def identifier(self) -> int: + return 4 + @property def name(self) -> str: return 'BRÖTGE' diff --git a/custom_components/sat/manufacturers/dedietrich.py b/custom_components/sat/manufacturers/dedietrich.py index 42e4b3b2..2f5d2e84 100644 --- a/custom_components/sat/manufacturers/dedietrich.py +++ b/custom_components/sat/manufacturers/dedietrich.py @@ -2,6 +2,10 @@ class DeDietrich(Manufacturer): + @property + def identifier(self) -> int: + return 4 + @property def name(self) -> str: return 'De Dietrich' diff --git a/custom_components/sat/manufacturers/ferroli.py b/custom_components/sat/manufacturers/ferroli.py index 05ad5ce6..0661cc72 100644 --- a/custom_components/sat/manufacturers/ferroli.py +++ b/custom_components/sat/manufacturers/ferroli.py @@ -2,6 +2,10 @@ class Ferroli(Manufacturer): + @property + def identifier(self) -> int: + return 9 + @property def name(self) -> str: return 'Ferroli' diff --git a/custom_components/sat/manufacturers/geminox.py b/custom_components/sat/manufacturers/geminox.py index 358e83a4..e5436904 100644 --- a/custom_components/sat/manufacturers/geminox.py +++ b/custom_components/sat/manufacturers/geminox.py @@ -2,6 +2,10 @@ class Geminox(Manufacturer): + @property + def identifier(self) -> int: + return 4 + @property def name(self) -> str: return 'Geminox' diff --git a/custom_components/sat/manufacturers/ideal.py b/custom_components/sat/manufacturers/ideal.py index 18ebf5d4..65c351c4 100644 --- a/custom_components/sat/manufacturers/ideal.py +++ b/custom_components/sat/manufacturers/ideal.py @@ -2,6 +2,10 @@ class Ideal(Manufacturer): + @property + def identifier(self) -> int: + return 6 + @property def name(self) -> str: return 'Ideal' diff --git a/custom_components/sat/manufacturers/immergas.py b/custom_components/sat/manufacturers/immergas.py index 08207733..f705be8a 100644 --- a/custom_components/sat/manufacturers/immergas.py +++ b/custom_components/sat/manufacturers/immergas.py @@ -2,6 +2,10 @@ class Immergas(Manufacturer): + @property + def identifier(self) -> int: + return 27 + @property def name(self) -> str: return 'Immergas' diff --git a/custom_components/sat/manufacturers/intergas.py b/custom_components/sat/manufacturers/intergas.py index a6c98760..190757b8 100644 --- a/custom_components/sat/manufacturers/intergas.py +++ b/custom_components/sat/manufacturers/intergas.py @@ -2,6 +2,10 @@ class Intergas(Manufacturer): + @property + def identifier(self) -> int: + return 173 + @property def name(self) -> str: return 'Intergas' diff --git a/custom_components/sat/manufacturers/itho.py b/custom_components/sat/manufacturers/itho.py new file mode 100644 index 00000000..cbe78ef3 --- /dev/null +++ b/custom_components/sat/manufacturers/itho.py @@ -0,0 +1,11 @@ +from ..manufacturer import Manufacturer + + +class Itho(Manufacturer): + @property + def identifier(self) -> int: + return 29 + + @property + def name(self) -> str: + return 'Itho' diff --git a/custom_components/sat/manufacturers/nefit.py b/custom_components/sat/manufacturers/nefit.py index 4f2b9669..a3b69b6c 100644 --- a/custom_components/sat/manufacturers/nefit.py +++ b/custom_components/sat/manufacturers/nefit.py @@ -2,6 +2,10 @@ class Nefit(Manufacturer): + @property + def identifier(self) -> int: + return 131 + @property def name(self) -> str: return 'Nefit' diff --git a/custom_components/sat/manufacturers/other.py b/custom_components/sat/manufacturers/other.py index e7e18660..a7276ab0 100644 --- a/custom_components/sat/manufacturers/other.py +++ b/custom_components/sat/manufacturers/other.py @@ -2,6 +2,10 @@ class Other(Manufacturer): + @property + def identifier(self) -> int: + return -1 + @property def name(self) -> str: return 'Other' diff --git a/custom_components/sat/manufacturers/radiant.py b/custom_components/sat/manufacturers/radiant.py index 43a6e4e8..e13d70e0 100644 --- a/custom_components/sat/manufacturers/radiant.py +++ b/custom_components/sat/manufacturers/radiant.py @@ -2,6 +2,10 @@ class Radiant(Manufacturer): + @property + def identifier(self) -> int: + return 41 + @property def name(self) -> str: return 'Radiant' diff --git a/custom_components/sat/manufacturers/remeha.py b/custom_components/sat/manufacturers/remeha.py new file mode 100644 index 00000000..c339c80f --- /dev/null +++ b/custom_components/sat/manufacturers/remeha.py @@ -0,0 +1,11 @@ +from ..manufacturer import Manufacturer + + +class Remeha(Manufacturer): + @property + def identifier(self) -> int: + return 11 + + @property + def name(self) -> str: + return 'Remeha' diff --git a/custom_components/sat/manufacturers/sime.py b/custom_components/sat/manufacturers/sime.py index 7cc0ade9..6445e553 100644 --- a/custom_components/sat/manufacturers/sime.py +++ b/custom_components/sat/manufacturers/sime.py @@ -2,6 +2,10 @@ class Sime(Manufacturer): + @property + def identifier(self) -> int: + return 27 + @property def name(self) -> str: return 'Sime' diff --git a/custom_components/sat/manufacturers/vaillant.py b/custom_components/sat/manufacturers/vaillant.py index 3e521938..19c5a45d 100644 --- a/custom_components/sat/manufacturers/vaillant.py +++ b/custom_components/sat/manufacturers/vaillant.py @@ -2,6 +2,10 @@ class Vaillant(Manufacturer): + @property + def identifier(self) -> int: + return 24 + @property def name(self) -> str: return 'Vaillant' diff --git a/custom_components/sat/manufacturers/viessmann.py b/custom_components/sat/manufacturers/viessmann.py index 41ad7db7..bad04007 100644 --- a/custom_components/sat/manufacturers/viessmann.py +++ b/custom_components/sat/manufacturers/viessmann.py @@ -2,6 +2,10 @@ class Viessmann(Manufacturer): + @property + def identifier(self) -> int: + return 33 + @property def name(self) -> str: return 'Viessmann' diff --git a/custom_components/sat/manufacturers/worcester.py b/custom_components/sat/manufacturers/worcester.py new file mode 100644 index 00000000..785c4091 --- /dev/null +++ b/custom_components/sat/manufacturers/worcester.py @@ -0,0 +1,11 @@ +from ..manufacturer import Manufacturer + + +class Worcester(Manufacturer): + @property + def identifier(self) -> int: + return 4 + + @property + def name(self) -> str: + return 'Worcester Bosch' From cad71150b24f2c087485f1496cdc979a16c1254a Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 25 Jan 2025 15:33:40 +0100 Subject: [PATCH 07/20] Fixed some tests --- custom_components/sat/manufacturer.py | 12 ++++++++++-- tests/test_manufacturer.py | 13 +++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index ce1b3c22..5a22e182 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -37,6 +37,14 @@ def name(self) -> str: class ManufacturerFactory: + @staticmethod + def all() -> List[Manufacturer]: + """Resolve a list of all Manufacturer instances.""" + return [ + ManufacturerFactory._import_class(module, name)() + for name, module in MANUFACTURERS.items() + ] + @staticmethod def resolve_by_name(name: str) -> Optional[Manufacturer]: """Resolve a Manufacturer instance by its name.""" @@ -50,8 +58,8 @@ def resolve_by_member_id(member_id: int) -> List[Manufacturer]: """Resolve a list of Manufacturer instances by member ID.""" return [ manufacturer - for name, module in MANUFACTURERS.items() - if (manufacturer := ManufacturerFactory._import_class(module, name)()).identifier == member_id + for manufacturer in ManufacturerFactory.all() + if manufacturer.identifier == member_id ] @staticmethod diff --git a/tests/test_manufacturer.py b/tests/test_manufacturer.py index 2107517b..890e449a 100644 --- a/tests/test_manufacturer.py +++ b/tests/test_manufacturer.py @@ -3,11 +3,10 @@ def test_resolve_by_name(): """Test resolving manufacturers by name.""" - for name, data in MANUFACTURERS.items(): - # Test valid name + for name, module in MANUFACTURERS.items(): manufacturer = ManufacturerFactory.resolve_by_name(name) assert manufacturer is not None, f"Manufacturer '{name}' should not be None" - assert manufacturer.__class__.__name__ == data["class"] + assert manufacturer.__class__.__name__ == name # Test invalid name manufacturer = ManufacturerFactory.resolve_by_name("InvalidName") @@ -16,9 +15,11 @@ def test_resolve_by_name(): def test_resolve_by_member_id(): """Test resolving manufacturers by member ID.""" - member_id_to_names = {data["id"]: [] for data in MANUFACTURERS.values()} - for name, data in MANUFACTURERS.items(): - member_id_to_names[data["id"]].append(name) + manufacturers = ManufacturerFactory.all() + member_id_to_names = {manufacturer.identifier: [] for manufacturer in manufacturers} + + for manufacturer in manufacturers: + member_id_to_names[manufacturer.identifier].append(type(manufacturer).__name__) for member_id, names in member_id_to_names.items(): manufacturers = ManufacturerFactory.resolve_by_member_id(member_id) From 47e3ddeb02fc8b93c2114dbb9a89c360c8c38aaf Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 25 Jan 2025 16:46:48 +0100 Subject: [PATCH 08/20] Cleaned up some more --- custom_components/sat/config_flow.py | 6 +- custom_components/sat/manufacturer.py | 73 +++++++++---------- custom_components/sat/manufacturers/atag.py | 8 +- custom_components/sat/manufacturers/baxi.py | 6 +- custom_components/sat/manufacturers/brotge.py | 6 +- .../{dedietrich.py => de_dietrich.py} | 6 +- .../sat/manufacturers/ferroli.py | 6 +- .../sat/manufacturers/geminox.py | 6 +- custom_components/sat/manufacturers/ideal.py | 6 +- .../sat/manufacturers/immergas.py | 6 +- .../sat/manufacturers/intergas.py | 6 +- custom_components/sat/manufacturers/itho.py | 6 +- custom_components/sat/manufacturers/nefit.py | 6 +- custom_components/sat/manufacturers/other.py | 6 +- .../sat/manufacturers/radiant.py | 6 +- custom_components/sat/manufacturers/remeha.py | 6 +- custom_components/sat/manufacturers/sime.py | 6 +- .../sat/manufacturers/vaillant.py | 6 +- .../sat/manufacturers/viessmann.py | 6 +- .../sat/manufacturers/worcester.py | 6 +- 20 files changed, 56 insertions(+), 133 deletions(-) rename custom_components/sat/manufacturers/{dedietrich.py => de_dietrich.py} (55%) diff --git a/custom_components/sat/config_flow.py b/custom_components/sat/config_flow.py index 701ae9ee..98920133 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -489,14 +489,14 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non try: manufacturers = ManufacturerFactory.resolve_by_member_id(coordinator.member_id) - default_manufacturer = manufacturers[0].name if len(manufacturers) > 0 else -1 + default_manufacturer = manufacturers[0].friendly_name if len(manufacturers) > 0 else -1 finally: await coordinator.async_will_remove_from_hass() options = [] - for name, _info in MANUFACTURERS.items(): + for name, _member_id in MANUFACTURERS.items(): manufacturer = ManufacturerFactory.resolve_by_name(name) - options.append({"value": name, "label": manufacturer.name}) + options.append({"value": name, "label": manufacturer.friendly_name}) return self.async_show_form( last_step=True, diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 5a22e182..904116be 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -1,68 +1,63 @@ -from abc import abstractmethod +from abc import ABC, abstractmethod +from typing import Optional, List, Type -from typing import List, Optional +from custom_components.sat.helpers import snake_case MANUFACTURERS = { - "ATAG": "atag", - "Baxi": "baxi", - "Brotge": "brotge", - "DeDietrich": "dedietrich", - "Ferroli": "ferroli", - "Geminox": "geminox", - "Ideal": "ideal", - "Immergas": "immergas", - "Intergas": "intergas", - "Itho": "itho", - "Nefit": "nefit", - "Radiant": "radiant", - "Remeha": "remeha", - "Sime": "sime", - "Vaillant": "vaillant", - "Viessmann": "viessmann", - "Worcester": "worcester", - "Other": "other", + "Atag": 4, + "Baxi": 4, + "Brotge": 4, + "DeDietrich": 4, + "Ferroli": 9, + "Geminox": 4, + "Ideal": 6, + "Immergas": 27, + "Intergas": 173, + "Itho": 29, + "Nefit": 131, + "Radiant": 41, + "Remeha": 11, + "Sime": 27, + "Vaillant": 24, + "Viessmann": 33, + "Worcester": 95, + "Other": -1, } -class Manufacturer: +class Manufacturer(ABC): + def __init__(self, member_id: int): + self._member_id = member_id + @property - @abstractmethod - def identifier(self) -> int: - pass + def member_id(self) -> int: + return self._member_id @property @abstractmethod - def name(self) -> str: + def friendly_name(self) -> str: pass class ManufacturerFactory: - @staticmethod - def all() -> List[Manufacturer]: - """Resolve a list of all Manufacturer instances.""" - return [ - ManufacturerFactory._import_class(module, name)() - for name, module in MANUFACTURERS.items() - ] - @staticmethod def resolve_by_name(name: str) -> Optional[Manufacturer]: """Resolve a Manufacturer instance by its name.""" - if not (module := MANUFACTURERS.get(name)): + if not (member_id := MANUFACTURERS.get(name)): return None - return ManufacturerFactory._import_class(module, name)() + return ManufacturerFactory._import_class(snake_case(name), name)(member_id) @staticmethod def resolve_by_member_id(member_id: int) -> List[Manufacturer]: """Resolve a list of Manufacturer instances by member ID.""" return [ - manufacturer - for manufacturer in ManufacturerFactory.all() - if manufacturer.identifier == member_id + ManufacturerFactory._import_class(snake_case(name), name)(identifier) + for name, identifier in MANUFACTURERS.items() + if member_id == identifier ] @staticmethod - def _import_class(module_name: str, class_name: str): + def _import_class(module_name: str, class_name: str) -> Type[Manufacturer]: """Dynamically import and return a Manufacturer class.""" return getattr(__import__(f"custom_components.sat.manufacturers.{module_name}", fromlist=[class_name]), class_name) diff --git a/custom_components/sat/manufacturers/atag.py b/custom_components/sat/manufacturers/atag.py index 03215582..d6a9fd39 100644 --- a/custom_components/sat/manufacturers/atag.py +++ b/custom_components/sat/manufacturers/atag.py @@ -1,11 +1,7 @@ from ..manufacturer import Manufacturer -class ATAG(Manufacturer): +class Atag(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'ATAG' diff --git a/custom_components/sat/manufacturers/baxi.py b/custom_components/sat/manufacturers/baxi.py index d65a340a..9b2b1c52 100644 --- a/custom_components/sat/manufacturers/baxi.py +++ b/custom_components/sat/manufacturers/baxi.py @@ -3,9 +3,5 @@ class Baxi(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Baxi' diff --git a/custom_components/sat/manufacturers/brotge.py b/custom_components/sat/manufacturers/brotge.py index 34f905a4..20b639c6 100644 --- a/custom_components/sat/manufacturers/brotge.py +++ b/custom_components/sat/manufacturers/brotge.py @@ -3,9 +3,5 @@ class Brotge(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'BRÖTGE' diff --git a/custom_components/sat/manufacturers/dedietrich.py b/custom_components/sat/manufacturers/de_dietrich.py similarity index 55% rename from custom_components/sat/manufacturers/dedietrich.py rename to custom_components/sat/manufacturers/de_dietrich.py index 2f5d2e84..02290a7e 100644 --- a/custom_components/sat/manufacturers/dedietrich.py +++ b/custom_components/sat/manufacturers/de_dietrich.py @@ -3,9 +3,5 @@ class DeDietrich(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'De Dietrich' diff --git a/custom_components/sat/manufacturers/ferroli.py b/custom_components/sat/manufacturers/ferroli.py index 0661cc72..6cc58b47 100644 --- a/custom_components/sat/manufacturers/ferroli.py +++ b/custom_components/sat/manufacturers/ferroli.py @@ -3,9 +3,5 @@ class Ferroli(Manufacturer): @property - def identifier(self) -> int: - return 9 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Ferroli' diff --git a/custom_components/sat/manufacturers/geminox.py b/custom_components/sat/manufacturers/geminox.py index e5436904..68d217e0 100644 --- a/custom_components/sat/manufacturers/geminox.py +++ b/custom_components/sat/manufacturers/geminox.py @@ -3,9 +3,5 @@ class Geminox(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Geminox' diff --git a/custom_components/sat/manufacturers/ideal.py b/custom_components/sat/manufacturers/ideal.py index 65c351c4..b92e9a92 100644 --- a/custom_components/sat/manufacturers/ideal.py +++ b/custom_components/sat/manufacturers/ideal.py @@ -3,9 +3,5 @@ class Ideal(Manufacturer): @property - def identifier(self) -> int: - return 6 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Ideal' diff --git a/custom_components/sat/manufacturers/immergas.py b/custom_components/sat/manufacturers/immergas.py index f705be8a..ba0d685f 100644 --- a/custom_components/sat/manufacturers/immergas.py +++ b/custom_components/sat/manufacturers/immergas.py @@ -3,9 +3,5 @@ class Immergas(Manufacturer): @property - def identifier(self) -> int: - return 27 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Immergas' diff --git a/custom_components/sat/manufacturers/intergas.py b/custom_components/sat/manufacturers/intergas.py index 190757b8..2f40f150 100644 --- a/custom_components/sat/manufacturers/intergas.py +++ b/custom_components/sat/manufacturers/intergas.py @@ -3,9 +3,5 @@ class Intergas(Manufacturer): @property - def identifier(self) -> int: - return 173 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Intergas' diff --git a/custom_components/sat/manufacturers/itho.py b/custom_components/sat/manufacturers/itho.py index cbe78ef3..1483700a 100644 --- a/custom_components/sat/manufacturers/itho.py +++ b/custom_components/sat/manufacturers/itho.py @@ -3,9 +3,5 @@ class Itho(Manufacturer): @property - def identifier(self) -> int: - return 29 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Itho' diff --git a/custom_components/sat/manufacturers/nefit.py b/custom_components/sat/manufacturers/nefit.py index a3b69b6c..cf0bf1c8 100644 --- a/custom_components/sat/manufacturers/nefit.py +++ b/custom_components/sat/manufacturers/nefit.py @@ -3,9 +3,5 @@ class Nefit(Manufacturer): @property - def identifier(self) -> int: - return 131 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Nefit' diff --git a/custom_components/sat/manufacturers/other.py b/custom_components/sat/manufacturers/other.py index a7276ab0..4bb57af4 100644 --- a/custom_components/sat/manufacturers/other.py +++ b/custom_components/sat/manufacturers/other.py @@ -3,9 +3,5 @@ class Other(Manufacturer): @property - def identifier(self) -> int: - return -1 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Other' diff --git a/custom_components/sat/manufacturers/radiant.py b/custom_components/sat/manufacturers/radiant.py index e13d70e0..bcedcaef 100644 --- a/custom_components/sat/manufacturers/radiant.py +++ b/custom_components/sat/manufacturers/radiant.py @@ -3,9 +3,5 @@ class Radiant(Manufacturer): @property - def identifier(self) -> int: - return 41 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Radiant' diff --git a/custom_components/sat/manufacturers/remeha.py b/custom_components/sat/manufacturers/remeha.py index c339c80f..4f5120bf 100644 --- a/custom_components/sat/manufacturers/remeha.py +++ b/custom_components/sat/manufacturers/remeha.py @@ -3,9 +3,5 @@ class Remeha(Manufacturer): @property - def identifier(self) -> int: - return 11 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Remeha' diff --git a/custom_components/sat/manufacturers/sime.py b/custom_components/sat/manufacturers/sime.py index 6445e553..9d34ddc3 100644 --- a/custom_components/sat/manufacturers/sime.py +++ b/custom_components/sat/manufacturers/sime.py @@ -3,9 +3,5 @@ class Sime(Manufacturer): @property - def identifier(self) -> int: - return 27 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Sime' diff --git a/custom_components/sat/manufacturers/vaillant.py b/custom_components/sat/manufacturers/vaillant.py index 19c5a45d..47af38cb 100644 --- a/custom_components/sat/manufacturers/vaillant.py +++ b/custom_components/sat/manufacturers/vaillant.py @@ -3,9 +3,5 @@ class Vaillant(Manufacturer): @property - def identifier(self) -> int: - return 24 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Vaillant' diff --git a/custom_components/sat/manufacturers/viessmann.py b/custom_components/sat/manufacturers/viessmann.py index bad04007..a9084e5f 100644 --- a/custom_components/sat/manufacturers/viessmann.py +++ b/custom_components/sat/manufacturers/viessmann.py @@ -3,9 +3,5 @@ class Viessmann(Manufacturer): @property - def identifier(self) -> int: - return 33 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Viessmann' diff --git a/custom_components/sat/manufacturers/worcester.py b/custom_components/sat/manufacturers/worcester.py index 785c4091..ff6b3e8a 100644 --- a/custom_components/sat/manufacturers/worcester.py +++ b/custom_components/sat/manufacturers/worcester.py @@ -3,9 +3,5 @@ class Worcester(Manufacturer): @property - def identifier(self) -> int: - return 4 - - @property - def name(self) -> str: + def friendly_name(self) -> str: return 'Worcester Bosch' From 0a2e21c23931d2fed12d1b136ba32bb4b858317f Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 25 Jan 2025 16:49:06 +0100 Subject: [PATCH 09/20] Fixed some tests --- tests/test_manufacturer.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/test_manufacturer.py b/tests/test_manufacturer.py index 890e449a..a295aeac 100644 --- a/tests/test_manufacturer.py +++ b/tests/test_manufacturer.py @@ -3,7 +3,8 @@ def test_resolve_by_name(): """Test resolving manufacturers by name.""" - for name, module in MANUFACTURERS.items(): + for name, data in MANUFACTURERS.items(): + # Test valid name manufacturer = ManufacturerFactory.resolve_by_name(name) assert manufacturer is not None, f"Manufacturer '{name}' should not be None" assert manufacturer.__class__.__name__ == name @@ -15,11 +16,9 @@ def test_resolve_by_name(): def test_resolve_by_member_id(): """Test resolving manufacturers by member ID.""" - manufacturers = ManufacturerFactory.all() - member_id_to_names = {manufacturer.identifier: [] for manufacturer in manufacturers} - - for manufacturer in manufacturers: - member_id_to_names[manufacturer.identifier].append(type(manufacturer).__name__) + member_id_to_names = {member_id: [] for name, member_id in MANUFACTURERS.items()} + for name, member_id in MANUFACTURERS.items(): + member_id_to_names[member_id].append(name) for member_id, names in member_id_to_names.items(): manufacturers = ManufacturerFactory.resolve_by_member_id(member_id) From a20c8675ea34a7e335d1b78f1d738992f111af42 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sat, 25 Jan 2025 16:49:58 +0100 Subject: [PATCH 10/20] Cleanup --- 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 98920133..40082409 100644 --- a/custom_components/sat/config_flow.py +++ b/custom_components/sat/config_flow.py @@ -494,7 +494,7 @@ async def async_step_manufacturer(self, _user_input: dict[str, Any] | None = Non await coordinator.async_will_remove_from_hass() options = [] - for name, _member_id in MANUFACTURERS.items(): + for name in MANUFACTURERS: manufacturer = ManufacturerFactory.resolve_by_name(name) options.append({"value": name, "label": manufacturer.friendly_name}) From 78e2ee420be9c56a0931d78854dd24ddea23e9b8 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 26 Jan 2025 15:38:18 +0100 Subject: [PATCH 11/20] Make use of the renamed friendly name property --- custom_components/sat/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/sat/entity.py b/custom_components/sat/entity.py index 0e6a4963..528f2d60 100644 --- a/custom_components/sat/entity.py +++ b/custom_components/sat/entity.py @@ -27,7 +27,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn def device_info(self): manufacturer = "Unknown" if self._coordinator.manufacturer is not None: - manufacturer = self._coordinator.manufacturer.name + manufacturer = self._coordinator.manufacturer.friendly_name return DeviceInfo( name=NAME, From e54edef400f2a7ef777eb0681bce9acb33abe4ee Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 26 Jan 2025 15:39:06 +0100 Subject: [PATCH 12/20] Make use of the renamed friendly name property --- 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 3142eba4..dcebc2ab 100644 --- a/custom_components/sat/sensor.py +++ b/custom_components/sat/sensor.py @@ -238,7 +238,7 @@ def name(self) -> str: @property def native_value(self) -> str: - return self._coordinator.manufacturer.name + return self._coordinator.manufacturer.friendly_name @property def available(self) -> bool: From 243693bae57b74f1517aaf4cdf792dd57a4cbccc Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 26 Jan 2025 15:51:46 +0100 Subject: [PATCH 13/20] No need to pass the member id when we can retrieve it ourselves --- custom_components/sat/manufacturer.py | 12 ++++++------ tests/test_manufacturer.py | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/custom_components/sat/manufacturer.py b/custom_components/sat/manufacturer.py index 904116be..7bdac462 100644 --- a/custom_components/sat/manufacturer.py +++ b/custom_components/sat/manufacturer.py @@ -26,8 +26,8 @@ class Manufacturer(ABC): - def __init__(self, member_id: int): - self._member_id = member_id + def __init__(self): + self._member_id = MANUFACTURERS.get(type(self).__name__) @property def member_id(self) -> int: @@ -46,15 +46,15 @@ def resolve_by_name(name: str) -> Optional[Manufacturer]: if not (member_id := MANUFACTURERS.get(name)): return None - return ManufacturerFactory._import_class(snake_case(name), name)(member_id) + return ManufacturerFactory._import_class(snake_case(name), name)() @staticmethod def resolve_by_member_id(member_id: int) -> List[Manufacturer]: """Resolve a list of Manufacturer instances by member ID.""" return [ - ManufacturerFactory._import_class(snake_case(name), name)(identifier) - for name, identifier in MANUFACTURERS.items() - if member_id == identifier + ManufacturerFactory._import_class(snake_case(name), name)() + for name, value in MANUFACTURERS.items() + if member_id == value ] @staticmethod diff --git a/tests/test_manufacturer.py b/tests/test_manufacturer.py index a295aeac..f1689d0a 100644 --- a/tests/test_manufacturer.py +++ b/tests/test_manufacturer.py @@ -25,7 +25,8 @@ def test_resolve_by_member_id(): assert len(manufacturers) == len(names), f"Expected {len(names)} manufacturers for member ID {member_id}" for manufacturer in manufacturers: - assert manufacturer.__class__.__name__ in names, f"Manufacturer name '{manufacturer.name}' not expected for member ID {member_id}" + assert manufacturer.member_id == member_id, f"Expected {manufacturer.member_id} for member ID {member_id}" + assert manufacturer.__class__.__name__ in names, f"Manufacturer name '{manufacturer.friendly_name}' not expected for member ID {member_id}" # Test invalid member ID manufacturers = ManufacturerFactory.resolve_by_member_id(999) From a3ce0d0d8a6651cc8be97b7cabfd9229990580d5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Tue, 28 Jan 2025 14:45:35 +0100 Subject: [PATCH 14/20] Update FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index f240cb75..9714cdb5 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: Alexwijn buy_me_a_coffee: alexwijn From 1c5745670f96cd3648c23ec3dd1f75ef0fd7038b Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 13:02:26 +0100 Subject: [PATCH 15/20] Cleanup, small improvements and improved Minimum Setpoint --- custom_components/sat/__init__.py | 10 +- custom_components/sat/area.py | 3 - custom_components/sat/binary_sensor.py | 2 +- custom_components/sat/boiler.py | 59 ++++++--- custom_components/sat/climate.py | 74 +++--------- custom_components/sat/const.py | 5 +- custom_components/sat/coordinator.py | 113 ++++++++++-------- custom_components/sat/esphome/__init__.py | 2 +- custom_components/sat/fake/__init__.py | 4 + custom_components/sat/mqtt/ems.py | 2 +- custom_components/sat/mqtt/opentherm.py | 2 +- custom_components/sat/overshoot_protection.py | 4 +- custom_components/sat/pwm.py | 74 +++++++++--- custom_components/sat/serial/__init__.py | 2 +- custom_components/sat/services.py | 49 ++++++++ custom_components/sat/services.yaml | 22 +++- custom_components/sat/simulator/__init__.py | 2 +- custom_components/sat/util.py | 38 +++++- 18 files changed, 306 insertions(+), 161 deletions(-) create mode 100644 custom_components/sat/services.py diff --git a/custom_components/sat/__init__.py b/custom_components/sat/__init__.py index b81b0df5..cb19dde3 100644 --- a/custom_components/sat/__init__.py +++ b/custom_components/sat/__init__.py @@ -17,11 +17,16 @@ CLIMATE, SENTRY, COORDINATOR, + OPTIONS_DEFAULTS, CONF_MODE, CONF_DEVICE, - CONF_ERROR_MONITORING, OPTIONS_DEFAULTS, + CONF_ERROR_MONITORING, + SERVICE_RESET_INTEGRAL, + SERVICE_PULSE_WIDTH_MODULATION, ) from .coordinator import SatDataUpdateCoordinatorFactory +from .services import async_register_services +from .util import get_climate_entities _LOGGER: logging.Logger = logging.getLogger(__name__) PLATFORMS = [CLIMATE_DOMAIN, SENSOR_DOMAIN, NUMBER_DOMAIN, BINARY_SENSOR_DOMAIN] @@ -57,6 +62,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): # Forward entry setup for used platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Register the services + await async_register_services(hass) + # Add an update listener for this entry entry.async_on_unload(entry.add_update_listener(async_reload_entry)) diff --git a/custom_components/sat/area.py b/custom_components/sat/area.py index f6c06122..05bb306c 100644 --- a/custom_components/sat/area.py +++ b/custom_components/sat/area.py @@ -9,9 +9,7 @@ from .heating_curve import HeatingCurve from .helpers import float_value from .pid import PID -from .pwm import PWM from .util import ( - create_pwm_controller, create_pid_controller, create_heating_curve_controller, ) @@ -27,7 +25,6 @@ def __init__(self, config_data: MappingProxyType[str, Any], config_options: Mapp # Create controllers with the given configuration options self.pid: PID = create_pid_controller(config_options) self.heating_curve: HeatingCurve = create_heating_curve_controller(config_data, config_options) - self.pwm: PWM = create_pwm_controller(self.heating_curve, config_data, config_options) @property def id(self) -> str: diff --git a/custom_components/sat/binary_sensor.py b/custom_components/sat/binary_sensor.py index d19bc43f..1834d3b4 100644 --- a/custom_components/sat/binary_sensor.py +++ b/custom_components/sat/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry(_hass: HomeAssistant, _config_entry: ConfigEntry, _a if coordinator.supports_setpoint_management: _async_add_entities([SatControlSetpointSynchroSensor(coordinator, _config_entry, climate)]) - if coordinator.supports_relative_modulation_management: + if coordinator.supports_relative_modulation: _async_add_entities([SatRelativeModulationSynchroSensor(coordinator, _config_entry, climate)]) if len(_config_entry.options.get(CONF_WINDOW_SENSORS, [])) > 0: diff --git a/custom_components/sat/boiler.py b/custom_components/sat/boiler.py index 16b67b74..0aa0d211 100644 --- a/custom_components/sat/boiler.py +++ b/custom_components/sat/boiler.py @@ -1,11 +1,30 @@ import logging +from enum import Enum +from typing import Optional from .const import MINIMUM_SETPOINT _LOGGER = logging.getLogger(__name__) STABILIZATION_MARGIN = 5 -EXCEED_SETPOINT_MARGIN = 0.1 +EXCEED_SETPOINT_MARGIN = 1.0 + + +class BoilerStatus(str, Enum): + HOT_WATER = "hot_water" + PREHEATING = "preheating" + HEATING_UP = "heating_up" + AT_SETPOINT = "at_setpoint" + COOLING_DOWN = "cooling_down" + NEAR_SETPOINT = "near_setpoint" + PUMP_STARTING = "pump_starting" + WAITING_FOR_FLAME = "waiting_for_flame" + OVERSHOOT_HANDLING = "overshoot_handling" + OVERSHOOT_STABILIZED = "overshoot_stabilized" + + IDLE = "idle" + UNKNOWN = "unknown" + INITIALIZING = "initializing" class BoilerState: @@ -13,30 +32,36 @@ class BoilerState: Represents the operational state of a boiler, including activity, flame status, hot water usage, and current temperature. """ - def __init__(self, device_active: bool, flame_active: bool, hot_water_active: bool, temperature: float): - """ - Initialize with the boiler's state parameters. + def __init__(self, device_active: bool, device_status: BoilerStatus, flame_active: bool, flame_on_since: Optional[int], hot_water_active: bool, temperature: float): + """Initialize with the boiler's state parameters.""" + self._flame_active: bool = flame_active + self._hot_water_active: bool = hot_water_active - :param device_active: Whether the boiler is currently operational. - :param flame_active: Whether the boiler's flame is ignited. - :param hot_water_active: Whether the boiler is heating water. - :param temperature: The current boiler temperature in Celsius. - """ - self._temperature = temperature - self._flame_active = flame_active - self._device_active = device_active - self._hot_water_active = hot_water_active + self._temperature: float = temperature + self._device_active: bool = device_active + self._device_status: BoilerStatus = device_status + self._flame_on_since: Optional[int] = flame_on_since @property def device_active(self) -> bool: """Indicates whether the boiler is running.""" return self._device_active + @property + def device_status(self) -> BoilerStatus: + """Indicates the boiler status.""" + return self._device_status + @property def flame_active(self) -> bool: """Indicates whether the flame is ignited.""" return self._flame_active + @property + def flame_on_since(self) -> Optional[int]: + """Indicates when the flame has been ignited.""" + return self._flame_on_since + @property def hot_water_active(self) -> bool: """Indicates whether the boiler is heating water.""" @@ -70,7 +95,7 @@ def update(self, boiler_temperature: float, boiler_temperature_derivative: float self._last_setpoint = setpoint if setpoint < self._last_setpoint and not self._adjusting_to_lower_setpoint: - self._handle_setpoint_decrease() + self._handle_setpoint_decrease() if not flame_active: self._handle_flame_inactive() @@ -107,10 +132,10 @@ def _handle_tracking(self, boiler_temperature: float, boiler_temperature_derivat if not self._warming_up and boiler_temperature_derivative == 0: return self._stop_tracking("Temperature not changing.", boiler_temperature, setpoint) - if setpoint <= boiler_temperature - EXCEED_SETPOINT_MARGIN: - return self._stop_tracking("Exceeds setpoint significantly.", boiler_temperature, setpoint) + if boiler_temperature - EXCEED_SETPOINT_MARGIN > setpoint: + return self._stop_tracking("Exceeds setpoint.", boiler_temperature, setpoint) - if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature < self._last_boiler_temperature: + if setpoint > boiler_temperature and setpoint - STABILIZATION_MARGIN < boiler_temperature + 1 < self._last_boiler_temperature: return self._stop_warming_up("Stabilizing below setpoint.", boiler_temperature, setpoint) def _handle_adjusting_to_lower_setpoint(self, boiler_temperature: float, boiler_temperature_derivative: float, setpoint: float): diff --git a/custom_components/sat/climate.py b/custom_components/sat/climate.py index 3574d799..5d47c3a6 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -31,22 +31,21 @@ from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, STATE_UNAVAILABLE, STATE_UNKNOWN, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant, ServiceCall, Event, CoreState +from homeassistant.core import HomeAssistant, Event, CoreState from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity from .area import Areas, SENSOR_TEMPERATURE_ID -from .boiler import BoilerState +from .boiler import BoilerStatus from .const import * -from .coordinator import SatDataUpdateCoordinator, DeviceState, DeviceStatus +from .coordinator import SatDataUpdateCoordinator, DeviceState from .entity import SatEntity -from .helpers import convert_time_str_to_seconds, seconds_since +from .helpers import convert_time_str_to_seconds from .manufacturers.geminox import Geminox from .pwm import PWMState from .relative_modulation import RelativeModulation, RelativeModulationState -from .setpoint_adjuster import SetpointAdjuster from .summer_simmer import SummerSimmer from .util import create_pid_controller, create_heating_curve_controller, create_pwm_controller, create_minimum_setpoint_controller @@ -180,9 +179,6 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._sensor_max_value_age = convert_time_str_to_seconds(config_options.get(CONF_SENSOR_MAX_VALUE_AGE)) self._window_minimum_open_time = convert_time_str_to_seconds(config_options.get(CONF_WINDOW_MINIMUM_OPEN_TIME)) - # Create the Setpoint Adjuster controller - self._setpoint_adjuster = SetpointAdjuster() - # Create PID controller with given configuration options self.pid = create_pid_controller(config_options) @@ -199,7 +195,7 @@ def __init__(self, coordinator: SatDataUpdateCoordinator, config_entry: ConfigEn self._minimum_setpoint = create_minimum_setpoint_controller(config_entry.data, config_options) # Create PWM controller with given configuration options - self.pwm = create_pwm_controller(self.heating_curve, config_entry.data, config_options) + self.pwm = create_pwm_controller(self.heating_curve, coordinator.supports_relative_modulation_management, config_entry.data, config_options) if self._simulation: _LOGGER.warning("Simulation mode!") @@ -232,9 +228,6 @@ async def async_added_to_hass(self) -> None: await self._register_event_listeners() await self.async_control_heating_loop() - # Register services - await self._register_services() - # Initialize the area system await self.areas.async_added_to_hass(self.hass) @@ -362,14 +355,6 @@ async def _restore_previous_state_or_set_defaults(self): self.async_write_ha_state() - async def _register_services(self): - async def reset_integral(_call: ServiceCall): - """Service to reset the integral part of the PID controller.""" - self.pid.reset() - self.areas.pids.reset() - - self.hass.services.async_register(DOMAIN, SERVICE_RESET_INTEGRAL, reset_integral) - @property def name(self): """Return the friendly name of the sensor.""" @@ -575,7 +560,10 @@ def pulse_width_modulation_enabled(self) -> bool: @property def relative_modulation_value(self) -> int: - return self._maximum_relative_modulation if self._relative_modulation.enabled else MINIMUM_RELATIVE_MOD + if not self._relative_modulation.enabled and self._coordinator.supports_relative_modulation_management: + return MINIMUM_RELATIVE_MODULATION + + return self._maximum_relative_modulation @property def relative_modulation_state(self) -> RelativeModulationState: @@ -587,8 +575,8 @@ def minimum_setpoint(self) -> float: if self._minimum_setpoint_version == 1 and self._minimum_setpoint.current is not None: return self._minimum_setpoint.current - if self._minimum_setpoint_version == 2 and self._setpoint_adjuster.current is not None: - return self._setpoint_adjuster.current + if self._minimum_setpoint_version == 2: + return self.pwm.setpoint return self._coordinator.minimum_setpoint @@ -698,12 +686,10 @@ async def _async_climate_changed(self, event: Event) -> None: # If the state has changed or the old state is not available, update the PID controller if not old_state or new_state.state != old_state.state: - self._setpoint_adjuster.reset() await self._async_control_pid(True) # If the target temperature has changed, update the PID controller elif new_attrs.get("temperature") != old_attrs.get("temperature"): - self._setpoint_adjuster.reset() await self._async_control_pid(True) # If the current temperature has changed, update the PID controller @@ -833,15 +819,7 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: self._setpoint = self._minimum_setpoint.current if self._minimum_setpoint_version == 2: - if self._coordinator.flame_active and seconds_since(self._coordinator.flame_on_since) > 6 and self._coordinator.device_status != DeviceStatus.PUMP_STARTING: - self._setpoint = self._setpoint_adjuster.adjust(self._coordinator.boiler_temperature - 2) - elif self._setpoint_adjuster.current is not None: - self._setpoint = self._setpoint_adjuster.current - elif not self._coordinator.flame_active: - self._setpoint = self._setpoint_adjuster.force(self._coordinator.boiler_temperature + 10) - elif self._setpoint is None: - _LOGGER.debug("Setpoint not available.") - return + self._setpoint = self.pwm.setpoint else: self._setpoint = self._coordinator.minimum_setpoint @@ -856,9 +834,6 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: async def _async_control_relative_modulation(self) -> None: """Control the relative modulation value based on the conditions.""" - if not self._coordinator.supports_relative_modulation_management: - _LOGGER.debug("Relative modulation management is not supported. Skipping control.") - return # Update relative modulation state await self._relative_modulation.update(self.pulse_width_modulation_enabled) @@ -899,7 +874,6 @@ async def _async_update_rooms_from_climates(self) -> None: async def reset_control_state(self): """Reset control state when major changes occur.""" self.pwm.disable() - self._setpoint_adjuster.reset() async def async_track_sensor_temperature(self, entity_id): """ @@ -943,30 +917,18 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> self._calculated_setpoint = round(self._alpha * self._calculate_control_setpoint() + (1 - self._alpha) * self._calculated_setpoint, 1) # Check for overshoot - if self._coordinator.device_status == DeviceStatus.OVERSHOOT_HANDLING: + if self._coordinator.device_status == BoilerStatus.OVERSHOOT_HANDLING: _LOGGER.info("Overshoot Handling detected, enabling Pulse Width Modulation.") self.pwm.enable() # Check if we are above the overshoot temperature - if ( - self._coordinator.device_status == DeviceStatus.COOLING_DOWN and - self._setpoint_adjuster.current is not None and math.floor(self._calculated_setpoint) > math.floor(self._setpoint_adjuster.current) - ): + if self._coordinator.device_status == BoilerStatus.COOLING_DOWN and math.floor(self._calculated_setpoint) > math.floor(self.pwm.setpoint): _LOGGER.info("Setpoint stabilization detected, disabling Pulse Width Modulation.") self.pwm.disable() - # Pulse Width Modulation + # Update Pulse Width Modulation when enabled if self.pulse_width_modulation_enabled: - boiler_state = BoilerState( - flame_active=self._coordinator.flame_active, - device_active=self._coordinator.device_active, - hot_water_active=self._coordinator.hot_water_active, - temperature=self._coordinator.boiler_temperature - ) - - await self.pwm.update(self._calculated_setpoint, boiler_state) - else: - self.pwm.reset() + await self.pwm.update(self._coordinator.state, self._calculated_setpoint) # Set the control setpoint to make sure we always stay in control await self._async_control_setpoint(self.pwm.state) @@ -982,9 +944,9 @@ async def async_control_heating_loop(self, _time: Optional[datetime] = None) -> await self.areas.async_control_heating_loops() # Control our dynamic minimum setpoint (version 1) - if not self._coordinator.hot_water_active and self._coordinator.flame_active: + if self._minimum_setpoint_version == 1 and not self._coordinator.hot_water_active and self._coordinator.flame_active: # Calculate the base return temperature - if self._coordinator.device_status == DeviceStatus.HEATING_UP: + if self._coordinator.device_status == BoilerStatus.HEATING_UP: self._minimum_setpoint.warming_up(self._coordinator.return_temperature) # Calculate the dynamic minimum setpoint diff --git a/custom_components/sat/const.py b/custom_components/sat/const.py index ceb0953b..2d36e9ec 100644 --- a/custom_components/sat/const.py +++ b/custom_components/sat/const.py @@ -19,8 +19,8 @@ MINIMUM_SETPOINT = 10 MAXIMUM_SETPOINT = 65 -MINIMUM_RELATIVE_MOD = 0 -MAXIMUM_RELATIVE_MOD = 100 +MINIMUM_RELATIVE_MODULATION = 0 +MAXIMUM_RELATIVE_MODULATION = 100 MAX_BOILER_TEMPERATURE_AGE = 60 @@ -166,6 +166,7 @@ # Services SERVICE_RESET_INTEGRAL = "reset_integral" +SERVICE_PULSE_WIDTH_MODULATION = "pulse_width_modulation" SERVICE_SET_OVERSHOOT_PROTECTION_VALUE = "set_overshoot_protection_value" SERVICE_START_OVERSHOOT_PROTECTION_CALCULATION = "start_overshoot_protection_calculation" diff --git a/custom_components/sat/coordinator.py b/custom_components/sat/coordinator.py index dfc91e1c..ac24967d 100644 --- a/custom_components/sat/coordinator.py +++ b/custom_components/sat/coordinator.py @@ -10,10 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .boiler import BoilerTemperatureTracker +from .boiler import BoilerTemperatureTracker, BoilerState, BoilerStatus from .const import * from .helpers import calculate_default_maximum_setpoint, seconds_since from .manufacturer import Manufacturer, ManufacturerFactory +from .manufacturers.geminox import Geminox +from .manufacturers.ideal import Ideal +from .manufacturers.intergas import Intergas +from .manufacturers.nefit import Nefit if TYPE_CHECKING: from .climate import SatClimate @@ -26,23 +30,6 @@ class DeviceState(str, Enum): OFF = "off" -class DeviceStatus(str, Enum): - HOT_WATER = "hot_water" - PREHEATING = "preheating" - HEATING_UP = "heating_up" - AT_SETPOINT = "at_setpoint" - COOLING_DOWN = "cooling_down" - NEAR_SETPOINT = "near_setpoint" - PUMP_STARTING = "pump_starting" - WAITING_FOR_FLAME = "waiting_for_flame" - OVERSHOOT_HANDLING = "overshoot_handling" - OVERSHOOT_STABILIZED = "overshoot_stabilized" - - IDLE = "idle" - UNKNOWN = "unknown" - INITIALIZING = "initializing" - - class SatDataUpdateCoordinatorFactory: @staticmethod def resolve(hass: HomeAssistant, mode: str, device: str, data: Mapping[str, Any], options: Mapping[str, Any] | None = None) -> SatDataUpdateCoordinator: @@ -91,7 +78,6 @@ def __init__(self, hass: HomeAssistant, data: Mapping[str, Any], options: Mappin self._options: Mapping[str, Any] = options or {} self._manufacturer: Manufacturer | None = None - self._device_state: DeviceState = DeviceState.OFF self._simulation: bool = bool(self._options.get(CONF_SIMULATION)) self._heating_system: str = str(data.get(CONF_HEATING_SYSTEM, HEATING_SYSTEM_UNKNOWN)) @@ -114,47 +100,60 @@ def device_type(self) -> str: def device_status(self): """Return the current status of the device.""" if self.boiler_temperature is None: - return DeviceStatus.INITIALIZING + return BoilerStatus.INITIALIZING if self.hot_water_active: - return DeviceStatus.HOT_WATER + return BoilerStatus.HOT_WATER if self.setpoint is None or self.setpoint <= MINIMUM_SETPOINT: - return DeviceStatus.IDLE + return BoilerStatus.IDLE if self.device_active: if self.boiler_temperature_cold is not None and self.boiler_temperature_cold > self.boiler_temperature: - if self.boiler_temperature_derivative is not None and self.boiler_temperature_derivative < 0: - return DeviceStatus.PUMP_STARTING + if self.boiler_temperature_derivative is not None and self.boiler_temperature_derivative <= 0: + return BoilerStatus.PUMP_STARTING if self._boiler_temperature_tracker.active and self.setpoint > self.boiler_temperature: - return DeviceStatus.PREHEATING + return BoilerStatus.PREHEATING if self.setpoint > self.boiler_temperature: if self.flame_active: if self._boiler_temperature_tracker.active: - return DeviceStatus.HEATING_UP + return BoilerStatus.HEATING_UP - return DeviceStatus.OVERSHOOT_HANDLING + return BoilerStatus.OVERSHOOT_HANDLING - return DeviceStatus.WAITING_FOR_FLAME + return BoilerStatus.WAITING_FOR_FLAME if abs(self.setpoint - self.boiler_temperature) <= DEADBAND: - return DeviceStatus.AT_SETPOINT + return BoilerStatus.AT_SETPOINT if self.boiler_temperature > self.setpoint: if self.flame_active: if self._boiler_temperature_tracker.active: if self.boiler_temperature - self.setpoint > 2: - return DeviceStatus.COOLING_DOWN + return BoilerStatus.COOLING_DOWN - return DeviceStatus.NEAR_SETPOINT + return BoilerStatus.NEAR_SETPOINT - return DeviceStatus.OVERSHOOT_HANDLING + return BoilerStatus.OVERSHOOT_HANDLING - return DeviceStatus.WAITING_FOR_FLAME + return BoilerStatus.WAITING_FOR_FLAME - return DeviceStatus.UNKNOWN + return BoilerStatus.UNKNOWN + + @property + def state(self) -> BoilerState: + return BoilerState( + flame_active=self.flame_active, + flame_on_since=self.flame_on_since, + + device_active=self.device_active, + device_status=self.device_status, + + temperature=self.boiler_temperature, + hot_water_active=self.hot_water_active, + ) @property def manufacturer(self) -> Manufacturer | None: @@ -296,17 +295,17 @@ def minimum_relative_modulation_value(self) -> float | None: def maximum_relative_modulation_value(self) -> float | None: return None + @property + def minimum_setpoint(self) -> float: + """Return the minimum setpoint temperature before the device starts to overshoot.""" + return float(self._data.get(CONF_MINIMUM_SETPOINT)) + @property def maximum_setpoint(self) -> float: """Return the maximum setpoint temperature that the device can support.""" default_maximum_setpoint = calculate_default_maximum_setpoint(self._heating_system) return float(self._options.get(CONF_MAXIMUM_SETPOINT, default_maximum_setpoint)) - @property - def minimum_setpoint(self) -> float: - """Return the minimum setpoint temperature before the device starts to overshoot.""" - return float(self._data.get(CONF_MINIMUM_SETPOINT)) - @property def supports_setpoint_management(self): """Returns whether the device supports setting a boiler setpoint. @@ -325,6 +324,18 @@ def supports_hot_water_setpoint_management(self): """ return False + @property + def supports_relative_modulation(self): + """Returns whether the device supports having relative modulation value. + + This property is used to determine whether the coordinator can retrieve the relative modulation value from the device. + If a device doesn't support the relative modulation value, the coordinator won't be able to retrieve the value. + """ + if isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit)): + return False + + return True + @property def supports_relative_modulation_management(self): """Returns whether the device supports setting a relative modulation value. @@ -332,7 +343,10 @@ def supports_relative_modulation_management(self): This property is used to determine whether the coordinator can send a relative modulation value to the device. If a device doesn't support relative modulation management, the coordinator won't be able to control the value. """ - return False + if isinstance(self.manufacturer, (Ideal, Intergas, Geminox, Nefit)): + return False + + return True @property def supports_maximum_setpoint_management(self): @@ -379,7 +393,7 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, return # Handle the temperature tracker - if self.setpoint is not None and self.boiler_temperature_derivative is not None and self.device_status is not DeviceStatus.HOT_WATER: + if self.setpoint is not None and self.boiler_temperature_derivative is not None and self.device_status is not BoilerStatus.HOT_WATER: self._boiler_temperature_tracker.update( flame_active=self.flame_active, setpoint=round(self.setpoint, 0), @@ -403,6 +417,8 @@ async def async_control_heating_loop(self, climate: Optional[SatClimate] = None, # Update the cold temperature of the boiler if boiler_temperature_cold := self._get_latest_boiler_cold_temperature(): self._boiler_temperature_cold = boiler_temperature_cold + elif self._boiler_temperature_cold is not None: + self._boiler_temperature_cold = min(self.boiler_temperature, self._boiler_temperature_cold) async def async_set_heater_state(self, state: DeviceState) -> None: """Set the state of the device heater.""" @@ -434,17 +450,16 @@ async def async_set_control_thermostat_setpoint(self, value: float) -> None: def _get_latest_boiler_cold_temperature(self) -> float | None: """Get the latest boiler cold temperature based on recent boiler temperatures.""" - for timestamp, temperature in reversed(self._boiler_temperatures): - if self._device_on_since is None or self._device_on_since > timestamp: - return temperature + max_temperature = None - if self._flame_on_since is None or self._flame_on_since > timestamp: - return temperature + for timestamp, temperature in self._boiler_temperatures: + is_before_device_on = self._device_on_since is None or timestamp < self._device_on_since + is_before_flame_on = self._flame_on_since is None or timestamp < self._flame_on_since - if self._boiler_temperature_cold is not None: - return min(self.boiler_temperature, self._boiler_temperature_cold) + if is_before_device_on and is_before_flame_on: + max_temperature = max(max_temperature, temperature) if max_temperature is not None else temperature - return None + return max_temperature class SatEntityCoordinator(DataUpdateCoordinator): diff --git a/custom_components/sat/esphome/__init__.py b/custom_components/sat/esphome/__init__.py index 620dff16..e0fef7ed 100644 --- a/custom_components/sat/esphome/__init__.py +++ b/custom_components/sat/esphome/__init__.py @@ -78,7 +78,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self): + def supports_relative_modulation(self): return True @property diff --git a/custom_components/sat/fake/__init__.py b/custom_components/sat/fake/__init__.py index 764c1203..325ddf2f 100644 --- a/custom_components/sat/fake/__init__.py +++ b/custom_components/sat/fake/__init__.py @@ -90,6 +90,10 @@ def supports_relative_modulation_management(self): return self.config.supports_relative_modulation_management + @property + def supports_relative_modulation(self): + return self.supports_relative_modulation_management + async def async_set_boiler_temperature(self, value: float) -> None: self._boiler_temperature = value diff --git a/custom_components/sat/mqtt/ems.py b/custom_components/sat/mqtt/ems.py index 3fd2308a..f9400ea2 100644 --- a/custom_components/sat/mqtt/ems.py +++ b/custom_components/sat/mqtt/ems.py @@ -48,7 +48,7 @@ def supports_maximum_setpoint_management(self) -> bool: return True @property - def supports_relative_modulation_management(self) -> bool: + def supports_relative_modulation(self) -> bool: return True @property diff --git a/custom_components/sat/mqtt/opentherm.py b/custom_components/sat/mqtt/opentherm.py index bac1dcde..26be6dc6 100644 --- a/custom_components/sat/mqtt/opentherm.py +++ b/custom_components/sat/mqtt/opentherm.py @@ -50,7 +50,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self): + def supports_relative_modulation(self): return True @property diff --git a/custom_components/sat/overshoot_protection.py b/custom_components/sat/overshoot_protection.py index 87a77337..b3413e97 100644 --- a/custom_components/sat/overshoot_protection.py +++ b/custom_components/sat/overshoot_protection.py @@ -2,7 +2,7 @@ import logging import time -from .const import OVERSHOOT_PROTECTION_SETPOINT, MINIMUM_SETPOINT, DEADBAND, MAXIMUM_RELATIVE_MOD +from .const import OVERSHOOT_PROTECTION_SETPOINT, MINIMUM_SETPOINT, DEADBAND, MAXIMUM_RELATIVE_MODULATION from .coordinator import DeviceState, SatDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ async def _trigger_heating_cycle(self, is_ready: bool) -> None: """Trigger a heating cycle with the coordinator.""" await self._coordinator.async_set_heater_state(DeviceState.ON) await self._coordinator.async_set_control_setpoint(await self._get_setpoint(is_ready)) - await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MOD) + await self._coordinator.async_set_control_max_relative_modulation(MAXIMUM_RELATIVE_MODULATION) await asyncio.sleep(SLEEP_INTERVAL) await self._coordinator.async_control_heating_loop() diff --git a/custom_components/sat/pwm.py b/custom_components/sat/pwm.py index ad623d9e..533bd88a 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -5,9 +5,10 @@ from homeassistant.core import State -from .boiler import BoilerState -from .const import HEATER_STARTUP_TIMEFRAME +from .boiler import BoilerState, BoilerStatus +from .const import HEATER_STARTUP_TIMEFRAME, MINIMUM_SETPOINT from .heating_curve import HeatingCurve +from .setpoint_adjuster import SetpointAdjuster _LOGGER = logging.getLogger(__name__) @@ -19,23 +20,38 @@ class PWMState(str, Enum): IDLE = "idle" +class Cycles: + """Encapsulates settings related to cycle time and maximum cycles.""" + + def __init__(self, maximum: int, maximum_time: int): + self._maximum = maximum + self._maximum_time = maximum_time + + @property + def maximum_time(self) -> int: + return self._maximum_time + + @property + def maximum(self) -> int: + return self._maximum + + class PWM: """Implements Pulse Width Modulation (PWM) control for managing boiler operations.""" - def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_duty_cycle: bool, max_cycles: int, force: bool = False): + def __init__(self, cycles: Cycles, heating_curve: HeatingCurve, supports_relative_modulation_management: bool, automatic_duty_cycle: bool, force: bool = False): """Initialize the PWM control.""" self._alpha: float = 0.2 self._force: bool = force self._last_boiler_temperature: float | None = None - self._max_cycles: int = max_cycles + self._cycles: Cycles = cycles self._heating_curve: HeatingCurve = heating_curve - self._max_cycle_time: int = max_cycle_time self._automatic_duty_cycle: bool = automatic_duty_cycle # Timing thresholds for duty cycle management self._on_time_lower_threshold: float = 180 - self._on_time_upper_threshold: float = 3600 / self._max_cycles + self._on_time_upper_threshold: float = 3600 / self._cycles.maximum self._on_time_max_threshold: float = self._on_time_upper_threshold * 2 # Duty cycle percentage thresholds @@ -44,9 +60,14 @@ def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_d self._min_duty_cycle_percentage: float = self._duty_cycle_lower_threshold / 2 self._max_duty_cycle_percentage: float = 1 - self._min_duty_cycle_percentage + # Initialize some helpers + self._setpoint: Optional[float] = None + self._setpoint_adjuster = SetpointAdjuster() + self._setpoint_offset: int = 0.5 if supports_relative_modulation_management else 1 + _LOGGER.debug( - "Initialized PWM control with duty cycle thresholds - Lower: %.2f%%, Upper: %.2f%%", - self._duty_cycle_lower_threshold * 100, self._duty_cycle_upper_threshold * 100 + "Initialized PWM control with duty cycle thresholds - Lower: %.2f%%, Upper: %.2f%%, Offset: %d°C", + self._duty_cycle_lower_threshold * 100, self._duty_cycle_upper_threshold * 100, self._setpoint_offset ) self.reset() @@ -54,7 +75,7 @@ def __init__(self, heating_curve: HeatingCurve, max_cycle_time: int, automatic_d def reset(self) -> None: """Reset the PWM control.""" self._enabled = False - self._cycles: int = 0 + self._current_cycle: int = 0 self._state: PWMState = PWMState.IDLE self._last_update: float = monotonic() self._duty_cycle: Tuple[int, int] | None = None @@ -75,9 +96,11 @@ def enable(self) -> None: def disable(self) -> None: """Disable the PWM control.""" + self.reset() self._enabled = False + self._setpoint_adjuster.reset() - async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: + async def update(self, boiler: BoilerState, requested_setpoint: float) -> None: """Update the PWM state based on the output of a PID controller.""" if not self._heating_curve.value or requested_setpoint is None or boiler.temperature is None: self._state = PWMState.IDLE @@ -93,7 +116,7 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: _LOGGER.debug("Initialized last boiler temperature to %.1f°C", boiler.temperature) if self._first_duty_cycle_start is None or (monotonic() - self._first_duty_cycle_start) > 3600: - self._cycles = 0 + self._current_cycle = 0 self._first_duty_cycle_start = monotonic() _LOGGER.info("CYCLES count reset for the rolling hour.") @@ -110,17 +133,25 @@ async def update(self, requested_setpoint: float, boiler: BoilerState) -> None: self._last_boiler_temperature = boiler.temperature _LOGGER.debug("Updated last boiler temperature to %.1f°C", boiler.temperature) + # Control the adjusted setpoint + if boiler.flame_active and boiler.temperature >= self._last_boiler_temperature and boiler.device_status != BoilerStatus.PUMP_STARTING: + self._setpoint = self._setpoint_adjuster.adjust(boiler.temperature - self._setpoint_offset) + elif self._setpoint_adjuster.current is not None: + self._setpoint = self._setpoint_adjuster.current + elif not boiler.flame_active: + self._setpoint = self._setpoint_adjuster.force(boiler.temperature + 10) + # State transitions for PWM if self._state != PWMState.ON and self._duty_cycle[0] >= HEATER_STARTUP_TIMEFRAME and (elapsed >= self._duty_cycle[1] or self._state == PWMState.IDLE): - if self._cycles >= self._max_cycles: + if self._current_cycle >= self._cycles.maximum: _LOGGER.info("Reached max cycles per hour, preventing new duty cycle.") return - self._cycles += 1 + self._current_cycle += 1 self._state = PWMState.ON self._last_update = monotonic() self._last_boiler_temperature = boiler.temperature - _LOGGER.info("Starting new duty cycle (ON state). Current CYCLES count: %d", self._cycles) + _LOGGER.info("Starting new duty cycle (ON state). Current CYCLES count: %d", self._current_cycle) return if self._state != PWMState.OFF and (self._duty_cycle[0] < HEATER_STARTUP_TIMEFRAME or elapsed >= self._duty_cycle[0] or self._state == PWMState.IDLE): @@ -150,8 +181,8 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) # If automatic duty cycle control is disabled if not self._automatic_duty_cycle: - on_time = self._last_duty_cycle_percentage * self._max_cycle_time - off_time = (1 - self._last_duty_cycle_percentage) * self._max_cycle_time + on_time = self._last_duty_cycle_percentage * self._cycles.maximum_time + off_time = (1 - self._last_duty_cycle_percentage) * self._cycles.maximum_time _LOGGER.debug( "Calculated on_time: %.0f seconds, off_time: %.0f seconds.", @@ -183,7 +214,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "Low duty cycle range, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -194,7 +225,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "Mid-range duty cycle, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -205,7 +236,7 @@ def _calculate_duty_cycle(self, requested_setpoint: float, boiler: BoilerState) _LOGGER.debug( "High duty cycle range, cycles this hour: %d. Calculated on_time: %d seconds, off_time: %d seconds.", - self._cycles, on_time, off_time + self._current_cycle, on_time, off_time ) return int(on_time), int(off_time) @@ -234,3 +265,8 @@ def duty_cycle(self) -> Optional[Tuple[int, int]]: def last_duty_cycle_percentage(self) -> Optional[float]: """Returns the last calculated duty cycle percentage.""" return round(self._last_duty_cycle_percentage * 100, 2) if self._last_duty_cycle_percentage is not None else None + + @property + def setpoint(self) -> float: + """Returns the adjusted setpoint when running an ON duty cycle.""" + return self._setpoint or MINIMUM_SETPOINT diff --git a/custom_components/sat/serial/__init__.py b/custom_components/sat/serial/__init__.py index d5b627e3..23b80805 100644 --- a/custom_components/sat/serial/__init__.py +++ b/custom_components/sat/serial/__init__.py @@ -67,7 +67,7 @@ def supports_maximum_setpoint_management(self) -> bool: return True @property - def supports_relative_modulation_management(self) -> bool: + def supports_relative_modulation(self) -> bool: return True @property diff --git a/custom_components/sat/services.py b/custom_components/sat/services.py new file mode 100644 index 00000000..de41a258 --- /dev/null +++ b/custom_components/sat/services.py @@ -0,0 +1,49 @@ +import logging + +import voluptuous as vol +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import DOMAIN, SERVICE_RESET_INTEGRAL, SERVICE_PULSE_WIDTH_MODULATION +from .util import get_climate_entities + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_register_services(hass: HomeAssistant) -> None: + async def reset_integral(call: ServiceCall): + """Service to reset the integral part of the PID controller.""" + target_entities = call.data.get("entity_id", []) + + for climate in get_climate_entities(hass, target_entities): + _LOGGER.info("Reset Integral action called for %s", climate.entity_id) + + climate.pid.reset() + climate.areas.pids.reset() + + hass.services.async_register( + DOMAIN, + service=SERVICE_RESET_INTEGRAL, + service_func=reset_integral, + schema=vol.Schema({vol.Required("entity_id"): list[str]}) + ) + + async def pulse_width_modulation(call: ServiceCall): + """Service to enable or disable Pulse Width Modulation.""" + enabled = call.data.get("enabled") + target_entities = call.data.get("entity_id", []) + + for climate in get_climate_entities(hass, target_entities): + _LOGGER.info("Pulse Width Modulation action called for %s with enabled=%s", climate.entity_id, enabled) + + if enabled: + climate.pwm.enable() + else: + climate.pwm.disable() + + hass.services.async_register( + DOMAIN, + service=SERVICE_PULSE_WIDTH_MODULATION, + service_func=pulse_width_modulation, + schema=vol.Schema({vol.Required("entity_id"): list[str], vol.Required("enabled"): cv.boolean}) + ) diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index be968678..4e93a587 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -1,3 +1,19 @@ -clear_integral: - name: Clear Integral - description: "This service clears the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." \ No newline at end of file +reset_integral: + name: Reset Integral + description: "This service reset 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." + target: + entity: + domain: climate + integration: "sat" + +pulse_width_modulation: + name: Pulse Width Modulation + description: "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again." + target: + entity: + domain: climate + integration: "sat" + fields: + enabled: + required: true + example: true \ No newline at end of file diff --git a/custom_components/sat/simulator/__init__.py b/custom_components/sat/simulator/__init__.py index 316e2048..dab11868 100644 --- a/custom_components/sat/simulator/__init__.py +++ b/custom_components/sat/simulator/__init__.py @@ -45,7 +45,7 @@ def supports_maximum_setpoint_management(self): return True @property - def supports_relative_modulation_management(self) -> float | None: + def supports_relative_modulation(self) -> float | None: return True @property diff --git a/custom_components/sat/util.py b/custom_components/sat/util.py index 553a48ea..a5680c67 100644 --- a/custom_components/sat/util.py +++ b/custom_components/sat/util.py @@ -1,12 +1,21 @@ +from __future__ import annotations + from types import MappingProxyType from typing import Any +from typing import TYPE_CHECKING + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from .const import * from .heating_curve import HeatingCurve from .helpers import convert_time_str_to_seconds from .minimum_setpoint import MinimumSetpoint from .pid import PID -from .pwm import PWM +from .pwm import PWM, Cycles + +if TYPE_CHECKING: + from .climate import SatClimate def create_pid_controller(config_options) -> PID: @@ -59,7 +68,7 @@ def create_heating_curve_controller(config_data, config_options) -> HeatingCurve return HeatingCurve(heating_system=heating_system, coefficient=coefficient, version=version) -def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any]) -> PWM | None: +def create_pwm_controller(heating_curve: HeatingCurve, supports_relative_modulation_management: bool, config_data: MappingProxyType[str, Any], config_options: MappingProxyType[str, Any]) -> PWM | None: """Create and return a PWM controller instance with the given configuration options.""" # Extract the configuration options max_duty_cycles = int(config_options.get(CONF_CYCLES_PER_HOUR)) @@ -67,5 +76,28 @@ def create_pwm_controller(heating_curve: HeatingCurve, config_data: MappingProxy max_cycle_time = int(convert_time_str_to_seconds(config_options.get(CONF_DUTY_CYCLE))) force = bool(config_data.get(CONF_MODE) == MODE_SWITCH) or bool(config_options.get(CONF_FORCE_PULSE_WIDTH_MODULATION)) + # Extra settings + cycles = Cycles(maximum=max_duty_cycles, maximum_time=max_cycle_time) + # Return a new PWM controller instance with the given configuration options - return PWM(heating_curve=heating_curve, max_cycle_time=max_cycle_time, automatic_duty_cycle=automatic_duty_cycle, max_cycles=max_duty_cycles, force=force) + return PWM(heating_curve=heating_curve, cycles=cycles, automatic_duty_cycle=automatic_duty_cycle, supports_relative_modulation_management=supports_relative_modulation_management, force=force) + + +def get_climate_entities(hass: "HomeAssistant", entity_ids: list[str]) -> list["SatClimate"]: + """Retrieve climate entities for the given entity IDs.""" + entities = [] + for entity_id in entity_ids: + registry = entity_registry.async_get(hass) + + if not (entry := registry.async_get(entity_id)): + continue + + if not (config_entry := hass.data[DOMAIN].get(entry.config_entry_id)): + continue + + if not (climate := config_entry.get(CLIMATE)): + continue + + entities.append(climate) + + return entities From 6f2c360a4399a405ae1ac0a67543a3ab8beb9dae Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 13:07:44 +0100 Subject: [PATCH 16/20] Move the action name and description to translations --- custom_components/sat/services.yaml | 4 ---- custom_components/sat/translations/en.json | 10 ++++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index 4e93a587..39c4eeba 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -1,14 +1,10 @@ reset_integral: - name: Reset Integral - description: "This service reset 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." target: entity: domain: climate integration: "sat" pulse_width_modulation: - name: Pulse Width Modulation - description: "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again." target: entity: domain: climate diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 8ba425a3..450c3b9d 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -250,5 +250,15 @@ "title": "System Configuration" } } + }, + "services": { + "reset_integral": { + "name": "Reset Integral", + "description": "This service reset the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." + }, + "pulse_width_modulation": { + "name": "Pulse Width Modulation", + "description": "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again." + } } } \ No newline at end of file From a77ab245d31e2e1d41d9b51bcb53655e17cc8504 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 13:10:56 +0100 Subject: [PATCH 17/20] Add missing translation --- custom_components/sat/translations/en.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 450c3b9d..040a69f1 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -258,7 +258,13 @@ }, "pulse_width_modulation": { "name": "Pulse Width Modulation", - "description": "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again." + "description": "Force enable or disable the Pulse Width Modulation, do note that it may be turned on or off automatically right after again.", + "fields": { + "enabled": { + "name": "Enabled", + "description": "The state you want to force." + } + } } } } \ No newline at end of file From f429f8d7d59ac33fc27f649add8913c710552095 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 20:28:49 +0100 Subject: [PATCH 18/20] Make sure we do not adjust the relative modulation when the coordinator reports it doesn't support it --- 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 5d47c3a6..d54d7a4e 100644 --- a/custom_components/sat/climate.py +++ b/custom_components/sat/climate.py @@ -834,6 +834,9 @@ async def _async_control_setpoint(self, pwm_state: PWMState) -> None: async def _async_control_relative_modulation(self) -> None: """Control the relative modulation value based on the conditions.""" + if not self._coordinator.supports_relative_modulation: + _LOGGER.debug("Relative modulation management is not supported. Skipping control.") + return # Update relative modulation state await self._relative_modulation.update(self.pulse_width_modulation_enabled) From 16202d9aa1f4be468fe6f84f1836c1e713c23a74 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 20:30:39 +0100 Subject: [PATCH 19/20] Some sanity checks --- 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 533bd88a..4c2a7ce1 100644 --- a/custom_components/sat/pwm.py +++ b/custom_components/sat/pwm.py @@ -51,7 +51,7 @@ def __init__(self, cycles: Cycles, heating_curve: HeatingCurve, supports_relativ # Timing thresholds for duty cycle management self._on_time_lower_threshold: float = 180 - self._on_time_upper_threshold: float = 3600 / self._cycles.maximum + self._on_time_upper_threshold: float = 3600 / max(1, self._cycles.maximum) self._on_time_max_threshold: float = self._on_time_upper_threshold * 2 # Duty cycle percentage thresholds From ab26cf237a6afb49efeceb447d943d08376a5bad Mon Sep 17 00:00:00 2001 From: Alex Wijnholds Date: Sun, 16 Feb 2025 20:31:47 +0100 Subject: [PATCH 20/20] Cleanup --- custom_components/sat/services.yaml | 2 +- custom_components/sat/translations/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/sat/services.yaml b/custom_components/sat/services.yaml index 39c4eeba..5c697ada 100644 --- a/custom_components/sat/services.yaml +++ b/custom_components/sat/services.yaml @@ -12,4 +12,4 @@ pulse_width_modulation: fields: enabled: required: true - example: true \ No newline at end of file + example: true diff --git a/custom_components/sat/translations/en.json b/custom_components/sat/translations/en.json index 040a69f1..9682c215 100644 --- a/custom_components/sat/translations/en.json +++ b/custom_components/sat/translations/en.json @@ -254,7 +254,7 @@ "services": { "reset_integral": { "name": "Reset Integral", - "description": "This service reset 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." + "description": "This service resets the integrating part of the PID controller for the specified climate entity. This may be useful if the integral value has become too large or if the PID controller's performance has degraded." }, "pulse_width_modulation": { "name": "Pulse Width Modulation",