From b03de5a04d5d6095661d8f9564cd2f901d05b314 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Thu, 20 Oct 2022 14:20:08 +0200 Subject: [PATCH] 1.0.5 1.0.5 - Added new item function ``post_value_if`` and ``oh_post_update_if`` to conditionally update an item - Added support for new alive event with openHAB 3.4 - Reworked file writer for textual thing config - Added support for ThingConfigStatusInfoEvent - MultiModeValue returns True/False if item value was changed - Updated dependencies --- docs/conf.py | 13 ++ docs/getting_started.rst | 51 +++++++ docs/installation.rst | 40 ++++- docs/interface_openhab.rst | 50 +++--- docs/requirements.txt | 4 +- docs/rule.rst | 2 +- docs/util.rst | 1 + readme.md | 3 + requirements_setup.txt | 8 +- run/conf_testing/config/thing_test.yml | 2 +- .../rules/openhab/test_event_types.py | 24 ++- .../rules/openhab/test_item_funcs.py | 20 ++- src/HABApp/__version__.py | 2 +- src/HABApp/core/const/const.py | 2 +- src/HABApp/core/items/base_valueitem.py | 36 +++++ src/HABApp/core/lib/funcs.py | 26 ++++ .../openhab/connection_handler/func_sync.py | 4 +- .../connection_handler/http_connection.py | 17 +-- .../openhab/connection_handler/sse_handler.py | 4 +- .../connection_logic/plugin_things/_log.py | 7 +- .../plugin_things/file_writer/__init__.py | 1 + .../plugin_things/file_writer/formatter.py | 89 +++++++++++ .../file_writer/formatter_builder.py | 105 +++++++++++++ .../plugin_things/file_writer/writer.py | 104 +++++++++++++ .../plugin_things/items_file.py | 144 ------------------ .../plugin_things/plugin_things.py | 12 +- src/HABApp/openhab/definitions/__init__.py | 2 +- src/HABApp/openhab/definitions/definitions.py | 87 ++++++++++- src/HABApp/openhab/definitions/rest/base.py | 11 +- src/HABApp/openhab/definitions/rest/links.py | 7 +- src/HABApp/openhab/definitions/rest/things.py | 56 ++++--- src/HABApp/openhab/events/__init__.py | 2 +- src/HABApp/openhab/events/thing_events.py | 27 ++++ src/HABApp/openhab/items/base_item.py | 35 +++++ src/HABApp/openhab/items/switch_item.py | 4 +- src/HABApp/openhab/items/thing_item.py | 4 +- src/HABApp/openhab/map_events.py | 8 +- src/HABApp/util/multimode/mode_value.py | 26 ++-- tests/conftest.py | 2 +- tests/helpers/__init__.py | 3 +- tests/helpers/inspect/__init__.py | 6 + tests/helpers/inspect/classes.py | 27 ++++ tests/helpers/inspect/module.py | 36 +++++ tests/helpers/inspect/signature.py | 39 +++++ tests/helpers/mock_file.py | 3 + tests/helpers/module_helpers.py | 64 -------- tests/helpers/traceback.py | 20 ++- tests/test_all/test_items.py | 2 +- .../test_events/test_core_filters.py | 2 +- tests/test_core/test_items/tests_all_items.py | 6 + tests/test_core/test_lib/test_compare.py | 44 ++++++ .../test_lib/test_format_traceback.py | 4 +- tests/test_mqtt/test_mqtt_filters.py | 4 +- .../test_events/test_from_dict.py | 12 +- .../test_events/test_oh_filters.py | 4 +- tests/test_openhab/test_helpers/__init__.py | 0 .../test_table.py} | 0 tests/test_openhab/test_items/test_all.py | 8 + tests/test_openhab/test_plugins/__init__.py | 0 .../test_plugins/test_thing/__init__.py | 0 .../test_thing/test_file_writer/__init__.py | 0 .../test_file_writer/test_builder.py | 86 +++++++++++ .../test_file_writer/test_formatter.py | 58 +++++++ .../test_writer.py} | 61 +++++--- tests/test_openhab/test_rest/test_items.py | 4 +- tests/test_openhab/test_rest/test_links.py | 24 +-- tests/test_openhab/test_rest/test_things.py | 112 ++++++++++++++ tests/test_openhab/test_values.py | 4 +- tests/test_utils/test_multivalue.py | 50 ++++++ 69 files changed, 1335 insertions(+), 390 deletions(-) create mode 100644 src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py create mode 100644 src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py create mode 100644 src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py create mode 100644 src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py delete mode 100644 src/HABApp/openhab/connection_logic/plugin_things/items_file.py create mode 100644 tests/helpers/inspect/__init__.py create mode 100644 tests/helpers/inspect/classes.py create mode 100644 tests/helpers/inspect/module.py create mode 100644 tests/helpers/inspect/signature.py delete mode 100644 tests/helpers/module_helpers.py create mode 100644 tests/test_core/test_lib/test_compare.py create mode 100644 tests/test_openhab/test_helpers/__init__.py rename tests/test_openhab/{test_helpers.py => test_helpers/test_table.py} (100%) create mode 100644 tests/test_openhab/test_plugins/__init__.py create mode 100644 tests/test_openhab/test_plugins/test_thing/__init__.py create mode 100644 tests/test_openhab/test_plugins/test_thing/test_file_writer/__init__.py create mode 100644 tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py create mode 100644 tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py rename tests/test_openhab/test_plugins/test_thing/{test_file_writer.py => test_file_writer/test_writer.py} (54%) create mode 100644 tests/test_openhab/test_rest/test_things.py diff --git a/docs/conf.py b/docs/conf.py index a6cbdfca..0c32a952 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -203,6 +203,17 @@ add_module_names = False python_use_unqualified_type_names = True +# -- nitpick configuration ------------------------------------------------- +nitpick_ignore = [ + ('py:data', 'Ellipsis') +] + +nitpick_ignore_regex = [ + (re.compile(r'py:data|py:class'), re.compile(r'typing\..+')), + (re.compile(r'py:class'), re.compile(r'(?:datetime|pendulum|aiohttp|pathlib)\..+')) +] + + # -- Extension configuration ------------------------------------------------- exec_code_working_dir = '../src' exec_code_source_folders = ['../src', '../tests'] @@ -229,6 +240,8 @@ autodoc_pydantic_field_swap_name_and_alias = True + + # ---------------------------------------------------------------------------------------------------------------------- # Post processing of default value diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 41578d05..8581503e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -303,3 +303,54 @@ Trigger an event when an item is constant HABApp.core.EventBus.post_event('Item_Name', ItemNoChangeEvent('Item_Name', 10)) runner.tear_down() # ------------ hide: stop ------------- + + +Convenience functions +------------------------------------------ + +HABApp provides some convenience functions which make the rule creation easier and reduce boiler plate code. + +post_value_if +"""""""""""""""""""""""""""""""""""""" + +``post_value_if`` will post a value to the item depending on its current state. +There are various comparisons available (see :meth:`documentation `) +Something similar is available for openHAB items (``oh_post_update_if``) + +.. exec_code:: + + # ------------ hide: start ------------ + import time, HABApp + from rule_runner import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + HABApp.core.Items.add_item(HABApp.core.items.Item('Item_Name')) + # ------------ hide: stop ------------- + + import HABApp + from HABApp.core.items import Item + + class MyFirstRule(HABApp.Rule): + def __init__(self): + super().__init__() + # Get the item or create it if it does not exist + self.my_item = Item.get_create_item('Item_Name') + + self.run.soon(self.say_something) + + def say_something(self): + + # This construct + if self.my_item != 'overwrite value': + self.my_item.post_value('Test') + + # ... is equivalent to + self.my_item.pos_value_if('Test', equal='overwrite value') + + + + MyFirstRule() + # ------------ hide: start ------------ + runner.process_events() + runner.tear_down() + # ------------ hide: stop ------------- diff --git a/docs/installation.rst b/docs/installation.rst index 7c1e3c9f..690044b9 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -329,7 +329,7 @@ Command line arguments # ------------ hide: stop ------------- -PyCharm +Usage with PyCharm ---------------------------------- It's recommended to use PyCharm as an IDE for writing rules. The IDE can provide auto complete and static checks which will help write error free rules and vastly speed up development. @@ -365,3 +365,41 @@ It's still necessary to supply a configuration file which can be done in the ``P | After a click on "OK" HABApp can be run/debugged directly from pycharm. | It's even possible to create breakpoints in rules and inspect all objects. + + +Install a development version of HABApp +--------------------------------------- + +To try out new features or test some functionality it's possible to install a branch directly from github. +Installation works only in a virtual environment. + +New features are typically first available in the ``Develop`` branch. + +#. Navigate to the folder where the virtual environment was created:: + + cd /opt/habapp + + +#. Activate the virtual environment + + Linux:: + + source bin/activate + + Windows:: + + Scripts\activate + + +#. Remove existing HABApp installation:: + + python3 -m pip uninstall habapp + +#. Install HABApp from the github branch (here ``Develop``):: + + python3 -m pip install git+https://github.com/spacemanspiff2007/HABApp.git@Develop + + +#. Run HABApp as usual (e.g. through ``systemctl``) or manually with:: + + habapp --config PATH_TO_CONFIGURATION_FOLDER diff --git a/docs/interface_openhab.rst b/docs/interface_openhab.rst index cebe5a3e..4973715b 100644 --- a/docs/interface_openhab.rst +++ b/docs/interface_openhab.rst @@ -4,21 +4,16 @@ openHAB ###################################### + ************************************** Additional configuration ************************************** -openHAB 2 -====================================== -For openHAB2 there is no additional configuration needed. - -openHAB 3 -====================================== For optimal performance it is recommended to use Basic Auth (available from openHAB 3.1 M3 on). It can be enabled through GUI or through textual configuration. Textual configuration --------------------------------------- +====================================== The settings are in the ``runtime.cfg``. Remove the ``#`` before the entry to activate it. @@ -29,34 +24,17 @@ Remove the ``#`` before the entry to activate it. GUI --------------------------------------- +====================================== It can be enabled through the gui in ``settings`` -> ``API Security`` -> ``Allow Basic Authentication``. .. image:: /images/openhab_api_config.png - -************************************** -Interaction with a openHAB -************************************** -All interaction with the openHAB is done through the ``self.oh`` or ``self.openhab`` object in the rule -or through an ``OpenhabItem``. - -.. image:: /gifs/openhab.gif - - - -Function parameters -====================================== -.. automodule:: HABApp.openhab.interface - :members: - :imported-members: - - .. _OPENHAB_ITEM_TYPES: + ************************************** -openhab item types +openHAB item types ************************************** Description and example @@ -242,6 +220,24 @@ Thing :member-order: groupwise + +************************************** +Interaction with a openHAB +************************************** +All interaction with the openHAB is done through the ``self.oh`` or ``self.openhab`` object in the rule +or through an ``OpenhabItem``. + +.. image:: /gifs/openhab.gif + + + +Function parameters +====================================== +.. automodule:: HABApp.openhab.interface + :members: + :imported-members: + + .. _OPENHAB_EVENT_TYPES: ************************************** diff --git a/docs/requirements.txt b/docs/requirements.txt index f8f1946d..c876b73b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,6 @@ # Packages required to build the documentation -sphinx >= 5.0, < 6.0 -sphinx-autodoc-typehints >= 1.18, < 2 +sphinx >= 5.2, < 6.0 +sphinx-autodoc-typehints >= 1.19, < 2 sphinx_rtd_theme == 1.0.0 sphinx-exec-code == 0.8 autodoc_pydantic >= 1.7, < 1.8 diff --git a/docs/rule.rst b/docs/rule.rst index cd260cd5..aa5d30c8 100644 --- a/docs/rule.rst +++ b/docs/rule.rst @@ -344,4 +344,4 @@ All available functions :var async_http: :ref:`Async http connections ` :var mqtt: :ref:`MQTT interaction ` :var openhab: :ref:`openhab interaction ` - :var oh: short alias for :py:class:`openhab` openhab + :var oh: short alias for :ref:`openhab ` diff --git a/docs/util.rst b/docs/util.rst index 80900e3a..23e3ca14 100644 --- a/docs/util.rst +++ b/docs/util.rst @@ -269,6 +269,7 @@ Basic Example # create two different modes which we will use and add them to the item auto = ValueMode('Automatic', initial_value=5) manu = ValueMode('Manual', initial_value=0) + # Add the auto mode with priority 0 and the manual mode with priority 10 item.add_mode(0, auto).add_mode(10, manu) # This shows how to enable/disable a mode and how to get a mode from the item diff --git a/readme.md b/readme.md index c9ea9688..313857ab 100644 --- a/readme.md +++ b/readme.md @@ -117,6 +117,9 @@ MyOpenhabRule() ``` # Changelog +#### 1.0.5 (20.10.2022) +- Added new item function ``post_value_if`` and ``oh_post_update_if`` to conditionally update an item + #### 1.0.4 (25.08.2022) - New RGB & HSB datatype for simpler color handling - Fixed Docker build diff --git a/requirements_setup.txt b/requirements_setup.txt index 742a7fd7..9c758643 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,15 +1,15 @@ aiohttp >= 3.8, < 3.9 -pydantic >= 1.9, < 1.10 +pydantic >= 1.10, < 1.11 pendulum >= 2.1.2, < 2.2 bidict >= 0.22, < 0.23 watchdog >= 2.1.7, < 2.2 -ujson >= 5.4, < 5.5 +ujson >= 5.5, < 5.6 paho-mqtt >= 1.6, < 1.7 -immutables == 0.18 +immutables == 0.19 eascheduler == 0.1.7 easyconfig == 0.2.4 -stack_data == 0.4.0 +stack_data == 0.5.1 voluptuous == 0.13.1 diff --git a/run/conf_testing/config/thing_test.yml b/run/conf_testing/config/thing_test.yml index 21a4d728..4f38dc92 100644 --- a/run/conf_testing/config/thing_test.yml +++ b/run/conf_testing/config/thing_test.yml @@ -14,7 +14,7 @@ channels: - channel_uid: .+rise#start link items: - type: Number - name: '{thing_uid, :([^:]+?)$}_Temperature_1' + name: 'Item_{thing_uid, :([^:]+?)$}_Temperature_1' label: '{thing_uid, :([^:]+)$} Temperature [%d %%]' icon: battery metadata: diff --git a/run/conf_testing/rules/openhab/test_event_types.py b/run/conf_testing/rules/openhab/test_event_types.py index 9aae78f0..6c24c41b 100644 --- a/run/conf_testing/rules/openhab/test_event_types.py +++ b/run/conf_testing/rules/openhab/test_event_types.py @@ -1,5 +1,4 @@ from HABApp.core.events import ValueUpdateEventFilter -from HABApp.openhab.definitions.definitions import ITEM_DIMENSIONS from HABAppTests import TestBaseRule, EventWaiter, OpenhabTmpItem, get_openhab_test_events, \ get_openhab_test_types, get_openhab_test_states, ItemWaiter @@ -15,8 +14,13 @@ def __init__(self): for oh_type in get_openhab_test_types(): self.add_test(f'{oh_type} events', self.test_events, oh_type, get_openhab_test_events(oh_type)) - for dimension in ITEM_DIMENSIONS: - self.add_test(f'Quantity {dimension} events', self.test_quantity_type_events, dimension) + dimensions = { + 'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°', + 'Dimensionless': '', + } + + for name, unit in dimensions.items(): + self.add_test(f'Quantity {name} events', self.test_quantity_type_events, name, unit) def test_events(self, item_type, test_values): item_name = f'{item_type}_value_test' @@ -32,21 +36,15 @@ def test_events(self, item_type, test_values): self.openhab.send_command(item_name, value) waiter.wait_for_event(value=value) - def test_quantity_type_events(self, dimension): - - unit_of_dimension = { - 'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°', - 'Dimensionless': '', - } - + def test_quantity_type_events(self, dimension, unit): item_name = f'{dimension}_event_test' with OpenhabTmpItem(f'Number:{dimension}', item_name) as item, \ - EventWaiter(item_name, ValueUpdateEventFilter()) as event_watier, \ + EventWaiter(item_name, ValueUpdateEventFilter()) as event_waiter, \ ItemWaiter(item) as item_waiter: for state in get_openhab_test_states('Number'): - self.openhab.post_update(item_name, f'{state} {unit_of_dimension[dimension]}'.strip()) - event_watier.wait_for_event(value=state) + self.openhab.post_update(item_name, f'{state} {unit}'.strip()) + event_waiter.wait_for_event(value=state) item_waiter.wait_for_state(state) diff --git a/run/conf_testing/rules/openhab/test_item_funcs.py b/run/conf_testing/rules/openhab/test_item_funcs.py index bacfc5bb..883642cf 100644 --- a/run/conf_testing/rules/openhab/test_item_funcs.py +++ b/run/conf_testing/rules/openhab/test_item_funcs.py @@ -2,7 +2,7 @@ import logging import typing -from HABApp.openhab.items import OpenhabItem +from HABApp.openhab.items import OpenhabItem, NumberItem from HABApp.openhab.items import SwitchItem, RollershutterItem, DimmerItem, ColorItem, ImageItem from HABAppTests import TestBaseRule, ItemWaiter, OpenhabTmpItem, get_openhab_test_states, get_openhab_test_types @@ -55,7 +55,7 @@ def __init__(self): def add_func_test(self, cls, params: set): # -> SwitchItem - self.add_test(str(cls).split('.')[-1][:-2], self.test_func, cls, params) + self.add_test(cls.__name__, self.test_func, cls, params) def test_func(self, item_type, test_params): @@ -103,6 +103,8 @@ def __init__(self): continue self.add_test(f'{k}.{name}', self.test_func, k, name, get_openhab_test_states(k)) + self.add_test('post_value_if', self.test_post_update_if) + def test_func(self, item_type, func_name, test_vals): with OpenhabTmpItem(item_type) as tmpitem, ItemWaiter(OpenhabItem.get_item(tmpitem.name)) as waiter: @@ -115,5 +117,19 @@ def test_func(self, item_type, func_name, test_vals): getattr(tmpitem, func_name)() waiter.wait_for_state(val) + @OpenhabTmpItem.create('Number', arg_name='oh_item') + def test_post_update_if(self, oh_item: OpenhabTmpItem): + item = NumberItem.get_item(oh_item.name) + + with ItemWaiter(OpenhabItem.get_item(item.name)) as waiter: + item.post_value_if(0, is_=None) + waiter.wait_for_state(0) + + item.post_value_if(1, eq=0) + waiter.wait_for_state(1) + + item.post_value_if(5, lower_equal=1) + waiter.wait_for_state(5) + TestOpenhabItemConvenience() diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index 8a81504c..858de170 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '1.0.4' +__version__ = '1.0.5' diff --git a/src/HABApp/core/const/const.py b/src/HABApp/core/const/const.py index 5ff961c9..dccce726 100644 --- a/src/HABApp/core/const/const.py +++ b/src/HABApp/core/const/const.py @@ -12,7 +12,7 @@ def __repr__(self): MISSING: Final = _MissingType.MISSING -STARTUP = time.time() +STARTUP: Final = time.monotonic() # Python Versions for feature control PYTHON_38: Final = sys.version_info >= (3, 8) diff --git a/src/HABApp/core/items/base_valueitem.py b/src/HABApp/core/items/base_valueitem.py index b95f2465..7baff20c 100644 --- a/src/HABApp/core/items/base_valueitem.py +++ b/src/HABApp/core/items/base_valueitem.py @@ -8,6 +8,8 @@ from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent from HABApp.core.internals import uses_post_event from HABApp.core.items.base_item import BaseItem +from HABApp.core.const import MISSING +from HABApp.core.lib.funcs import compare as _compare log = logging.getLogger('HABApp') @@ -62,6 +64,40 @@ def post_value(self, new_value) -> bool: ) return state_changed + def post_value_if(self, new_value, *, equal=MISSING, eq=MISSING, not_equal=MISSING, ne=MISSING, + lower_than=MISSING, lt=MISSING, lower_equal=MISSING, le=MISSING, + greater_than=MISSING, gt=MISSING, greater_equal=MISSING, ge=MISSING, + is_=MISSING, is_not=MISSING) -> bool: + """ + Post a value depending on the current state of the item. If one of the comparisons is true the new state + will be posted. + + :param new_value: new value to post + :param equal: item state has to be equal to the passed value + :param eq: item state has to be equal to the passed value + :param not_equal: item state has to be not equal to the passed value + :param ne: item state has to be not equal to the passed value + :param lower_than: item state has to be lower than the passed value + :param lt: item state has to be lower than the passed value + :param lower_equal: item state has to be lower equal the passed value + :param le: item state has to be lower equal the passed value + :param greater_than: item state has to be greater than the passed value + :param gt: item state has to be greater than the passed value + :param greater_equal: item state has to be greater equal the passed value + :param ge: tem state has to be greater equal the passed value + :param is_: item state has to be the same object as the passt value (e.g. None) + :param is_not: item state has to be not the same object as the passt value (e.g. None) + + :return: `True` if the new value was posted else `False` + """ + + if _compare(self.value, equal=equal, eq=eq, not_equal=not_equal, ne=ne, + lower_than=lower_than, lt=lt, lower_equal=lower_equal, le=le, + greater_than=greater_than, gt=gt, greater_equal=greater_equal, ge=ge, is_=is_, is_not=is_not): + self.post_value(new_value) + return True + return False + def get_value(self, default_value=None) -> typing.Any: """Return the value of the item. This is a helper function that returns a default in case the item value is None. diff --git a/src/HABApp/core/lib/funcs.py b/src/HABApp/core/lib/funcs.py index 82be5368..2f59f87c 100644 --- a/src/HABApp/core/lib/funcs.py +++ b/src/HABApp/core/lib/funcs.py @@ -1,5 +1,8 @@ from pathlib import Path from typing import List, Iterable, TYPE_CHECKING +import operator as _operator + +from HABApp.core.const import MISSING if TYPE_CHECKING: import HABApp @@ -14,3 +17,26 @@ def list_files(folder: Path, file_filter: 'HABApp.core.files.watcher.file_watche def sort_files(files: Iterable[Path]) -> List[Path]: return sorted(files) + + +CMP_OPS = { + 'lt': _operator.lt, 'lower_than': _operator.lt, + 'le': _operator.le, 'lower_equal': _operator.le, + 'eq': _operator.eq, 'equal': _operator.eq, + 'ne': _operator.ne, 'not_equal': _operator.ne, + 'gt': _operator.gt, 'greater_than': _operator.gt, + 'ge': _operator.ge, 'greater_equal': _operator.ge, + + 'is_': _operator.is_, + 'is_not': _operator.is_not, +} + + +def compare(value, **kwargs) -> bool: + + for name, cmp_value in kwargs.items(): + if cmp_value is MISSING: + continue + if CMP_OPS[name](value, cmp_value): + return True + return False diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py index 122827e7..2e9f7495 100644 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ b/src/HABApp/openhab/connection_handler/func_sync.py @@ -89,8 +89,8 @@ def validate(_in): assert item_type == 'Group', f'Item type must be "Group"! Is: {item_type}' if group_function: - assert group_function in definitions.GROUP_FUNCTIONS, \ - f'{item_type} is not a group function: {", ".join(definitions.GROUP_FUNCTIONS)}' + assert group_function in definitions.GROUP_ITEM_FUNCTIONS, \ + f'{item_type} is not a group function: {", ".join(definitions.GROUP_ITEM_FUNCTIONS)}' return run_coro_from_thread( async_create_item( diff --git a/src/HABApp/openhab/connection_handler/http_connection.py b/src/HABApp/openhab/connection_handler/http_connection.py index 835b13d3..08ed8312 100644 --- a/src/HABApp/openhab/connection_handler/http_connection.py +++ b/src/HABApp/openhab/connection_handler/http_connection.py @@ -2,8 +2,8 @@ import logging import traceback import typing -from typing import Any, Optional, Final from asyncio import Queue, sleep, QueueEmpty +from typing import Any, Optional, Final import aiohttp from aiohttp.client import ClientResponse, _RequestContextManager @@ -229,21 +229,18 @@ async def start_sse_event_listener(): e_str = event.data - # Alive event from openhab to detect dropped connections - # -> Can be ignored on the HABApp side - if e_str == '{"type":"ALIVE"}': - continue - try: e_json = _load_json(e_str) - except ValueError: + except (ValueError, TypeError): log_events.warning(f'Invalid json: {e_str}') continue - except TypeError: - log_events.warning(f'Invalid json: {e_str}') + + # Alive event from openhab to detect dropped connections + # -> Can be ignored on the HABApp side + if e_json.get('type') == 'ALIVE': continue - # Log sse event + # Log raw sse event if log_events.isEnabledFor(logging.DEBUG): log_events._log(logging.DEBUG, e_str, []) diff --git a/src/HABApp/openhab/connection_handler/sse_handler.py b/src/HABApp/openhab/connection_handler/sse_handler.py index de6c3364..a4ee1123 100644 --- a/src/HABApp/openhab/connection_handler/sse_handler.py +++ b/src/HABApp/openhab/connection_handler/sse_handler.py @@ -11,7 +11,7 @@ from HABApp.core.wrapper import process_exception from HABApp.openhab.connection_handler import http_connection from HABApp.openhab.events import GroupItemStateChangedEvent, ItemAddedEvent, ItemRemovedEvent, ItemUpdatedEvent, \ - ThingStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent + ThingStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, ThingConfigStatusInfoEvent from HABApp.openhab.item_to_reg import add_to_registry, remove_from_registry, remove_thing_from_registry, \ add_thing_to_registry from HABApp.openhab.map_events import get_event @@ -43,7 +43,7 @@ def on_sse_event(event_dict: dict): post_event(event.name, event) return None - if isinstance(event, (ThingStatusInfoEvent, ThingUpdatedEvent)): + if isinstance(event, (ThingStatusInfoEvent, ThingUpdatedEvent, ThingConfigStatusInfoEvent)): __thing = get_item(event.name) # type: HABApp.openhab.items.Thing __thing.process_event(event) post_event(event.name, event) diff --git a/src/HABApp/openhab/connection_logic/plugin_things/_log.py b/src/HABApp/openhab/connection_logic/plugin_things/_log.py index 60116294..253a753a 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/_log.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/_log.py @@ -1,5 +1,6 @@ import logging +from typing import Final as _Final -log = logging.getLogger('HABApp.openhab.thing') -log_cfg = logging.getLogger('HABApp.openhab.thing.cfg') -log_item = logging.getLogger('HABApp.openhab.thing.item') +log: _Final = logging.getLogger('HABApp.openhab.thing') +log_cfg: _Final = log.getChild('cfg') +log_item: _Final = log.getChild('item') diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py new file mode 100644 index 00000000..4aeae1a1 --- /dev/null +++ b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/__init__.py @@ -0,0 +1 @@ +from .writer import ItemsFileWriter diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py new file mode 100644 index 00000000..17629ed5 --- /dev/null +++ b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter.py @@ -0,0 +1,89 @@ +from typing import Final, Iterable, List, Dict, TypeVar + +from HABApp.core.const.const import PYTHON_311 + +if not PYTHON_311: + from typing_extensions import Self +else: + from typing import Self + + +class ValueFormatter: + def __init__(self, value: str): + self.value: Final = value + + def len(self): + return len(self.value) + + def format(self, width: int) -> str: + return f'{self.value:<{width}s}' + + +class EmptyFormatter(ValueFormatter): + def __init__(self): + super(EmptyFormatter, self).__init__('') + + +TYPE_FORMATTER = TypeVar('TYPE_FORMATTER', bound=ValueFormatter) + + +class FormatterScope: + def __init__(self, field_names: Iterable[str], + skip_alignment: Iterable[str] = tuple(), min_width: Dict[str, int] = {}): + self.lines: List[Dict[str, TYPE_FORMATTER]] = [] + self.keys: Final = tuple(field_names) + + # alignment options + self._align_skip: Final = skip_alignment + self._align_min_width: Final = min_width + + assert set(skip_alignment).issubset(self.keys) + + def add(self, obj: Dict[str, TYPE_FORMATTER]) -> Self: + assert set(obj.keys()).issubset(self.keys) + self.lines.append(obj) + return self + + def get_indent_dict(self, ) -> Dict[str, int]: + columns: Dict[str, List[TYPE_FORMATTER]] = {key: [] for key in self.keys} + for line_dict in self.lines: + for key in self.keys: + formatter = line_dict.get(key) + if formatter is None: + formatter = EmptyFormatter() + columns[key].append(formatter) + + column_width = {key: max(map(lambda x: x.len(), column)) for key, column in columns.items()} + + for key, width in column_width.items(): + # indent to multiples of 4, if the entries are missing do not indent + if width and key not in self._align_skip: + add = width % 4 + if not add: + add = 4 + width += add + + # option to set a minimum width + width = max(width, self._align_min_width.get(key, 0)) + + column_width[key] = width + + return column_width + + def get_lines(self) -> List[str]: + if not self.lines: + return [] + + column_width = self.get_indent_dict() + + ret_lines = [] + for line_dict in self.lines: # type: Dict[str, TYPE_FORMATTER] + line_vals = [] + for key, value_formatter in line_dict.items(): + width = column_width.get(key, 0) + if width == 0: + continue + line_vals.append(value_formatter.format(width)) + ret_lines.append(''.join(line_vals).rstrip()) + + return ret_lines diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py new file mode 100644 index 00000000..07bfb232 --- /dev/null +++ b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/formatter_builder.py @@ -0,0 +1,105 @@ +from typing import Final, Optional, Callable, Any + +from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem +from .formatter import TYPE_FORMATTER, EmptyFormatter, ValueFormatter + + +class BuilderBase: + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + raise NotImplementedError() + + +class ConstValueFormatterBuilder(BuilderBase): + def __init__(self, value: str, condition: Optional[Callable] = None): + self.value: Final = value + self.condition: Final = condition + + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + if self.condition is None or self.condition(item_obj): + return ValueFormatter(self.value) + return EmptyFormatter() + + +class ValueFormatterBuilder(BuilderBase): + def __init__(self, name: str, fmt_value: str): + self.name: Final = name + self.fmt_value: Final = fmt_value + + def get_value(self, item_obj: UserItem): + return getattr(item_obj, self.name) + + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + value = self.get_value(item_obj) + if not isinstance(value, str): + raise ValueError('Expected str!') + + value = value.strip() + if not value: + return EmptyFormatter() + return ValueFormatter(self.fmt_value.format(value)) + + +class MultipleValueFormatterBuilder(ValueFormatterBuilder): + + def __init__(self, name: str, fmt_value: str, wrapped_by: str): + super().__init__(name, fmt_value) + self.wrapped_by: Final = wrapped_by + + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + values = self.get_value(item_obj) + if not isinstance(values, (list, set, tuple, frozenset)): + raise ValueError('Expected container!') + + values = map(lambda x: x.strip(), values) + + # remove all empty str and sort + values = sorted(filter(None, values)) + if not values: + return EmptyFormatter() + + value = ', '.join(map(self.fmt_value.format, values)) + return ValueFormatter(self.wrapped_by.format(value)) + + +class LinkFormatter(BuilderBase): + + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + link = item_obj.link.strip() + if not link: + return EmptyFormatter() + + value = f'channel = "{link:s}"' + if item_obj.metadata: + value += ',' + + return ValueFormatter(value) + + +def metadata_key_value(key: str, value: Any): + return f'{key}={value}' if not isinstance(value, str) else f'{key}="{value}"' + + +class MetadataFormatter(BuilderBase): + + def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': + metdata = item_obj.metadata + if not metdata: + return EmptyFormatter() + + strs = [] + for name, __meta in sorted(metdata.items(), key=lambda x: x[0]): + value = __meta['value'] + config = __meta['config'] + + # a=1 or a="test" + metadata_str = metadata_key_value(name, value) + if config: + config_strs = [] + for config_key, config_value in sorted(config.items(), key=lambda x: x[0]): + config_strs.append(metadata_key_value(config_key, config_value)) + + metadata_str += f' [{", ".join(config_strs)}]' + + strs.append(metadata_str) + + return ValueFormatter(', '.join(strs)) diff --git a/src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py new file mode 100644 index 00000000..3c3a8adf --- /dev/null +++ b/src/HABApp/openhab/connection_logic/plugin_things/file_writer/writer.py @@ -0,0 +1,104 @@ +import re +from pathlib import Path +from typing import Iterable, Optional, List, Dict + +from HABApp.core.const.const import PYTHON_311 +from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem +from .formatter import FormatterScope +from .formatter_builder import ValueFormatterBuilder, MultipleValueFormatterBuilder, ConstValueFormatterBuilder, \ + MetadataFormatter, LinkFormatter + +if not PYTHON_311: + from typing_extensions import Self +else: + from typing import Self + + +FIELD_ORDER = ( + 'type', 'name', 'label', 'icon', 'groups', 'tags', 'bracket_open', 'link', 'metadata', 'bracket_close' +) + +RE_GROUP_NAMES = re.compile(r'([A-Za-z0-9-]+?)(?=[A-Z_ -])') + + +def brackets_needed(obj: UserItem): + return obj.link or obj.metadata + + +class ItemsFileWriter: + def __init__(self): + self.items: List[UserItem] = [] + + def add_item(self, obj) -> Self: + self.items.append(obj) + return self + + def add_items(self, objs: Iterable[UserItem]) -> Self: + self.items.extend(objs) + return self + + def group_items(self) -> List[List[UserItem]]: + grouped_items: Dict[Optional[str], List[UserItem]] = {} + not_grouped: List[UserItem] = [] + for item in self.items: + if m := RE_GROUP_NAMES.match(item.name): + grouped_items.setdefault(m.group(1), []).append(item) + else: + not_grouped.append(item) + + ret = [] + # sort alphabetical by key + for key, values in sorted(grouped_items.items(), key=lambda x: x[0]): + # if it's only one value it'll be not written in a block + if len(values) <= 1: + not_grouped.extend(values) + continue + + ret.append(values) + + # single entry items get created last + if not_grouped: + ret.append(not_grouped) + + return ret + + def generate(self) -> str: + groups = self.group_items() + + builder = { + 'type': ValueFormatterBuilder('type', '{:s}'), + 'name': ValueFormatterBuilder('name', '{:s}'), + 'label': ValueFormatterBuilder('label', '"{:s}"'), + 'icon': ValueFormatterBuilder('icon', '<{:s}>'), + 'groups': MultipleValueFormatterBuilder('groups', '{:s}', '({:s})'), + 'tags': MultipleValueFormatterBuilder('tags', '"{:s}"', '[{:s}]'), + 'bracket_open': ConstValueFormatterBuilder('{', condition=brackets_needed), + 'link': LinkFormatter(), + 'metadata': MetadataFormatter(), + 'bracket_close': ConstValueFormatterBuilder('}', condition=brackets_needed), + } + + lines = [] + + for group in groups: + scope = FormatterScope(FIELD_ORDER, ('bracket_open', 'bracket_close', 'metadata')) + for item in group: + scope.add({k: v.create_formatter(item) for k, v in builder.items()}) + + lines.extend(scope.get_lines()) + lines.extend(['']) + + return '\n'.join(lines) + + def create_file(self, file: Path): + + output = self.generate() + + # only write changes + if file.is_file(): + existing = file.read_text('utf-8') + if existing == output: + return False + + file.write_text(output, encoding='utf-8') + return True diff --git a/src/HABApp/openhab/connection_logic/plugin_things/items_file.py b/src/HABApp/openhab/connection_logic/plugin_things/items_file.py deleted file mode 100644 index 1d78ba0d..00000000 --- a/src/HABApp/openhab/connection_logic/plugin_things/items_file.py +++ /dev/null @@ -1,144 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Optional -import re - -from .cfg_validator import UserItem - - -RE_GROUP_NAMES = re.compile(r'([A-Za-z0-9-]+?)(?=[A-Z_ -])') - - -def _get_item_val_dict(field_fmt: Dict[str, str], item: UserItem): - new = {} - for k, format in field_fmt.items(): - if k in ('bracket_open', 'metadata', 'bracket_close'): - continue - val = item.__dict__[k] - if isinstance(val, list): - if k == 'tags': - val = ', '.join(map('"{}"'.format, val)) - else: - val = ', '.join(val) - - new[k] = format.format(val) if val else '' - - if item.link or item.metadata: - new['bracket_open'] = '{' - new['bracket_close'] = '}' - else: - new['bracket_open'] = '' - new['bracket_close'] = '' - - if item.metadata: - __m = [] - for k, __meta in item.metadata.items(): - __val = __meta['value'] - __cfg = __meta['config'] - - _str = f'{k}={__val}' if not isinstance(__val, str) else f'{k}="{__val}"' - if __cfg: - __conf_strs = [] - for _k, _v in __cfg.items(): - __conf_strs.append(f'{_k}={_v}' if not isinstance(_v, str) else f'{_k}="{_v}"') - _str += f' [{", ".join(__conf_strs)}]' - __m.append(_str) - - # link needs the "," so we indent properly - if item.link: - new['link'] += ',' - # metadata - new['metadata'] = ', '.join(__m) - else: - new['metadata'] = '' - - return new - - -def _get_fmt_str(field_fmt: Dict[str, str], vals: List[Dict[str, str]]) -> str: - w_dict = {} - for k in field_fmt.keys(): - # - # w_dict[k] = 0 - # continue - - width = max(map(len, map(lambda x: x[k], vals)), default=0) - # indent to multiples of 4, if the entries are missing do not indent - if width and k not in ('bracket_open', 'bracket_close', 'metadata'): - add = width % 4 - if not add: - add = 4 - width += add - w_dict[k] = width - - ret = '' - for k in field_fmt.keys(): - w = w_dict[k] - if not w: - ret += f'{{{k}:s}}' # format crashes with with=0 so this is a different format string - continue - else: - ret += f'{{{k}:{w}s}}' - return ret - - -def create_items_file(path: Path, items_dict: Dict[str, UserItem]): - # if we don't have any items we don't create an empty file - if not items_dict: - return None - - field_fmt = { - 'type': '{}', - 'name': '{}', - 'label': '"{}"', - 'icon': '<{}>', - 'groups': '({})', - 'tags': '[{}]', - 'bracket_open': '', - 'link': 'channel = "{}"', - 'metadata': '{}', - 'bracket_close': '', - } - - grouped_items: Dict[Optional[str], List[Dict[str, str]]] = {None: []} - for _name, _item in items_dict.items(): - m = RE_GROUP_NAMES.match(_name) - grp = grouped_items.setdefault(m.group(1) if m is not None else None, []) - grp.append(_get_item_val_dict(field_fmt, _item)) - - # aggregate single entry items to a block - _aggr = [] - for _name, _items in grouped_items.items(): - if len(_items) <= 1 and _name is not None: - _aggr.append(_name) - for _name in _aggr: - grouped_items[None].extend(grouped_items[_name]) - grouped_items.pop(_name) - - # single entry items get created at the end of file - if None in grouped_items: - grouped_items[None] = grouped_items.pop(None) - - lines = [] - for _name, _item_vals in grouped_items.items(): - # skip empty items - if not _item_vals: - continue - - fmt = _get_fmt_str(field_fmt, _item_vals) - - for _val in _item_vals: - _l = fmt.format(**_val) - lines.append(_l.strip() + '\n') - - # newline aber each name block - lines.append('\n') - - # If we have multiple parts configs in one file we separate them with newlines - if path.is_file(): - lines.insert(0, '\n') - lines.insert(0, '\n') - lines.insert(0, '\n') - - # Use append, file was deleted when we loaded the config - with path.open(mode='a', encoding='utf-8') as file: - file.writelines(lines) diff --git a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index 15dbec13..dcfa3efb 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -15,7 +15,7 @@ from HABApp.openhab.connection_logic.plugin_things.filters import apply_filters, log_overview from ._log import log from .item_worker import create_item, cleanup_items -from .items_file import create_items_file +from .file_writer import ItemsFileWriter from .thing_worker import update_thing_cfg from .._plugin import OnConnectPlugin @@ -92,9 +92,8 @@ async def file_load(self, name: str, path: Path): self.do_cleanup.reset() # output file - output_file = path.with_suffix('.items') - if output_file.is_file(): - output_file.unlink() + items_file_path = path.with_suffix('.items') + items_file_writer = ItemsFileWriter() # we also get events when the file gets deleted if not path.is_file(): @@ -188,9 +187,10 @@ async def file_load(self, name: str, path: Path): self.do_cleanup.reset() - create_items_file(output_file, create_items) - + items_file_writer.add_items(create_items.values()) self.cache_cfg = [] + items_file_writer.create_file(items_file_path) + PLUGIN_MANUAL_THING_CFG = ManualThingConfig.create_plugin() diff --git a/src/HABApp/openhab/definitions/__init__.py b/src/HABApp/openhab/definitions/__init__.py index ca77ced4..9c53f7c4 100644 --- a/src/HABApp/openhab/definitions/__init__.py +++ b/src/HABApp/openhab/definitions/__init__.py @@ -1,3 +1,3 @@ -from .definitions import ITEM_TYPES, ITEM_DIMENSIONS, GROUP_FUNCTIONS +from .definitions import ITEM_TYPES, ITEM_DIMENSIONS, GROUP_ITEM_FUNCTIONS from .values import OnOffValue, PercentValue, UpDownValue, HSBValue, QuantityValue, OpenClosedValue, RawValue from . import rest diff --git a/src/HABApp/openhab/definitions/definitions.py b/src/HABApp/openhab/definitions/definitions.py index a07283d8..87b9e557 100644 --- a/src/HABApp/openhab/definitions/definitions.py +++ b/src/HABApp/openhab/definitions/definitions.py @@ -1,8 +1,83 @@ -ITEM_TYPES = { - 'String', 'Number', 'Switch', 'Contact', 'Dimmer', 'Rollershutter', - 'Color', 'DateTime', 'Location', 'Player', 'Group', 'Image', 'Call', -} +from enum import Enum as _Enum +import typing -ITEM_DIMENSIONS = {'Length', 'Temperature', 'Pressure', 'Speed', 'Intensity', 'Angle', 'Dimensionless'} -GROUP_FUNCTIONS = {'AND', 'OR', 'NAND', 'NOR', 'AVG', 'MAX', 'MIN', 'SUM'} +def _get_str_enum_values(obj: typing.Type[_Enum]) -> typing.Set[str]: + return {_member.value for _member in obj} + + +class ItemType(str, _Enum): + STRING = 'String' + NUMBER = 'Number' + SWITCH = 'Switch' + CONTACT = 'Contact' + DIMMER = 'Dimmer' + ROLLERSHUTTER = 'Rollershutter' + COLOR = 'Color' + DATETIME = 'DateTime' + LOCATION = 'Location' + PLAYER = 'Player' + GROUP = 'Group' + IMAGE = 'Image' + CALL = 'Call' + + +ITEM_TYPES: typing.Final = _get_str_enum_values(ItemType) + + +class ItemDimensions(str, _Enum): + ACCELERATION = 'Acceleration' + ANGLE = 'Angle' + AREAL_DENSITY = 'ArealDensity' + CATALYTIC_ACTIVITY = 'CatalyticActivity' + DATA_AMOUNT = 'DataAmount' + DATA_TRANSFER_RATE = 'DataTransferRate' + DENSITY = 'Density' + DIMENSIONLESS = 'Dimensionless' + ELECTRIC_CAPACITANCE = 'ElectricCapacitance' + ELECTRIC_CHARGE = 'ElectricCharge' + ELECTRIC_CONDUCTANCE = 'ElectricConductance' + ELECTRIC_CONDUCTIVITY = 'ElectricConductivity' + ELECTRIC_CURRENT = 'ElectricCurrent' + ELECTRIC_INDUCTANCE = 'ElectricInductance' + ELECTRIC_POTENTIAL = 'ElectricPotential' + ELECTRIC_RESISTANCE = 'ElectricResistance' + ENERGY = 'Energy' + FORCE = 'Force' + FREQUENCY = 'Frequency' + ILLUMINANCE = 'Illuminance' + INTENSITY = 'Intensity' + LENGTH = 'Length' + LUMINOUS_FLUX = 'LuminousFlux' + LUMINOUS_INTENSITY = 'LuminousIntensity' + MAGNETIC_FLUX = 'MagneticFlux' + MAGNETIC_FLUX_DENSITY = 'MagneticFluxDensity' + MASS = 'Mass' + POWER = 'Power' + PRESSURE = 'Pressure' + RADIATION_DOSE_ABSORBED = 'RadiationDoseAbsorbed' + RADIATION_DOSE_EFFECTIVE = 'RadiationDoseEffective' + RADIOACTIVITY = 'Radioactivity' + SOLID_ANGLE = 'SolidAngle' + SPEED = 'Speed' + TEMPERATURE = 'Temperature' + TIME = 'Time' + VOLUME = 'Volume' + VOLUMETRIC_FLOW_RATE = 'VolumetricFlowRate' + + +ITEM_DIMENSIONS: typing.Final = _get_str_enum_values(ItemDimensions) + + +class GroupItemFunctions(str, _Enum): + AND = 'AND' + AVG = 'AVG' + MAX = 'MAX' + MIN = 'MIN' + NAND = 'NAND' + NOR = 'NOR' + OR = 'OR' + SUM = 'SUM' + + +GROUP_ITEM_FUNCTIONS: typing.Final = _get_str_enum_values(GroupItemFunctions) diff --git a/src/HABApp/openhab/definitions/rest/base.py b/src/HABApp/openhab/definitions/rest/base.py index b2adf6e7..b1c2cf3c 100644 --- a/src/HABApp/openhab/definitions/rest/base.py +++ b/src/HABApp/openhab/definitions/rest/base.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, Extra +from pydantic import BaseModel, Extra, validator class RestBase(BaseModel): @@ -6,4 +6,11 @@ class RestBase(BaseModel): # default configuration for RestAPI models class Config: extra = Extra.forbid - allow_population_by_field_name = True + + +def none_is_empty_str(v) -> str: + return None if v == 'NONE' else v + + +def make_none_empty_str(*name: str): + return validator(*name, allow_reuse=True)(none_is_empty_str) diff --git a/src/HABApp/openhab/definitions/rest/links.py b/src/HABApp/openhab/definitions/rest/links.py index 58a151be..3f277231 100644 --- a/src/HABApp/openhab/definitions/rest/links.py +++ b/src/HABApp/openhab/definitions/rest/links.py @@ -6,13 +6,10 @@ class ItemChannelLinkDefinition(RestBase): - item_name: str = Field(alias='itemName') + editable: bool channel_uid: str = Field(alias='channelUID') configuration: Dict[str, Any] = {} - - # This field is OH3 only - # Todo: Remove this comment once we go OH3 - editable: bool = False + item_name: str = Field(alias='itemName') class LinkNotFoundError(Exception): diff --git a/src/HABApp/openhab/definitions/rest/things.py b/src/HABApp/openhab/definitions/rest/things.py index ef38a361..5d02d986 100644 --- a/src/HABApp/openhab/definitions/rest/things.py +++ b/src/HABApp/openhab/definitions/rest/things.py @@ -1,20 +1,23 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional, Tuple -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator class OpenhabThingChannelDefinition(BaseModel): - linkedItems: Optional[List[str]] - uid: Optional[str] - id: Optional[str] - channelTypeUID: Optional[str] - itemType: Optional[str] - kind: Optional[str] - label: Optional[str] - description: Optional[str] - defaultTags: Optional[List[str]] - properties: Optional[Dict[str, Any]] - configuration: Optional[Dict[str, Any]] + uid: str + id: str + channel_type: str = Field(..., alias='channelTypeUID') + kind: str + + label: str = '' + description: str = '' + item_type: Optional[str] = Field(None, alias='itemType') + + linked_items: Tuple[str, ...] = Field(default_factory=tuple, alias='linkedItems') + default_tags: Tuple[str, ...] = Field(default_factory=tuple, alias='defaultTags') + + properties: Dict[str, Any] = Field(default_factory=dict) + configuration: Dict[str, Any] = Field(default_factory=dict) class OpenhabThingStatus(BaseModel): @@ -22,19 +25,26 @@ class OpenhabThingStatus(BaseModel): detail: str = Field(..., alias='statusDetail') description: Optional[str] = None + @validator('detail') + def _parse_detail(cls, v): + return '' if v == 'NONE' else v -class OpenhabThingDefinition(BaseModel): - channels: Optional[List[OpenhabThingChannelDefinition]] - location: Optional[str] - firmwareStatus: Optional[Dict[str, str]] - editable: Optional[bool] +class OpenhabThingDefinition(BaseModel): # These are mandatory fields - label: str + editable: bool status: OpenhabThingStatus = Field(..., alias='statusInfo') - uid: str = Field(..., alias='UID') thing_type: str = Field(..., alias='thingTypeUID') - bridge_uid: Optional[str] = Field(None, alias='bridgeUID') + uid: str = Field(..., alias='UID') - configuration: Dict[str, Any] - properties: Dict[str, Any] + # These fields are optional, but we want to have a value set + # because it simplifies the thing handling a lot + bridge_uid: Optional[str] = Field(None, alias='bridgeUID') + label: str = '' + location: str = '' + + # Containers should always have a default, so it's easy to iterate over them + channels: Tuple[OpenhabThingChannelDefinition, ...] = Field(default_factory=tuple) + configuration: Dict[str, Any] = Field(default_factory=dict) + firmwareStatus: Dict[str, str] = Field(default_factory=dict) + properties: Dict[str, Any] = Field(default_factory=dict) diff --git a/src/HABApp/openhab/events/__init__.py b/src/HABApp/openhab/events/__init__.py index a3221de5..2198a8bd 100644 --- a/src/HABApp/openhab/events/__init__.py +++ b/src/HABApp/openhab/events/__init__.py @@ -3,5 +3,5 @@ ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent from .channel_events import ChannelTriggeredEvent, ChannelDescriptionChangedEvent from .thing_events import ThingStatusInfoChangedEvent, ThingStatusInfoEvent, \ - ThingFirmwareStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent + ThingFirmwareStatusInfoEvent, ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, ThingConfigStatusInfoEvent from .event_filters import ItemStateEventFilter, ItemStateChangedEventFilter, ItemCommandEventFilter diff --git a/src/HABApp/openhab/events/thing_events.py b/src/HABApp/openhab/events/thing_events.py index 5770f4c3..17ade135 100644 --- a/src/HABApp/openhab/events/thing_events.py +++ b/src/HABApp/openhab/events/thing_events.py @@ -68,6 +68,33 @@ def __repr__(self): f'old_status: {self.old_status}, old_detail: {self.old_detail}>' +class ThingConfigStatusInfoEvent(OpenhabEvent): + """ + :ivar str name: + :ivar Dict[str, str] config_messages: + """ + name: str + config_messages: Dict[str, str] + + def __init__(self, name: str = '', config_messages: Optional[Dict[str, str]] = None): + super().__init__() + + self.name: str = name + self.config_messages: Dict[str, str] = config_messages if config_messages is not None else {} + + @classmethod + def from_dict(cls, topic: str, payload: dict): + # openhab/things/zwave:device:gehirn:node29/config/status + name = topic[15:-14] + msgs = payload['configStatusMessages'] + return cls( + name=name, config_messages={param_name: msg_type for d in msgs for param_name, msg_type in d.items()} + ) + + def __repr__(self): + return f'<{self.__class__.__name__} name: {self.name}, config_messages: {self.config_messages}>' + + class ThingFirmwareStatusInfoEvent(OpenhabEvent): """ :ivar str name: diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index 50c3ef15..d909f78b 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -5,6 +5,7 @@ from HABApp.core.const import MISSING from HABApp.core.items import BaseValueItem +from HABApp.core.lib.funcs import compare as _compare from HABApp.openhab.interface import get_persistence_data, post_update, send_command @@ -54,6 +55,40 @@ def oh_post_update(self, value: Any = MISSING): """ post_update(self.name, self.value if value is MISSING else value) + def oh_post_update_if(self, new_value, *, equal=MISSING, eq=MISSING, not_equal=MISSING, ne=MISSING, + lower_than=MISSING, lt=MISSING, lower_equal=MISSING, le=MISSING, + greater_than=MISSING, gt=MISSING, greater_equal=MISSING, ge=MISSING, + is_=MISSING, is_not=MISSING) -> bool: + """ + Post a value depending on the current state of the item. If one of the comparisons is true the new state + will be posted. + + :param new_value: new value to post + :param equal: item state has to be equal to the passed value + :param eq: item state has to be equal to the passed value + :param not_equal: item state has to be not equal to the passed value + :param ne: item state has to be not equal to the passed value + :param lower_than: item state has to be lower than the passed value + :param lt: item state has to be lower than the passed value + :param lower_equal: item state has to be lower equal the passed value + :param le: item state has to be lower equal the passed value + :param greater_than: item state has to be greater than the passed value + :param gt: item state has to be greater than the passed value + :param greater_equal: item state has to be greater equal the passed value + :param ge: tem state has to be greater equal the passed value + :param is_: item state has to be the same object as the passt value (e.g. None) + :param is_not: item state has to be not the same object as the passt value (e.g. None) + + :return: `True` if the new value was posted else `False` + """ + + if _compare(self.value, equal=equal, eq=eq, not_equal=not_equal, ne=ne, + lower_than=lower_than, lt=lt, lower_equal=lower_equal, le=le, + greater_than=greater_than, gt=gt, greater_equal=greater_equal, ge=ge, is_=is_, is_not=is_not): + post_update(self.name, new_value) + return True + return False + def get_persistence_data(self, persistence: Optional[str] = None, start_time: Optional[datetime.datetime] = None, end_time: Optional[datetime.datetime] = None): diff --git a/src/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py index 0f54f02b..6a9c1ecb 100644 --- a/src/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -26,11 +26,11 @@ def set_value(self, new_value) -> bool: def is_on(self) -> bool: """Test value against on-value""" - return True if self.value == OnOffValue.ON else False + return self.value == OnOffValue.ON def is_off(self) -> bool: """Test value against off-value""" - return True if self.value == OnOffValue.OFF else False + return self.value == OnOffValue.OFF def __str__(self): return self.value diff --git a/src/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py index d01bd74e..5e72098a 100644 --- a/src/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -6,7 +6,7 @@ from pendulum import now as pd_now from HABApp.core.items import BaseItem -from HABApp.openhab.events import ThingStatusInfoEvent, ThingUpdatedEvent +from HABApp.openhab.events import ThingStatusInfoEvent, ThingUpdatedEvent, ThingConfigStatusInfoEvent from HABApp.openhab.interface import set_thing_enabled @@ -57,6 +57,8 @@ def process_event(self, event): self.__update_timestamps( old_label != self.label or old_configuration != self.configuration or old_properties != self.properties ) + elif isinstance(event, ThingConfigStatusInfoEvent): + pass return None diff --git a/src/HABApp/openhab/map_events.py b/src/HABApp/openhab/map_events.py index 4cd2d472..54ec80d9 100644 --- a/src/HABApp/openhab/map_events.py +++ b/src/HABApp/openhab/map_events.py @@ -6,7 +6,8 @@ ItemUpdatedEvent, ItemRemovedEvent, ItemStatePredictedEvent, GroupItemStateChangedEvent, \ ChannelTriggeredEvent, ChannelDescriptionChangedEvent, \ ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, \ - ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent + ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent, \ + ThingConfigStatusInfoEvent EVENT_LIST = [ @@ -19,11 +20,14 @@ # thing events ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, - ThingStatusInfoEvent, ThingStatusInfoChangedEvent, ThingFirmwareStatusInfoEvent + ThingStatusInfoEvent, ThingStatusInfoChangedEvent, + ThingFirmwareStatusInfoEvent, + ThingConfigStatusInfoEvent, ] _events: Dict[str, Type[OpenhabEvent]] = {k.__name__: k for k in EVENT_LIST} _events['FirmwareStatusInfoEvent'] = ThingFirmwareStatusInfoEvent # Naming from openHAB is inconsistent here +_events['ConfigStatusInfoEvent'] = ThingConfigStatusInfoEvent # Naming from openHAB is inconsistent here def get_event(_in_dict: dict) -> OpenhabEvent: diff --git a/src/HABApp/util/multimode/mode_value.py b/src/HABApp/util/multimode/mode_value.py index 221278c3..2a4894ba 100644 --- a/src/HABApp/util/multimode/mode_value.py +++ b/src/HABApp/util/multimode/mode_value.py @@ -83,36 +83,44 @@ def set_value(self, value, only_on_change: bool = False): :param value: new value :param only_on_change: will set/enable the mode only if value differs or the mode is disabled + :returns: False if the value was not set, True otherwise """ # Possibility to set the mode only on change - if only_on_change and self.__enabled and self.__value == value: - return None + if only_on_change: + change = value != self.__value + + # If we set the same value and the mode is disabled we enable it which counts as a change + if not change and self.__enable_on_value and not self.__enabled: + change = True + + if not change: + return False self.__value = value self.last_update = datetime.now() - if self.__enable_on_value is True and self.__value is not None: + if self.__enable_on_value and self.__value is not None: self.__enabled = True if self.logger is not None: self.logger.info(f'[{"x" if self.__enabled else " "}] {self.name}: {self.__value}') self.parent.calculate_value() - return None + return True - def set_enabled(self, value: bool, only_on_change: bool = False): + def set_enabled(self, value: bool, only_on_change: bool = False) -> bool: """Enable or disable this value and recalculate overall value :param value: True/False :param only_on_change: enable only on change - :return: + :return: True if the value was set else False """ assert isinstance(value, bool), value # Possibility to enable/disable only on change - if only_on_change and value == self.__enabled: - return None + if only_on_change and (value == self.__value or not self.__enabled): + return False self.__enabled = value self.last_update = datetime.now() @@ -121,7 +129,7 @@ def set_enabled(self, value: bool, only_on_change: bool = False): self.logger.info(f'[{"x" if self.__enabled else " "}] {self.name}') self.parent.calculate_value() - return None + return True def calculate_lower_priority_value(self) -> typing.Any: # Trigger recalculation, this way we keep the output of MultiModeValue synchronized diff --git a/tests/conftest.py b/tests/conftest.py index 474e000f..80956e26 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from HABApp.core.asyncio import async_context from HABApp.core.const.topics import TOPIC_ERRORS from HABApp.core.internals import setup_internals, EventBus, ItemRegistry -from .helpers import params, parent_rule, sync_worker, eb, get_dummy_cfg +from tests.helpers import params, parent_rule, sync_worker, eb, get_dummy_cfg if typing.TYPE_CHECKING: parent_rule = parent_rule diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 187930bf..196f0f32 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -3,5 +3,6 @@ from .parameters import params from .event_bus import eb, TestEventBus from .mock_file import MockFile -from .module_helpers import get_module_classes, check_class_annotations from .habapp_config import get_dummy_cfg + +from . import inspect diff --git a/tests/helpers/inspect/__init__.py b/tests/helpers/inspect/__init__.py new file mode 100644 index 00000000..b6d65d83 --- /dev/null +++ b/tests/helpers/inspect/__init__.py @@ -0,0 +1,6 @@ +from .module import get_module_classes + +# isort: split + +from .classes import check_class_annotations +from .signature import assert_same_signature diff --git a/tests/helpers/inspect/classes.py b/tests/helpers/inspect/classes.py new file mode 100644 index 00000000..58dca305 --- /dev/null +++ b/tests/helpers/inspect/classes.py @@ -0,0 +1,27 @@ +import inspect +from typing import Iterable, Optional + +from tests.helpers.inspect.module import get_module_classes + + +def check_class_annotations(module_name: str, exclude: Optional[Iterable[str]] = None): + """Ensure that the annotations match with the actual variables""" + + classes = get_module_classes(module_name, exclude=exclude) + for name, cls in classes.items(): + c = cls() + args = dict(filter( + lambda x: not x[0].startswith('__'), + dict(inspect.getmembers(c, lambda x: not inspect.ismethod(x))).items()) + ) + + # Check that all vars are in __annotations__ + for arg_name in args: + assert arg_name in c.__annotations__, f'"{arg_name}" is missing in annotations!"\n' \ + f'members : {", ".join(sorted(args))}\n' \ + f'annotations: {", ".join(sorted(c.__annotations__))}' + + for arg_name in c.__annotations__: + assert arg_name in args, f'"{arg_name}" is missing in args!"\n' \ + f'members : {", ".join(sorted(args))}\n' \ + f'annotations: {", ".join(sorted(c.__annotations__))}' diff --git a/tests/helpers/inspect/module.py b/tests/helpers/inspect/module.py new file mode 100644 index 00000000..9c38237d --- /dev/null +++ b/tests/helpers/inspect/module.py @@ -0,0 +1,36 @@ +import importlib +import inspect +import sys +from typing import Iterable, Optional, Union, Tuple, List, Callable + + +def get_module_classes(module_name: str, /, exclude: Optional[Iterable[Union[str, type]]] = None, include_imported=True, + subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): + + filters: List[Callable[[type], bool]] = [ + lambda x: inspect.isclass(x) + ] + + if not include_imported: + filters.append(lambda x: x.__module__ == module_name) + + if exclude is not None: + for exclude_obj in exclude: + if isinstance(exclude_obj, str): + filters.append(lambda x, obj=exclude_obj: x.__name__ != obj) + else: + filters.append(lambda x, obj=exclude_obj: x is not obj) + + if subclass is not None: + filters.append(lambda x: issubclass(x, subclass)) + + # Ensure that the class is not the subclass + if not include_subclass: + sub_cmp = subclass if isinstance(subclass, tuple) else tuple([subclass]) + filters.append(lambda x: all(map(lambda cls_obj: x is not cls_obj, sub_cmp))) + + importlib.import_module(module_name) + return dict(inspect.getmembers( + sys.modules[module_name], + lambda x: all(map(lambda f: f(x), filters)) + )) diff --git a/tests/helpers/inspect/signature.py b/tests/helpers/inspect/signature.py new file mode 100644 index 00000000..eca3b282 --- /dev/null +++ b/tests/helpers/inspect/signature.py @@ -0,0 +1,39 @@ +import inspect +from typing import Optional + +import pytest + + +def assert_same_signature(func_a, func_b): + sig_a = inspect.signature(func_a) + sig_b = inspect.signature(func_b) + assert sig_a == sig_b, f'\n {sig_a}\n {sig_b}\n' + + doc_a = inspect.getdoc(func_a) + doc_b = inspect.getdoc(func_b) + assert doc_a == doc_b + + return True + + +def test_assert_same_signature(): + def func1(a: int, b: Optional[str] = None) -> float: + """Doc1""" + + def func1_no_ret(a: int, b: Optional[str] = None): + """Doc1""" + + def func1_diff_args(a: int, b: str = None) -> float: + """Doc1""" + + def func1_diff_doc(a: int, b: Optional[str] = None) -> float: + """Doc2""" + + with pytest.raises(AssertionError): + assert_same_signature(func1, func1_no_ret) + + with pytest.raises(AssertionError): + assert_same_signature(func1, func1_diff_args) + + with pytest.raises(AssertionError): + assert_same_signature(func1, func1_diff_doc) diff --git a/tests/helpers/mock_file.py b/tests/helpers/mock_file.py index 898c784a..4a96db5f 100644 --- a/tests/helpers/mock_file.py +++ b/tests/helpers/mock_file.py @@ -64,3 +64,6 @@ def with_suffix(self, suffix): @property def name(self): return self.path.name + + def read_text(self, encoding: str) -> str: + return self.data diff --git a/tests/helpers/module_helpers.py b/tests/helpers/module_helpers.py deleted file mode 100644 index deeed4f6..00000000 --- a/tests/helpers/module_helpers.py +++ /dev/null @@ -1,64 +0,0 @@ -import importlib -import inspect -import sys -from typing import Iterable, Optional, Union, Tuple, List, Callable - -# Todo: Make this positional only if we go >= python 3.8 -# def get_module_classes(module_name: str, /, exclude: Optional[Iterable[Union[str, type]]] = None, -# include_imported=True, -# subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): - - -def get_module_classes(module_name: str, exclude: Optional[Iterable[Union[str, type]]] = None, include_imported=True, - subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): - - filters: List[Callable[[type], bool]] = [ - lambda x: inspect.isclass(x) - ] - - if not include_imported: - filters.append(lambda x: x.__module__ == module_name) - - if exclude is not None: - for exclude_obj in exclude: - if isinstance(exclude_obj, str): - filters.append(lambda x, obj=exclude_obj: x.__name__ != obj) - else: - filters.append(lambda x, obj=exclude_obj: x is not obj) - - if subclass is not None: - filters.append(lambda x: issubclass(x, subclass)) - - # Ensure that the class is not the subclass - if not include_subclass: - sub_cmp = subclass if isinstance(subclass, tuple) else tuple([subclass]) - filters.append(lambda x: all(map(lambda cls_obj: x is not cls_obj, sub_cmp))) - - importlib.import_module(module_name) - return dict(inspect.getmembers( - sys.modules[module_name], - lambda x: all(map(lambda f: f(x), filters)) - )) - - -def check_class_annotations(module_name: str, exclude: Optional[Iterable[str]] = None, skip_imports=True): - """Ensure that the annotations match with the actual variables""" - - classes = get_module_classes(module_name, exclude=exclude) - for name, cls in classes.items(): - c = cls() - args = dict(filter( - lambda x: not x[0].startswith('__'), - dict(inspect.getmembers(c, lambda x: not inspect.ismethod(x))).items()) - ) - - # Check that all vars are in __annotations__ - for arg_name in args: - assert arg_name in c.__annotations__, f'"{arg_name}" is missing in annotations!"\n' \ - f'members : {", ".join(sorted(args))}\n' \ - f'annotations: {", ".join(sorted(c.__annotations__))}' - - for arg_name in c.__annotations__: - assert arg_name in args, f'"{arg_name}" is missing in args!"\n' \ - f'members : {", ".join(sorted(args))}\n' \ - f'annotations: {", ".join(sorted(c.__annotations__))}' diff --git a/tests/helpers/traceback.py b/tests/helpers/traceback.py index e568244e..0b36d92b 100644 --- a/tests/helpers/traceback.py +++ b/tests/helpers/traceback.py @@ -2,7 +2,7 @@ from pathlib import Path -def process_traceback(traceback: str) -> str: +def remove_dyn_parts_from_traceback(traceback: str) -> str: # object ids traceback = re.sub(r' at 0x[0-9A-Fa-f]+', ' at 0x' + 'A' * 16, traceback) @@ -13,3 +13,21 @@ def process_traceback(traceback: str) -> str: traceback = traceback.replace(m.group(0), f'File "{fname}"') return traceback + + +def test_remove_dyn_parts_from_traceback(): + + traceback = """ +File "C:\\My\\Folder\\HABApp\\tests\\test_core\\test_lib\\test_format_traceback.py", line 19 in exec_func +File "/My/Folder/HABApp/tests/test_core/test_lib/test_format_traceback.py", line 19 in exec_func +func = + File "C:\\My\\Folder\\HABApp\\tests\\test_core\\test_lib\\test_format_traceback.py", line 19, in exec_func +""" + processed = remove_dyn_parts_from_traceback(traceback) + + assert processed == """ +File "test_core/test_lib/test_format_traceback.py", line 19 in exec_func +File "test_core/test_lib/test_format_traceback.py", line 19 in exec_func +func = + File "test_core/test_lib/test_format_traceback.py", line 19, in exec_func +""" diff --git a/tests/test_all/test_items.py b/tests/test_all/test_items.py index 1f836abf..0ace4564 100644 --- a/tests/test_all/test_items.py +++ b/tests/test_all/test_items.py @@ -2,7 +2,7 @@ from HABApp.core.items import BaseValueItem, HINT_TYPE_ITEM_OBJ from HABApp.mqtt.items import MqttBaseItem -from tests.helpers import get_module_classes +from tests.helpers.inspect import get_module_classes def get_item_classes(skip=tuple()): diff --git a/tests/test_core/test_events/test_core_filters.py b/tests/test_core/test_events/test_core_filters.py index ddfa376d..5af8766f 100644 --- a/tests/test_core/test_events/test_core_filters.py +++ b/tests/test_core/test_events/test_core_filters.py @@ -3,7 +3,7 @@ from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent from HABApp.core.events.filter import EventFilter, ValueChangeEventFilter, ValueUpdateEventFilter, NoEventFilter, \ OrFilterGroup, AndFilterGroup -from tests.helpers import check_class_annotations +from tests.helpers.inspect import check_class_annotations def test_class_annotations(): diff --git a/tests/test_core/test_items/tests_all_items.py b/tests/test_core/test_items/tests_all_items.py index e19d1cc7..e351a1a6 100644 --- a/tests/test_core/test_items/tests_all_items.py +++ b/tests/test_core/test_items/tests_all_items.py @@ -59,3 +59,9 @@ def test_time_value_change(self): assert i._last_update.dt > pd_now(UTC) - timedelta(milliseconds=100) assert i._last_change.dt > pd_now(UTC) - timedelta(milliseconds=100) + + def test_post_if(self): + i = self.CLS('test') + assert i.post_value_if(0, is_=None) + assert i.post_value_if(1, eq=0) + assert not i.post_value_if(1, eq=0) diff --git a/tests/test_core/test_lib/test_compare.py b/tests/test_core/test_lib/test_compare.py new file mode 100644 index 00000000..bcdab868 --- /dev/null +++ b/tests/test_core/test_lib/test_compare.py @@ -0,0 +1,44 @@ +from HABApp.core.const import MISSING +from HABApp.core.lib.funcs import compare + + +def test_compare_single(): + assert compare(1, eq=1) + assert compare(1, equal=1) + + assert compare(1, le=1) + assert compare(1, lower_equal=1) + assert compare(1, le=2) + assert compare(1, lower_equal=2) + + assert compare(1, lt=2) + assert compare(1, lower_than=2) + assert not compare(1, lt=1) + assert not compare(1, lower_than=1) + + assert compare(1, gt=0) + assert compare(1, greater_than=0) + assert not compare(1, gt=1) + assert not compare(1, greater_than=1) + + assert compare(1, ge=0) + assert compare(1, greater_equal=0) + assert compare(1, ge=1) + assert compare(1, greater_equal=1) + + assert compare(None, is_=None) + assert not compare(None, is_=True) + + assert compare(None, is_not=True) + assert not compare(None, is_not=None) + + +def test_compare_multi(): + assert compare(5, le=5, ge=7) + assert compare(7, le=5, ge=7) + assert not compare(6, le=5, ge=7) + + +def test_compare_missing(): + assert compare(5, le=5, ge=MISSING) + assert not compare(7, le=5, ge=MISSING) diff --git a/tests/test_core/test_lib/test_format_traceback.py b/tests/test_core/test_lib/test_format_traceback.py index 13bffcd0..275efdee 100644 --- a/tests/test_core/test_lib/test_format_traceback.py +++ b/tests/test_core/test_lib/test_format_traceback.py @@ -7,7 +7,7 @@ from HABApp.core.const.json import load_json, dump_json from HABApp.core.lib import format_exception from easyconfig import create_app_config -from tests.helpers.traceback import process_traceback +from tests.helpers.traceback import remove_dyn_parts_from_traceback from HABApp.core.lib.exceptions.format_frame import SUPPRESSED_HABAPP_PATHS, skip_file from pathlib import Path @@ -20,7 +20,7 @@ def exec_func(func) -> str: except Exception as e: msg = '\n' + '\n'.join(format_exception(e)) - msg = process_traceback(msg) + msg = remove_dyn_parts_from_traceback(msg) return msg diff --git a/tests/test_mqtt/test_mqtt_filters.py b/tests/test_mqtt/test_mqtt_filters.py index aab6e744..e6027c71 100644 --- a/tests/test_mqtt/test_mqtt_filters.py +++ b/tests/test_mqtt/test_mqtt_filters.py @@ -1,13 +1,13 @@ from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueChangeEventFilter, MqttValueUpdateEvent, \ MqttValueUpdateEventFilter -from tests.helpers import check_class_annotations +from tests.helpers.inspect import check_class_annotations def test_class_annotations(): """EventFilter relies on the class annotations so we test that every event has those""" exclude = ['MqttValueChangeEventFilter', 'MqttValueUpdateEventFilter'] - check_class_annotations('HABApp.mqtt.events', exclude=exclude, skip_imports=False) + check_class_annotations('HABApp.mqtt.events', exclude=exclude) def test_mqtt_filter(): diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index 1bfbeec0..37c6f71e 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -4,7 +4,7 @@ from HABApp.openhab.events import ChannelTriggeredEvent, GroupItemStateChangedEvent, ItemAddedEvent, ItemCommandEvent, \ ItemStateChangedEvent, ItemStateEvent, ItemStatePredictedEvent, ItemUpdatedEvent, \ ThingStatusInfoChangedEvent, ThingStatusInfoEvent, ThingFirmwareStatusInfoEvent, ChannelDescriptionChangedEvent, \ - ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent + ThingAddedEvent, ThingRemovedEvent, ThingUpdatedEvent, ThingConfigStatusInfoEvent from HABApp.openhab.map_events import get_event, EVENT_LIST @@ -271,6 +271,16 @@ def test_thing_ThingUpdatedEvent(): assert isinstance(event, ThingUpdatedEvent) +def test_thing_ConfigStatusInfoEvent(): + data = { + 'topic': 'openhab/things/zwave:device:gehirn:node29/config/status', + 'payload': '{"configStatusMessages":[{"parameterName":"config_11_2","type":"PENDING"}]}', + 'type': 'ConfigStatusInfoEvent' + } + event = get_event(data) + assert isinstance(event, ThingConfigStatusInfoEvent) + + @pytest.mark.parametrize('cls', [*EVENT_LIST]) def test_event_has_name(cls): # this test ensure that alle events have a name argument diff --git a/tests/test_openhab/test_events/test_oh_filters.py b/tests/test_openhab/test_events/test_oh_filters.py index 5f34e6a5..e21968ab 100644 --- a/tests/test_openhab/test_events/test_oh_filters.py +++ b/tests/test_openhab/test_events/test_oh_filters.py @@ -1,13 +1,13 @@ from HABApp.openhab.events import ItemStateChangedEvent, ItemStateChangedEventFilter, ItemStateEvent, \ ItemStateEventFilter, ItemCommandEventFilter, ItemCommandEvent -from tests.helpers import check_class_annotations +from tests.helpers.inspect import check_class_annotations def test_class_annotations(): """EventFilter relies on the class annotations so we test that every event has those""" exclude = ['OpenhabEvent', 'ItemStateChangedEventFilter', 'ItemStateEventFilter', 'ItemCommandEventFilter'] - check_class_annotations('HABApp.openhab.events', exclude=exclude, skip_imports=False) + check_class_annotations('HABApp.openhab.events', exclude=exclude) def test_oh_filters(): diff --git a/tests/test_openhab/test_helpers/__init__.py b/tests/test_openhab/test_helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_openhab/test_helpers.py b/tests/test_openhab/test_helpers/test_table.py similarity index 100% rename from tests/test_openhab/test_helpers.py rename to tests/test_openhab/test_helpers/test_table.py diff --git a/tests/test_openhab/test_items/test_all.py b/tests/test_openhab/test_items/test_all.py index 1304970b..97781848 100644 --- a/tests/test_openhab/test_items/test_all.py +++ b/tests/test_openhab/test_items/test_all.py @@ -2,10 +2,12 @@ import pytest +from HABApp.core.items import Item from HABApp.openhab.items import Thing, ColorItem, ImageItem from HABApp.openhab.items.base_item import OpenhabItem from HABApp.openhab.map_items import _items as item_dict from tests.helpers.docs import get_ivars +from tests.helpers.inspect import assert_same_signature @pytest.mark.parametrize('cls', (c for c in item_dict.values())) @@ -27,6 +29,12 @@ def test_set_name(cls): assert isinstance(c, OpenhabItem) +@pytest.mark.parametrize('cls', (c for c in item_dict.values())) +def test_conditional_function_call_signature(cls): + assert_same_signature(Item.post_value_if, cls.post_value_if) + assert_same_signature(Item.post_value_if, cls.oh_post_update_if) + + @pytest.mark.parametrize('cls', (c for c in item_dict.values())) def test_doc_ivar(cls): diff --git a/tests/test_openhab/test_plugins/__init__.py b/tests/test_openhab/test_plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_openhab/test_plugins/test_thing/__init__.py b/tests/test_openhab/test_plugins/test_thing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/__init__.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py new file mode 100644 index 00000000..77a3793a --- /dev/null +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py @@ -0,0 +1,86 @@ +from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter_builder import ValueFormatterBuilder, \ + EmptyFormatter, \ + MultipleValueFormatterBuilder, LinkFormatter, MetadataFormatter + + +def test_value_formatter(): + b = ValueFormatterBuilder('test', '"{:s}"') + + # obj access + class TestData1: + test = 'asdf ' + + f = b.create_formatter(TestData1) + assert f.value == '"asdf"' + + class TestDataEmpty: + test = ' ' + + f = b.create_formatter(TestDataEmpty) + assert isinstance(f, EmptyFormatter) + assert f.value == '' + + +def test_multiple_value_formatter(): + b = MultipleValueFormatterBuilder('test', '"{:s}"', '({:s})') + + class TestData1: + test = ('asdf ', ' bsdf') + + f = b.create_formatter(TestData1) + assert f.value == '("asdf", "bsdf")' + + class TestDataEmpty: + test = (' ', '', ' ') + + f = b.create_formatter(TestDataEmpty) + assert isinstance(f, EmptyFormatter) + assert f.value == '' + assert f.len() == 0 + + +def test_link_formatter(): + b = LinkFormatter() + + class TestData: + link = 'my:link' + metadata = {} + + f = b.create_formatter(TestData) + assert f.value == 'channel = "my:link"' + + class TestData: + link = 'my:link' + metadata = {'key': 'value'} + + f = b.create_formatter(TestData) + assert f.value == 'channel = "my:link",' + + +def test_metadata_formatter(): + b = MetadataFormatter() + + class TestData: + metadata = {'name': {'value': 'enabled', 'config': {}}} + + f = b.create_formatter(TestData) + assert f.value == 'name="enabled"' + + class TestData: + metadata = { + 'name1': {'value': 'enabled', 'config': {}}, + 'name2': {'value': 'asdf', 'config': {'cfg1': 1}}, + } + + f = b.create_formatter(TestData) + assert f.value == 'name1="enabled", name2="asdf" [cfg1=1]' + + + class TestData: + metadata = { + 'name2': {'value': 'asdf', 'config': {'cfg2': 1, 'cfg1': 'test'}}, + 'name1': {'value': 'enabled', 'config': {}}, + } + + f = b.create_formatter(TestData) + assert f.value == 'name1="enabled", name2="asdf" [cfg1="test", cfg2=1]' diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py new file mode 100644 index 00000000..d4cfb421 --- /dev/null +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py @@ -0,0 +1,58 @@ +from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter import FormatterScope +from HABApp.openhab.connection_logic.plugin_things.file_writer.formatter_builder import ValueFormatter + + +def test_scope(): + assert FormatterScope(field_names=('a', 'd1')).get_lines() == [] + + s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1')) + s.lines = [ + {'a': ValueFormatter('val_1a'), 'b': ValueFormatter('val_1_bbbbb'), 'c': ValueFormatter('val_1 __cc__'), }, + {'a': ValueFormatter('val_2__a'), 'b': ValueFormatter('val_2b'), 'c': ValueFormatter('val_2 ____cc__'), }, + ] + + assert s.get_lines() == [ + 'val_1a val_1_bbbbb val_1 __cc__', + 'val_2__a val_2b val_2 ____cc__' + ] + + +def test_scope_missing(): + s = FormatterScope(field_names=('a', 'c')) + s.lines = [ + {'a': ValueFormatter('val_1a'), 'c': ValueFormatter('val_1 __cc__'), }, + {'a': ValueFormatter('val_2__a'), 'b': ValueFormatter('val_2b'), 'c': ValueFormatter('val_2 ____cc__'), }, + ] + + assert s.get_lines() == [ + 'val_1a val_1 __cc__', + 'val_2__a val_2 ____cc__' + ] + + +def test_scope_skip(): + s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1'), skip_alignment=('b1', 'd', 'd1')) + s.lines = [ + {'a': ValueFormatter('val_1a'), 'b': ValueFormatter('val_1_bbbbb'), 'b1': ValueFormatter('{'), + 'c': ValueFormatter('val_1 __cc__'), 'd': ValueFormatter('val_1 __d'), 'd1': ValueFormatter('}'), }, + {'a': ValueFormatter('val_2__a'), 'b': ValueFormatter('val_2b'), 'b1': ValueFormatter('{'), + 'c': ValueFormatter('val_2 ____cc__'), 'd': ValueFormatter('val_1 __d___'), 'd1': ValueFormatter('}'), }, + ] + + assert s.get_lines() == [ + 'val_1a val_1_bbbbb {val_1 __cc__ val_1 __d }', + 'val_2__a val_2b {val_2 ____cc__ val_1 __d___}' + ] + + +def test_scope_min_width(): + s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1', ), min_width={'a': 15}) + s.lines = [ + {'a': ValueFormatter('val_1a'), 'b': ValueFormatter('val_1_bbbbb'), 'c': ValueFormatter('val_1 __cc__'), }, + {'a': ValueFormatter('val_2__a'), 'b': ValueFormatter('val_2b'), 'c': ValueFormatter('val_2 ____cc__'), }, + ] + + assert s.get_lines() == [ + 'val_1a val_1_bbbbb val_1 __cc__', + 'val_2__a val_2b val_2 ____cc__' + ] diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py similarity index 54% rename from tests/test_openhab/test_plugins/test_thing/test_file_writer.py rename to tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py index 3ee33309..a34fd879 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py @@ -1,7 +1,10 @@ # flake8: noqa + import io -from HABApp.openhab.connection_logic.plugin_things.items_file import create_items_file, UserItem +from HABApp.openhab.connection_logic.plugin_things.cfg_validator import UserItem +from HABApp.openhab.connection_logic.plugin_things.file_writer.writer import ItemsFileWriter + class MyStringIO(io.StringIO): def __init__(self, *args, **kwargs): @@ -19,11 +22,15 @@ def close(self, *args, **kwargs): def is_file(self): return self.exists + def read_text(self, encoding): + return self.getvalue() -def test_creation(tmp_path_factory): + def write_text(self, data, encoding): + return self.write(data) - meta_auto = {'auto_update': {'value': 'False', 'config': {}}} +def get_test_objs(): + meta_auto = {'auto_update': {'value': 'False', 'config': {}}} objs = [ UserItem('String', 'Test_zwave_o_1', '', '', [], [], 'zwave:link:device', {}), UserItem('String', 'Test_zwave_o_2', '', '', [], [], 'zwave:link:device1', metadata=meta_auto), @@ -35,28 +42,48 @@ def test_creation(tmp_path_factory): UserItem('String', 'SoloName', '', '', [], [], '', {}), ] + return objs - t = MyStringIO() - create_items_file(t, {k.name: k for k in objs}) - # print('\n' + '-' * 120 + '\n' + t.text + '-' * 120) +def get_result() -> str: + ret = """ +String NewName {auto_update="False"} +String NewName1 {auto_update="False"} - expected = """String Test_zwave_o_1 {channel = "zwave:link:device" } +String Test_zwave_o_1 {channel = "zwave:link:device" } String Test_zwave_o_2 {channel = "zwave:link:device1", auto_update="False"} String Test_zwave_no ["tag1"] String Test_zwave_all "label1" (grp1) ["tag1", "tag2"] -String NewName {auto_update="False"} -String NewName1 {auto_update="False"} - String SoloName - """ - assert expected == t.text + return ret.lstrip('\n') + + +def test_writer_str(): + writer = ItemsFileWriter().add_items(get_test_objs()) + assert writer.generate() == get_result() + + +def test_no_write(): + file = MyStringIO(get_result()) + file.exists = True + + writer = ItemsFileWriter().add_items(get_test_objs()) + assert not writer.create_file(file) + + +def test_write_no_exist(): + file = MyStringIO(get_result()) + file.exists = False + + writer = ItemsFileWriter().add_items(get_test_objs()) + assert writer.create_file(file) + - # When the file already exists we append with newlines - t = MyStringIO() - t.exists = True - create_items_file(t, {k.name: k for k in objs}) +def test_write_content_different(): + file = MyStringIO(get_result() + 'asdf') + file.exists = True - assert '\n\n\n' + expected == t.text + writer = ItemsFileWriter().add_items(get_test_objs()) + assert writer.create_file(file) diff --git a/tests/test_openhab/test_rest/test_items.py b/tests/test_openhab/test_rest/test_items.py index b6f48c92..162124a6 100644 --- a/tests/test_openhab/test_rest/test_items.py +++ b/tests/test_openhab/test_rest/test_items.py @@ -50,7 +50,7 @@ def test_group_item(): "label": "Christmas Tree", "category": "christmas_tree", "tags": [], - "groupNames": ["Group1", "Group2"] + "groupNames": ["Group1", "Group2"], }, { "link": "http://ip:port/rest/items/frontgardenPower", @@ -61,7 +61,7 @@ def test_group_item(): "label": "Outside Power", "category": "poweroutlet", "tags": [], - "groupNames": ["Group1", "Group2"] + "groupNames": ["Group1", "Group2"], } ], "groupType": "Switch", diff --git a/tests/test_openhab/test_rest/test_links.py b/tests/test_openhab/test_rest/test_links.py index 7cb7b57f..5161893a 100644 --- a/tests/test_openhab/test_rest/test_links.py +++ b/tests/test_openhab/test_rest/test_links.py @@ -5,7 +5,8 @@ def test_simple(): _in = { "channelUID": "zwave:device:controller:node15:sensor_luminance", "configuration": {}, - "itemName": "ZWaveItem1" + "itemName": "ZWaveItem1", + 'editable': False, } o = ItemChannelLinkDefinition(**_in) assert o.channel_uid == 'zwave:device:controller:node15:sensor_luminance' @@ -19,27 +20,10 @@ def test_configuration(): 'profile': 'follow', 'offset': 1, }, - "itemName": "ZWaveItem1" + "itemName": "ZWaveItem1", + 'editable': False, } o = ItemChannelLinkDefinition(**_in) assert o.channel_uid == 'zwave:device:controller:node15:sensor_luminance' assert o.item_name == 'ZWaveItem1' assert o.configuration == {'profile': 'follow', 'offset': 1} - - -def test_creation(): - - uid = 'zwave:device:controller:node15:sensor_luminance' - name = 'ZWaveItem1' - - o = ItemChannelLinkDefinition(item_name=name, channel_uid=uid) - assert o.channel_uid == uid - assert o.item_name == name - assert o.configuration == {} - - assert o.dict(by_alias=True) == { - "channelUID": "zwave:device:controller:node15:sensor_luminance", - "itemName": "ZWaveItem1", - "configuration": {}, - 'editable': False, - } diff --git a/tests/test_openhab/test_rest/test_things.py b/tests/test_openhab/test_rest/test_things.py new file mode 100644 index 00000000..7eea48f2 --- /dev/null +++ b/tests/test_openhab/test_rest/test_things.py @@ -0,0 +1,112 @@ +from HABApp.openhab.definitions.rest.things import OpenhabThingDefinition + + +def test_thing_summary(): + _in = { + "statusInfo": { + "status": "UNINITIALIZED", + "statusDetail": "NONE" + }, + "editable": True, + "label": "Astronomische Sonnendaten", + "UID": "astro:sun:d522ba4b56", + "thingTypeUID": "astro:sun" + } + + thing = OpenhabThingDefinition.parse_obj(_in) + + assert thing.editable is True + assert thing.uid == 'astro:sun:d522ba4b56' + assert thing.label == 'Astronomische Sonnendaten' + assert thing.thing_type == 'astro:sun' + + assert thing.status.status == 'UNINITIALIZED' + assert thing.status.detail == '' + + +def test_thing_full(): + _in = { + "channels": [ + { + "linkedItems": [ + "LinkedItem1", + "LinkedItem2" + ], + "uid": "astro:sun:d522ba4b56:rise#start", + "id": "rise#start", + "channelTypeUID": "astro:start", + "itemType": "DateTime", + "kind": "STATE", + "label": "Startzeit", + "description": "Die Startzeit des Ereignisses", + "defaultTags": [], + "properties": {}, + "configuration": { + "offset": 0 + }, + }, + { + "linkedItems": [], + "uid": "astro:sun:d522ba4b56:eveningNight#duration", + "id": "eveningNight#duration", + "channelTypeUID": "astro:duration", + "itemType": "Number:Time", + "kind": "STATE", + "label": "Dauer", + "description": "Die Dauer des Ereignisses", + "defaultTags": [], + "properties": {}, + "configuration": {} + }, + { + "linkedItems": [], + "uid": "astro:sun:d522ba4b56:eclipse#event", + "id": "eclipse#event", + "channelTypeUID": "astro:sunEclipseEvent", + "kind": "TRIGGER", + "label": "Sonnenfinsternisereignis", + "description": "Sonnenfinsternisereignis", + "defaultTags": [], + "properties": {}, + "configuration": { + "offset": 0 + } + }, + ], + "statusInfo": { + "status": "UNINITIALIZED", + "statusDetail": "NONE" + }, + "editable": True, + "label": "Astronomische Sonnendaten", + "configuration": { + "useMeteorologicalSeason": False, + "interval": 300, + "geolocation": "46.123,2.123" + }, + "properties": {}, + "UID": "astro:sun:d522ba4b56", + "thingTypeUID": "astro:sun" + } + + thing = OpenhabThingDefinition.parse_obj(_in) + + c0, c1, c2 = thing.channels + + assert c0.linked_items == ("LinkedItem1", "LinkedItem2") + assert c0.configuration == {"offset": 0} + + assert c1.linked_items == tuple() + assert c1.configuration == {} + + assert thing.status.status == 'UNINITIALIZED' + assert thing.status.detail == '' + + assert thing.editable is True + assert thing.label == 'Astronomische Sonnendaten' + + assert thing.configuration == {"useMeteorologicalSeason": False, "interval": 300, "geolocation": "46.123,2.123"} + assert thing.properties == {} + + assert thing.uid == 'astro:sun:d522ba4b56' + assert thing.thing_type == 'astro:sun' diff --git a/tests/test_openhab/test_values.py b/tests/test_openhab/test_values.py index 6b3de89a..c5a7582b 100644 --- a/tests/test_openhab/test_values.py +++ b/tests/test_openhab/test_values.py @@ -2,7 +2,6 @@ from HABApp.openhab.definitions import HSBValue, OnOffValue, OpenClosedValue, PercentValue, QuantityValue, RawValue, \ UpDownValue -from HABApp.openhab.definitions import ITEM_DIMENSIONS @pytest.mark.parametrize( @@ -37,9 +36,8 @@ def test_quantity_value(): 'Dimensionless': '', } - for dimension in ITEM_DIMENSIONS: + for unit in unit_of_dimension.values(): for val in (-103.3, -3, 0, 0.33535, 5, 55.5, 105.5): - unit = unit_of_dimension[dimension] v = QuantityValue(f'{val} {unit}') assert v.value == val assert v.unit == unit diff --git a/tests/test_utils/test_multivalue.py b/tests/test_utils/test_multivalue.py index 03e88045..9b035632 100644 --- a/tests/test_utils/test_multivalue.py +++ b/tests/test_utils/test_multivalue.py @@ -1,3 +1,6 @@ +from typing import Tuple +from unittest.mock import Mock + import pytest from HABApp.core.const import MISSING @@ -31,6 +34,53 @@ def test_diff_prio(parent_rule: DummyRule): assert p.value == 8888 +def get_value_mode(enabled: bool, enable_on_value: bool, current_value=0) -> Tuple[Mock, ValueMode]: + parent = Mock() + parent.calculate_value = Mock() + + mode = ValueMode('mode1', current_value, enable_on_value=enable_on_value, enabled=enabled) + mode.parent = parent + return parent.calculate_value, mode + + +def test_only_on_change_1(parent_rule: DummyRule): + calculate_value, mode = get_value_mode(enabled=False, enable_on_value=False) + + assert not mode.set_value(0, only_on_change=True) + calculate_value.assert_not_called() + + +def test_only_on_change_2(parent_rule: DummyRule): + calculate_value, mode = get_value_mode(enabled=True, enable_on_value=False) + + assert not mode.set_value(0, only_on_change=True) + calculate_value.assert_not_called() + + +def test_only_on_change_3(parent_rule: DummyRule): + calculate_value, mode = get_value_mode(enabled=False, enable_on_value=True) + + assert mode.set_value(0, only_on_change=True) + calculate_value.assert_called_once() + + +def test_only_on_change_4(parent_rule: DummyRule): + calculate_value, mode = get_value_mode(enabled=True, enable_on_value=True) + + assert not mode.set_value(0, only_on_change=True) + calculate_value.assert_not_called() + + +@pytest.mark.parametrize('enabled', (True, False)) +@pytest.mark.parametrize('enable_on_value', (True, False)) +def test_only_on_change_diff_value(parent_rule: DummyRule, enabled, enable_on_value): + + calculate_value, mode = get_value_mode(enabled=enabled, enable_on_value=enable_on_value) + + assert mode.set_value(1, only_on_change=True) + calculate_value.assert_called_once() + + def test_calculate_lower_priority_value(parent_rule: DummyRule): p = MultiModeItem('TestItem', default_value=99) m1 = ValueMode('modea', '1234')