Skip to content

Commit

Permalink
Merge pull request #96 from nobbi1991/winter_sun_protection
Browse files Browse the repository at this point in the history
Winter avoid sun protection
  • Loading branch information
nobbi1991 authored Nov 30, 2024
2 parents a4ce37f + 347d427 commit e64967c
Show file tree
Hide file tree
Showing 7 changed files with 356 additions and 19 deletions.
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- added rule `habapp_rules.actors.light_bathroom.BathroomLight` to control bathroom light
- added the python version to `habapp_rules.core.version.SetVersions`
- added `habapp_rules.sensors.sun.WinterFilter` to filter the sun signal depending on heating state. This can be used to avoid sun protection when heating is active
- added `habapp_rules.actors.heating.HeatingActive` which can be used to set a heating flag if one of the heating actors is active
- improved `habapp_rules.core.timeout_list`

# Bugfix
Expand Down
23 changes: 23 additions & 0 deletions habapp_rules/actors/config/heating.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Config models for heating rules."""

import datetime

import HABApp.openhab.items
import pydantic

Expand All @@ -19,3 +21,24 @@ class KnxHeatingConfig(habapp_rules.core.pydantic_base.ConfigBase):

items: KnxHeatingItems = pydantic.Field(..., description="items for heating rule")
parameter: None = None


class HeatingActiveItems(habapp_rules.core.pydantic_base.ItemBase):
"""Items for active heating rule."""

control_values: list[HABApp.openhab.items.NumberItem] = pydantic.Field(..., description="list of control value items")
output: HABApp.openhab.items.SwitchItem = pydantic.Field(..., description="output item, which is ON when at least one control value is above the threshold")


class HeatingActiveParameter(habapp_rules.core.pydantic_base.ParameterBase):
"""Parameters for active heating rule."""

threshold: float = pydantic.Field(0, description="control value threshold")
extended_active_time: datetime.timedelta = pydantic.Field(datetime.timedelta(days=1), description="extended time to keep the output item ON, after last control value change below the threshold")


class HeatingActiveConfig(habapp_rules.core.pydantic_base.ConfigBase):
"""Config for active heating rule."""

items: HeatingActiveItems = pydantic.Field(..., description="items for active heating rule")
parameter: HeatingActiveParameter = pydantic.Field(HeatingActiveParameter(), description="parameters for active heating rule")
118 changes: 99 additions & 19 deletions habapp_rules/actors/heating.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,42 @@
"""Heating rules."""

import logging

import HABApp

import habapp_rules.actors.config.heating
from habapp_rules.core.helper import send_if_different

LOGGER = logging.getLogger(__name__)


class KnxHeating(HABApp.Rule):
"""Rule which can be used to control a heating actor which only supports temperature offsets (e.g. MDT).
This rule uses a virtual temperature OpenHAB item for the target temperature. If this changes, the new offset is calculated and sent to the actor.
If the actor feedback temperature changes (e.g. through mode change), the new target temperature is updated to the virtual temperature item.
This rule uses a virtual temperature OpenHAB item for the target temperature. If this changes, the new offset is calculated and sent to the actor.
If the actor feedback temperature changes (e.g. through mode change), the new target temperature is updated to the virtual temperature item.
# KNX-things:
Thing device heating_actor "KNX heating actor"{
# KNX-things:
Thing device heating_actor "KNX heating actor"{
Type number : target_temperature "Target Temperature" [ ga="9.001:<3/6/11"]
Type number : temperature_offset "Temperature Offset" [ ga="9.002:3/6/22" ]
}
# Items:
Number:Temperature target_temperature_OH "Target Temperature" <temperature> ["Setpoint", "Temperature"] {unit="°C", stateDescription=""[pattern="%.1f %unit%", min=5, max=27, step=0.5]}
Number:Temperature target_temperature_KNX "Target Temperature KNX" <temperature> {channel="knx:device:bridge:heating_actor:target_temperature", unit="°C", stateDescription=""[pattern="%.1f %unit%"]}
Number temperature_offset "Temperature Offset" <temperature> {channel="knx:device:bridge:heating_actor:temperature_offset", stateDescription=""[pattern="%.1f °C", min=-5, max=5, step=0.5]}
# Config:
config = habapp_rules.actors.config.heating.KnxHeatingConfig(
items=habapp_rules.actors.config.heating.KnxHeatingItems(
virtual_temperature="target_temperature_OH",
actor_feedback_temperature="target_temperature_KNX",
temperature_offset="temperature_offset"
))
# Rule init:
habapp_rules.actors.heating.KnxHeating(config)
# Items:
Number:Temperature target_temperature_OH "Target Temperature" <temperature> ["Setpoint", "Temperature"] {unit="°C", stateDescription=""[pattern="%.1f %unit%", min=5, max=27, step=0.5]}
Number:Temperature target_temperature_KNX "Target Temperature KNX" <temperature> {channel="knx:device:bridge:heating_actor:target_temperature", unit="°C", stateDescription=""[pattern="%.1f %unit%"]}
Number temperature_offset "Temperature Offset" <temperature> {channel="knx:device:bridge:heating_actor:temperature_offset", stateDescription=""[pattern="%.1f °C", min=-5, max=5, step=0.5]}
# Config:
config = habapp_rules.actors.config.heating.KnxHeatingConfig(
items=habapp_rules.actors.config.heating.KnxHeatingItems(
virtual_temperature="target_temperature_OH",
actor_feedback_temperature="target_temperature_KNX",
temperature_offset="temperature_offset"
))
# Rule init:
habapp_rules.actors.heating.KnxHeating(config)
"""

def __init__(self, config: habapp_rules.actors.config.heating.KnxHeatingConfig) -> None:
Expand Down Expand Up @@ -77,3 +82,78 @@ def _cb_virtual_temperature_command(self, event: HABApp.openhab.events.ItemComma
offset_new = event.value - self._temperature + self._config.items.temperature_offset.value
self._config.items.temperature_offset.oh_send_command(offset_new)
self._temperature = event.value


class HeatingActive(HABApp.Rule):
"""Rule sets a switch item to ON if any of the heating control values are above 0.
# Items:
Number control_value_1 "Control Value 1"
Number control_value_2 "Control Value 2"
Switch heating_active "Heating Active"
# Config:
config = habapp_rules.actors.config.heating.HeatingActiveConfig(
items=habapp_rules.actors.config.heating.HeatingActiveItems(
control_values=["control_value_1", "control_value_2"]
output="heating_active"
))
# Rule init:
habapp_rules.actors.heating.HeatingActive(config)
"""

def __init__(self, config: habapp_rules.actors.config.heating.HeatingActiveConfig) -> None:
"""Initialize the HeatingActive rule.
Args:
config: Config of the HeatingActive rule
"""
HABApp.Rule.__init__(self)
self._config = config

self._extended_lock = self.run.countdown(self._config.parameter.extended_active_time, self._cb_lock_end)

# callbacks
self._config.items.output.listen_event(self._cb_output, HABApp.openhab.events.ItemStateChangedEventFilter())
for itm in self._config.items.control_values:
itm.listen_event(self._cb_control_value, HABApp.openhab.events.ItemStateChangedEventFilter())

# reset extended lock if output is on
if self._config.items.output.is_on():
# start countdown if the output is already on
self._extended_lock.reset()

# set initial value
elif self._config.items.output.value is None:
ctr_values = [itm.value for itm in self._config.items.control_values if itm.value is not None]
target_state = any(value > self._config.parameter.threshold for value in ctr_values) if ctr_values else False
send_if_different(self._config.items.output, "ON" if target_state else "OFF")

@staticmethod
def _cb_output(event: HABApp.openhab.events.ItemStateChangedEvent) -> None:
"""Callback which is triggered if the output changed.
Args:
event: original trigger event
"""
LOGGER.debug(f"Heating active output changed to {event.value}")

def _cb_control_value(self, event: HABApp.openhab.events.ItemStateChangedEvent) -> None:
"""Callback which is triggered if any of the control values changed.
If the received value of the event or any of the other control values is above 0, it sets the output item to ON.
Otherwise, it sets the output item to OFF.
"""
if event.value > self._config.parameter.threshold or any(itm.value > self._config.parameter.threshold for itm in self._config.items.control_values if itm.value is not None):
send_if_different(self._config.items.output, "ON")
self._extended_lock.reset()
elif not self._extended_lock.next_run_datetime:
send_if_different(self._config.items.output, "OFF")

def _cb_lock_end(self) -> None:
"""Callback function that is triggered when the extended lock period ends.
Sets the output item to OFF if the lock period has expired.
"""
send_if_different(self._config.items.output, "OFF")
16 changes: 16 additions & 0 deletions habapp_rules/sensors/config/sun.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,19 @@ class SunPositionConfig(habapp_rules.core.pydantic_base.ConfigBase):

items: SunPositionItems = pydantic.Field(..., description="items for sun position filter")
parameter: SunPositionParameter = pydantic.Field(..., description="parameter for sun position filter")


class WinterFilterItems(habapp_rules.core.pydantic_base.ItemBase):
"""Items for WinterFilter."""

sun: HABApp.openhab.items.SwitchItem = pydantic.Field(..., description="sun is shining")
output: HABApp.openhab.items.SwitchItem = pydantic.Field(..., description="output item")
heating_active: HABApp.openhab.items.SwitchItem = pydantic.Field(..., description="heating is active")
presence_state: HABApp.openhab.items.StringItem | None = pydantic.Field(None, description="presence state")


class WinterFilterConfig(habapp_rules.core.pydantic_base.ConfigBase):
"""Config model for WinterFilter."""

items: WinterFilterItems = pydantic.Field(..., description="items for sun position filter")
parameter: None = pydantic.Field(None, description="parameter for sun position filter")
75 changes: 75 additions & 0 deletions habapp_rules/sensors/sun.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import habapp_rules.core.logger
import habapp_rules.core.state_machine_rule
import habapp_rules.sensors.config.sun
from habapp_rules.core.helper import send_if_different
from habapp_rules.system import PresenceState

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -238,3 +240,76 @@ def _update_output(self, _: HABApp.openhab.events.ItemStateChangedEvent | None)

if filter_output != self._config.items.output.value:
self._config.items.output.oh_send_command(filter_output)


class WinterFilter(HABApp.Rule):
"""Rule to filter the sun sensor depending on heating and presence state.
# Items:
Switch sun "Sun is shining"
Switch heating_active "Heating is active"
Switch sun_filtered "Sun filtered"
# Config:
config = habapp_rules.sensors.config.sun.WinterFilterConfig(
items=habapp_rules.sensors.config.sun.WinterFilterItems(
sun="sun",
heating_active="heating_active",
output="sun_filtered",
)
)
# Rule init:
habapp_rules.sensors.sun.WinterFilter(config)
"""

def __init__(self, config: habapp_rules.sensors.config.sun.WinterFilterConfig) -> None:
"""Init of sun position filter.
Args:
config: config for the sun position filter
"""
HABApp.Rule.__init__(self)
self._config = config

# callbacks
config.items.sun.listen_event(self._cb_sun, HABApp.openhab.events.ItemStateChangedEventFilter())
if config.items.heating_active is not None:
config.items.heating_active.listen_event(self._cb_heating, HABApp.openhab.events.ItemStateChangedEventFilter())
if config.items.presence_state is not None:
config.items.presence_state.listen_event(self.cb_presence_state, HABApp.openhab.events.ItemStateChangedEventFilter())

def _check_conditions_and_set_output(self) -> None:
"""Check conditions and set output.
The output will be on, if the sun is up, the heating is off and somebody is at home.
"""
heating_on = self._config.items.heating_active.is_on()
absence = self._config.items.presence_state.value != PresenceState.PRESENCE.value if self._config.items.presence_state is not None else True

target_state = self._config.items.sun.is_on() and (not heating_on or not absence)
send_if_different(self._config.items.output, "ON" if target_state else "OFF")

def _cb_sun(self, event: HABApp.openhab.events.ItemStateChangedEvent) -> None: # noqa: ARG002
"""Callback which is triggered if sun state changed.
Args:
event: original trigger event
"""
self._check_conditions_and_set_output()

def _cb_heating(self, event: HABApp.openhab.events.ItemStateChangedEvent) -> None: # noqa: ARG002
"""Callback which is triggered if heating state changed.
Args:
event: original trigger event
"""
self._check_conditions_and_set_output()

def cb_presence_state(self, event: HABApp.openhab.events.ItemStateChangedEvent) -> None: # noqa: ARG002
"""Callback which is triggered if presence_state changed.
Args:
event: original trigger event
"""
self._check_conditions_and_set_output()
79 changes: 79 additions & 0 deletions tests/actors/heating.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Test heating rules."""

import collections
import datetime
import unittest.mock

import HABApp
import HABApp.rule.scheduler.job_ctrl

import habapp_rules.actors.config.heating
import habapp_rules.actors.heating
Expand Down Expand Up @@ -78,3 +81,79 @@ def test_virtual_temperature_command(self) -> None:

self.assertEqual(test_case.expected_new_offset, round(self._rule._config.items.temperature_offset.value, 1))
self.assertEqual(test_case.event_value, self._rule._temperature)


class TestHeatingActive(tests.helper.test_case_base.TestCaseBase):
"""Test HeatingActive."""

def setUp(self) -> None:
"""Setup test case."""
tests.helper.test_case_base.TestCaseBase.setUp(self)
tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Unittest_ctr_value_1", None)
tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Unittest_ctr_value_2", None)
tests.helper.oh_item.add_mock_item(HABApp.openhab.items.SwitchItem, "Unittest_heating_active_1", None)
tests.helper.oh_item.add_mock_item(HABApp.openhab.items.SwitchItem, "Unittest_heating_active_2", None)

self._config_1 = habapp_rules.actors.config.heating.HeatingActiveConfig(items=habapp_rules.actors.config.heating.HeatingActiveItems(control_values=["Unittest_ctr_value_1", "Unittest_ctr_value_2"], output="Unittest_heating_active_1"))
self._config_2 = habapp_rules.actors.config.heating.HeatingActiveConfig(
items=habapp_rules.actors.config.heating.HeatingActiveItems(control_values=["Unittest_ctr_value_1", "Unittest_ctr_value_2"], output="Unittest_heating_active_2"),
parameter=habapp_rules.actors.config.heating.HeatingActiveParameter(extended_active_time=datetime.timedelta(seconds=10), threshold=20),
)

self._rule_1 = habapp_rules.actors.heating.HeatingActive(self._config_1)
self._rule_2 = habapp_rules.actors.heating.HeatingActive(self._config_2)

def test_init(self) -> None:
"""Test __init__."""
self.assertTrue(isinstance(self._rule_1._extended_lock, HABApp.rule.scheduler.job_ctrl.CountdownJobControl))
self.assertIsNone(self._rule_1._extended_lock.next_run_datetime)
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "OFF")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "OFF")
self.assertIn("_cb_lock_end", self._rule_1._extended_lock._job.executor._func.name)

def test_init_when_output_is_on(self) -> None:
"""Test __init__ when output is on."""
TestCase = collections.namedtuple("TestCase", "state, reset_called")

test_cases = [TestCase("ON", True), TestCase("OFF", False), TestCase(None, False)]

for test_case in test_cases:
with self.subTest(test_case=test_case):
tests.helper.oh_item.set_state("Unittest_heating_active_1", test_case.state)
with unittest.mock.patch("HABApp.rule.scheduler.job_builder.HABAppJobBuilder.countdown") as run_countdown_mock:
habapp_rules.actors.heating.HeatingActive(self._config_1)
if test_case.reset_called:
run_countdown_mock.return_value.reset.assert_called_once()
else:
run_countdown_mock.return_value.reset.assert_not_called()

def test_set_output(self) -> None:
"""Test _set_output."""
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "OFF")

# ctr_value_1 changes to 0
tests.helper.oh_item.item_state_change_event("Unittest_ctr_value_1", 0)
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "OFF")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "OFF")

# ctr_value_1 changes to 10
tests.helper.oh_item.item_state_change_event("Unittest_ctr_value_1", 10)
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "ON")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "OFF")

# ctr_value_2 changes to 21
tests.helper.oh_item.item_state_change_event("Unittest_ctr_value_2", 21)
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "ON")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "ON")

# both change to 0
tests.helper.oh_item.item_state_change_event("Unittest_ctr_value_1", 0)
tests.helper.oh_item.item_state_change_event("Unittest_ctr_value_2", 0)
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "ON")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "ON")

# lock period ends
self._rule_1._cb_lock_end()
self._rule_2._cb_lock_end()
tests.helper.oh_item.assert_value("Unittest_heating_active_1", "OFF")
tests.helper.oh_item.assert_value("Unittest_heating_active_2", "OFF")
Loading

0 comments on commit e64967c

Please sign in to comment.