From 22556a43771777822130ec080bf678d86e932a25 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 Date: Sat, 28 Sep 2019 07:30:41 +0200 Subject: [PATCH] 0.8.0 (#73) Reworked Parameters: - can now be used to setup rules dynamically - can be imported and created through ``HABApp.Parameter`` - own documentation page Reworked ``MultiValueItem``: - Removed ``MultiValue`` - Added lots of documentation Other: - ``run_in`` supports ``timedelta`` - Fixed example (fixes #72) - Added more documentation --- .gitignore | 1 - HABApp/__init__.py | 2 + HABApp/__version__.py | 2 +- HABApp/core/Items.py | 2 +- HABApp/core/__init__.py | 2 +- HABApp/core/event_bus_listener.py | 2 +- HABApp/core/items/item.py | 11 + HABApp/openhab/oh_connection.py | 2 +- HABApp/parameters/__init__.py | 2 + HABApp/parameters/parameter.py | 62 +++++ .../parameter_file_watcher.py} | 241 ++++++++---------- HABApp/parameters/parameters.py | 64 +++++ HABApp/rule/__init__.py | 2 +- HABApp/rule/interfaces/rule_subprocess.py | 2 +- HABApp/rule/rule.py | 42 +-- HABApp/rule/rule_parameter.py | 56 ---- HABApp/rule_manager/__init__.py | 1 - HABApp/rule_manager/rule_manager.py | 6 +- HABApp/runtime/runtime.py | 6 +- HABApp/util/__init__.py | 2 +- HABApp/util/multi_value.py | 123 --------- HABApp/util/multimode_item.py | 226 ++++++++++++++++ _doc/_plugins/sphinx_execute_code.py | 5 +- _doc/index.rst | 1 + _doc/installation.rst | 21 +- _doc/parameters.rst | 77 ++++++ _doc/rule.rst | 54 +--- _doc/util.rst | 140 ++++++++-- conf/rules/openhab_rule.py | 13 +- conf_testing/config.yml | 34 +++ conf_testing/logging.yml | 59 +++++ conf_testing/parameters/param_file.yml | 1 + conf_testing/rules/test_parameter_files.py | 31 +++ tests/test_core/test_all_items.py | 6 +- tests/test_rule/test_rule_params.py | 106 ++++---- tests/test_utils/test_multivalue.py | 43 +--- 36 files changed, 929 insertions(+), 521 deletions(-) create mode 100644 HABApp/parameters/__init__.py create mode 100644 HABApp/parameters/parameter.py rename HABApp/{rule_manager/rule_parameters.py => parameters/parameter_file_watcher.py} (66%) create mode 100644 HABApp/parameters/parameters.py delete mode 100644 HABApp/rule/rule_parameter.py delete mode 100644 HABApp/util/multi_value.py create mode 100644 HABApp/util/multimode_item.py create mode 100644 _doc/parameters.rst create mode 100644 conf_testing/config.yml create mode 100644 conf_testing/logging.yml create mode 100644 conf_testing/parameters/param_file.yml create mode 100644 conf_testing/rules/test_parameter_files.py diff --git a/.gitignore b/.gitignore index edabf42d..0a3c9c29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .idea __pycache__ /conf -/conf_testing /build \ No newline at end of file diff --git a/HABApp/__init__.py b/HABApp/__init__.py index 8200d247..8f32ff36 100644 --- a/HABApp/__init__.py +++ b/HABApp/__init__.py @@ -9,4 +9,6 @@ import HABApp.runtime from HABApp.rule import Rule +from HABApp.parameters import Parameter + #from HABApp.runtime import Runtime diff --git a/HABApp/__version__.py b/HABApp/__version__.py index 0a58dae6..702b5789 100644 --- a/HABApp/__version__.py +++ b/HABApp/__version__.py @@ -1 +1 @@ -__VERSION__ = '0.7.2' +__VERSION__ = '0.8.0' diff --git a/HABApp/core/Items.py b/HABApp/core/Items.py index caccaf19..946bd32d 100644 --- a/HABApp/core/Items.py +++ b/HABApp/core/Items.py @@ -25,7 +25,7 @@ def get_all_items() -> typing.List[__Item]: return list(_ALL_ITEMS.values()) -def get_item_names() -> typing.List[str]: +def get_all_item_names() -> typing.List[str]: return list(_ALL_ITEMS.keys()) diff --git a/HABApp/core/__init__.py b/HABApp/core/__init__.py index ef391490..ca3c39f1 100644 --- a/HABApp/core/__init__.py +++ b/HABApp/core/__init__.py @@ -6,4 +6,4 @@ import HABApp.core.items import HABApp.core.EventBus -import HABApp.core.Items \ No newline at end of file +import HABApp.core.Items diff --git a/HABApp/core/event_bus_listener.py b/HABApp/core/event_bus_listener.py index e2ba4a8d..5ab69758 100644 --- a/HABApp/core/event_bus_listener.py +++ b/HABApp/core/event_bus_listener.py @@ -9,7 +9,7 @@ def __init__(self, name, callback, event_type=AllEvents): assert isinstance(callback, WrappedFunction) self.name: str = name - self.func = callback + self.func: WrappedFunction = callback self.event_filter = event_type diff --git a/HABApp/core/items/item.py b/HABApp/core/items/item.py index ec5633f2..ba664a6d 100644 --- a/HABApp/core/items/item.py +++ b/HABApp/core/items/item.py @@ -5,6 +5,17 @@ class Item: + @classmethod + def get_item(cls, name: str): + """Returns an already existing item. If it does not exist or has a different item type an exception will occur. + + :param name: Name of the item + :return: the item + """ + item = HABApp.core.Items.get_item(name) + assert isinstance(item, cls), f'{cls} != {type(item)}' + return item + @classmethod def get_create_item(cls, name: str, default_state=None): """Creates a new item in HABApp and returns it or returns the already existing one with the given name diff --git a/HABApp/openhab/oh_connection.py b/HABApp/openhab/oh_connection.py index 4cb1123a..87de3da4 100644 --- a/HABApp/openhab/oh_connection.py +++ b/HABApp/openhab/oh_connection.py @@ -156,7 +156,7 @@ async def update_all_items(self) -> int: HABApp.core.Items.set_item(new_item) # remove items which are no longer available - ist = set(HABApp.core.Items.get_item_names()) + ist = set(HABApp.core.Items.get_all_item_names()) soll = {k['name'] for k in data} for k in ist - soll: HABApp.core.Items.pop_item(k) diff --git a/HABApp/parameters/__init__.py b/HABApp/parameters/__init__.py new file mode 100644 index 00000000..ca89781d --- /dev/null +++ b/HABApp/parameters/__init__.py @@ -0,0 +1,2 @@ + +from .parameter import Parameter \ No newline at end of file diff --git a/HABApp/parameters/parameter.py b/HABApp/parameters/parameter.py new file mode 100644 index 00000000..846f2e96 --- /dev/null +++ b/HABApp/parameters/parameter.py @@ -0,0 +1,62 @@ +import typing + +from .parameters import add_parameter as _add_parameter +from .parameters import get_value as _get_value + + +class Parameter: + def __init__(self, filename: str, *keys, default_value: typing.Any = 'ToDo'): + """Class to dynamically access parameters which are loaded from file. + + :param filename: filename (without extension) + :param keys: structure in the file + :param default_value: default value for the parameter. + Is used to create the file and the structure if it does not exist yet. + """ + + assert isinstance(filename, str), type(filename) + self.filename: str = filename + self.keys = keys + + # as a convenience try to create the file and the file structure + _add_parameter(self.filename, *self.keys, default_value=default_value) + + @property + def value(self): + """Return the current value. This will do the lookup so make sure to not cache this value, otherwise + the parameter might not work as expected. + """ + return _get_value(self.filename, *self.keys) + + def __eq__(self, other): + return self.value == other + + def __lt__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.value < other + + def __le__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.value <= other + + def __ge__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.value >= other + + def __gt__(self, other): + if not isinstance(other, (int, float)): + return NotImplemented + + return self.value > other + + def __repr__(self): + return f' Sche self.__future_events.append(future_event) return future_event - def run_in(self, seconds: int, callback, *args, **kwargs) -> ScheduledCallback: + def run_in(self, seconds: typing.Union[int, datetime.timedelta], callback, *args, **kwargs) -> ScheduledCallback: """ Run the callback in x seconds - :param int seconds: Wait time in seconds before calling the function + :param int seconds: Wait time in seconds or a timedelta obj before calling the function :param callback: |param_scheduled_cb| :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - assert isinstance(seconds, int), f'{seconds} ({type(seconds)})' + assert isinstance(seconds, (int, datetime.timedelta)), f'{seconds} ({type(seconds)})' + fut = datetime.timedelta(seconds=seconds) if not isinstance(seconds, datetime.timedelta) else seconds cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) - future_event = ScheduledCallback(datetime.timedelta(seconds=seconds), cb, *args, **kwargs) + future_event = ScheduledCallback(fut, cb, *args, **kwargs) self.__future_events.append(future_event) return future_event @@ -411,17 +412,6 @@ def run_soon(self, callback, *args, **kwargs) -> ScheduledCallback: self.__future_events.append(future_event) return future_event - def get_rule_parameter(self, file_name: str, *keys, default_value='ToDo') -> RuleParameter: - """ - - :param file_name: Name of the file (without extension), will get created if it doesn't exist - :param keys: section name which value shall be loaded - :param default_value: if the corresponding entry in the file does not exist - it will be created with default_value - """ - assert isinstance(file_name, str), type(file_name) - return RuleParameter(self.__runtime.rule_params, file_name, *keys, default_value=default_value) - def get_rule(self, rule_name: str) -> 'typing.Union[Rule, typing.List[Rule]]': assert rule_name is None or isinstance(rule_name, str), type(rule_name) return self.__runtime.rule_manager.get_rule(rule_name) @@ -436,6 +426,24 @@ def register_on_unload(self, func): assert func not in self.__unload_functions, 'Function was already registered!' self.__unload_functions.append(func) + # ----------------------------------------------------------------------------------------------------------------- + # deprecated functions + # ----------------------------------------------------------------------------------------------------------------- + def get_rule_parameter(self, file_name: str, *keys, default_value='ToDo'): + """ + + :param file_name: Name of the file (without extension), will get created if it doesn't exist + :param keys: section name which value shall be loaded + :param default_value: if the corresponding entry in the file does not exist + it will be created with default_value + """ + warnings.warn("'get_rule_parameter' is deprecated, use 'HABApp.parameters.get_parameter()' instead", + DeprecationWarning, 2) + + assert isinstance(file_name, str), type(file_name) + import HABApp.parameters + return HABApp.parameters.get_parameter(file_name, *keys, default_value=default_value) + # ----------------------------------------------------------------------------------------------------------------- # internal functions # ----------------------------------------------------------------------------------------------------------------- diff --git a/HABApp/rule/rule_parameter.py b/HABApp/rule/rule_parameter.py deleted file mode 100644 index 9e823ac8..00000000 --- a/HABApp/rule/rule_parameter.py +++ /dev/null @@ -1,56 +0,0 @@ -import typing -import warnings - -from ..rule_manager import RuleParameters - - -class RuleParameter: - def __init__(self, rule_parameters: RuleParameters, filename: str, *keys, default_value: typing.Any = 'ToDo'): - - assert isinstance(rule_parameters, RuleParameters), type(rule_parameters) - self.__parameters: RuleParameters = rule_parameters - - assert isinstance(filename, str) - self.filename: str = filename - self.keys = keys - - # as a convenience try to create the file and the file structure - self.__parameters.add_param(self.filename, *self.keys, default_value=default_value) - - @property - def value(self): - return self.__parameters.get_param(self.filename, *self.keys) - - def get_value(self): - warnings.warn("The 'get_value' method is deprecated, use 'value' instead", DeprecationWarning, 2) - return self.value - - def __eq__(self, other): - return self.value == other - - def __lt__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.value < other - - def __le__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.value <= other - - def __ge__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.value >= other - - def __gt__(self, other): - if not isinstance(other, (int, float)): - return NotImplemented - - return self.value > other - - def __repr__(self): - return f' bool: - """Returns if the value is enabled""" - return self.__enabled - - def set_value(self, value): - """Set new value and recalculate overall value - - :param value: new value - """ - self.__enabled = True if value is not None else False - self.__value = value - - self.last_update = datetime.datetime.now() - - self.__parent.recalculate_value(self) - - def set_enabled(self, value: bool): - """Enable or disable this value and recalculate overall value - - :param value: True/False - """ - assert value is True or value is False, value - self.__enabled = value - - self.last_update = datetime.datetime.now() - - self.__parent.recalculate_value(self) - - def __str__(self): - return str(self.__value) - - def __repr__(self): - return f'<{self.__class__.__name__} enabled: {self.__enabled}, value: {self.__value}>' - - -class MultiValue: - """Thread safe value prioritizer""" - - def __init__(self, on_value_change): - """ - - :param on_value_change: Callback with one arg which will be called on every change - """ - self.on_value_change = on_value_change - - self.__value = None - - self.__children: typing.Dict[int, ValueWithPriority] = {} - self.__lock = Lock() - - @property - def value(self): - """Returns the current value""" - return self.__value - - def get_create_value(self, priority: int, initial_value=None) -> ValueWithPriority: - """ Create a new instance which can be used to set values - - :param priority: priority of the value - :param initial_value: initial value - """ - assert isinstance(priority, int), type(priority) - - if priority in self.__children: - return self.__children[priority] - - self.__children[priority] = ret = ValueWithPriority(self, initial_value) - return ret - - def recalculate_value(self, child): - """Recalculate the output value and call the registered callback (if output has changed) - - :param child: child that changed - :return: output value - """ - - # recalculate value - new_value = None - - with self.__lock: - for priority, child in sorted(self.__children.items()): - assert isinstance(child, ValueWithPriority) - - if not child.enabled: - continue - new_value = child.value - - value_changed = new_value != self.__value - self.__value = new_value - - # Notify that the value has changed - if value_changed: - self.on_value_change(new_value) - - return new_value diff --git a/HABApp/util/multimode_item.py b/HABApp/util/multimode_item.py new file mode 100644 index 00000000..f89dbf40 --- /dev/null +++ b/HABApp/util/multimode_item.py @@ -0,0 +1,226 @@ +import datetime +import logging +import operator +import typing +from threading import Lock + +from HABApp.core.items import Item + + +class MultiModeValue: + """MultiModeValue + + :ivar datetime.datetime last_update: Timestamp of the last update/enable of this value + :ivar typing.Optional[datetime.timedelta] auto_disable_after: Automatically disable this mode after + a given timedelta on the next recalculation + :ivar typing.Optional[str] auto_disable_on: Automatically disable this mode if the state with lower priority + is ``>``, ``>=``, ``<``, ``<=``, ``==`` or ``!=`` than the own value + :vartype calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] + :ivar calc_value_func: Function to calculate the new value (e.g. ``min`` or ``max``). Any function that accepts two + Arguments can be used. First arg is value with lower priority, second argument is own value. + """ + DISABLE_OPERATORS = { + '>': operator.gt, '<': operator.lt, '>=': operator.ge, '<=': operator.le, + '==': operator.eq, '!=': operator.ne, None: None + } + + def __init__(self, parent, name: str, initial_value=None, auto_disable_on=None, auto_disable_after=None, + calc_value_func=None): + + assert isinstance(parent, MultiModeItem), type(parent) + assert isinstance(name, str), type(name) + self.__parent: MultiModeItem = parent + self.__name = name + + self.__value = None + self.__enabled = False + + self.last_update: datetime.datetime = datetime.datetime.now() + + # do not call callback for initial value + if initial_value is not None: + self.__enabled = True + self.__value = initial_value + + assert isinstance(auto_disable_after, datetime.timedelta) or auto_disable_after is None, \ + type(auto_disable_after) + assert auto_disable_on in MultiModeValue.DISABLE_OPERATORS, auto_disable_on + self.auto_disable_after: typing.Optional[datetime.timedelta] = auto_disable_after + self.auto_disable_on: str = auto_disable_on + + self.calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] = calc_value_func + + @property + def value(self): + """Returns the current value""" + return self.__value + + @property + def enabled(self) -> bool: + """Returns if the value is enabled""" + return self.__enabled + + def set_value(self, value): + """Set new value and recalculate overall value + + :param value: new value + """ + self.__enabled = True if value is not None else False + self.__value = value + + self.last_update = datetime.datetime.now() + + self.__parent.log(logging.INFO, f'{self.__name} set value to {self.__value}') + + self.__parent.calculate_value() + + def set_enabled(self, value: bool): + """Enable or disable this value and recalculate overall value + + :param value: True/False + """ + assert value is True or value is False, value + self.__enabled = value + + self.last_update = datetime.datetime.now() + + self.__parent.log(logging.INFO, f'{self.__name} {"enabled" if self.__enabled else "disabled"}') + + self.__parent.calculate_value() + + def __operator_on_value(self, operator_str: str, low_prio_value): + try: + return MultiModeValue.DISABLE_OPERATORS[operator_str](low_prio_value, self.__value) + except TypeError as e: + self.__parent.log(logging.WARNING, f'{e}! {low_prio_value}({type(low_prio_value)}) {operator_str:s} ' + f'{self.__value}({type(self.__value)})') + return False + + def calculate_value(self, value_with_lower_priority): + + # so we don't spam the log if we are already disabled + if not self.__enabled: + return value_with_lower_priority + + # Automatically disable after certain time + if isinstance(self.auto_disable_after, datetime.timedelta): + if datetime.datetime.now() > self.last_update + self.auto_disable_after: + self.__enabled = True + self.last_update = datetime.datetime.now() + self.__parent.log(logging.INFO, f'{self.__name} disabled (after {self.auto_disable_after})!') + + # Automatically disable if <> etc. + if self.auto_disable_on is not None: + if self.__operator_on_value(self.auto_disable_on, value_with_lower_priority): + self.__enabled = False + self.last_update = datetime.datetime.now() + self.__parent.log(logging.INFO, f'{self.__name} disabled ' + f'({value_with_lower_priority}{self.auto_disable_on}{self.__value})!') + + if not self.__enabled: + return value_with_lower_priority + + if self.calc_value_func is None: + return self.__value + return self.calc_value_func(value_with_lower_priority, self.__value) + + def __repr__(self): + return f'<{self.__class__.__name__} {self.__name} enabled: {self.__enabled}, value: {self.__value}>' + + +class MultiModeItem(Item): + """Thread safe value prioritizer :class:`~HABApp.core.items.Item` + + :ivar logger: Assign a logger to get log messages about the different modes + """ + + @classmethod + def get_create_item(cls, name: str, logger: logging.getLoggerClass() = None): + item = super().get_create_item(name, None) + item.logger = logger + return item + + def __init__(self, name: str, state=None): + super().__init__(name=name, state=state) + + self.__values_by_prio: typing.Dict[int, MultiModeValue] = {} + self.__values_by_name: typing.Dict[str, MultiModeValue] = {} + + self.__lock = Lock() + + self.logger: typing.Optional[logging._loggerClass] = None + + def log(self, level, text, *args, **kwargs): + if self.logger is not None: + self.logger.log(level, f'{self.name}: ' + text, *args, **kwargs) + + def create_mode( + self, name: str, priority: int, initial_value: typing.Optional[typing.Any] = None, + auto_disable_on: typing.Optional[str] = None, + auto_disable_after: typing.Optional[datetime.timedelta] = None, + calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] = None + ) -> MultiModeValue: + """Create a new mode with priority + + :param name: Name of the new mode + :param priority: Priority of the mode + :param initial_value: Initial value, will also enable the mode + :param auto_disable_on: Automatically disable the mode if the lower priority state is ``>`` or ``<`` the value. + See :attr:`~HABApp.util.multimode_item.MultiModeValue` + :param auto_disable_after: Automatically disable the mode after a timedelta if a recalculate is done + See :attr:`~HABApp.util.multimode_item.MultiModeValue` + :param calc_value_func: See :attr:`~HABApp.util.multimode_item.MultiModeValue` + :return: The newly created MultiModeValue + """ + # Silently overwrite the values + # assert not name.lower() in self.__values_by_name, name.lower() + # assert not priority in self.__values_by_prio, priority + + with self.__lock: + ret = MultiModeValue( + self, name, + initial_value=initial_value, + auto_disable_on=auto_disable_on, auto_disable_after=auto_disable_after, + calc_value_func=calc_value_func + ) + self.__values_by_prio[priority] = ret + self.__values_by_name[name.lower()] = ret + return ret + + def get_mode(self, name: str) -> MultiModeValue: + """Returns a created mode + + :param name: name of the mode (case insensitive) + :return: The requested MultiModeValue + """ + return self.__values_by_name[name.lower()] + + def get_value_until(self, mode_to_stop): + assert isinstance(mode_to_stop, MultiModeValue), type(mode_to_stop) + new_value = None + with self.__lock: + for priority, child in sorted(self.__values_by_prio.items()): + if child is mode_to_stop: + return new_value + + assert isinstance(child, MultiModeValue) + new_value = child.calculate_value(new_value) + raise ValueError() + + def calculate_value(self) -> typing.Any: + """Recalculate the output value and post the state to the event bus (if it is not None) + + :return: new value + """ + + # recalculate value + new_value = None + with self.__lock: + for priority, child in sorted(self.__values_by_prio.items()): + assert isinstance(child, MultiModeValue) + new_value = child.calculate_value(new_value) + + # Notify that the value has changed + if new_value is not None: + self.post_state(new_value) + return new_value diff --git a/_doc/_plugins/sphinx_execute_code.py b/_doc/_plugins/sphinx_execute_code.py index ffce74fe..cc89285e 100644 --- a/_doc/_plugins/sphinx_execute_code.py +++ b/_doc/_plugins/sphinx_execute_code.py @@ -53,6 +53,7 @@ class ExecuteCode(Directive): 'linenos': directives.flag, 'ignore_stderr': directives.flag, 'output_language': directives.unchanged, # Runs specified pygments lexer on output data + 'hide_code': directives.flag, 'hide_output': directives.flag, 'header_code': directives.unchanged, @@ -115,7 +116,9 @@ def run(self): code_results['linenos'] = 'linenos' in self.options code_results['language'] = output_language - output.append(code_results) + + if 'hide_output' not in self.options: + output.append(code_results) return output diff --git a/_doc/index.rst b/_doc/index.rst index 3400ff04..71a55217 100644 --- a/_doc/index.rst +++ b/_doc/index.rst @@ -11,6 +11,7 @@ Welcome to the HABApp documentation! configuration logging rule + parameters interface_openhab interface_mqtt asyncio diff --git a/_doc/installation.rst b/_doc/installation.rst index c692711a..8e7e3736 100644 --- a/_doc/installation.rst +++ b/_doc/installation.rst @@ -63,6 +63,7 @@ Upgrading #. Observe the logs for errors in case there were changes + Error message while installing ujson ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -81,6 +82,16 @@ Several working alternatives can be found `here "Clear" on the HABapp conta After starting the container again, everything should immediately work again. ---------------------------------- -HABApp Parameters +Upgrading to a newer version +---------------------------------- + +It is recommended to upgrade the installation on another machine. Configure your production instance in the configuration +and set the ``listen_only`` switch(es) in the configuration to ``True``. Observe the logs for any errors. +This way if there were any breaking changes rules can easily be fixed before problems occur on the running installation. + +---------------------------------- +HABApp arguments ---------------------------------- .. execute_code:: diff --git a/_doc/parameters.rst b/_doc/parameters.rst new file mode 100644 index 00000000..03270e32 --- /dev/null +++ b/_doc/parameters.rst @@ -0,0 +1,77 @@ + +Parameters +================================== + +Parameters +------------------------------ +Parameters are values which can easily be changed without having to reload the rules. +Values will be picked up during runtime as soon as they get edited in the corresponding file. +If the file doesn't exist yet it will automatically be generated in the configured `param` folder. +Parameters are perfect for boundaries (e.g. if value is below param switch something on). + + +.. execute_code:: + :hide_output: + + # hide + from HABApp.parameters.parameters import _PARAMETERS + _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} + + from tests import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + # hide + + import HABApp + + class MyRuleWithParameters(HABApp.Rule): + def __init__(self): + super().__init__() + + # construct parameter once, default_value can be anything + self.min_value = HABApp.Parameter( 'param_file_testrule', 'min_value', default_value=10) + + # deeper structuring is possible through specifying multiple keys + self.min_value_nested = HABApp.Parameter( + 'param_file_testrule', + 'Rule A', 'subkey1', 'subkey2', + default_value=['a', 'b', 'c'] # defaults can also be dicts or lists + ) + + self.listen_event('test_item', self.on_change_event, HABApp.core.events.ValueChangeEvent) + + def on_change_event( event): + + # the parameter can be used like a normal variable, comparison works as expected + if self.min_value < event.value: + pass + + # The current value can be accessed through the value-property, but don't cache it! + current_value = self.min_value.value + + + MyRuleWithParameters() + + # hide + HABApp.core.EventBus.post_event('test_watch', HABApp.core.events.ValueChangeEvent('test_item', 5, 6)) + runner.tear_down() + # hide + +Created file: + +.. code-block:: yaml + + min_value: 10 + Rule A: + subkey1: + subkey2: + - a + - b + - c + +Changes in the file will be automatically picked up through :class:`~HABApp.parameters.Parameter`. + +.. autoclass:: HABApp.parameters.Parameter + :members: + + .. automethod:: __init__ \ No newline at end of file diff --git a/_doc/rule.rst b/_doc/rule.rst index 5496d0d7..e77e9838 100644 --- a/_doc/rule.rst +++ b/_doc/rule.rst @@ -1,6 +1,4 @@ -.. module:: HABApp - Rule ================================== @@ -156,56 +154,6 @@ All functions return an instance of ScheduledCallback .. autoclass:: HABApp.rule.scheduler.ScheduledCallback :members: -Parameters ------------------------------- -Parameters are values which can easily be changed without having to reload the rules. -Values will be picked up during runtime as soon as they get edited in the corresponding file. -If the file doesn't exist yet it will automatically be generated in the configured `param` folder. -Parameters are perfect for boundaries (e.g. if value is below param switch something on). - -.. list-table:: - :widths: auto - :header-rows: 1 - - * - Function - - Description - - * - :meth:`~HABApp.Rule.get_rule_parameter` - - returns a parameter object - -Example:: - - def __init__(self): - super().__init__() - - # construct parameter once, default_value can be anything - self.min_value = self.get_rule_parameter( 'param_file_testrule', 'min_value', default_value=10) - - # deeper structuring is possible through specifying multiple keys - self.min_value_nested = self.get_rule_parameter( - 'param_file_testrule', - 'Rule A', 'subkey1', 'subkey2', - default_value=['a', 'b', 'c'] # defaults can also be dicts or lists - ) - - def on_change_event( event): - # the parameter can be used like a normal variable, comparison works as expected - if self.min_value < event.value: - pass - - -Created file: - -.. code-block:: yaml - - min_value: 10 - Rule A: - subkey1: - subkey2: - - a - - b - - c - Running external tools ------------------------------ @@ -260,7 +208,7 @@ Example:: All available functions ------------------------------ -.. autoclass:: Rule +.. autoclass:: HABApp.Rule :members: :var async_http: :ref:`Async http connections ` diff --git a/_doc/util.rst b/_doc/util.rst index 4bf68c25..62ed1236 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -1,7 +1,7 @@ .. module:: HABApp.util -util - rule creation utilities +util - helpers and utilities ================================== The util package contains useful classes which make rule creation easier. @@ -57,43 +57,143 @@ Documentation .. automethod:: __init__ -MultiValue +MultiModeItem ------------------------------ -Prioritizer which automatically switches between values with different priorities. +Prioritizer item which automatically switches between values with different priorities. Very useful when different states or modes overlap, e.g. automatic and manual mode. etc. -Example +Basic Example ^^^^^^^^^^^^^^^^^^ .. execute_code:: # hide - from HABApp.util import MultiValue + import HABApp + from tests import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() # hide - def print_value(val): - print( f' Output is {val}') + import HABApp + from HABApp.core.events import ValueUpdateEvent + from HABApp.util import MultiModeItem + + class MyMultiModeItemTestRule(HABApp.Rule): + def __init__(self): + super().__init__() + + # create a new MultiModeItem + item = MultiModeItem.get_create_item('MultiModeTestItem') + self.listen_event(item, self.item_update, ValueUpdateEvent) + + # create two different modes which we will use + item.create_mode('Automatic', 0, initial_value=5) + item.create_mode('Manual', 10, initial_value=0) - p = MultiValue(on_value_change=print_value) - prio5 = p.get_create_value(priority=5, initial_value=5) - prio4 = p.get_create_value(priority=4, initial_value=7) + # This shows how to enable/disable a mode + print('disable/enable the higher priority mode') + item.get_mode('manual').set_enabled(False) + item.get_mode('manual').set_value(11) - # values can be enabled/disabled - print('set_enabled:') - prio5.set_enabled(False) - prio5.set_enabled(True) + # This shows that changes of the lower priority only show when + # the mode with the higher priority gets disabled + print('') + print('Set value of lower priority') + item.get_mode('automatic').set_value(55) + print('Disable higher priority') + item.get_mode('manual').set_enabled(False) + + def item_update(self, event): + print(f'State: {event.value}') + + MyMultiModeItemTestRule() + # hide + runner.tear_down() + # hide - # Values can be set and will be change automatically according to priority - print('set_value:') - prio4.set_value(20) # since prio5 is still enabled and has higher priority this will have no effect - prio5.set_value(10) +Advanced Example +^^^^^^^^^^^^^^^^^^ +.. execute_code:: + + # hide + import logging + import sys + root = logging.getLogger('AdvancedMultiMode') + root.setLevel(logging.DEBUG) + + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(logging.DEBUG) + formatter = logging.Formatter('[%(name)17s] %(levelname)8s | %(message)s') + handler.setFormatter(formatter) + root.addHandler(handler) + + + import HABApp + from tests import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + # hide + + import logging + import HABApp + from HABApp.core.events import ValueUpdateEvent + from HABApp.util import MultiModeItem + + class MyMultiModeItemTestRule(HABApp.Rule): + def __init__(self): + super().__init__() + + # create a new MultiModeItem and assign logger + log = logging.getLogger('AdvancedMultiMode') + item = MultiModeItem.get_create_item('MultiModeTestItem', log) + self.listen_event(item, self.item_update, ValueUpdateEvent) + + # create two different modes which we will use + item.create_mode('Automatic', 2, initial_value=5) + item.create_mode('Manual', 10).set_value(10) + print(f'{repr(item.get_mode("Automatic"))}') + print(f'{repr(item.get_mode("Manual"))}') + + + # it is possible to automatically disable a mode + # this will disable the manual mode if the automatic mode + # sets a value greater equal manual mode + print('') + print('-' * 80) + print('Automatically disable mode') + print('-' * 80) + item.get_mode('manual').auto_disable_on = '>=' # disable when low priority value >= mode value + + item.get_mode('Automatic').set_value(11) # <-- manual now gets disabled because + item.get_mode('Automatic').set_value(4) # the lower priority value is >= itself + + + # It is possible to use functions to calculate the new value for a mode. + # E.g. shutter control and the manual mode moves the shades. If it's dark the automatic + # mode closes the shutter again. This could be achievied by automatically disable the + # manual mode or if the state should be remembered then the max function should be used + print('') + print('-' * 80) + print('Use of functions') + print('-' * 80) + item.create_mode('Manual', 10, initial_value=5, calc_value_func=max) # overwrite the earlier declaration + item.get_mode('Automatic').set_value(7) + item.get_mode('Automatic').set_value(3) + + def item_update(self, event): + print(f'State: {event.value}') + + MyMultiModeItemTestRule() + # hide + runner.tear_down() + # hide Documentation ^^^^^^^^^^^^^^^^^^ -.. autoclass:: MultiValue +.. autoclass:: MultiModeItem :members: -.. autoclass:: HABApp.util.multi_value.ValueWithPriority +.. autoclass:: HABApp.util.multimode_item.MultiModeValue :members: diff --git a/conf/rules/openhab_rule.py b/conf/rules/openhab_rule.py index 8983a779..e2e379d7 100644 --- a/conf/rules/openhab_rule.py +++ b/conf/rules/openhab_rule.py @@ -30,18 +30,17 @@ def item_state_change(self, event): # interaction is available through self.openhab or self.oh self.openhab.send_command('TestItemCommand', 'ON') + # example for interaction with openhab item type + switch_item = SwitchItem.get_create_item('TestSwitch') + if switch_item.is_on(): + switch_item.off() + def item_command(self, event): assert isinstance(event, ItemCommandEvent) print( f'{event}') # interaction is available through self.openhab or self.oh - self.oh.post_update('TestItemUpdate', 123) - - # example for interaction with openhab item type - switch_item = self.get_item('TestSwitch') - assert isinstance(switch_item, SwitchItem) - if switch_item.is_on(): - switch_item.off() + self.oh.post_update('ReceivedCommand', str(event)) MyOpenhabRule() diff --git a/conf_testing/config.yml b/conf_testing/config.yml new file mode 100644 index 00000000..b6ed1f0c --- /dev/null +++ b/conf_testing/config.yml @@ -0,0 +1,34 @@ +directories: + logging: ../conf/log + rules: rules + lib: lib + param: param +mqtt: + connection: + client_id: HABApp + host: 'localhost' + password: '' + port: 1883 + tls: false + tls_insecure: false + user: '' + publish: + qos: 0 + retain: false + subscribe: + qos: 0 + topics: + - '#' + - 0 +openhab: + connection: + host: localhost + password: '' + port: 8080 + user: '' + general: + listen_only: false + ping: + enabled: true + interval: 10 + item: 'Ping' diff --git a/conf_testing/logging.yml b/conf_testing/logging.yml new file mode 100644 index 00000000..69105526 --- /dev/null +++ b/conf_testing/logging.yml @@ -0,0 +1,59 @@ +version : 1 + + +formatters: + HABApp_format: + format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' + + +handlers: + HABApp_default: + class: logging.handlers.RotatingFileHandler + filename: 'z:/Python/HABApp/conf/log/HABApp.log' + maxBytes: 10_000_000 + backupCount: '3' + + formatter: HABApp_format + level: DEBUG + + EventFile: + class: logging.handlers.RotatingFileHandler + filename: 'z:/Python/HABApp/conf/log/events.log' + maxBytes: 10_485_760 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + + BufferEventFile: + class: logging.handlers.MemoryHandler + capacity: 10 + formatter: HABApp_format + target: EventFile + level: DEBUG + + +loggers: + HABApp: + level: DEBUG + handlers: + - HABApp_default + propagate: False + + HABApp.Shutdown: + level: DEBUG + handlers: + - HABApp_default + propagate: False + + HABApp.openhab: + level: DEBUG + handlers: + - HABApp_default + propagate: False + + HABApp.EventBus: + level: DEBUG + handlers: + - BufferEventFile + propagate: False diff --git a/conf_testing/parameters/param_file.yml b/conf_testing/parameters/param_file.yml new file mode 100644 index 00000000..6dd4f3a5 --- /dev/null +++ b/conf_testing/parameters/param_file.yml @@ -0,0 +1 @@ +key: 10 diff --git a/conf_testing/rules/test_parameter_files.py b/conf_testing/rules/test_parameter_files.py new file mode 100644 index 00000000..b1ac4829 --- /dev/null +++ b/conf_testing/rules/test_parameter_files.py @@ -0,0 +1,31 @@ +import logging + +import HABApp +from HABAppTests import TestBaseRule + +log = logging.getLogger('HABApp.TestParameterFiles') + +# User Parameter files to create rules dynamically +try: + assert HABApp.parameters.get_parameter_value('param_file', 'key') != 10, \ + f'Loading of Parameters does not work properly' +except Exception as e: + log.error(e) + + +class TestParamFile(TestBaseRule): + """This rule is testing the Parameter implementation""" + + def __init__(self): + super().__init__() + + self.add_test('ParamFile', self.test_param_file) + + def test_param_file(self): + p = HABApp.parameters.Parameter('param_file', 'key') + assert p < 11 + assert p.value == 10 + return True + + +TestParamFile() diff --git a/tests/test_core/test_all_items.py b/tests/test_core/test_all_items.py index ef8bef07..a2ee678b 100644 --- a/tests/test_core/test_all_items.py +++ b/tests/test_core/test_all_items.py @@ -7,11 +7,11 @@ class TestCasesItem(unittest.TestCase): def tearDown(self) -> None: - for name in Items.get_item_names(): + for name in Items.get_all_item_names(): Items.pop_item(name) def setUp(self) -> None: - for name in Items.get_item_names(): + for name in Items.get_all_item_names(): Items.pop_item(name) def test_item(self): @@ -23,7 +23,7 @@ def test_item(self): self.assertTrue(Items.item_exists(NAME)) self.assertIs(created_item, Items.get_item(NAME)) - self.assertEqual(Items.get_item_names(), [NAME]) + self.assertEqual(Items.get_all_item_names(), [NAME]) self.assertEqual(Items.get_all_items(), [created_item]) self.assertIs(created_item, Items.pop_item(NAME)) diff --git a/tests/test_rule/test_rule_params.py b/tests/test_rule/test_rule_params.py index 8ccf01ff..d03a494d 100644 --- a/tests/test_rule/test_rule_params.py +++ b/tests/test_rule/test_rule_params.py @@ -1,72 +1,72 @@ -import unittest +import pytest -from HABApp.rule.rule_parameter import RuleParameter, RuleParameters +from HABApp.parameters.parameter import Parameter +import HABApp.parameters.parameters as Parameters -class TestCases(unittest.TestCase): +@pytest.fixture(scope="function") +def params(): + Parameters.ParameterFileWatcher.UNITTEST = True + Parameters.setup(None, None) + yield None + Parameters._PARAMETERS.clear() - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.params: RuleParameters = None +def test_lookup(params): - def setUp(self): - RuleParameters.UNITTEST = True - self.params = RuleParameters(None, None) + data = {'key1': {'key2': 'value2'}} + Parameters.set_parameter_file('file1', data) + p = Parameter('file1', 'key1', 'key2') + assert p == 'value2' - def tearDown(self): - RuleParameters.UNITTEST = False + data['key1']['key2'] = 3 + assert p == 3 - def test_lookup(self): - self.params.params['file1'] = {'key1': {'key2': 'value2'}} - p = RuleParameter(self.params, 'file1', 'key1', 'key2') - self.assertEqual(p, 'value2') - self.params.params['file1']['key1']['key2'] = 3 - self.assertEqual(p, 3) +def test_int_operators(params): + Parameters.set_parameter_file('file', {'key': 5}) + p = Parameter('file', 'key') + assert p == 5 + assert p != 6 - def test_int_operators(self): - self.params.params['file'] = {'key': 5} - p = RuleParameter(self.params, 'file', 'key') - self.assertEqual(p, 5) - self.assertNotEqual(p, 6) + assert p < 6 + assert p <= 5 + assert p >= 5 + assert p > 4 - self.assertTrue(p < 6) - self.assertTrue(p <= 5) - self.assertTrue(p >= 5) - self.assertTrue(p > 4) + Parameters.set_parameter_file('file', {'key': 15}) + assert not p < 6 + assert not p <= 5 + assert p >= 5 + assert p > 4 - self.params.params['file'] = {'key': 15} - self.assertFalse(p < 6) - self.assertFalse(p <= 5) - self.assertTrue(p >= 5) - self.assertTrue(p > 4) - def test_float_operators(self): - self.params.params['file'] = {'key': 5.5} - p = RuleParameter(self.params, 'file', 'key') +def test_float_operators(params): + Parameters.set_parameter_file('file', {'key': 5.5}) + p = Parameter('file', 'key') - self.assertTrue(p < 6) - self.assertFalse(p <= 5) - self.assertTrue(p >= 5) - self.assertTrue(p > 4) + assert p < 6 + assert not p <= 5 + assert p >= 5 + assert p > 4 - def test_simple_key_creation(self): - RuleParameter(self.params, 'file', 'key') - self.assertEqual(self.params.params, {'file': {'key': 'ToDo'}}) - RuleParameter(self.params, 'file', 'key2') - self.assertEqual(self.params.params, {'file': {'key': 'ToDo', 'key2': 'ToDo'}}) - def test_structured_key_creation(self): - RuleParameter(self.params, 'file', 'key1', 'key1') - RuleParameter(self.params, 'file', 'key1', 'key2') - self.assertEqual(self.params.params, {'file': {'key1': {'key1': 'ToDo', 'key2': 'ToDo'}}}) +def test_simple_key_creation(params): - def test_structured_default_value(self): - RuleParameter(self.params, 'file', 'key1', 'key1', default_value=123) - RuleParameter(self.params, 'file', 'key1', 'key2', default_value=[1, 2, 3]) - self.assertEqual(self.params.params, {'file': {'key1': {'key1': 123, 'key2': [1, 2, 3]}}}) + Parameter('file', 'key') + assert Parameters.get_parameter_file('file') == {'key': 'ToDo'} + Parameter('file', 'key2') + assert Parameters.get_parameter_file('file') == {'key': 'ToDo', 'key2': 'ToDo'} -if __name__ == '__main__': - unittest.main() + +def test_structured_key_creation(params): + Parameter('file', 'key1', 'key1') + Parameter('file', 'key1', 'key2') + assert Parameters.get_parameter_file('file') == {'key1': {'key1': 'ToDo', 'key2': 'ToDo'}} + + +def test_structured_default_value(params): + Parameter('file', 'key1', 'key1', default_value=123) + Parameter('file', 'key1', 'key2', default_value=[1, 2, 3]) + assert Parameters.get_parameter_file('file') == {'key1': {'key1': 123, 'key2': [1, 2, 3]}} diff --git a/tests/test_utils/test_multivalue.py b/tests/test_utils/test_multivalue.py index b8bbd40d..171a8b57 100644 --- a/tests/test_utils/test_multivalue.py +++ b/tests/test_utils/test_multivalue.py @@ -1,46 +1,23 @@ -from unittest.mock import MagicMock - -from HABApp.util import MultiValue - - -def test_same_priority(): - - on_change_func = MagicMock() - - p = MultiValue(on_value_change=on_change_func) - a1 = p.get_create_value(0, '1234') - a2 = p.get_create_value(0, '1234') - - a2.set_value(1) - assert p.value == 1 - on_change_func.assert_called_once_with(1) - - a1.set_value(2) - assert p.value == 2 - on_change_func.assert_called_with(2) +from HABApp.util import MultiModeItem def test_diff_prio(): + p = MultiModeItem('TestItem') + p.create_mode('modea', 1, '1234') + p.create_mode('modeb', 2, '4567') - on_change_func = MagicMock() - - p = MultiValue(on_value_change=on_change_func) - p1 = p.get_create_value(1, '1234') - p2 = p.get_create_value(2, '4567') + p1 = p.get_mode('modea') + p2 = p.get_mode('modeb') p1.set_value(5) - assert p.value == '4567' - on_change_func.assert_called_with('4567') + assert p.state == '4567' p2.set_enabled(False) - assert p.value == 5 - on_change_func.assert_called_with(5) + assert p.state == 5 p2.set_enabled(True) - assert p.value == '4567' - on_change_func.assert_called_with('4567') + assert p.state == '4567' p2.set_enabled(False) p2.set_value(8888) - assert p.value == 8888 - on_change_func.assert_called_with(8888) + assert p.state == 8888