From 3b21a95a768f003524c13464e0970e9426e8607c Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Fri, 8 Oct 2021 08:16:00 +0200 Subject: [PATCH] 0.31.0 (#237) --- .flake8 | 1 + _doc/_plugins/sphinx_execute_code.py | 238 ------------------ _doc/advanced_usage.rst | 11 +- _doc/asyncio.rst | 4 +- _doc/conf.py | 12 +- _doc/getting_started.rst | 64 ++--- _doc/installation.rst | 12 +- _doc/interface_habapp.rst | 2 +- _doc/interface_mqtt.rst | 13 +- _doc/interface_openhab.rst | 29 ++- _doc/parameters.rst | 37 ++- _doc/requirements.txt | 1 + _doc/rule.rst | 30 +-- _doc/rule_examples.rst | 37 ++- _doc/util.rst | 56 ++--- conf/rules/openhab_things.py | 8 +- conf/rules/openhab_to_mqtt_rule.py | 7 +- conf_testing/config.yml | 2 +- conf_testing/lib/HABAppTests/__init__.py | 2 +- .../lib/HABAppTests/compare_values.py | 7 +- conf_testing/lib/HABAppTests/errors.py | 10 + conf_testing/lib/HABAppTests/event_waiter.py | 84 ++++--- conf_testing/lib/HABAppTests/item_waiter.py | 12 +- .../lib/HABAppTests/openhab_tmp_item.py | 72 +++++- conf_testing/lib/HABAppTests/test_base.py | 196 --------------- .../lib/HABAppTests/test_rule/__init__.py | 1 + .../{ => test_rule}/_rest_patcher.py | 38 ++- .../lib/HABAppTests/test_rule/_rule_ids.py | 62 +++++ .../lib/HABAppTests/test_rule/_rule_status.py | 8 + .../test_rule/test_case/__init__.py | 3 + .../test_rule/test_case/test_case.py | 29 +++ .../test_rule/test_case/test_result.py | 82 ++++++ .../lib/HABAppTests/test_rule/test_rule.py | 191 ++++++++++++++ conf_testing/lib/HABAppTests/utils.py | 29 ++- conf_testing/logging.yml | 4 +- .../rules/{ => habapp}/test_habapp.py | 75 +++--- .../{ => habapp}/test_parameter_files.py | 1 - .../rules/{ => habapp}/test_scheduler.py | 0 conf_testing/rules/{ => habapp}/test_utils.py | 9 +- .../test_event_types.py} | 18 +- conf_testing/rules/openhab/test_groups.py | 62 +++++ .../{ => openhab}/test_habapp_internals.py | 38 +-- .../test_interface.py} | 27 +- .../test_interface_links.py} | 0 .../rules/openhab/test_item_change.py | 28 +++ .../test_item_funcs.py} | 14 +- conf_testing/rules/openhab/test_items.py | 58 +++++ .../test_max_sse_msg_size.py} | 12 +- conf_testing/rules/openhab/test_things.py | 15 ++ conf_testing/rules/openhab_bugs.py | 2 +- conf_testing/rules/test_mqtt.py | 10 +- conf_testing/rules/test_openhab_groups.py | 47 ---- readme.md | 10 + src/HABApp/__main__.py | 20 +- src/HABApp/__version__.py | 2 +- src/HABApp/core/context.py | 1 + src/HABApp/core/files/file/file.py | 2 +- src/HABApp/core/files/file/file_state.py | 1 + src/HABApp/core/files/manager/worker.py | 1 + src/HABApp/core/items/base_item.py | 7 +- src/HABApp/mqtt/mqtt_connection.py | 34 +-- src/HABApp/mqtt/mqtt_payload.py | 56 +++++ src/HABApp/openhab/__init__.py | 4 + .../openhab/connection_handler/func_sync.py | 9 +- .../connection_handler/http_connection.py | 19 +- .../openhab/connection_handler/sse_handler.py | 76 ++++++ .../openhab/connection_logic/connection.py | 68 ----- .../connection_logic/plugin_load_items.py | 32 +-- src/HABApp/openhab/definitions/rest/items.py | 4 +- src/HABApp/openhab/definitions/rest/links.py | 4 + src/HABApp/openhab/events/item_events.py | 75 +++--- src/HABApp/openhab/item_to_reg.py | 67 +++++ src/HABApp/openhab/items/base_item.py | 20 +- src/HABApp/openhab/items/color_item.py | 5 +- src/HABApp/openhab/items/group_item.py | 9 + src/HABApp/openhab/items/image_item.py | 15 +- src/HABApp/openhab/map_items.py | 78 +++--- src/HABApp/rule/interfaces/__init__.py | 4 +- src/HABApp/rule/interfaces/_http.py | 106 ++++++++ src/HABApp/rule/interfaces/http.py | 91 ------- src/HABApp/rule/interfaces/http_interface.py | 1 + src/HABApp/rule/interfaces/rule_subprocess.py | 1 - src/HABApp/rule/rule.py | 71 +++++- .../rule/scheduler/habappschedulerview.py | 12 +- src/HABApp/runtime/runtime.py | 55 ++-- src/HABApp/util/functions/__init__.py | 2 +- tests/conftest.py | 15 +- .../test_core/test_items/test_item_search.py | 62 +++++ tests/test_mqtt/test_retain.py | 1 + tests/test_mqtt/test_values.py | 27 ++ .../test_events/test_from_dict.py | 33 +++ tests/test_openhab/test_items/test_image.py | 4 +- tests/test_openhab/test_items/test_mapping.py | 20 +- tests/test_openhab/test_rest/test_items.py | 3 +- tests/test_openhab/test_rest/test_links.py | 3 +- 95 files changed, 1704 insertions(+), 1236 deletions(-) delete mode 100644 _doc/_plugins/sphinx_execute_code.py create mode 100644 conf_testing/lib/HABAppTests/errors.py delete mode 100644 conf_testing/lib/HABAppTests/test_base.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/__init__.py rename conf_testing/lib/HABAppTests/{ => test_rule}/_rest_patcher.py (67%) create mode 100644 conf_testing/lib/HABAppTests/test_rule/_rule_ids.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/_rule_status.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py create mode 100644 conf_testing/lib/HABAppTests/test_rule/test_rule.py rename conf_testing/rules/{ => habapp}/test_habapp.py (60%) rename conf_testing/rules/{ => habapp}/test_parameter_files.py (97%) rename conf_testing/rules/{ => habapp}/test_scheduler.py (100%) rename conf_testing/rules/{ => habapp}/test_utils.py (83%) rename conf_testing/rules/{test_openhab_event_types.py => openhab/test_event_types.py} (76%) create mode 100644 conf_testing/rules/openhab/test_groups.py rename conf_testing/rules/{ => openhab}/test_habapp_internals.py (55%) rename conf_testing/rules/{test_openhab_interface.py => openhab/test_interface.py} (86%) rename conf_testing/rules/{test_openhab_interface_links.py => openhab/test_interface_links.py} (100%) create mode 100644 conf_testing/rules/openhab/test_item_change.py rename conf_testing/rules/{test_openhab_item_funcs.py => openhab/test_item_funcs.py} (99%) create mode 100644 conf_testing/rules/openhab/test_items.py rename conf_testing/rules/{test_max_image_size.py => openhab/test_max_sse_msg_size.py} (73%) create mode 100644 conf_testing/rules/openhab/test_things.py delete mode 100644 conf_testing/rules/test_openhab_groups.py create mode 100644 src/HABApp/mqtt/mqtt_payload.py create mode 100644 src/HABApp/openhab/connection_handler/sse_handler.py create mode 100644 src/HABApp/openhab/item_to_reg.py create mode 100644 src/HABApp/rule/interfaces/_http.py delete mode 100644 src/HABApp/rule/interfaces/http.py create mode 100644 src/HABApp/rule/interfaces/http_interface.py create mode 100644 tests/test_core/test_items/test_item_search.py create mode 100644 tests/test_mqtt/test_values.py diff --git a/.flake8 b/.flake8 index f3965ae1..27e656a9 100644 --- a/.flake8 +++ b/.flake8 @@ -27,3 +27,4 @@ exclude = # the interfaces will throw unused imports src/HABApp/openhab/interface.py, src/HABApp/openhab/interface_async.py, + src/HABApp/rule/interfaces/http_interface.py, diff --git a/_doc/_plugins/sphinx_execute_code.py b/_doc/_plugins/sphinx_execute_code.py deleted file mode 100644 index d832cdef..00000000 --- a/_doc/_plugins/sphinx_execute_code.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env/python -""" -This is a fork from sphinx-execute-code - -Available options: - 'linenos': directives.flag, - 'output_language': directives.unchanged, - 'hide_code': directives.flag, - 'hide_headers': directives.flag, - 'filename': directives.path, - 'hide_filename': directives.flag, - 'hide_filename': directives.flag, - 'precode': directives.unchanged, -Usage: -.. example_code: - :linenos: - :hide_code: - print 'Execute this python code' -""" -import functools -import os -import re -import subprocess -import sys -import traceback -from pathlib import Path -from typing import Optional - -from docutils import nodes -from docutils.parsers.rst import Directive, directives -from sphinx.errors import ExtensionError -from sphinx.util import logging - -log = logging.getLogger(__name__) - -ADDITIONAL_PATH = Path(__file__).parent.parent.parent - - -def PrintException( func): - - @functools.wraps(func) - def f(*args, **kwargs): - try: - return func(*args, **kwargs) - except ExtensionError: - raise - except Exception as e: - print("\n{}\n{}".format( e, traceback.format_exc())) - raise - return f - - -re_line = re.compile(r'File "", line (\d+),') - - -class CodeException(Exception): - def __init__(self, ret: int, stderr: str): - self.ret = ret - self.err = stderr - - self.line: Optional[int] = None - - # Find the last line where the error happened - for m in re_line.finditer(self.err): - self.line = int(m.group(1)) - - -class ExecuteCode(Directive): - """ Sphinx class for execute_code directive - """ - has_content = True - required_arguments = 0 - optional_arguments = 6 - - option_spec = { - 'linenos': directives.flag, - 'ignore_stderr': directives.flag, - 'output_language': directives.unchanged, # Runs specified pygments lexer on output data - - 'hide_code': directives.flag, - 'hide_output': directives.flag, - 'header_code': directives.unchanged, - 'header_output': directives.unchanged, - } - - @PrintException - def run(self): - """ Executes python code for an RST document, taking input from content or from a filename - :return: - """ - language = self.options.get('language', 'python') - output_language = self.options.get('output_language', 'none') - - output = [] - - shown_code = '' - executed_code = '' - - hide = False - skip = False - for line in self.content: - line_switch = line.replace(' ', '').lower() - if line_switch == '#hide': - hide = not hide - continue - if line_switch == '#skip': - skip = not skip - continue - - if not hide: - shown_code += line + '\n' - if not skip: - executed_code += line + '\n' - - shown_code = shown_code.strip() - executed_code = executed_code.strip() - - # Show the example code - if 'hide_code' not in self.options: - input_code = nodes.literal_block(shown_code, shown_code) - - input_code['language'] = language - input_code['linenos'] = 'linenos' in self.options - if 'header_code' in self.options: - output.append(nodes.caption(text=self.options['header_code'])) - output.append(input_code) - - # Show the code results - if 'header_output' in self.options: - output.append(nodes.caption(text=self.options['header_output'])) - - try: - code_results = execute_code(executed_code, ignore_stderr='ignore_stderr' in self.options) - except CodeException as e: - # Newline so we don't have the build message mixed up with logs - print('\n') - - code_lines = executed_code.splitlines() - - # If we don't get the line we print everything - if e.line is None: - e.line = len(code_lines) - - for i in range(max(0, e.line - 8), e.line - 1): - log.error(f' {code_lines[i]}') - log.error(f' {code_lines[e.line - 1]} <--') - - log.error('') - for line in e.err.splitlines(): - log.error(line) - - raise ExtensionError('Could not execute code!') from None - - if 'ignore_stderr' not in self.options: - for out in code_results.split('\n'): - if 'Error in ' in out: - log.error(f'Possible Error in codeblock: {out}') - - code_results = nodes.literal_block(code_results, code_results) - - code_results['linenos'] = 'linenos' in self.options - code_results['language'] = output_language - - if 'hide_output' not in self.options: - output.append(code_results) - return output - - -WORKING_DIR = None - - -def execute_code(code, ignore_stderr) -> str: - - env = os.environ.copy() - - # Add additional PATH so we find the "tests" folder - try: - paths = env['PYTHONPATH'].split(os.pathsep) - paths.insert(0, str(ADDITIONAL_PATH)) - env['PYTHONPATH'] = os.pathsep.join(paths) - except KeyError: - env['PYTHONPATH'] = str(ADDITIONAL_PATH) - - run = subprocess.run([sys.executable, '-c', code], capture_output=True, cwd=WORKING_DIR, env=env) - if run.returncode != 0: - # print('') - # print(f'stdout: {run.stdout.decode()}') - # print(f'stderr: {run.stderr.decode()}') - raise CodeException(run.returncode, run.stderr.decode()) from None - - if ignore_stderr: - return run.stdout.decode().strip() - - return (run.stdout.decode() + run.stderr.decode()).strip() - - -def builder_ready(app): - global WORKING_DIR - - folder = app.config.execute_code_working_dir - if folder is None: - WORKING_DIR = folder - return None - - # Make sure the folder is valid - if isinstance(folder, str): - folder = Path(folder) - else: - assert isinstance(folder, Path) - if not folder.is_dir(): - log.error(f'Configuration execute_code_working_dir does not point to a directory: {folder}') - WORKING_DIR = folder - - # Search for a python package and print a warning if we find none - # since this is the only reason to specify a working dir - for f in folder.iterdir(): - if not f.is_dir(): - continue - - # log warning if we don't find a python package - for file in f.iterdir(): - if file.name == '__init__.py': - return None - - log.warning(f'No Python package found in {folder}') - return None - - -def setup(app): - """ Register sphinx_execute_code directive with Sphinx """ - - assert (ADDITIONAL_PATH / 'tests').is_dir(), ADDITIONAL_PATH - - app.add_config_value('execute_code_working_dir', None, 'env') - - app.connect('builder-inited', builder_ready) - app.add_directive('execute_code', ExecuteCode) - return {'version': '0.2'} diff --git a/_doc/advanced_usage.rst b/_doc/advanced_usage.rst index b86b0ec3..393dc52c 100644 --- a/_doc/advanced_usage.rst +++ b/_doc/advanced_usage.rst @@ -191,7 +191,7 @@ Examples: Add an openhab mock item to the item registry -.. execute_code:: +.. exec_code:: :hide_output: import HABApp @@ -202,15 +202,14 @@ Add an openhab mock item to the item registry Remove the mock item from the registry -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ import HABApp from HABApp.openhab.items import SwitchItem HABApp.core.Items.add_item(SwitchItem('my_switch', 'ON')) - # hide - + # ------------ hide: stop ------------- HABApp.core.Items.pop_item('my_switch') @@ -219,7 +218,7 @@ Note that there are some item methods that encapsulate communication with openha These currently do not work with the mock items. The state has to be changed like any internal item. -.. execute_code:: +.. exec_code:: :hide_output: import HABApp diff --git a/_doc/asyncio.rst b/_doc/asyncio.rst index a62e8fab..ef905da5 100644 --- a/_doc/asyncio.rst +++ b/_doc/asyncio.rst @@ -17,9 +17,9 @@ Async http calls are available through the ``self.async_http`` object in rule in Functions ^^^^^^^^^^^^^^^^^^^^^^^^ -.. autoclass:: HABApp.rule.interfaces.AsyncHttpConnection +.. automodule:: HABApp.rule.interfaces.http :members: - + :imported-members: Examples ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/_doc/conf.py b/_doc/conf.py index 3d5aedb5..51e3e72e 100644 --- a/_doc/conf.py +++ b/_doc/conf.py @@ -14,14 +14,11 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os -import pathlib import sys # required for autodoc sys.path.insert(0, os.path.join(os.path.abspath('..'), 'src')) -sys.path.insert(0, os.path.abspath('./_plugins')) - # -- Project information ----------------------------------------------------- project = 'HABApp' @@ -35,7 +32,7 @@ try: from HABApp import __version__ version = __version__ - print(f'Building doc for {version}') + print(f'Building docs for {version}') except Exception as e: print('Exception', e) version = 'dev' @@ -52,7 +49,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx_autodoc_typehints', - 'sphinx_execute_code', + 'sphinx_exec_code', 'sphinx.ext.inheritance_diagram', ] @@ -203,9 +200,8 @@ epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- - -execute_code_working_dir = pathlib.Path(__file__).parent.parent / 'src' -assert execute_code_working_dir.is_dir(), execute_code_working_dir +exec_code_working_dir = '../src' +exec_code_folders = ['../src', '../tests'] autodoc_member_order = 'bysource' autoclass_content = 'both' diff --git a/_doc/getting_started.rst b/_doc/getting_started.rst index 52bf8791..f295ad19 100644 --- a/_doc/getting_started.rst +++ b/_doc/getting_started.rst @@ -18,13 +18,13 @@ Rules are written as classes that inherit from :class:`HABApp.Rule`. Once the cl rules in the HABApp rule engine. So lets write a small rule which prints something. -.. execute_code:: +.. exec_code:: - # hide - from tests import SimpleRuleRunner + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp # Rules are classes that inherit from HABApp.Rule @@ -41,23 +41,23 @@ rules in the HABApp rule engine. So lets write a small rule which prints somethi # Rules MyFirstRule() - # hide + # ------------ hide: start ------------ runner.process_events() runner.tear_down() - # hide + # ------------ hide: stop ------------- A more generic rule ------------------------------ It is also possible to instantiate the rules with parameters. This often comes in handy if there is some logic that shall be applied to different items. -.. execute_code:: +.. exec_code:: - # hide - from tests import SimpleRuleRunner + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp class MyFirstRule(HABApp.Rule): @@ -75,10 +75,10 @@ This often comes in handy if there is some logic that shall be applied to differ MyFirstRule(i) for t in ['Text 1', 'Text 2']: MyFirstRule(t) - # hide + # ------------ hide: start ------------ runner.process_events() runner.tear_down() - # hide + # ------------ hide: stop ------------- Interacting with items @@ -91,7 +91,7 @@ to share states across rules and/or files. An item is created and added to the item registry through the corresponding class factory method -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.core.items import Item @@ -103,10 +103,10 @@ Posting values from the item will automatically create the events on the event b This example will create an item in HABApp (locally) and post some updates to it. To access items from openhab use the correct openhab item type (see :ref:`the openhab item description `). -.. execute_code:: - :header_output: Output +.. exec_code:: + :caption: Output - # hide + # ------------ hide: start ------------ import logging import sys root = logging.getLogger('HABApp') @@ -118,10 +118,10 @@ To access items from openhab use the correct openhab item type (see :ref:`the op handler.setFormatter(formatter) root.addHandler(handler) - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item @@ -147,10 +147,10 @@ To access items from openhab use the correct openhab item type (see :ref:`the op MyFirstRule() - # hide + # ------------ hide: start ------------ runner.process_events() runner.tear_down() - # hide + # ------------ hide: stop ------------- Watch items for events @@ -158,16 +158,16 @@ Watch items for events It is possible to watch items for changes or updates. -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ from HABApp.core.items import Item Item.get_create_item('Item_Name', initial_value='Some value') - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent @@ -197,25 +197,25 @@ It is possible to watch items for changes or updates. print(f'Last update of {self.my_item.name}: {self.my_item.last_update}') MyFirstRule() - # hide + # ------------ hide: start ------------ i = Item.get_create_item('Item_Name') i.post_value('Changed value') runner.process_events() runner.tear_down() - # hide + # ------------ hide: stop ------------- Trigger an event when an item is constant ------------------------------------------ -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() HABApp.core.Items.create_item('test_watch', HABApp.core.items.Item) - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item @@ -240,7 +240,7 @@ Trigger an event when an item is constant print(f'{event}') MyFirstRule() - # hide + # ------------ hide: start ------------ HABApp.core.EventBus.post_event('Item_Name', ItemNoChangeEvent('Item_Name', 10)) runner.tear_down() - # hide + # ------------ hide: stop ------------- diff --git a/_doc/installation.rst b/_doc/installation.rst index 3d16af79..008904d6 100644 --- a/_doc/installation.rst +++ b/_doc/installation.rst @@ -198,14 +198,14 @@ This way if there were any breaking changes rules can easily be fixed before pro HABApp arguments ---------------------------------- -.. execute_code:: - :header_code: Execute habapp with "-h" to view possible command line arguments +.. exec_code:: + :caption: Execute habapp with "-h" to view possible command line arguments - # skip + # ------------ skip: start ------------ habapp -h - # skip + # ------------ skip: stop ------------- - # hide + # ------------ hide: start ------------ import HABApp.__main__ HABApp.__cmd_args__.parse_args(['-h']) - # hide + # ------------ hide: stop ------------- diff --git a/_doc/interface_habapp.rst b/_doc/interface_habapp.rst index 78f2aae3..f04ba7cb 100644 --- a/_doc/interface_habapp.rst +++ b/_doc/interface_habapp.rst @@ -40,7 +40,7 @@ The item makes implementing time logic like "Has it been dark for the last hour? "Was there frost during the last six hours?" really easy. And since it is just like a normal item triggering on changes etc. is possible, too. -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.core.items import AggregationItem diff --git a/_doc/interface_mqtt.rst b/_doc/interface_mqtt.rst index fd8f313b..10d1a030 100644 --- a/_doc/interface_mqtt.rst +++ b/_doc/interface_mqtt.rst @@ -52,14 +52,14 @@ Mqtt item types Mqtt items have an additional publish method which make interaction with the mqtt broker easier. -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ import HABApp from unittest.mock import MagicMock HABApp.mqtt.items.mqtt_item.publish = MagicMock() - # hide + # ------------ hide: stop ------------- from HABApp.mqtt.items import MqttItem @@ -91,15 +91,14 @@ MqttPairItem An item that consolidates a topic that reports states from a device and a topic that is used to write to a device. It is created on the topic that reports the state from the device. -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ import HABApp from unittest.mock import MagicMock HABApp.mqtt.items.mqtt_pair_item.publish = MagicMock() - # hide - + # ------------ hide: stop ------------- from HABApp.mqtt.items import MqttPairItem # MqttPairItem works out of the box with zigbee2mqtt diff --git a/_doc/interface_openhab.rst b/_doc/interface_openhab.rst index 7c56d646..c310c9cb 100644 --- a/_doc/interface_openhab.rst +++ b/_doc/interface_openhab.rst @@ -66,14 +66,14 @@ provide convenience functions which simplify many things. Example: -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import HABApp from HABApp.openhab.items import ContactItem, SwitchItem HABApp.core.Items.add_item(ContactItem('MyContact', initial_value='OPEN')) HABApp.core.Items.add_item(SwitchItem('MySwitch', initial_value='OFF')) - # hide + # ------------ hide: stop ------------- from HABApp.openhab.items import ContactItem, SwitchItem my_contact = ContactItem.get_item('MyContact') @@ -129,6 +129,17 @@ DimmerItem :member-order: groupwise +DatetimeItem +====================================== +.. inheritance-diagram:: HABApp.openhab.items.DatetimeItem + :parts: 1 + +.. autoclass:: HABApp.openhab.items.DatetimeItem + :members: + :inherited-members: + :member-order: groupwise + + RollershutterItem ====================================== .. inheritance-diagram:: HABApp.openhab.items.RollershutterItem @@ -856,16 +867,16 @@ Check status if thing is constant Sometimes ``Things`` recover automatically from small outages. This rule only triggers then the ``Thing`` is constant for 60 seconds. -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() thing_item = HABApp.openhab.items.Thing('my:thing:uid') HABApp.core.Items.add_item(thing_item) - # hide + # ------------ hide: stop ------------- from HABApp import Rule from HABApp.core.events import ItemNoChangeEvent from HABApp.openhab.items import Thing @@ -885,8 +896,8 @@ for 60 seconds. CheckThing('my:thing:uid') - # hide + # ------------ hide: start ------------ thing_item.status = 'ONLINE' HABApp.core.EventBus.post_event('my:thing:uid', ItemNoChangeEvent('test_watch', 60)) runner.tear_down() - # hide + # ------------ hide: stop ------------- diff --git a/_doc/parameters.rst b/_doc/parameters.rst index 306a6af4..1d712693 100644 --- a/_doc/parameters.rst +++ b/_doc/parameters.rst @@ -10,17 +10,17 @@ If the file doesn't exist yet it will automatically be generated in the configur Parameters are perfect for boundaries (e.g. if value is below param switch something on). Currently there are is :class:`~HABApp.parameters.Parameter` and :class:`~HABApp.parameters.DictParameter` available. -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ from HABApp.parameters.parameters import _PARAMETERS _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp @@ -52,10 +52,10 @@ Currently there are is :class:`~HABApp.parameters.Parameter` and :class:`~HABApp MyRuleWithParameters() - # hide + # ------------ hide: start ------------ HABApp.core.EventBus.post_event('test_watch', HABApp.core.events.ValueChangeEvent('test_item', 5, 6)) runner.tear_down() - # hide + # ------------ hide: stop ------------- Created file: @@ -82,10 +82,10 @@ missing keys etc. when the file is loaded. Example -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ from pathlib import Path import HABApp @@ -95,8 +95,7 @@ Example add_folder(PARAM_PREFIX, Path('/params'), 0) _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} - # hide - + # ------------ hide: stop ------------- import HABApp import voluptuous @@ -133,20 +132,17 @@ Just add the "reloads on" entry to the file. key2: v: 12 -.. execute_code:: - :header_code: rule +.. exec_code:: + :caption: rule - # hide + # ------------ hide: start ------------ from HABApp.parameters.parameters import _PARAMETERS _PARAMETERS['my_param'] = {'key1': {'v': 10}, 'key2': {'v': 12}} - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide - - - + # ------------ hide: stop ------------- import HABApp class MyRule(HABApp.Rule): @@ -159,10 +155,9 @@ Just add the "reloads on" entry to the file. cfg = HABApp.DictParameter('my_param') # this will get the file content for k, v in cfg.items(): MyRule(k, v) - - # hide + # ------------ hide: start ------------ runner.tear_down() - # hide + # ------------ hide: stop ------------- diff --git a/_doc/requirements.txt b/_doc/requirements.txt index c51cd0e0..f2c6c717 100644 --- a/_doc/requirements.txt +++ b/_doc/requirements.txt @@ -2,3 +2,4 @@ sphinx sphinx-autodoc-typehints sphinx_rtd_theme +sphinx-exec-code diff --git a/_doc/rule.rst b/_doc/rule.rst index 00a40f2c..84ce7de9 100644 --- a/_doc/rule.rst +++ b/_doc/rule.rst @@ -25,8 +25,8 @@ The preferred way to get and create items is through the class factories :class: and :class:`~HABApp.core.items.Item.get_create_item` since this ensures the proper item class and provides type hints when using an IDE! -.. execute_code:: - :header_code: Example: +.. exec_code:: + :caption: Example: :hide_output: from HABApp.core.items import Item @@ -40,13 +40,13 @@ If it changes there will be additionally a :class:`~HABApp.core.ValueChangeEvent It is possible to check the item value by comparing it -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ from HABApp.core.items import Item Item.get_create_item('MyItem', initial_value=5) - # hide + # ------------ hide: stop ------------- from HABApp.core.items import Item my_item = Item.get_item('MyItem') @@ -75,17 +75,17 @@ There is the possibility to reduce the function calls to a certain event type wi An overview over the events can be found on :ref:`the HABApp event section `, :ref:`the openhab event section ` and the :ref:`the mqtt event section ` -.. execute_code:: +.. exec_code:: :hide_output: - :header_code: Example + :caption: Example - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() HABApp.core.Items.create_item('MyItem', HABApp.core.items.Item) - # hide + # ------------ hide: stop ------------- from HABApp import Rule from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent from HABApp.core.items import Item @@ -126,17 +126,17 @@ There are convenience Filters (e.g. :class:`~HABApp.core.events.ValueUpdateEvent :members: -.. execute_code:: +.. exec_code:: :hide_output: - :header_code: Example + :caption: Example - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() HABApp.core.Items.create_item('MyItem', HABApp.core.items.Item) - # hide + # ------------ hide: stop ------------- from HABApp import Rule from HABApp.core.events import EventFilter, ValueUpdateEventFilter, ValueUpdateEvent from HABApp.core.items import Item diff --git a/_doc/rule_examples.rst b/_doc/rule_examples.rst index c843ba4f..05f5ff93 100644 --- a/_doc/rule_examples.rst +++ b/_doc/rule_examples.rst @@ -17,15 +17,15 @@ Trigger an event when an item is constant ------------------------------------------ Get an even when the item is constant for 5 and for 10 seconds. -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() HABApp.core.Items.create_item('test_watch', HABApp.core.items.Item) - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item from HABApp.core.events import ItemNoChangeEvent @@ -48,11 +48,11 @@ Get an even when the item is constant for 5 and for 10 seconds. print(f'{event}') MyRule() - # hide + # ------------ hide: start ------------ HABApp.core.EventBus.post_event('test_watch', ItemNoChangeEvent('test_watch', 5)) HABApp.core.EventBus.post_event('test_watch', ItemNoChangeEvent('test_watch', 10)) runner.tear_down() - # hide + # ------------ hide: stop ------------- Turn something off after movement @@ -60,18 +60,18 @@ Turn something off after movement Turn a device off 30 seconds after one of the movement sensors in a room signals movement. -.. execute_code:: +.. exec_code:: :hide_output: - # hide + # ------------ hide: start ------------ import time, HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() HABApp.core.Items.create_item('movement_sensor1', HABApp.core.items.Item) HABApp.core.Items.create_item('movement_sensor2', HABApp.core.items.Item) HABApp.core.Items.create_item('my_device', HABApp.core.items.Item) - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item from HABApp.core.events import ValueUpdateEvent @@ -99,9 +99,9 @@ Turn a device off 30 seconds after one of the movement sensors in a room signals self.device.post_value('OFF') MyCountdownRule() - # hide + # ------------ hide: start ------------ runner.tear_down() - # hide + # ------------ hide: stop ------------- Process Errors in Rules ------------------------------------------ @@ -109,15 +109,14 @@ This example shows how to create a rule with a function which will be called whe The rule function then can push the error message to an openhab item or e.g. use Pushover to send the error message to the mobile device (see :doc:`Avanced Usage ` for more information). -.. execute_code:: - :ignore_stderr: +.. exec_code:: - # hide + # ------------ hide: start ------------ import datetime - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide + # ------------ hide: stop ------------- import HABApp from HABApp.core.events.habapp_events import HABAppException @@ -145,7 +144,7 @@ to the mobile device (see :doc:`Avanced Usage ` for more informa def faulty_function(self): 1 / 0 FaultyRule() - # hide + # ------------ hide: start ------------ runner.process_events() runner.tear_down() - # hide + # ------------ hide: stop ------------- diff --git a/_doc/util.rst b/_doc/util.rst index 2e2d3b0b..fc5195a8 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -17,7 +17,7 @@ This function is very useful together with the all possible functions of :class: For example it can be used to automatically disable or calculate the new value of the :class:`~HABApp.util.multimode.ValueMode` It behaves like the standard python function except that it will ignore ``None`` values which are sometimes set as the item state. -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.util.functions import min @@ -35,7 +35,7 @@ This function is very useful together with the all possible functions of :class: For example it can be used to automatically disable or calculate the new value of the :class:`~HABApp.util.multimode.ValueMode` It behaves like the standard python function except that it will ignore ``None`` values which are sometimes set as the item state. -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.util.functions import max @@ -50,7 +50,7 @@ rgb_to_hsb Converts a rgb value to hsb color space -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.util.functions import rgb_to_hsb @@ -65,7 +65,7 @@ hsb_to_rgb Converts a hsb value to the rgb color space -.. execute_code:: +.. exec_code:: :hide_output: from HABApp.util.functions import hsb_to_rgb @@ -80,7 +80,7 @@ CounterItem Example ^^^^^^^^^^^^^^^^^^ -.. execute_code:: +.. exec_code:: from HABApp.util import CounterItem c = CounterItem.get_create_item('MyCounter', initial_value=5) @@ -101,12 +101,11 @@ Statistics Example ^^^^^^^^^^^^^^^^^^ -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ from HABApp.util import Statistics - # hide - + # ------------ hide: stop ------------- s = Statistics(max_samples=4) for i in range(1,4): s.add_value(i) @@ -126,15 +125,14 @@ Very useful when different states or modes overlap, e.g. automatic and manual mo Basic Example ^^^^^^^^^^^^^^^^^^ -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide - + # ------------ hide: stop ------------- import HABApp from HABApp.core.events import ValueUpdateEvent from HABApp.util.multimode import MultiModeItem, ValueMode @@ -169,15 +167,15 @@ Basic Example print(f'State: {event.value}') MyMultiModeItemTestRule() - # hide + # ------------ hide: start ------------ runner.tear_down() - # hide + # ------------ hide: stop ------------- Advanced Example ^^^^^^^^^^^^^^^^^^ -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import logging import sys root = logging.getLogger('AdvancedMultiMode') @@ -191,11 +189,10 @@ Advanced Example import HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - # hide - + # ------------ hide: stop ------------- import logging import HABApp from HABApp.core.events import ValueUpdateEvent @@ -258,9 +255,9 @@ Advanced Example print(f'Item value: {event.value}') MyMultiModeItemTestRule() - # hide + # ------------ hide: start ------------ runner.tear_down() - # hide + # ------------ hide: stop ------------- Example SwitchItemValueMode @@ -268,18 +265,17 @@ Example SwitchItemValueMode The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is controlled by a OpenHAB :class:`~HABApp.openhab.items.SwitchItem`. This is very useful if the mode shall be deactivated from the OpenHAB sitemaps. -.. execute_code:: +.. exec_code:: - # hide + # ------------ hide: start ------------ import HABApp - from tests import SimpleRuleRunner + from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() from HABApp.openhab.items import SwitchItem HABApp.core.Items.add_item(SwitchItem('Automatic_Enabled', initial_value='ON')) - # hide - + # ------------ hide: stop ------------- import HABApp from HABApp.core.events import ValueUpdateEvent from HABApp.openhab.items import SwitchItem @@ -312,9 +308,9 @@ The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is cont item.add_mode(101, ValueMode('Manual')) MyMultiModeItemTestRule() - # hide + # ------------ hide: start ------------ runner.tear_down() - # hide + # ------------ hide: stop ------------- Documentation diff --git a/conf/rules/openhab_things.py b/conf/rules/openhab_things.py index a5d9fad7..e04fcf89 100644 --- a/conf/rules/openhab_things.py +++ b/conf/rules/openhab_things.py @@ -1,4 +1,3 @@ -import HABApp from HABApp import Rule from HABApp.openhab.events import ThingStatusInfoChangedEvent from HABApp.openhab.items import Thing @@ -8,10 +7,9 @@ class CheckAllThings(Rule): def __init__(self): super().__init__() - for thing in HABApp.core.Items.get_all_items(): - if isinstance(thing, Thing): - thing.listen_event(self.thing_status_changed, ThingStatusInfoChangedEvent) - print(f'{thing.name}: {thing.status}') + for thing in self.get_items(Thing): + thing.listen_event(self.thing_status_changed, ThingStatusInfoChangedEvent) + print(f'{thing.name}: {thing.status}') def thing_status_changed(self, event: ThingStatusInfoChangedEvent): print(f'{event.name} changed from {event.old_status} to {event.status}') diff --git a/conf/rules/openhab_to_mqtt_rule.py b/conf/rules/openhab_to_mqtt_rule.py index 1aa447ce..b0413998 100644 --- a/conf/rules/openhab_to_mqtt_rule.py +++ b/conf/rules/openhab_to_mqtt_rule.py @@ -1,7 +1,6 @@ import HABApp from HABApp.openhab.events import ItemStateEvent -from HABApp.openhab.items import Thing -from HABApp.mqtt.items import MqttItem +from HABApp.openhab.items import OpenhabItem class ExampleOpenhabToMQTTRule(HABApp.Rule): @@ -10,9 +9,7 @@ class ExampleOpenhabToMQTTRule(HABApp.Rule): def __init__(self): super().__init__() - for item in HABApp.core.Items.get_all_items(): - if isinstance(item, (Thing, MqttItem)): - continue + for item in self.get_items(OpenhabItem): item.listen_event(self.process_update, ItemStateEvent) def process_update(self, event): diff --git a/conf_testing/config.yml b/conf_testing/config.yml index d0ecccd3..6b48e14c 100644 --- a/conf_testing/config.yml +++ b/conf_testing/config.yml @@ -42,5 +42,5 @@ openhab: wait_for_openhab: true ping: enabled: true - interval: 10 + interval: 30 item: Ping diff --git a/conf_testing/lib/HABAppTests/__init__.py b/conf_testing/lib/HABAppTests/__init__.py index b9c19684..c6138f1e 100644 --- a/conf_testing/lib/HABAppTests/__init__.py +++ b/conf_testing/lib/HABAppTests/__init__.py @@ -1,6 +1,6 @@ from .utils import get_random_name, run_coro, find_astro_sun_thing, get_bytes_text -from .test_base import TestBaseRule, TestResult +from .test_rule import TestBaseRule, TestResult from .event_waiter import EventWaiter from .item_waiter import ItemWaiter from .openhab_tmp_item import OpenhabTmpItem diff --git a/conf_testing/lib/HABAppTests/compare_values.py b/conf_testing/lib/HABAppTests/compare_values.py index 13b39193..7bb3a1f9 100644 --- a/conf_testing/lib/HABAppTests/compare_values.py +++ b/conf_testing/lib/HABAppTests/compare_values.py @@ -2,7 +2,8 @@ def get_equal_text(value1, value2): + return f'{get_value_text(value1)} {"==" if value1 == value2 else "!="} {get_value_text(value2)}' - return f'{get_bytes_text(value1)} ({str(type(value1))[8:-2]}) ' \ - f'{"==" if value1 == value2 else "!="} ' \ - f'{get_bytes_text(value2)} ({str(type(value2))[8:-2]})' + +def get_value_text(value) -> str: + return f'{get_bytes_text(value)} ({str(type(value))[8:-2]})' diff --git a/conf_testing/lib/HABAppTests/errors.py b/conf_testing/lib/HABAppTests/errors.py new file mode 100644 index 00000000..4da094cd --- /dev/null +++ b/conf_testing/lib/HABAppTests/errors.py @@ -0,0 +1,10 @@ + + +class TestCaseFailed(Exception): + def __init__(self, msg: str): + self.msg = msg + + +class TestCaseWarning(Exception): + def __init__(self, msg: str): + self.msg = msg diff --git a/conf_testing/lib/HABAppTests/event_waiter.py b/conf_testing/lib/HABAppTests/event_waiter.py index e44c5a59..b36e512e 100644 --- a/conf_testing/lib/HABAppTests/event_waiter.py +++ b/conf_testing/lib/HABAppTests/event_waiter.py @@ -1,60 +1,66 @@ import logging import time +from typing import TypeVar, Type, Dict, Any +from typing import Union import HABApp -from .compare_values import get_equal_text, get_bytes_text +from HABApp.core.items import BaseValueItem +from HABAppTests.errors import TestCaseFailed +from .compare_values import get_equal_text, get_value_text log = logging.getLogger('HABApp.Tests') +EVENT_TYPE = TypeVar('EVENT_TYPE') + class EventWaiter: - def __init__(self, name, event_type, timeout=1, check_value=True): - self.event_name = name + def __init__(self, name: Union[BaseValueItem, str], event_type: Type[EVENT_TYPE], timeout=1): + if isinstance(name, BaseValueItem): + name = name.name assert isinstance(name, str) + + self.name = name self.event_type = event_type + self.timeout = timeout + self.event_listener = HABApp.core.EventBusListener( - self.event_name, + self.name, HABApp.core.WrappedFunction(self.__process_event), self.event_type ) - self.timeout = timeout - self._event = None - self.last_event = None - self.check_value = check_value - self.events_ok = True + self._received_events = [] def __process_event(self, event): assert isinstance(event, self.event_type) - self._event = event + self._received_events.append(event) - def wait_for_event(self, value=None): + def clear(self): + self._received_events.clear() + + def wait_for_event(self, **kwargs) -> EVENT_TYPE: start = time.time() - self._event = None - while self._event is None: - time.sleep(0.01) + + while True: + time.sleep(0.02) if time.time() > start + self.timeout: - self.events_ok = False - log.error(f'Timeout while waiting for ({str(self.event_type).split(".")[-1][:-2]}) ' - f'for {self.event_name} with value {get_bytes_text(value)}') - return False + expected_values = "with " + ", ".join([f"{__k}={__v}" for __k, __v in kwargs.items()]) if kwargs else "" + raise TestCaseFailed(f'Timeout while waiting for ({str(self.event_type).split(".")[-1][:-2]}) ' + f'for {self.name} {expected_values}') - if self._event is None: + if not self._received_events: continue - self.last_event = self._event - if self.check_value: - values_same = self.compare_event_value(value) - if not values_same: - self.events_ok = False + event = self._received_events.pop() - self._event = None - return values_same + if kwargs: + if self.compare_event_value(event, kwargs): + return event + continue - self._event = None - return True + return event raise ValueError() @@ -65,11 +71,19 @@ def __enter__(self) -> 'EventWaiter': def __exit__(self, exc_type, exc_val, exc_tb): HABApp.core.EventBus.remove_listener(self.event_listener) - def compare_event_value(self, value_set): - value_get = self._event.value - - equal = value_get == value_set - - (log.debug if equal else log.error)(f'Got event for {self._event.name}: ' - f'{get_equal_text(value_set, value_get)}') + @staticmethod + def compare_event_value(event, kwargs: Dict[str, Any]): + only_value = 'value' in kwargs and len(kwargs) == 1 + val_msg = [] + + equal = True + for key, expected in kwargs.items(): + value = getattr(event, key) + if expected != value: + equal = False + val_msg.append((f'{key}: ' if not only_value else '') + get_equal_text(expected, value)) + else: + val_msg.append((f'{key}: ' if not only_value else '') + get_value_text(value)) + + log.debug(f'Got {event.__class__.__name__} for {event.name}: {", ".join(val_msg)}') return equal diff --git a/conf_testing/lib/HABAppTests/item_waiter.py b/conf_testing/lib/HABAppTests/item_waiter.py index c1ee9e1f..9b23e290 100644 --- a/conf_testing/lib/HABAppTests/item_waiter.py +++ b/conf_testing/lib/HABAppTests/item_waiter.py @@ -2,9 +2,11 @@ import time import HABApp -from .compare_values import get_equal_text -log = logging.getLogger('HABApp.Tests') +from HABAppTests.compare_values import get_equal_text +from HABAppTests.errors import TestCaseFailed + +log = logging.getLogger('HABApp.Tests') class ItemWaiter: @@ -15,8 +17,6 @@ def __init__(self, item, timeout=1, item_compare: bool = True): self.timeout = timeout self.item_compare = item_compare - self.states_ok = True - def wait_for_state(self, state=None): start = time.time() @@ -28,9 +28,7 @@ def wait_for_state(self, state=None): return True if time.time() > end: - self.states_ok = False - log.error(f'Timeout waiting for {self.item.name} {get_equal_text(state, self.item.value)}') - return False + raise TestCaseFailed(f'Timeout waiting for {self.item.name} {get_equal_text(state, self.item.value)}') raise ValueError() diff --git a/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/conf_testing/lib/HABAppTests/openhab_tmp_item.py index 20636291..d7bfc7cf 100644 --- a/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -1,31 +1,77 @@ import time +from typing import List, Optional import HABApp -from . import get_random_name +from . import get_random_name, EventWaiter class OpenhabTmpItem: - def __init__(self, item_name, item_type): - self.item_name = item_name - self.item_type = item_type + @staticmethod + def use(type: str, name: Optional[str] = None, arg_name: str = 'item'): + def decorator(func): + def new_func(*args, **kwargs): + assert arg_name not in kwargs, f'arg {arg_name} already set' + item = OpenhabTmpItem(type, name) + try: + kwargs[arg_name] = item + return func(*args, **kwargs) + finally: + item.remove() + return new_func + return decorator - if self.item_name is None: - self.item_name = get_random_name() + @staticmethod + def create(type: str, name: Optional[str] = None): + def decorator(func): + def new_func(*args, **kwargs): + with OpenhabTmpItem(type, name): + return func(*args, **kwargs) + return new_func + return decorator + + def __init__(self, item_type: str, item_name: Optional[str] = None): + self.type: str = item_type + self.name = get_random_name(item_type) if item_name is None else item_name def __enter__(self) -> HABApp.openhab.items.OpenhabItem: + return self.create_item() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.remove() + + def remove(self): + HABApp.openhab.interface.remove_item(self.name) + + def _create(self, label="", category="", tags: List[str] = [], groups: List[str] = [], + group_type: str = '', group_function: str = '', + group_function_params: List[str] = []): interface = HABApp.openhab.interface + interface.create_item(self.type, self.name, label=label, category=category, + tags=tags, groups=groups, group_type=group_type, + group_function=group_function, group_function_params=group_function_params) - if not interface.item_exists(self.item_name): - interface.create_item(self.item_type, self.item_name) + def create_item(self, label="", category="", tags: List[str] = [], groups: List[str] = [], + group_type: str = '', group_function: str = '', + group_function_params: List[str] = []) -> HABApp.openhab.items.OpenhabItem: + + self._create(label=label, category=category, tags=tags, groups=groups, group_type=group_type, + group_function=group_function, group_function_params=group_function_params) # wait max 1 sec for the item to be created stop = time.time() + 1 - while not HABApp.core.Items.item_exists(self.item_name): + while not HABApp.core.Items.item_exists(self.name): time.sleep(0.01) if time.time() > stop: - raise TimeoutError(f'Item {self.item_name} was not found!') + raise TimeoutError(f'Item {self.name} was not found!') - return HABApp.openhab.items.OpenhabItem.get_item(self.item_name) + return HABApp.openhab.items.OpenhabItem.get_item(self.name) - def __exit__(self, exc_type, exc_val, exc_tb): - HABApp.openhab.interface.remove_item(self.item_name) + def modify(self, label="", category="", tags: List[str] = [], groups: List[str] = [], + group_type: str = '', group_function: str = '', group_function_params: List[str] = []): + + with EventWaiter(self.name, HABApp.openhab.events.ItemUpdatedEvent) as w: + + self._create(label=label, category=category, tags=tags, groups=groups, group_type=group_type, + group_function=group_function, group_function_params=group_function_params) + + w.wait_for_event() diff --git a/conf_testing/lib/HABAppTests/test_base.py b/conf_testing/lib/HABAppTests/test_base.py deleted file mode 100644 index 146b9782..00000000 --- a/conf_testing/lib/HABAppTests/test_base.py +++ /dev/null @@ -1,196 +0,0 @@ -import logging -import threading -import typing - -import HABApp -from HABApp.core.events.habapp_events import HABAppException -from ._rest_patcher import RestPatcher - -log = logging.getLogger('HABApp.Tests') - -LOCK = threading.Lock() - - -class TestResult: - def __init__(self): - self.run = 0 - self.io = 0 - self.nio = 0 - self.skipped = 0 - - def __iadd__(self, other): - assert isinstance(other, TestResult) - self.run += other.run - self.io += other.io - self.nio += other.nio - self.skipped += other.skipped - return self - - def __repr__(self): - return f'Processed {self.run:d} Tests: IO: {self.io} NIO: {self.nio} skipped: {self.skipped}' - - -class TestConfig: - def __init__(self): - self.skip_on_failure = False - self.warning_is_error = False - - -RULE_CTR = 0 -TESTS_RULES: typing.Dict[int, 'TestBaseRule'] = {} - - -def get_next_id(rule): - global RULE_CTR - with LOCK: - RULE_CTR += 1 - TESTS_RULES[RULE_CTR] = rule - return RULE_CTR - - -def pop_rule(rule_id: int): - with LOCK: - TESTS_RULES.pop(rule_id) - - -class TestBaseRule(HABApp.Rule): - """This rule is testing the OpenHAB data types by posting values and checking the events""" - - def __init__(self): - super().__init__() - - self.__tests_funcs = {} - self.tests_started = False - - self.__id = get_next_id(self) - self.register_on_unload(lambda: pop_rule(self.__id)) - - self.config = TestConfig() - - # we have to chain the rules later, because we register the rules only once we loaded successfully. - self.run.at(2, self.__execute_run) - - # collect warnings and infos - self.listen_event(HABApp.core.const.topics.WARNINGS, self.__warning) - self.listen_event(HABApp.core.const.topics.ERRORS, self.__error) - self.__warnings = 0 - self.__errors = 0 - - def __warning(self, event: str): - self.__warnings += 1 - for line in event.splitlines(): - log.warning(line) - - def __error(self, event): - self.__errors += 1 - msg = event.to_str() if isinstance(event, HABAppException) else event - for line in msg.splitlines(): - log.error(line) - - def __execute_run(self): - with LOCK: - if self.__id != RULE_CTR: - return None - - result = TestResult() - for k, rule in sorted(TESTS_RULES.items()): - assert isinstance(rule, TestBaseRule) - if rule.tests_started: - continue - r = TestResult() - rule.run_tests(r) - result += r - - log.info('-' * 120) - log.info(str(result)) if not result.nio else log.error(str(result)) - print(str(result)) - return None - - def add_test(self, name, func, *args, **kwargs): - assert name not in self.__tests_funcs, name - self.__tests_funcs[name] = (func, args, kwargs) - - def set_up(self): - pass - - def tear_down(self): - pass - - def run_tests(self, result: TestResult): - self.tests_started = True - - try: - with RestPatcher(self.__class__.__name__ + '.' + 'set_up'): - self.set_up() - except Exception as e: - log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') - for line in HABApp.core.wrapper.format_exception(e): - log.error(line) - result.nio += 1 - return None - - test_count = len(self.__tests_funcs) - log.info(f'Running {test_count} tests for {self.rule_name}') - - for name, test_data in self.__tests_funcs.items(): - self.__run_test(name, test_data, result) - - # TEAR DOWN - try: - with RestPatcher(self.__class__.__name__ + '.' + 'tear_down'): - self.tear_down() - except Exception as e: - log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') - for line in HABApp.core.wrapper.format_exception(e): - log.error(line) - result.nio += 1 - - def __run_test(self, name: str, data: tuple, result: TestResult): - test_count = len(self.__tests_funcs) - width = test_count // 10 + 1 - - result.run += 1 - - self.__warnings = 0 - self.__errors = 0 - - # add possibility to skip on failure - if self.config.skip_on_failure: - if result.nio: - result.skipped += 1 - log.warning(f'Test {result.run:{width}}/{test_count} "{name}" skipped!') - return None - - try: - func = data[0] - args = data[1] - kwargs = data[2] - with RestPatcher(self.__class__.__name__ + '.' + name): - msg = func(*args, **kwargs) - except Exception as e: - log.error(f'Test "{name}" failed: {e}') - for line in HABApp.core.wrapper.format_exception(e): - log.error(line) - result.nio += 1 - return None - - if msg is True or msg is None: - msg = '' - - if self.__errors: - msg = f'{", " if msg else ""}{self.__errors} error{"s" if self.__errors != 1 else ""} in worker' - if self.config.warning_is_error and self.__warnings: - msg = f'{", " if msg else ""}{self.__errors} warning{"s" if self.__errors != 1 else ""} in worker' - - if msg == '': - result.io += 1 - log.info(f'Test {result.run:{width}}/{test_count} "{name}" successful!') - elif isinstance(msg, str) and msg.lower() == 'SKIP': - result.skipped += 1 - log.info(f'Test {result.run:{width}}/{test_count} "{name}" skipped!') - else: - result.nio += 1 - if isinstance(msg, bool): - log.error(f'Test {result.run:{width}}/{test_count} "{name}" failed') - else: - log.error(f'Test {result.run:{width}}/{test_count} "{name}" failed: {msg} ({type(msg)})') diff --git a/conf_testing/lib/HABAppTests/test_rule/__init__.py b/conf_testing/lib/HABAppTests/test_rule/__init__.py new file mode 100644 index 00000000..233adc7b --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/__init__.py @@ -0,0 +1 @@ +from .test_rule import TestBaseRule, TestResult diff --git a/conf_testing/lib/HABAppTests/_rest_patcher.py b/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py similarity index 67% rename from conf_testing/lib/HABAppTests/_rest_patcher.py rename to conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py index 5dc4d516..a0adf431 100644 --- a/conf_testing/lib/HABAppTests/_rest_patcher.py +++ b/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py @@ -3,9 +3,11 @@ import pprint import HABApp.openhab.connection_handler.http_connection +import HABApp.openhab.connection_logic.connection from HABApp.openhab.connection_handler.http_connection import HTTP_PREFIX FUNC_PATH = HABApp.openhab.connection_handler.func_async +SSE_PATH = HABApp.openhab.connection_handler.sse_handler def shorten_url(url: str): @@ -19,7 +21,16 @@ class RestPatcher: def __init__(self, name: str): self.name = name self.logged_name = False - self.log = logging.getLogger('HABApp.Rest') + self._log = logging.getLogger('HABApp.Rest') + + def log(self, msg: str): + # Log name when we log the first message + if not self.logged_name: + self.logged_name = True + self._log.debug('') + self._log.debug(f'{self.name}:') + + self._log.debug(msg) def wrap(self, to_call): async def resp_wrap(*args, **kwargs): @@ -35,16 +46,16 @@ async def resp_wrap(*args, **kwargs): # Log name when we log the first message if not self.logged_name: self.logged_name = True - self.log.debug('') - self.log.debug(f'{self.name}:') + self.log('') + self.log(f'{self.name}:') - self.log.debug( + self.log( f'{resp.request_info.method:^6s} {shorten_url(resp.request_info.url)} ({resp.status}){out}' ) - if resp.status >= 300: - self.log.debug(f'{"":6s} Header request : {resp.request_info.headers}') - self.log.debug(f'{"":6s} Header response: {resp.headers}') + if resp.status >= 300 and kwargs.get('log_404', True): + self.log(f'{"":6s} Header request : {resp.request_info.headers}') + self.log(f'{"":6s} Header response: {resp.headers}') def wrap_content(content_func): async def content_func_wrap(*cargs, **ckwargs): @@ -57,7 +68,7 @@ async def content_func_wrap(*cargs, **ckwargs): lines = txt.splitlines() for i, l in enumerate(lines): - self.log.debug(f'{"->" if not i else "":^6s} {l}') + self.log(f'{"->" if not i else "":^6s} {l}') return t return content_func_wrap @@ -67,21 +78,32 @@ async def content_func_wrap(*cargs, **ckwargs): return resp return resp_wrap + def wrap_sse(self, to_wrap): + def new_call(_dict): + self.log(f'{"SSE":^6s} {_dict}') + return to_wrap(_dict) + return new_call + def __enter__(self): self._get = FUNC_PATH.get self._put = FUNC_PATH.put self._post = FUNC_PATH.post self._delete = FUNC_PATH.delete + self._sse = SSE_PATH.get_event + FUNC_PATH.get = self.wrap(self._get) FUNC_PATH.put = self.wrap(self._put) FUNC_PATH.post = self.wrap(self._post) FUNC_PATH.delete = self.wrap(self._delete) + SSE_PATH.get_event = self.wrap_sse(self._sse) + def __exit__(self, exc_type, exc_val, exc_tb): FUNC_PATH.get = self._get FUNC_PATH.put = self._put FUNC_PATH.post = self._post FUNC_PATH.delete = self._delete + SSE_PATH.get_event = self._sse return False diff --git a/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py b/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py new file mode 100644 index 00000000..512f0418 --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py @@ -0,0 +1,62 @@ +import threading +import typing + +import HABAppTests +from ._rule_status import TestRuleStatus + +LOCK = threading.Lock() + + +RULE_CTR = 0 +TESTS_RULES: typing.Dict[int, 'HABAppTests.TestBaseRule'] = {} + + +class RuleID: + def __init__(self, id: int): + self.__id = id + + def is_newest(self) -> bool: + with LOCK: + if self.__id != RULE_CTR: + return False + return True + + def remove(self): + pop_test_rule(self.__id) + + +def get_next_id(rule) -> RuleID: + global RULE_CTR + with LOCK: + RULE_CTR += 1 + TESTS_RULES[RULE_CTR] = rule + + obj = RuleID(RULE_CTR) + + rule.register_on_unload(obj.remove) + return obj + + +def pop_test_rule(id: int): + with LOCK: + rule = TESTS_RULES.pop(id) + rule._rule_status = TestRuleStatus.FINISHED + + +def get_test_rules() -> typing.Iterable['HABAppTests.TestBaseRule']: + ret = [] + for k, rule in sorted(TESTS_RULES.items()): + assert isinstance(rule, HABAppTests.TestBaseRule) + if rule._rule_status is not TestRuleStatus.CREATED: + continue + ret.append(rule) + + return tuple(ret) + + +def test_rules_running() -> bool: + for rule in TESTS_RULES.values(): + status = rule._rule_status + if status is not TestRuleStatus.CREATED and status is not TestRuleStatus.FINISHED: + return True + return False diff --git a/conf_testing/lib/HABAppTests/test_rule/_rule_status.py b/conf_testing/lib/HABAppTests/test_rule/_rule_status.py new file mode 100644 index 00000000..27df05eb --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/_rule_status.py @@ -0,0 +1,8 @@ +from enum import Enum, auto + + +class TestRuleStatus(Enum): + CREATED = auto() + PENDING = auto() + RUNNING = auto() + FINISHED = auto() diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py b/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py new file mode 100644 index 00000000..a77584fd --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py @@ -0,0 +1,3 @@ +from .test_result import TestResultStatus, TestResult +# isort: split +from .test_case import TestCase diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py b/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py new file mode 100644 index 00000000..f7782566 --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py @@ -0,0 +1,29 @@ +import time + +from HABAppTests.test_rule._rest_patcher import RestPatcher +from HABAppTests.test_rule.test_case import TestResult, TestResultStatus + + +class TestCase: + def __init__(self, name: str, func: callable, args=[], kwargs={}): + self.name = name + self.func = func + self.args = args + self.kwargs = kwargs + + def run(self, res: TestResult) -> TestResult: + time.sleep(0.05) + + try: + with RestPatcher(f'{res.cls_name}.{res.test_name}'): + ret = self.func(*self.args, **self.kwargs) + if ret: + res.set_state(TestResultStatus.FAILED) + res.add_msg(f'{ret}') + else: + res.set_state(TestResultStatus.PASSED) + except Exception as e: + res.exception(e) + + time.sleep(0.05) + return res diff --git a/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py b/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py new file mode 100644 index 00000000..fcc29a01 --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py @@ -0,0 +1,82 @@ +import logging +from enum import IntEnum, auto +from typing import List, Optional + +import HABApp +from HABAppTests.errors import TestCaseFailed, TestCaseWarning + +log = logging.getLogger('HABApp.Tests') + + +class TestResultStatus(IntEnum): + NOT_SET = auto() + + SKIPPED = auto() + PASSED = auto() + + WARNING = auto() + + FAILED = auto() + ERROR = auto() + + +assert TestResultStatus.NOT_SET < TestResultStatus.SKIPPED +assert TestResultStatus.SKIPPED < TestResultStatus.PASSED +assert TestResultStatus.PASSED < TestResultStatus.WARNING +assert TestResultStatus.WARNING < TestResultStatus.FAILED +assert TestResultStatus.FAILED < TestResultStatus.ERROR + + +class TestResult: + def __init__(self, cls_name: str, test_name: str, test_nr: str = ''): + self.cls_name = cls_name + self.test_name = test_name + self.test_nr = test_nr + + self.state = TestResultStatus.NOT_SET + self.msgs: List[str] = [] + + def is_set(self): + return self.state != TestResultStatus.NOT_SET + + def set_state(self, new_state: TestResultStatus): + if self.state <= new_state: + self.state = new_state + + def exception(self, e: Exception): + if isinstance(e, TestCaseFailed): + self.set_state(TestResultStatus.FAILED) + self.add_msg(e.msg) + return None + if isinstance(e, TestCaseWarning): + self.set_state(TestResultStatus.WARNING) + self.add_msg(e.msg) + return None + + self.add_msg(f'Exception: {e}') + self.state = TestResultStatus.ERROR + + for line in HABApp.core.wrapper.format_exception(e): + log.error(line) + + def add_msg(self, msg: str): + self.msgs.append(msg) + + def log(self, name: Optional[str] = None): + if name is None: + name = f'{self.cls_name}.{self.test_name}' + nr = f' {self.test_nr} ' if self.test_nr else ' ' + msg = ': ' + ", ".join(self.msgs) if self.msgs else '' + + prefix = f'{nr}"{name}"' + + if self.state is TestResultStatus.PASSED: + return log.info(f'{prefix} successful') + if self.state is TestResultStatus.SKIPPED: + return log.warning(f'{prefix} skipped') + if self.state is TestResultStatus.WARNING: + return log.warning(f'{prefix} warning{msg}') + if self.state is TestResultStatus.FAILED: + return log.error(f'{prefix} failed{msg}') + if self.state is TestResultStatus.ERROR: + return log.error(f'{prefix} error{msg}') diff --git a/conf_testing/lib/HABAppTests/test_rule/test_rule.py b/conf_testing/lib/HABAppTests/test_rule/test_rule.py new file mode 100644 index 00000000..c214a794 --- /dev/null +++ b/conf_testing/lib/HABAppTests/test_rule/test_rule.py @@ -0,0 +1,191 @@ +import logging +from typing import Dict +from typing import List + +import HABApp +from HABAppTests.test_rule.test_case import TestResult, TestResultStatus, TestCase +from ._rule_ids import get_test_rules, get_next_id, test_rules_running +from ._rule_status import TestRuleStatus + +log = logging.getLogger('HABApp.Tests') + + +class TestConfig: + def __init__(self): + self.skip_on_failure = False + self.warning_is_error = False + + +class TestBaseRule(HABApp.Rule): + """This rule is testing the OpenHAB data types by posting values and checking the events""" + + def __init__(self): + super().__init__() + self._rule_status = TestRuleStatus.CREATED + self._rule_id = get_next_id(self) + self._tests: Dict[str, TestCase] = {} + + self.__warnings = [] + self.__errors = [] + self.__sub_warning = None + self.__sub_errors = None + + self.config = TestConfig() + + self.__worst_result = TestResultStatus.PASSED + + # we have to chain the rules later, because we register the rules only once we loaded successfully. + self.run.at(2, self.__execute_run) + + # ------------------------------------------------------------------------------------------------------------------ + # Overrides and test + def set_up(self): + pass + + def tear_down(self): + pass + + def add_test(self, name, func: callable, *args, **kwargs): + tc = TestCase(name, func, args, kwargs) + assert tc.name not in self._tests + self._tests[tc.name] = tc + + # ------------------------------------------------------------------------------------------------------------------ + # Rule execution + def __execute_run(self): + if not self._rule_id.is_newest(): + return None + + # If we currently run a test wait until it is complete + if test_rules_running(): + self.run.at(2, self.__execute_run) + return None + + ergs = [] + rules = get_test_rules() + for rule in rules: + # mark rules for execution + rule._rule_status = TestRuleStatus.PENDING + for rule in rules: + # It's possible that we unload a rule before it was run + if rule._rule_status is not TestRuleStatus.PENDING: + continue + ergs.extend(rule._run_tests()) + + skipped = tuple(filter(lambda x: x.state is TestResultStatus.SKIPPED, ergs)) + passed = tuple(filter(lambda x: x.state is TestResultStatus.PASSED, ergs)) + warning = tuple(filter(lambda x: x.state is TestResultStatus.WARNING, ergs)) + failed = tuple(filter(lambda x: x.state is TestResultStatus.FAILED, ergs)) + error = tuple(filter(lambda x: x.state is TestResultStatus.ERROR, ergs)) + + def plog(msg: str): + print(msg) + log.info(msg) + + parts = [f'{len(ergs)} executed', f'{len(passed)} passed'] + if skipped: + parts.append(f'{len(skipped)} skipped') + if warning: + parts.append(f'{len(warning)} warning{"" if len(warning) == 1 else "s"}') + parts.append(f'{len(failed)} failed') + if error: + parts.append(f'{len(error)} error{"" if len(error) == 1 else "s"}') + + plog('') + plog('-' * 120) + plog(', '.join(parts)) + + # ------------------------------------------------------------------------------------------------------------------ + # Event from the worker + def __event_warning(self, event): + self.__warnings.append(event) + + def __event_error(self, event): + self.__errors.append(event) + + def __worker_events_sub(self): + assert self.__sub_warning is None + assert self.__sub_errors is None + self.__sub_warning = self.listen_event(HABApp.core.const.topics.WARNINGS, self.__event_warning) + self.__sub_errors = self.listen_event(HABApp.core.const.topics.ERRORS, self.__event_error) + + def __worker_events_cancel(self): + if self.__sub_warning is not None: + self.__sub_warning.cancel() + if self.__sub_errors is not None: + self.__sub_errors.cancel() + + # ------------------------------------------------------------------------------------------------------------------ + # Test execution + def __exec_tc(self, res: TestResult, tc: TestCase): + self.__warnings.clear() + self.__errors.clear() + + tc.run(res) + + if self.__warnings: + res.set_state(TestResultStatus.WARNING) + ct = len(self.__warnings) + msg = f'{ct} warning{"s" if ct != 1 else ""} in worker' + res.add_msg(msg) + self.__warnings.clear() + + if self.__errors: + res.set_state(TestResultStatus.ERROR) + ct = len(self.__errors) + msg = f'{ct} error{"s" if ct != 1 else ""} in worker' + res.add_msg(msg) + self.__errors.clear() + + self.__worst_result = max(self.__worst_result, res.state) + + def _run_tests(self) -> List[TestResult]: + self._rule_status = TestRuleStatus.RUNNING + self.__worker_events_sub() + + results = [] + + # setup + tc = TestCase('set_up', self.set_up) + tr = TestResult(self.__class__.__name__, tc.name) + self.__exec_tc(tr, tc) + if tr.state is not tr.state.PASSED: + results.append(tr) + + results.extend(self.__run_tests()) + + # tear down + tc = TestCase('tear_down', self.set_up) + tr = TestResult(self.__class__.__name__, tc.name) + self.__exec_tc(tr, tc) + if tr.state is not tr.state.PASSED: + results.append(tr) + + self.__worker_events_cancel() + self._rule_status = TestRuleStatus.FINISHED + return results + + def __run_tests(self) -> List[TestResult]: + count = len(self._tests) + width = 1 + while count >= 10 ** width: + width += 1 + + c_name = self.__class__.__name__ + results = [ + TestResult(c_name, tc.name, f'{i + 1:{width}d}/{count}') for i, tc in enumerate(self._tests.values()) + ] + + log.info('') + log.info(f'Running {count} tests for {c_name}') + + for res, tc in zip(results, self._tests.values()): + if self.config.skip_on_failure and self.__worst_result >= TestResultStatus.FAILED: + res.set_state(TestResultStatus.SKIPPED) + res.log() + continue + + self.__exec_tc(res, tc) + res.log() + + return results diff --git a/conf_testing/lib/HABAppTests/utils.py b/conf_testing/lib/HABAppTests/utils.py index 103caaff..660234ed 100644 --- a/conf_testing/lib/HABAppTests/utils.py +++ b/conf_testing/lib/HABAppTests/utils.py @@ -8,8 +8,31 @@ from HABApp.openhab.items import Thing -def get_random_name() -> str: - return ''.join(random.choice(string.ascii_letters) for _ in range(20)) +__RAND_PREFIX = { + 'String': 'Str', 'Number': 'Num', 'Switch': 'Sw', 'Contact': 'Con', 'Dimmer': 'Dim', 'Rollershutter': 'Rol', + 'Color': 'Col', 'DateTime': 'Dt', 'Location': 'Loc', 'Player': 'Pl', 'Group': 'Grp', 'Image': 'Img', + 'HABApp': 'Ha' +} + + +def __get_fill_char(skip: str, upper=False) -> str: + skip += 'il' + skip = skip.upper() if upper else skip.lower() + rnd = random.choice(string.ascii_uppercase if upper else string.ascii_lowercase) + while rnd in skip: + rnd = random.choice(string.ascii_uppercase if upper else string.ascii_lowercase) + return rnd + + +def get_random_name(item_type: str) -> str: + name = name_prev = __RAND_PREFIX[item_type.split(':')[0]] + + for c in range(3): + name += __get_fill_char(name_prev, upper=True) + + while len(name) < 10: + name += __get_fill_char(name_prev) + return name def run_coro(coro: typing.Coroutine): @@ -28,5 +51,5 @@ def find_astro_sun_thing() -> str: def get_bytes_text(value): if isinstance(value, bytes) and len(value) > 100 * 1024: - return b2a_hex(value[0:100]).decode() + ' ... ' + b2a_hex(value[-100:]).decode() + return b2a_hex(value[0:40]).decode() + ' ... ' + b2a_hex(value[-40:]).decode() return value diff --git a/conf_testing/logging.yml b/conf_testing/logging.yml index 82df887c..6c752aeb 100644 --- a/conf_testing/logging.yml +++ b/conf_testing/logging.yml @@ -1,6 +1,8 @@ formatters: HABApp_format: format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' + HABApp_REST: + format: '[%(asctime)s] [%(name)11s] %(levelname)8s | %(message)s' handlers: @@ -37,7 +39,7 @@ handlers: maxBytes: 10_485_760 backupCount: 3 - formatter: HABApp_format + formatter: HABApp_REST level: DEBUG BufferEventFile: diff --git a/conf_testing/rules/test_habapp.py b/conf_testing/rules/habapp/test_habapp.py similarity index 60% rename from conf_testing/rules/test_habapp.py rename to conf_testing/rules/habapp/test_habapp.py index 1b73bb3d..6283fc80 100644 --- a/conf_testing/rules/test_habapp.py +++ b/conf_testing/rules/habapp/test_habapp.py @@ -1,4 +1,5 @@ import time + import HABApp from HABApp.core.events import ItemNoUpdateEvent, ItemNoChangeEvent, ValueUpdateEvent from HABApp.core.items import Item @@ -19,42 +20,38 @@ def check_event(self, event: ItemNoUpdateEvent): assert abs(dur) < 0.05, f'Time wrong: {abs(dur):.2f}' def item_events(self, changes=False, secs=5, values=[]): - item_name = get_random_name() - self.secs = secs + item_name = get_random_name('HABApp') self.watch_item = Item.get_create_item(item_name) - watcher = (self.watch_item.watch_change if changes else self.watch_item.watch_update)(secs) + self.secs = secs + watcher = (self.watch_item.watch_change if changes else self.watch_item.watch_update)(secs) event = ItemNoUpdateEvent if not changes else ItemNoChangeEvent listener = self.listen_event(self.watch_item, self.check_event, event) - def _run(): - self.ts_set = 0 - for step, value in enumerate(values): - if step: - time.sleep(0.2) - self.ts_set = time.time() - self.watch_item.set_value(value) - with EventWaiter(self.watch_item.name, event, secs + 2, check_value=False) as w: - w.wait_for_event(value) - if not w.events_ok: - listener.cancel() - return w.events_ok - return True - - if not _run(): - return False - - HABApp.core.Items.pop_item(item_name) - assert not HABApp.core.Items.item_exists(item_name) - time.sleep(1) - self.watch_item = Item.get_create_item(item_name) + try: + self._run(values, event) - if not _run(): - return False + HABApp.core.Items.pop_item(item_name) + assert not HABApp.core.Items.item_exists(item_name) - listener.cancel() - watcher.cancel() - return True + time.sleep(0.5) + + self.watch_item = Item.get_create_item(item_name) + self._run(values, event) + finally: + listener.cancel() + watcher.cancel() + return None + + def _run(self, values, event): + self.ts_set = 0 + for step, value in enumerate(values): + if step: + time.sleep(0.2) + self.ts_set = time.time() + self.watch_item.set_value(value) + with EventWaiter(self.watch_item.name, event, self.secs + 2) as w: + w.wait_for_event(seconds=self.secs) TestItemEvents() @@ -70,22 +67,20 @@ def check_event(self, event: ValueUpdateEvent): assert event.name == self.watch_item.name, f'Wrong name: {event.name} != {self.watch_item.name}' assert event.value == 123, f'Wrong value: {event.value} != 123' - def trigger_event(self): - self.watch_item = Item.get_create_item(get_random_name()) - listener = self.watch_item.listen_event(self.check_event, ValueUpdateEvent) + def set_up(self): + self.watch_item = Item.get_create_item(get_random_name('HABApp')) + self.listener = self.watch_item.listen_event(self.check_event, ValueUpdateEvent) + def tear_down(self): + self.listener.cancel() + + def trigger_event(self): self.run.at( 1, HABApp.core.EventBus.post_event, self.watch_item.name, ValueUpdateEvent(self.watch_item.name, 123) ) - with EventWaiter(self.watch_item.name, ValueUpdateEvent, 2, check_value=True) as w: - w.wait_for_event(123) - if not w.events_ok: - listener.cancel() - return w.events_ok - - listener.cancel() - return True + with EventWaiter(self.watch_item.name, ValueUpdateEvent, 2) as w: + w.wait_for_event(value=123) TestItemListener() diff --git a/conf_testing/rules/test_parameter_files.py b/conf_testing/rules/habapp/test_parameter_files.py similarity index 97% rename from conf_testing/rules/test_parameter_files.py rename to conf_testing/rules/habapp/test_parameter_files.py index b8104d2e..af97a05d 100644 --- a/conf_testing/rules/test_parameter_files.py +++ b/conf_testing/rules/habapp/test_parameter_files.py @@ -26,7 +26,6 @@ def test_param_file(self): p = HABApp.Parameter('param_file', 'key') assert p < 11 assert p.value == 10 - return True TestParamFile() diff --git a/conf_testing/rules/test_scheduler.py b/conf_testing/rules/habapp/test_scheduler.py similarity index 100% rename from conf_testing/rules/test_scheduler.py rename to conf_testing/rules/habapp/test_scheduler.py diff --git a/conf_testing/rules/test_utils.py b/conf_testing/rules/habapp/test_utils.py similarity index 83% rename from conf_testing/rules/test_utils.py rename to conf_testing/rules/habapp/test_utils.py index 9f1d354e..3231653c 100644 --- a/conf_testing/rules/test_utils.py +++ b/conf_testing/rules/habapp/test_utils.py @@ -8,7 +8,6 @@ log = logging.getLogger('HABApp.Tests.MultiMode') - class TestSwitchMode(TestBaseRule): """This rule is testing the Parameter implementation""" @@ -19,9 +18,9 @@ def __init__(self): self.add_test('SwitchItemValueMode inverted', self.test_sw_mode_inverted) def test_sw_mode(self): - mm = MultiModeItem.get_create_item(get_random_name()) + mm = MultiModeItem.get_create_item(get_random_name('HABApp')) - with OpenhabTmpItem(None, 'Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: + with OpenhabTmpItem('Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: switch.on() waiter.wait_for_state('ON') @@ -44,9 +43,9 @@ def test_sw_mode(self): HABApp.core.Items.pop_item(mm.name) def test_sw_mode_inverted(self): - mm = MultiModeItem.get_create_item(get_random_name()) + mm = MultiModeItem.get_create_item(get_random_name('HABApp')) - with OpenhabTmpItem(None, 'Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: + with OpenhabTmpItem('Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: switch.on() waiter.wait_for_state('ON') diff --git a/conf_testing/rules/test_openhab_event_types.py b/conf_testing/rules/openhab/test_event_types.py similarity index 76% rename from conf_testing/rules/test_openhab_event_types.py rename to conf_testing/rules/openhab/test_event_types.py index a2cd81b9..d141e3be 100644 --- a/conf_testing/rules/test_openhab_event_types.py +++ b/conf_testing/rules/openhab/test_event_types.py @@ -13,7 +13,7 @@ def __init__(self): # test the states 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)) + 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) @@ -21,19 +21,16 @@ def __init__(self): def test_events(self, item_type, test_values): item_name = f'{item_type}_value_test' - with OpenhabTmpItem(item_name, item_type), EventWaiter(item_name, ValueUpdateEvent) as waiter: + with OpenhabTmpItem(item_type, item_name), EventWaiter(item_name, ValueUpdateEvent) as waiter: for value in test_values: self.openhab.post_update(item_name, value) - waiter.wait_for_event(value) + waiter.wait_for_event(value=value) # Contact does not support commands if item_type != 'Contact': self.openhab.send_command(item_name, value) - waiter.wait_for_event(value) - - all_events_ok = waiter.events_ok - return all_events_ok + waiter.wait_for_event(value=value) def test_quantity_type_events(self, dimension): @@ -43,17 +40,14 @@ def test_quantity_type_events(self, dimension): } item_name = f'{dimension}_event_test' - with OpenhabTmpItem(item_name, f'Number:{dimension}') as item, \ + with OpenhabTmpItem(f'Number:{dimension}', item_name) as item, \ EventWaiter(item_name, ValueUpdateEvent) as event_watier, \ 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]}') - event_watier.wait_for_event(state) + event_watier.wait_for_event(value=state) item_waiter.wait_for_state(state) - all_events_ok = event_watier.events_ok - return all_events_ok - TestOpenhabEventTypes() diff --git a/conf_testing/rules/openhab/test_groups.py b/conf_testing/rules/openhab/test_groups.py new file mode 100644 index 00000000..75bebd06 --- /dev/null +++ b/conf_testing/rules/openhab/test_groups.py @@ -0,0 +1,62 @@ +from HABApp.openhab.items import SwitchItem, GroupItem +from HABAppTests import ItemWaiter, TestBaseRule, OpenhabTmpItem, EventWaiter +from HABApp.openhab.events import ItemUpdatedEvent +from HABAppTests.errors import TestCaseFailed + + +class TestOpenhabGroupFunction(TestBaseRule): + + def __init__(self): + super().__init__() + + self.group = OpenhabTmpItem('Group') + self.item1 = OpenhabTmpItem('Switch') + self.item2 = OpenhabTmpItem('Switch') + + self.add_test('Group function', self.test_group_update) + self.add_test('Group member change', self.add_item_to_grp) + + def set_up(self): + self.item1.create_item(groups=[self.group.name]) + self.item2.create_item(groups=[self.group.name]) + self.group.create_item(group_type='Switch', group_function='OR', group_function_params=['ON', 'OFF']) + + def tear_down(self): + self.item1.remove() + self.item2.remove() + self.group.remove() + + def test_group_update(self): + item1 = SwitchItem.get_item(self.item1.name) + item2 = SwitchItem.get_item(self.item2.name) + group = GroupItem.get_item(self.group.name) + + with ItemWaiter(group) as waiter: + waiter.wait_for_state(None) + + item1.oh_post_update('ON') + waiter.wait_for_state('ON') + + item1.oh_post_update('OFF') + waiter.wait_for_state('OFF') + + item2.oh_post_update('ON') + waiter.wait_for_state('ON') + + def add_item_to_grp(self): + new_item = OpenhabTmpItem('Switch') + try: + with EventWaiter(self.group.name, ItemUpdatedEvent) as w: + new_item.create_item(groups=[self.group.name]) + event = w.wait_for_event() + while event.name != self.group.name: + w.wait_for_event() + except TestCaseFailed: + return None + finally: + new_item.remove() + + raise TestCaseFailed(f'Event for group {self.group.name} reveived but expected none!') + + +TestOpenhabGroupFunction() diff --git a/conf_testing/rules/test_habapp_internals.py b/conf_testing/rules/openhab/test_habapp_internals.py similarity index 55% rename from conf_testing/rules/test_habapp_internals.py rename to conf_testing/rules/openhab/test_habapp_internals.py index 44563e0e..7aa9585c 100644 --- a/conf_testing/rules/test_habapp_internals.py +++ b/conf_testing/rules/openhab/test_habapp_internals.py @@ -1,20 +1,17 @@ from HABApp.openhab.connection_handler.func_async import async_get_item_with_habapp_meta, async_set_habapp_metadata, \ async_remove_habapp_metadata from HABApp.openhab.definitions.rest.habapp_data import HABAppThingPluginData -from HABApp.openhab.events import ItemUpdatedEvent -from HABApp.openhab.interface import create_item -from HABApp.openhab.items import StringItem, NumberItem, DatetimeItem -from HABAppTests import TestBaseRule, OpenhabTmpItem, run_coro, EventWaiter +from HABAppTests import TestBaseRule, OpenhabTmpItem, run_coro -class TestMetadata(TestBaseRule): +class OpenhabMetaData(TestBaseRule): def __init__(self): super().__init__() - self.add_test('create meta', self.create_meta) + self.add_test('async', self.create_meta) def create_meta(self): - with OpenhabTmpItem(None, 'String') as tmpitem: + with OpenhabTmpItem('String') as tmpitem: d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) assert d['metadata']['HABApp'] is None @@ -24,7 +21,6 @@ def create_meta(self): d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) assert isinstance(d['metadata']['HABApp'], HABAppThingPluginData) - # create valid data run_coro(async_set_habapp_metadata( tmpitem.name, HABAppThingPluginData(created_link='asdf', created_ns=['a', 'b'])) @@ -41,29 +37,5 @@ def create_meta(self): d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) assert d['metadata']['HABApp'] is None - return True - - -TestMetadata() - - -class ChangeItemType(TestBaseRule): - - def __init__(self): - super().__init__() - self.add_test('change_item', self.change_item) - - def change_item(self): - with OpenhabTmpItem(None, 'Number') as tmpitem: - NumberItem.get_item(tmpitem.name) - - create_item('String', tmpitem.name) - EventWaiter(tmpitem.name, ItemUpdatedEvent(tmpitem.name, 'String'), 2, False) - StringItem.get_item(tmpitem.name) - - create_item('DateTime', tmpitem.name) - EventWaiter(tmpitem.name, ItemUpdatedEvent(tmpitem.name, 'DateTime'), 2, False) - DatetimeItem.get_item(tmpitem.name) - -ChangeItemType() +OpenhabMetaData() diff --git a/conf_testing/rules/test_openhab_interface.py b/conf_testing/rules/openhab/test_interface.py similarity index 86% rename from conf_testing/rules/test_openhab_interface.py rename to conf_testing/rules/openhab/test_interface.py index 40141faa..a65e93c8 100644 --- a/conf_testing/rules/test_openhab_interface.py +++ b/conf_testing/rules/openhab/test_interface.py @@ -1,5 +1,3 @@ -import random -import string import time import HABApp @@ -33,10 +31,9 @@ def test_item_exists(self): assert self.openhab.item_exists('TestString') def test_item_create_delete(self): - test_defs = [] for type in get_openhab_test_types(): - test_defs.append((type, get_random_name())) + test_defs.append((type, get_random_name(type))) test_defs.append(('Number', 'HABApp_Ping')) for item_type, item_name in test_defs: @@ -49,7 +46,7 @@ def test_item_create_delete(self): assert not self.openhab.item_exists(item_name) def test_item_change_type(self): - test_item = ''.join(random.choice(string.ascii_letters) for _ in range(20)) + test_item = get_random_name('String') assert not self.openhab.item_exists(test_item) self.openhab.create_item('String', test_item) @@ -71,8 +68,8 @@ def test_item_change_type(self): self.openhab.remove_item(test_item) def test_item_create_delete_group(self): - test_item = ''.join(random.choice(string.ascii_letters) for _ in range(20)) - test_group = ''.join(random.choice(string.ascii_letters) for _ in range(20)) + test_item = get_random_name('String') + test_group = get_random_name('Group') assert not self.openhab.item_exists(test_item) assert not self.openhab.item_exists(test_item) @@ -88,12 +85,11 @@ def test_item_create_delete_group(self): self.openhab.remove_item(test_group) self.openhab.remove_item(test_item) - def test_post_update(self, oh_type, values): if isinstance(values, str): values = [values] - with OpenhabTmpItem(None, oh_type) as item, ItemWaiter(item) as waiter: + with OpenhabTmpItem(oh_type) as item, ItemWaiter(item) as waiter: for value in values: self.openhab.post_update(item, value) waiter.wait_for_state(value) @@ -103,8 +99,6 @@ def test_post_update(self, oh_type, values): self.openhab.send_command(item, value) waiter.wait_for_state(value) - return waiter.states_ok - def test_umlaute(self): LABEL = 'äöß' NAME = 'TestUmlaute' @@ -114,12 +108,12 @@ def test_umlaute(self): assert ret.label == LABEL, f'"{LABEL}" != "{ret.label}"' def test_openhab_item_not_found(self): - test_item = ''.join(random.choice(string.ascii_letters) for _ in range(20)) + test_item = get_random_name('String') try: self.openhab.get_item(test_item) except Exception as e: if isinstance(e, HABApp.openhab.errors.ItemNotFoundError): - return True + return None return 'Exception not raised!' @@ -129,19 +123,16 @@ def test_item_definition(self): self.openhab.get_item('TestString') def test_metadata(self): - with OpenhabTmpItem(None, 'String') as item: + with OpenhabTmpItem('String') as item: self.openhab.set_metadata(item, 'MyNameSpace', 'MyValue', {'key': 'value'}) self.openhab.remove_metadata(item, 'MyNameSpace') def test_async_oder(self): - with OpenhabTmpItem('AsyncOrderTest', 'String') as item, ItemWaiter(item) as waiter: + with OpenhabTmpItem('String', 'AsyncOrderTest') as item, ItemWaiter(item) as waiter: for _ in range(10): for i in range(0, 5): item.oh_post_update(i) waiter.wait_for_state('4') - time.sleep(1) - return waiter.states_ok - TestOpenhabInterface() diff --git a/conf_testing/rules/test_openhab_interface_links.py b/conf_testing/rules/openhab/test_interface_links.py similarity index 100% rename from conf_testing/rules/test_openhab_interface_links.py rename to conf_testing/rules/openhab/test_interface_links.py diff --git a/conf_testing/rules/openhab/test_item_change.py b/conf_testing/rules/openhab/test_item_change.py new file mode 100644 index 00000000..337c7da6 --- /dev/null +++ b/conf_testing/rules/openhab/test_item_change.py @@ -0,0 +1,28 @@ +from HABApp.openhab.events import ItemUpdatedEvent +from HABApp.openhab.interface import create_item +from HABApp.openhab.items import StringItem, NumberItem, DatetimeItem +from HABAppTests import TestBaseRule, OpenhabTmpItem, EventWaiter + + +class ChangeItemType(TestBaseRule): + + def __init__(self): + super().__init__() + self.add_test('change_item', self.change_item) + + def change_item(self): + with OpenhabTmpItem('Number') as tmpitem: + NumberItem.get_item(tmpitem.name) + + with EventWaiter(tmpitem.name, ItemUpdatedEvent, 2) as e: + create_item('String', tmpitem.name) + e.wait_for_event(type='String', name=tmpitem.name) + StringItem.get_item(tmpitem.name) + + with EventWaiter(tmpitem.name, ItemUpdatedEvent, 2) as e: + create_item('DateTime', tmpitem.name) + e.wait_for_event(type='DateTime', name=tmpitem.name) + DatetimeItem.get_item(tmpitem.name) + + +ChangeItemType() diff --git a/conf_testing/rules/test_openhab_item_funcs.py b/conf_testing/rules/openhab/test_item_funcs.py similarity index 99% rename from conf_testing/rules/test_openhab_item_funcs.py rename to conf_testing/rules/openhab/test_item_funcs.py index f3c3b4d2..bacfc5bb 100644 --- a/conf_testing/rules/test_openhab_item_funcs.py +++ b/conf_testing/rules/openhab/test_item_funcs.py @@ -10,7 +10,7 @@ @dataclasses.dataclass(frozen=True) -class TestParam(): +class TestParam: func_name: str result: typing.Union[str, float, int, tuple] func_params: typing.Union[str, float, int, tuple] = None @@ -63,7 +63,7 @@ def test_func(self, item_type, test_params): item_type = str(item_type).split('.')[-1][:-6] item_name = f'{item_type}_item_test' - with OpenhabTmpItem(item_name, item_type) as item, ItemWaiter(OpenhabItem.get_item(item_name)) as waiter: + with OpenhabTmpItem(item_type, item_name) as item, ItemWaiter(OpenhabItem.get_item(item_name)) as waiter: for test_param in test_params: assert isinstance(test_param, TestParam) @@ -88,10 +88,6 @@ def test_func(self, item_type, test_params): # reset state so we don't get false positives item.set_value(None) - test_ok = waiter.states_ok - - return test_ok - TestOpenhabItemFuncs() @@ -109,7 +105,7 @@ def __init__(self): def test_func(self, item_type, func_name, test_vals): - with OpenhabTmpItem(None, item_type) as tmpitem, ItemWaiter(OpenhabItem.get_item(tmpitem.name)) as waiter: + with OpenhabTmpItem(item_type) as tmpitem, ItemWaiter(OpenhabItem.get_item(tmpitem.name)) as waiter: for val in test_vals: getattr(tmpitem, func_name)(val) waiter.wait_for_state(val) @@ -119,9 +115,5 @@ def test_func(self, item_type, func_name, test_vals): getattr(tmpitem, func_name)() waiter.wait_for_state(val) - test_ok = waiter.states_ok - - return test_ok - TestOpenhabItemConvenience() diff --git a/conf_testing/rules/openhab/test_items.py b/conf_testing/rules/openhab/test_items.py new file mode 100644 index 00000000..2c1f0294 --- /dev/null +++ b/conf_testing/rules/openhab/test_items.py @@ -0,0 +1,58 @@ +from HABApp.openhab.items import StringItem, GroupItem +from HABAppTests import TestBaseRule, OpenhabTmpItem + + +class OpenhabItems(TestBaseRule): + + def __init__(self): + super().__init__() + + self.add_test('ApiDoc', self.test_api) + self.add_test('MemberTags', self.test_tags) + self.add_test('MemberGroups', self.test_groups) + + def test_api(self): + with OpenhabTmpItem('String') as item: + self.openhab.get_item(item.name) + + @OpenhabTmpItem.use('String', arg_name='oh_item') + def test_tags(self, oh_item: OpenhabTmpItem): + oh_item.create_item(tags=['tag1', 'tag2']) + + item = StringItem.get_item(oh_item.name) + assert item.tags == {'tag1', 'tag2'} + + oh_item.modify(tags=['tag1', 'tag4']) + assert item.tags == {'tag1', 'tag4'} + + oh_item.modify() + assert item.tags == set() + + @OpenhabTmpItem.use('String', arg_name='oh_item') + @OpenhabTmpItem.create('Group', 'group1') + @OpenhabTmpItem.create('Group', 'group2') + def test_groups(self, oh_item: OpenhabTmpItem): + grp1 = GroupItem.get_item('group1') + grp2 = GroupItem.get_item('group2') + + assert grp1.members == tuple() + assert grp2.members == tuple() + + oh_item.create_item(groups=['group1']) + + item = StringItem.get_item(oh_item.name) + assert item.groups == {'group1'} + assert grp1.members == (item, ) + + oh_item.modify(groups=['group1', 'group2']) + assert item.groups == {'group1', 'group2'} + assert grp1.members == (item, ) + assert grp2.members == (item, ) + + oh_item.modify() + assert item.groups == set() + assert grp1.members == tuple() + assert grp2.members == tuple() + + +OpenhabItems() diff --git a/conf_testing/rules/test_max_image_size.py b/conf_testing/rules/openhab/test_max_sse_msg_size.py similarity index 73% rename from conf_testing/rules/test_max_image_size.py rename to conf_testing/rules/openhab/test_max_sse_msg_size.py index 38a9b1db..dc548fb6 100644 --- a/conf_testing/rules/test_max_image_size.py +++ b/conf_testing/rules/openhab/test_max_sse_msg_size.py @@ -20,27 +20,21 @@ def test_img_size(self): _b1 = b'0x00' * 200 * 1024 _b2 = b'0xFF' * 200 * 1024 - with OpenhabTmpItem(None, 'Image') as item, ItemWaiter(OpenhabItem.get_item(item.name)) as item_waiter, \ + with OpenhabTmpItem('Image') as item, ItemWaiter(OpenhabItem.get_item(item.name)) as item_waiter, \ EventWaiter(item.name, ItemStateChangedEvent) as event_waiter: k = 383 _b1 = b'\xFF\xD8\xFF' + b'\x00' * (1024 - 3) + b'\x00' * (k - 1) * 1024 _b2 = b'\xFF\xD8\xFF' + b'\xFF' * (1024 - 3) + b'\x00' * (k - 1) * 1024 item.oh_post_update(_b1) - event_waiter.wait_for_event(_b1) + event_waiter.wait_for_event(value=_b1) item_waiter.wait_for_state(_b1) item.oh_post_update(_b2) - event_waiter.wait_for_event(_b2) + event_waiter.wait_for_event(value=_b2, old_value=_b1) item_waiter.wait_for_state(_b2) - assert event_waiter.last_event.value == _b2 - assert event_waiter.last_event.old_value == _b1 log.info(f'Image with {len(_b2) / 1024 :.0f}k ok!') - test_ok = item_waiter.states_ok and event_waiter.events_ok - - return test_ok - TestMaxImageSize() diff --git a/conf_testing/rules/openhab/test_things.py b/conf_testing/rules/openhab/test_things.py new file mode 100644 index 00000000..bb1f6eec --- /dev/null +++ b/conf_testing/rules/openhab/test_things.py @@ -0,0 +1,15 @@ +from HABAppTests import TestBaseRule +from HABAppTests.utils import find_astro_sun_thing + + +class OpenhabThings(TestBaseRule): + + def __init__(self): + super().__init__() + self.add_test('ApiDoc', self.test_api) + + def test_api(self): + self.openhab.get_thing(find_astro_sun_thing()) + + +OpenhabThings() diff --git a/conf_testing/rules/openhab_bugs.py b/conf_testing/rules/openhab_bugs.py index 50100c07..116c50a1 100644 --- a/conf_testing/rules/openhab_bugs.py +++ b/conf_testing/rules/openhab_bugs.py @@ -17,7 +17,7 @@ def __init__(self): def create_meta(self): astro_thing = find_astro_sun_thing() astro_channel = f"{astro_thing}:rise#start" - name = get_random_name() + name = get_random_name('DateTime') # create item and link run_coro(async_create_item('DateTime', name, 'MyCustomLabel', tags=['Tag1'])) diff --git a/conf_testing/rules/test_mqtt.py b/conf_testing/rules/test_mqtt.py index 92e089f6..7a5cf70e 100644 --- a/conf_testing/rules/test_mqtt.py +++ b/conf_testing/rules/test_mqtt.py @@ -38,23 +38,19 @@ def test_mqtt_pair_item(self): # Ensure we send on the write topic with EventWaiter(topic_write, ValueUpdateEvent) as event_waiter: item.publish('ddddddd') - event_waiter.wait_for_event('ddddddd') + event_waiter.wait_for_event(value='ddddddd') # Read Topic has to be updated properly with ItemWaiter(item) as item_waiter: self.mqtt.publish(topic_read, 'asdfasdf') item_waiter.wait_for_state(item) - return event_waiter.events_ok and item_waiter.states_ok - def test_mqtt_events(self, event_type): topic = 'test/event_topic' with EventWaiter(topic, event_type) as waiter: for data in self.mqtt_test_data: self.mqtt.publish(topic, data) - waiter.wait_for_event(data) - - return waiter.events_ok + waiter.wait_for_event(value=data) def test_mqtt_state(self): my_item = MqttItem.get_create_item('test/item_topic') @@ -63,8 +59,6 @@ def test_mqtt_state(self): my_item.publish(data) waiter.wait_for_state(data) - return waiter.states_ok - def test_mqtt_item_creation(self): topic = 'mqtt/item/creation' assert HABApp.core.Items.item_exists(topic) is False diff --git a/conf_testing/rules/test_openhab_groups.py b/conf_testing/rules/test_openhab_groups.py deleted file mode 100644 index 4a9cc14c..00000000 --- a/conf_testing/rules/test_openhab_groups.py +++ /dev/null @@ -1,47 +0,0 @@ -from HABApp.openhab.items import SwitchItem, GroupItem -from HABAppTests import ItemWaiter, TestBaseRule, get_random_name - - -class TestOpenhabGroupFunction(TestBaseRule): - - def __init__(self): - super().__init__() - - self.group = 'Group_' + get_random_name() - self.item1 = 'Item1_' + get_random_name() - self.item2 = 'Item2_' + get_random_name() - - self.add_test('Group Update', self.test_group_update) - - def set_up(self): - self.oh.create_item('Switch', self.item1, groups=[self.group]) - self.oh.create_item('Switch', self.item2, groups=[self.group]) - self.oh.create_item('Group', self.group, group_type='Switch', - group_function='OR', group_function_params=['ON', 'OFF']) - - def tear_down(self): - self.oh.remove_item(self.item1) - self.oh.remove_item(self.item2) - self.oh.remove_item(self.group) - - def test_group_update(self): - item1 = SwitchItem.get_item(self.item1) - item2 = SwitchItem.get_item(self.item2) - group = GroupItem.get_item(self.group) - - with ItemWaiter(group) as waiter: - waiter.wait_for_state(None) - - item1.oh_post_update('ON') - waiter.wait_for_state('ON') - - item1.oh_post_update('OFF') - waiter.wait_for_state('OFF') - - item2.oh_post_update('ON') - waiter.wait_for_state('ON') - - return waiter.states_ok - - -TestOpenhabGroupFunction() diff --git a/readme.md b/readme.md index c357f53a..af9c19d3 100644 --- a/readme.md +++ b/readme.md @@ -102,6 +102,16 @@ MyOpenhabRule() ``` # Changelog +#### 0.31.0 (08.10.2021) +- added self.get_items to easily search for items in a rule +- added full support for tags and groups on OpenhabItem +- Application should now properly shut down when there is a PermissionError +- Added DatetimeItem to docs +- Label in commandOption is optional +- Added message when file is removed +- Examples in the docs get checked with a newly created sphinx extension +- Reworked the openhab tests + #### 0.30.3 (17.06.2021) - add support for custom ca cert for MQTT - Scheduler runs only when the rule file has been loaded properly diff --git a/src/HABApp/__main__.py b/src/HABApp/__main__.py index 43f97ddf..e9a2ec04 100644 --- a/src/HABApp/__main__.py +++ b/src/HABApp/__main__.py @@ -9,6 +9,16 @@ from HABApp.__cmd_args__ import parse_args +def register_signal_handler(): + def shutdown_handler(sig, frame): + print('Shutting down ...') + HABApp.runtime.shutdown.request_shutdown() + + # register shutdown helper + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + def main() -> typing.Union[int, str]: # This has do be done before we create HABApp because of the possible sleep time @@ -18,14 +28,7 @@ def main() -> typing.Union[int, str]: try: app = HABApp.runtime.Runtime() - - def shutdown_handler(sig, frame): - print('Shutting down ...') - HABApp.runtime.shutdown.request_shutdown() - - # register shutdown helper - signal.signal(signal.SIGINT, shutdown_handler) - signal.signal(signal.SIGTERM, shutdown_handler) + register_signal_handler() # start workers try: @@ -33,6 +36,7 @@ def shutdown_handler(sig, frame): HABApp.core.const.loop.run_forever() except asyncio.CancelledError: pass + except HABApp.config.InvalidConfigException: pass except Exception as e: diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index bef01dd4..c3d10d7c 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.30.3' +__version__ = '0.31.0' diff --git a/src/HABApp/core/context.py b/src/HABApp/core/context.py index 24113ca2..9773ccd1 100644 --- a/src/HABApp/core/context.py +++ b/src/HABApp/core/context.py @@ -1,6 +1,7 @@ from contextvars import ContextVar as _ContextVar from typing import Callable as _Callable + async_context = _ContextVar('async_ctx') diff --git a/src/HABApp/core/files/file/file.py b/src/HABApp/core/files/file/file.py index 88ab63bc..7da48d75 100644 --- a/src/HABApp/core/files/file/file.py +++ b/src/HABApp/core/files/file/file.py @@ -125,7 +125,7 @@ async def unload(self): self.set_state(FileState.FAILED) return None - self.set_state(FileState.PENDING) + self.set_state(FileState.REMOVED) return None def file_changed(self, file: HABAppFile): diff --git a/src/HABApp/core/files/file/file_state.py b/src/HABApp/core/files/file/file_state.py index cd4a77bb..6bcba99c 100644 --- a/src/HABApp/core/files/file/file_state.py +++ b/src/HABApp/core/files/file/file_state.py @@ -12,6 +12,7 @@ class FileState(Enum): PROPERTIES_ERROR = auto() PENDING = auto() + REMOVED = auto() def __str__(self): return str(self.name) diff --git a/src/HABApp/core/files/manager/worker.py b/src/HABApp/core/files/manager/worker.py index 73715195..bbc89b6a 100644 --- a/src/HABApp/core/files/manager/worker.py +++ b/src/HABApp/core/files/manager/worker.py @@ -26,6 +26,7 @@ async def process_file(name: str, file: Path): existing = FILES.pop(name, None) if existing is not None: await existing.unload() + log.debug(f'Removed {existing.name}') return None FILES[name] = HABApp.core.files.file.create_file(name, file) diff --git a/src/HABApp/core/items/base_item.py b/src/HABApp/core/items/base_item.py index fe57ca7b..d2cbe249 100644 --- a/src/HABApp/core/items/base_item.py +++ b/src/HABApp/core/items/base_item.py @@ -1,5 +1,5 @@ import datetime -from typing import Any, Callable, Union +from typing import Any, Callable, Type, Union, TypeVar from eascheduler.const import local_tz from pendulum import UTC, DateTime @@ -122,3 +122,8 @@ def _on_item_remove(self): """This function gets automatically called when the item is removed from the item registry """ _add_tmp_data(self) + + +# Hints for functions that use an item class as an input parameter +TYPE_ITEM = TypeVar('TYPE_ITEM', bound=BaseItem) +TYPE_ITEM_CLS = Type[TYPE_ITEM] diff --git a/src/HABApp/mqtt/mqtt_connection.py b/src/HABApp/mqtt/mqtt_connection.py index 445a399a..c8cb407b 100644 --- a/src/HABApp/mqtt/mqtt_connection.py +++ b/src/HABApp/mqtt/mqtt_connection.py @@ -6,9 +6,9 @@ import HABApp from HABApp.core import Items -from HABApp.core.const.json import load_json -from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueUpdateEvent from HABApp.core.wrapper import log_exception +from HABApp.mqtt.events import MqttValueChangeEvent, MqttValueUpdateEvent +from HABApp.mqtt.mqtt_payload import get_msg_payload from HABApp.runtime import shutdown log = logging.getLogger('HABApp.mqtt.connection') @@ -150,34 +150,10 @@ def subscription_changed(): @log_exception def process_msg(client, userdata, message: mqtt.MQTTMessage): - topic: str = message.topic - - try: - payload = message.payload.decode("utf-8") - if log_msg.isEnabledFor(logging.DEBUG): - log_msg._log(logging.DEBUG, f'{topic} ({message.qos}): {payload}', []) - - # load json dict and list - if payload.startswith('{') and payload.endswith('}') or payload.startswith('[') and payload.endswith(']'): - try: - payload = load_json(payload) - except ValueError: - pass - else: - # try to cast to int/float - try: - payload = int(payload) - except ValueError: - try: - payload = float(payload) - except ValueError: - pass - except UnicodeDecodeError: - # Payload ist binary - payload = message.payload - if log_msg.isEnabledFor(logging.DEBUG): - log_msg._log(logging.DEBUG, f'{topic} ({message.qos}): {payload[:20]}...', []) + topic, payload = get_msg_payload(message) + if topic is None: + return None _item = None # type: typing.Optional[HABApp.mqtt.items.MqttBaseItem] try: diff --git a/src/HABApp/mqtt/mqtt_payload.py b/src/HABApp/mqtt/mqtt_payload.py new file mode 100644 index 00000000..ac5f6a35 --- /dev/null +++ b/src/HABApp/mqtt/mqtt_payload.py @@ -0,0 +1,56 @@ +import logging +from typing import Tuple, Any, Optional + +from paho.mqtt.client import MQTTMessage + +from HABApp.core.const.json import load_json +from HABApp.core.wrapper import process_exception + +log = logging.getLogger('HABApp.EventBus.mqtt') + + +def get_msg_payload(msg: MQTTMessage) -> Tuple[Optional[str], Any]: + try: + topic = msg._topic.decode('utf-8') + raw = msg.payload + + try: + val = raw.decode("utf-8") + except UnicodeDecodeError: + # Payload ist a byte stream + if log.isEnabledFor(logging.DEBUG): + log._log(logging.DEBUG, f'{topic} ({msg.qos}): {raw[:20]}...', []) + return topic, raw + + if log.isEnabledFor(logging.DEBUG): + log._log(logging.DEBUG, f'{topic} ({msg.qos}): {val}', []) + + # None + if val == 'none' or val == 'None': + return topic, None + + # bool + if val == 'true' or val == 'True': + return topic, True + if val == 'false' or val == 'False': + return topic, False + + # int + if val.isdecimal(): + return topic, int(val) + + # json list/dict + if val.startswith('{') and val.endswith('}') or val.startswith('[') and val.endswith(']'): + try: + return topic, load_json(val) + except ValueError: + return topic, val + + # float or str + try: + return topic, float(val) + except ValueError: + return topic, val + except Exception as e: + process_exception('get_msg_payload', e, logger=log) + return None, None diff --git a/src/HABApp/openhab/__init__.py b/src/HABApp/openhab/__init__.py index 91bc6f50..523134d8 100644 --- a/src/HABApp/openhab/__init__.py +++ b/src/HABApp/openhab/__init__.py @@ -2,8 +2,12 @@ import HABApp.openhab.errors import HABApp.openhab.events +# isort: split + import HABApp.openhab.interface_async import HABApp.openhab.interface +# isort: split + # items use the interface for the convenience functions import HABApp.openhab.items diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py index 451c0698..bbccf949 100644 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ b/src/HABApp/openhab/connection_handler/func_sync.py @@ -9,7 +9,6 @@ from HABApp.core.const import loop from HABApp.core.context import async_context, AsyncContextError from HABApp.core.items.base_valueitem import BaseValueItem, BaseItem -from HABApp.core.wrapper import log_exception from HABApp.openhab.definitions.rest import OpenhabItemDefinition, OpenhabThingDefinition, ItemChannelLinkDefinition from .func_async import async_post_update, async_send_command, async_create_item, async_get_item, async_get_thing, \ async_set_metadata, async_remove_metadata, async_get_channel_link, async_create_channel_link, \ @@ -19,7 +18,6 @@ from ..definitions.helpers import OpenhabPersistenceData -@log_exception def post_update(item_name: str, state: Any): """ Post an update to the item @@ -38,7 +36,6 @@ def post_update(item_name: str, state: Any): create_task(async_post_update(item_name, state)) -@log_exception def send_command(item_name: str, command): """ Send the specified command to the item @@ -46,9 +43,9 @@ def send_command(item_name: str, command): :param item_name: item name or item :param command: command """ - assert isinstance(item_name, (str, HABApp.openhab.items.base_item.BaseValueItem)), type(item_name) + assert isinstance(item_name, (str, BaseValueItem)), type(item_name) - if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): + if isinstance(item_name, BaseValueItem): item_name = item_name.name if async_context.get(None) is None: @@ -57,7 +54,6 @@ def send_command(item_name: str, command): create_task(async_send_command(item_name, command)) -@log_exception def create_item(item_type: str, name: str, label="", category="", tags: List[str] = [], groups: List[str] = [], group_type: str = '', group_function: str = '', group_function_params: List[str] = []): @@ -157,7 +153,6 @@ def get_thing(thing_name: str) -> OpenhabThingDefinition: return fut.result() -@log_exception def remove_item(item_name: str): """ Removes an item from the openHAB item registry diff --git a/src/HABApp/openhab/connection_handler/http_connection.py b/src/HABApp/openhab/connection_handler/http_connection.py index 36309543..62b187fa 100644 --- a/src/HABApp/openhab/connection_handler/http_connection.py +++ b/src/HABApp/openhab/connection_handler/http_connection.py @@ -41,7 +41,6 @@ ON_CONNECTED: typing.Callable = None ON_DISCONNECTED: typing.Callable = None -ON_SSE_EVENT: typing.Callable[[typing.Dict[str, Any]], Any] = None async def get(url: str, log_404=True, disconnect_on_error=False, **kwargs: Any) -> ClientResponse: @@ -259,7 +258,8 @@ async def start_connection(): async def start_sse_event_listener(): try: # cache so we don't have to look up every event - call = ON_SSE_EVENT + _load_json = load_json + _see_handler = on_sse_event event_prefix = 'openhab' if not IS_OH2 else 'smarthome' @@ -273,19 +273,24 @@ async def start_sse_event_listener(): session=HTTP_SESSION ) as event_source: async for event in event_source: + + e_str = event.data + try: - event = load_json(event.data) + e_json = _load_json(e_str) except ValueError: + log_events.warning(f'Invalid json: {e_str}') continue except TypeError: + log_events.warning(f'Invalid json: {e_str}') continue # Log sse event if log_events.isEnabledFor(logging.DEBUG): - log_events._log(logging.DEBUG, event, []) + log_events._log(logging.DEBUG, e_str, []) # process - call(event) + _see_handler(e_json) except asyncio.CancelledError: # This exception gets raised if we cancel the coroutine @@ -379,3 +384,7 @@ def __load_cfg(): # setup config __load_cfg() HABApp.config.CONFIG.subscribe_for_changes(__load_cfg) + + +# import it here otherwise we get cyclic imports +from HABApp.openhab.connection_handler.sse_handler import on_sse_event # noqa: E402 diff --git a/src/HABApp/openhab/connection_handler/sse_handler.py b/src/HABApp/openhab/connection_handler/sse_handler.py new file mode 100644 index 00000000..80afcde5 --- /dev/null +++ b/src/HABApp/openhab/connection_handler/sse_handler.py @@ -0,0 +1,76 @@ +import HABApp +import HABApp.core +import HABApp.openhab.events +from HABApp.core import Items, EventBus +from HABApp.core.Items import ItemNotFoundException +from HABApp.core.events import ValueUpdateEvent +from HABApp.core.logger import log_warning +from HABApp.core.wrapper import ignore_exception +from HABApp.openhab.connection_handler import http_connection +from HABApp.openhab.events import ThingStatusInfoEvent, GroupItemStateChangedEvent, ItemRemovedEvent, ItemAddedEvent, \ + ItemUpdatedEvent +from HABApp.openhab.map_events import get_event +from HABApp.openhab.map_items import map_item +from HABApp.openhab.item_to_reg import add_to_registry, remove_from_registry + + +log = http_connection.log + + +@ignore_exception +def on_sse_event(event_dict: dict): + + # Lookup corresponding OpenHAB event + event = get_event(event_dict) + + # Update item in registry BEFORE posting to the event bus + # so the items have the correct state when we process the event in a rule + try: + if isinstance(event, ValueUpdateEvent): + __item = Items.get_item(event.name) # type: HABApp.core.items.base_item.BaseValueItem + __item.set_value(event.value) + EventBus.post_event(event.name, event) + return None + + if isinstance(event, ThingStatusInfoEvent): + __thing = Items.get_item(event.name) # type: HABApp.openhab.items.Thing + __thing.process_event(event) + EventBus.post_event(event.name, event) + return None + + # Workaround because there is no GroupItemStateEvent + if isinstance(event, GroupItemStateChangedEvent): + __item = Items.get_item(event.name) # type: HABApp.openhab.items.GroupItem + __item.set_value(event.value) + EventBus.post_event(event.name, event) + return None + except ItemNotFoundException: + log_warning(log, f'Received {event.__class__.__name__} for {event.name} but item does not exist!') + + # Post the event anyway + EventBus.post_event(event.name, event) + return None + + if isinstance(event, ItemRemovedEvent): + remove_from_registry(event.name) + EventBus.post_event(event.name, event) + return None + + # Events which change the ItemRegistry + new_item = None + if isinstance(event, ItemAddedEvent): + new_item = map_item(event.name, event.type, None, event.tags, event.groups) + if new_item is None: + return None + + if isinstance(event, ItemUpdatedEvent): + new_item = map_item(event.name, event.type, None, event.tags, event.groups) + if new_item is None: + return None + + if new_item is not None: + add_to_registry(new_item) + + # Send Event to Event Bus + HABApp.core.EventBus.post_event(event.name, event) + return None diff --git a/src/HABApp/openhab/connection_logic/connection.py b/src/HABApp/openhab/connection_logic/connection.py index bd35adf1..56cafe9a 100644 --- a/src/HABApp/openhab/connection_logic/connection.py +++ b/src/HABApp/openhab/connection_logic/connection.py @@ -1,11 +1,4 @@ -import HABApp -import HABApp.core -import HABApp.openhab.events -from HABApp.core import Items -from HABApp.core.wrapper import ignore_exception from HABApp.openhab.connection_handler import http_connection -from HABApp.openhab.map_events import get_event -from HABApp.openhab.map_items import map_item from ._plugin import on_connect, on_disconnect, setup_plugins log = http_connection.log @@ -17,7 +10,6 @@ def setup(): # initialize callbacks http_connection.ON_CONNECTED = on_connect http_connection.ON_DISCONNECTED = on_disconnect - http_connection.ON_SSE_EVENT = on_sse_event # shutdown handler for connection shutdown.register_func(http_connection.stop_connection, msg='Stopping openHAB connection') @@ -32,63 +24,3 @@ def setup(): async def start(): await http_connection.start_connection() - - -@ignore_exception -def on_sse_event(event_dict: dict): - - # Lookup corresponding OpenHAB event - event = get_event(event_dict) - - # Update item in registry BEFORE posting to the event bus - # so the items have the correct state when we process the event in a rule - try: - if isinstance(event, HABApp.core.events.ValueUpdateEvent): - __item = Items.get_item(event.name) # type: HABApp.core.items.base_item.BaseValueItem - __item.set_value(event.value) - HABApp.core.EventBus.post_event(event.name, event) - return None - - if isinstance(event, HABApp.openhab.events.ThingStatusInfoEvent): - __thing = Items.get_item(event.name) # type: HABApp.openhab.items.Thing - __thing.process_event(event) - HABApp.core.EventBus.post_event(event.name, event) - return None - - # Workaround because there is no GroupItemStateEvent - if isinstance(event, HABApp.openhab.events.GroupItemStateChangedEvent): - __item = Items.get_item(event.name) # type: HABApp.openhab.items.GroupItem - __item.set_value(event.value) - HABApp.core.EventBus.post_event(event.name, event) - return None - except HABApp.core.Items.ItemNotFoundException: - pass - - # Events which change the ItemRegistry - if isinstance(event, (HABApp.openhab.events.ItemAddedEvent, HABApp.openhab.events.ItemUpdatedEvent)): - item = map_item(event.name, event.type, 'NULL') - if item is None: - return None - - # check already existing item so we can print a warning if something changes - try: - existing_item = Items.get_item(item.name) - if isinstance(existing_item, item.__class__): - # it's the same item class so we don't replace it! - item = existing_item - else: - log.warning(f'Item changed type from {existing_item.__class__} to {item.__class__}') - # remove the item so it can be added again - Items.pop_item(item.name) - except Items.ItemNotFoundException: - pass - - # always overwrite with new definition - Items.add_item(item) - - elif isinstance(event, HABApp.openhab.events.ItemRemovedEvent): - Items.pop_item(event.name) - - # Send Event to Event Bus - HABApp.core.EventBus.post_event(event.name, event) - return None diff --git a/src/HABApp/openhab/connection_logic/plugin_load_items.py b/src/HABApp/openhab/connection_logic/plugin_load_items.py index 22e3ca01..d778a130 100644 --- a/src/HABApp/openhab/connection_logic/plugin_load_items.py +++ b/src/HABApp/openhab/connection_logic/plugin_load_items.py @@ -6,6 +6,7 @@ from HABApp.openhab.map_items import map_item from ._plugin import OnConnectPlugin from ..interface_async import async_get_items, async_get_things +from HABApp.openhab.item_to_reg import add_to_registry, fresh_item_sync log = logging.getLogger('HABApp.openhab.items') @@ -18,25 +19,16 @@ async def on_connect_function(self): if data is None: return None + fresh_item_sync() + found_items = len(data) for _dict in data: item_name = _dict['name'] - new_item = map_item(item_name, _dict['type'], _dict['state']) + new_item = map_item(item_name, _dict['type'], _dict['state'], tuple(_dict['tags']), + tuple(_dict['groupNames'])) # type: HABApp.openhab.items.OpenhabItem if new_item is None: continue - - try: - # if the item already exists and it has the correct type just update its state - # Since we load the items before we load the rules this should actually never happen - existing_item = Items.get_item(item_name) # type: HABApp.core.items.BaseValueItem - if isinstance(existing_item, new_item.__class__): - existing_item.set_value(new_item.value) # use the converted state from the new item here - new_item = existing_item - except Items.ItemNotFoundException: - pass - - # create new item or change item type - Items.add_item(new_item) + add_to_registry(new_item, True) # remove items which are no longer available ist = set(Items.get_all_item_names()) @@ -56,22 +48,22 @@ async def on_connect_function(self): for t_dict in data: name = t_dict['UID'] try: - thing = HABApp.core.Items.get_item(name) + thing = Items.get_item(name) if not isinstance(thing, Thing): log.warning(f'Item {name} has the wrong type ({type(thing)}), expected Thing') thing = Thing(name) - except HABApp.core.Items.ItemNotFoundException: + except Items.ItemNotFoundException: thing = Thing(name) thing.status = t_dict['statusInfo']['status'] - HABApp.core.Items.add_item(thing) + Items.add_item(thing) # remove things which were deleted - ist = set(HABApp.core.Items.get_all_item_names()) + ist = set(Items.get_all_item_names()) soll = {k['UID'] for k in data} for k in ist - soll: - if isinstance(HABApp.core.Items.get_item(k), Thing): - HABApp.core.Items.pop_item(k) + if isinstance(Items.get_item(k), Thing): + Items.pop_item(k) log.info(f'Updated {len(data):d} Things') return None diff --git a/src/HABApp/openhab/definitions/rest/items.py b/src/HABApp/openhab/definitions/rest/items.py index 89f56ed2..9b8ee973 100644 --- a/src/HABApp/openhab/definitions/rest/items.py +++ b/src/HABApp/openhab/definitions/rest/items.py @@ -5,12 +5,12 @@ class StateOptionDefinition(BaseModel): value: str - label: str + label: Optional[str] = None class CommandOptionDefinition(BaseModel): command: str - label: str + label: Optional[str] = None class CommandDescriptionDefinition(BaseModel): diff --git a/src/HABApp/openhab/definitions/rest/links.py b/src/HABApp/openhab/definitions/rest/links.py index 43bc9ab5..58a151be 100644 --- a/src/HABApp/openhab/definitions/rest/links.py +++ b/src/HABApp/openhab/definitions/rest/links.py @@ -10,6 +10,10 @@ class ItemChannelLinkDefinition(RestBase): 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 + class LinkNotFoundError(Exception): pass diff --git a/src/HABApp/openhab/events/item_events.py b/src/HABApp/openhab/events/item_events.py index 6af017d9..35539816 100644 --- a/src/HABApp/openhab/events/item_events.py +++ b/src/HABApp/openhab/events/item_events.py @@ -1,8 +1,8 @@ -import typing -import HABApp.core +from typing import Any, FrozenSet -from ..map_values import map_openhab_values +import HABApp.core from .base_event import OpenhabEvent +from ..map_values import map_openhab_values # smarthome/items/NAME/state -> 16 # openhab/items/NAME/state -> 14 @@ -16,14 +16,14 @@ class ItemStateEvent(OpenhabEvent, HABApp.core.events.ValueUpdateEvent): :ivar ~.value: """ name: str - value: typing.Any + value: Any - def __init__(self, name: str = '', value: typing.Any = None): + def __init__(self, name: str = '', value: Any = None): super().__init__() # smarthome/items/NAME/state self.name: str = name - self.value: typing.Any = value + self.value: Any = value @classmethod def from_dict(cls, topic: str, payload: dict): @@ -41,15 +41,15 @@ class ItemStateChangedEvent(OpenhabEvent, HABApp.core.events.ValueChangeEvent): :ivar ~.old_value: """ name: str - value: typing.Any - old_value: typing.Any + value: Any + old_value: Any - def __init__(self, name: str = '', value: typing.Any = None, old_value: typing.Any = None): + def __init__(self, name: str = '', value: Any = None, old_value: Any = None): super().__init__() self.name: str = name - self.value: typing.Any = value - self.old_value: typing.Any = old_value + self.value: Any = value + self.old_value: Any = old_value @classmethod def from_dict(cls, topic: str, payload: dict): @@ -70,13 +70,13 @@ class ItemCommandEvent(OpenhabEvent): :ivar ~.value: """ name: str - value: typing.Any + value: Any - def __init__(self, name: str = '', value: typing.Any = None): + def __init__(self, name: str = '', value: Any = None): super().__init__() self.name: str = name - self.value: typing.Any = value + self.value: Any = value @classmethod def from_dict(cls, topic: str, payload: dict): @@ -91,40 +91,56 @@ class ItemAddedEvent(OpenhabEvent): """ :ivar str ~.name: :ivar str ~.type: + :ivar Tuple[str,...] ~.tags: + :ivar Tuple[str,...] ~.group_names: """ name: str type: str + tags: FrozenSet[str] + groups: FrozenSet[str] - def __init__(self, name: str = '', type: str = ''): + def __init__(self, name: str = '', type: str = '', + tags: FrozenSet[str] = frozenset(), group_names: FrozenSet[str] = frozenset()): super().__init__() self.name: str = name self.type: str = type + self.tags: FrozenSet[str] = tags + self.groups: FrozenSet[str] = group_names @classmethod def from_dict(cls, topic: str, payload: dict): # {'topic': 'smarthome/items/NAME/added' # 'payload': '{"type":"Contact","name":"Test","tags":[],"groupNames":[]}' # 'type': 'ItemAddedEvent'} - return cls(payload['name'], payload['type']) + return cls(payload['name'], payload['type'], frozenset(payload['tags']), frozenset(payload['groupNames'])) def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' + tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else "" + grps = f' {{{", ".join(sorted(self.groups))}}}' if self.groups else "" + return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}, tags:{tags}, groups:{grps}>' class ItemUpdatedEvent(OpenhabEvent): """ :ivar str ~.name: :ivar str ~.type: + :ivar Tuple[str,...] ~.tags: + :ivar Tuple[str,...] ~.group_names: """ name: str type: str + tags: FrozenSet[str] + groups: FrozenSet[str] - def __init__(self, name: str = '', type: str = ''): + def __init__(self, name: str = '', type: str = '', + tags: FrozenSet[str] = tuple(), group_names: FrozenSet[str] = tuple()): super().__init__() self.name: str = name self.type: str = type + self.tags: FrozenSet[str] = tags + self.groups: FrozenSet[str] = group_names @classmethod def from_dict(cls, topic: str, payload: dict): @@ -132,10 +148,13 @@ def from_dict(cls, topic: str, payload: dict): # 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]}, # {"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', # 'type': 'ItemUpdatedEvent' - return cls(topic[NAME_START:-8], payload[0]['type']) + new = payload[0] + return cls(topic[NAME_START:-8], new['type'], frozenset(new['tags']), frozenset(new['groupNames'])) def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' + tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else "" + grps = f' {{{", ".join(sorted(self.groups))}}}' if self.groups else "" + return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}, tags:{tags}, groups:{grps}>' class ItemRemovedEvent(OpenhabEvent): @@ -164,14 +183,14 @@ class ItemStatePredictedEvent(OpenhabEvent): :ivar ~.value: """ name: str - value: typing.Any + value: Any - def __init__(self, name: str = '', value: typing.Any = None): + def __init__(self, name: str = '', value: Any = None): super().__init__() # smarthome/items/NAME/state self.name: str = name - self.value: typing.Any = value + self.value: Any = value @classmethod def from_dict(cls, topic: str, payload: dict): @@ -191,17 +210,17 @@ class GroupItemStateChangedEvent(OpenhabEvent): """ name: str item: str - value: typing.Any - old_value: typing.Any + value: Any + old_value: Any - def __init__(self, name: str = '', item: str = '', value: typing.Any = None, old_value: typing.Any = None): + def __init__(self, name: str = '', item: str = '', value: Any = None, old_value: Any = None): super().__init__() self.name: str = name self.item: str = item - self.value: typing.Any = value - self.old_value: typing.Any = old_value + self.value: Any = value + self.old_value: Any = old_value @classmethod def from_dict(cls, topic: str, payload: dict): diff --git a/src/HABApp/openhab/item_to_reg.py b/src/HABApp/openhab/item_to_reg.py new file mode 100644 index 00000000..4843ddde --- /dev/null +++ b/src/HABApp/openhab/item_to_reg.py @@ -0,0 +1,67 @@ +import logging +from typing import Dict, Set, Tuple + +import HABApp +from HABApp.core import Items +from HABApp.core.logger import log_warning + +log = logging.getLogger('HABApp.openhab.items') + + +def add_to_registry(item: 'HABApp.openhab.items.OpenhabItem', set_value=False): + name = item.name + for grp in item.groups: + MEMBERS.setdefault(grp, set()).add(name) + + if not Items.item_exists(name): + return Items.add_item(item) + + existing = Items.get_item(name) + if isinstance(existing, item.__class__): + # If we load directly through the API and not through an event we have to set the value + if set_value: + existing.set_value(item.value) + + # remove old groups + for grp in set(existing.groups) - set(item.groups): + MEMBERS.get(grp, set()).discard(name) + + # same type - it was only an item update (e.g. label)! + existing.tags = item.tags + existing.groups = item.groups + return None + + log_warning(log, f'Item type changed from {existing.__class__} to {item.__class__}') + + # Replace existing item with the updated definition + Items.pop_item(name) + Items.add_item(item) + + +def remove_from_registry(name: str): + if not Items.item_exists(name): + return None + + item = Items.get_item(name) # type: HABApp.openhab.items.OpenhabItem + for grp in item.groups: + MEMBERS.get(grp, set()).discard(name) + + if isinstance(item, HABApp.openhab.items.GroupItem): + MEMBERS.pop(name, None) + + Items.pop_item(name) + return None + + +MEMBERS: Dict[str, Set[str]] = {} + + +def fresh_item_sync(): + MEMBERS.clear() + + +def get_members(group_name: str) -> Tuple['HABApp.openhab.items.OpenhabItem', ...]: + ret = [] + for name in MEMBERS.get(group_name, []): + ret.append(Items.get_item(name)) + return tuple(sorted(ret)) diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index 47aace2f..8e9c0878 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -1,32 +1,38 @@ -import typing import datetime +from typing import Any, FrozenSet, Optional from HABApp.core.const import MISSING from HABApp.core.items.base_valueitem import BaseValueItem -from HABApp.openhab.interface import post_update, send_command, get_persistence_data +from HABApp.openhab.interface import get_persistence_data, post_update, send_command class OpenhabItem(BaseValueItem): """Base class for items which exists in OpenHAB. """ - def oh_send_command(self, value: typing.Any = MISSING): + def __init__(self, name: str, initial_value=None, + tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset()): + super().__init__(name, initial_value) + self.tags: FrozenSet[str] = tags + self.groups: FrozenSet[str] = groups + + def oh_send_command(self, value: Any = MISSING): """Send a command to the openHAB item :param value: (optional) value to be sent. If not specified the item value will be used. """ send_command(self.name, self.value if value is MISSING else value) - def oh_post_update(self, value: typing.Any = MISSING): + def oh_post_update(self, value: Any = MISSING): """Post an update to the openHAB item :param value: (optional) value to be posted. If not specified the item value will be used. """ post_update(self.name, self.value if value is MISSING else value) - def get_persistence_data(self, persistence: typing.Optional[str] = None, - start_time: typing.Optional[datetime.datetime] = None, - end_time: typing.Optional[datetime.datetime] = None): + def get_persistence_data(self, persistence: Optional[str] = None, + start_time: Optional[datetime.datetime] = None, + end_time: Optional[datetime.datetime] = None): """Query historical data from the OpenHAB persistence service :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index 5bf5935a..e95fb821 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -11,8 +11,9 @@ class ColorItem(OpenhabItem, OnOffCommand, PercentCommand): - def __init__(self, name: str, h=0.0, s=0.0, b=0.0): - super().__init__(name=name, initial_value=(h, s, b)) + def __init__(self, name: str, h=0.0, s=0.0, b=0.0, + tags: Tuple[str, ...] = tuple(), groups: Tuple[str, ...] = tuple()): + super().__init__(name=name, initial_value=(h, s, b), tags=tags, groups=groups) self.hue: float = min(max(0.0, h), HUE_FACTOR) self.saturation: float = min(max(0.0, s), PERCENT_FACTOR) diff --git a/src/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py index e43b49d5..8d4b8be2 100644 --- a/src/HABApp/openhab/items/group_item.py +++ b/src/HABApp/openhab/items/group_item.py @@ -1,5 +1,8 @@ +from typing import Tuple + from HABApp.core.events import ComplexEventValue from HABApp.openhab.items.base_item import OpenhabItem +from HABApp.openhab.item_to_reg import get_members class GroupItem(OpenhabItem): @@ -10,3 +13,9 @@ def set_value(self, new_value) -> bool: if isinstance(new_value, ComplexEventValue): new_value = new_value.value return super().set_value(new_value) + + @property + def members(self) -> Tuple[OpenhabItem, ...]: + """Resolves and then returns all group members""" + + return get_members(self.name) diff --git a/src/HABApp/openhab/items/image_item.py b/src/HABApp/openhab/items/image_item.py index b3245096..418ef3d7 100644 --- a/src/HABApp/openhab/items/image_item.py +++ b/src/HABApp/openhab/items/image_item.py @@ -1,11 +1,11 @@ -import typing from base64 import b64encode +from typing import FrozenSet, Optional from HABApp.openhab.items.base_item import OpenhabItem from ..definitions import RawValue -def _convert_bytes(data: bytes, img_type: typing.Optional[str]) -> str: +def _convert_bytes(data: bytes, img_type: Optional[str]) -> str: assert isinstance(data, bytes), type(data) # try to automatically found out what kind of file we have @@ -22,11 +22,12 @@ def _convert_bytes(data: bytes, img_type: typing.Optional[str]) -> str: class ImageItem(OpenhabItem): """ImageItem which accepts and converts the data types from OpenHAB""" - def __init__(self, name: str, initial_value=None): - super().__init__(name, initial_value) + def __init__(self, name: str, initial_value=None, + tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset()): + super().__init__(name, initial_value, tags, groups) # this item is unique because we also save the image type and thus have two states - self.image_type: typing.Optional[str] = None + self.image_type: Optional[str] = None def set_value(self, new_value) -> bool: assert isinstance(new_value, RawValue) or new_value is None, type(new_value) @@ -42,7 +43,7 @@ def set_value(self, new_value) -> bool: # bytes return super().set_value(new_value.value) - def oh_post_update(self, data: bytes, img_type: typing.Optional[str] = None): + def oh_post_update(self, data: bytes, img_type: Optional[str] = None): """Post an update to an openhab image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. @@ -51,7 +52,7 @@ def oh_post_update(self, data: bytes, img_type: typing.Optional[str] = None): """ return super().oh_post_update(_convert_bytes(data, img_type)) - def oh_send_command(self, data: bytes, img_type: typing.Optional[str] = None): + def oh_send_command(self, data: bytes, img_type: Optional[str] = None): """Send a command to an openhab image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. diff --git a/src/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py index a54c56a7..b87f470e 100644 --- a/src/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -1,8 +1,8 @@ import datetime import logging -import typing +from typing import FrozenSet, Optional -from HABApp.core.items.base_item import BaseItem +import HABApp from HABApp.core.wrapper import process_exception from HABApp.openhab.definitions.values import QuantityValue, RawValue from HABApp.openhab.items import ColorItem, ContactItem, DatetimeItem, DimmerItem, GroupItem, ImageItem, LocationItem, \ @@ -11,54 +11,54 @@ log = logging.getLogger('HABApp.openhab') -def map_item(name, openhab_type: str, openhab_value: str) -> typing.Optional[BaseItem]: +def map_item(name: str, type: str, value: Optional[str], tags: FrozenSet[str], groups: FrozenSet[str]) -> \ + Optional['HABApp.openhab.items.OpenhabItem']: try: - assert isinstance(openhab_type, str), type(openhab_type) - assert isinstance(openhab_value, str), type(openhab_value) + assert isinstance(type, str) + assert value is None or isinstance(value, str) - value: typing.Optional[str] = openhab_value - if openhab_value == 'NULL' or openhab_value == 'UNDEF': + if value == 'NULL' or value == 'UNDEF': value = None # Quantity types are like this: Number:Temperature and have a unit set: "12.3 °C". # We have to remove the dimension from the type and remove the unit from the value - if ':' in openhab_type: - openhab_type, dimension = openhab_type.split(':') + if ':' in type: + type, dimension = type.split(':') # if the item is not initialized its None and has no dimension if value is not None: value, _ = QuantityValue.split_unit(value) # Specific classes - if openhab_type == "Switch": - return SwitchItem(name, value) + if type == "Switch": + return SwitchItem(name, value, tags=tags, groups=groups) - if openhab_type == "String": - return StringItem(name, value) + if type == "String": + return StringItem(name, value, tags=tags, groups=groups) - if openhab_type == "Contact": - return ContactItem(name, value) + if type == "Contact": + return ContactItem(name, value, tags=tags, groups=groups) - if openhab_type == "Rollershutter": + if type == "Rollershutter": if value is None: - return RollershutterItem(name, value) - return RollershutterItem(name, float(value)) + return RollershutterItem(name, value, tags=tags, groups=groups) + return RollershutterItem(name, float(value), tags=tags, groups=groups) - if openhab_type == "Dimmer": + if type == "Dimmer": if value is None: - return DimmerItem(name, value) - return DimmerItem(name, float(value)) + return DimmerItem(name, value, tags=tags, groups=groups) + return DimmerItem(name, float(value), tags=tags, groups=groups) - if openhab_type == "Number": + if type == "Number": if value is None: - return NumberItem(name, value) + return NumberItem(name, value, tags=tags, groups=groups) # Number items can be int or float try: - return NumberItem(name, int(value)) + return NumberItem(name, int(value), tags=tags, groups=groups) except ValueError: - return NumberItem(name, float(value)) + return NumberItem(name, float(value), tags=tags, groups=groups) - if openhab_type == "DateTime": + if type == "DateTime": if value is None: return DatetimeItem(name, value) # Todo: remove this once we go >= OH3.1 @@ -73,30 +73,30 @@ def map_item(name, openhab_type: str, openhab_value: str) -> typing.Optional[Bas # --> TypeError: can't compare offset-naive and offset-aware datetimes dt = dt.astimezone(tz=None) # Changes datetime object so it uses system timezone dt = dt.replace(tzinfo=None) # Removes timezone awareness - return DatetimeItem(name, dt) + return DatetimeItem(name, dt, tags=tags, groups=groups) - if openhab_type == "Color": + if type == "Color": if value is None: - return ColorItem(name) - return ColorItem(name, *(float(k) for k in value.split(','))) + return ColorItem(name, tags=tags, groups=groups) + return ColorItem(name, *(float(k) for k in value.split(',')), tags=tags, groups=groups) - if openhab_type == "Image": - img = ImageItem(name) + if type == "Image": + img = ImageItem(name, tags=tags, groups=groups) if value is None: return img img.set_value(RawValue(value)) return img - if openhab_type == "Group": - return GroupItem(name, value) + if type == "Group": + return GroupItem(name, value, tags=tags, groups=groups) - if openhab_type == "Location": - return LocationItem(name, value) + if type == "Location": + return LocationItem(name, value, tags=tags, groups=groups) - if openhab_type == "Player": - return PlayerItem(name, value) + if type == "Player": + return PlayerItem(name, value, tags=tags, groups=groups) - raise ValueError(f'Unknown Openhab type: {openhab_type} for {name}') + raise ValueError(f'Unknown Openhab type: {type} for {name}') except Exception as e: process_exception('map_items', e, logger=log) diff --git a/src/HABApp/rule/interfaces/__init__.py b/src/HABApp/rule/interfaces/__init__.py index f8355932..82c3f30d 100644 --- a/src/HABApp/rule/interfaces/__init__.py +++ b/src/HABApp/rule/interfaces/__init__.py @@ -1,4 +1,2 @@ - -from .http import AsyncHttpConnection - +from HABApp.rule.interfaces import http_interface as http from .rule_subprocess import async_subprocess_exec, FinishedProcessInfo diff --git a/src/HABApp/rule/interfaces/_http.py b/src/HABApp/rule/interfaces/_http.py new file mode 100644 index 00000000..763477ea --- /dev/null +++ b/src/HABApp/rule/interfaces/_http.py @@ -0,0 +1,106 @@ +from typing import Any, Optional, Mapping + +import aiohttp + +from HABApp.core.const import loop +from HABApp.core.const.json import dump_json + + +CLIENT: Optional[aiohttp.ClientSession] = None + + +async def create_client(): + global CLIENT + assert CLIENT is None + + CLIENT = aiohttp.ClientSession(json_serialize=dump_json, loop=loop) + + from HABApp.runtime import shutdown + shutdown.register_func(CLIENT.close, msg='Closing generic http connection') + + +def get(url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ + -> aiohttp.client._RequestContextManager: + """http get request + + :param url: Request URL + :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) + to be sent as parameters in the query string of the new request. + `Params example + `_ + :param kwargs: See `aiohttp request `_ + for further possible kwargs + :return: awaitable + """ + assert CLIENT is not None + return CLIENT.get(url, params=params, **kwargs) + + +def post(url: str, params: Optional[Mapping[str, str]] = None, + data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: + """http post request + + :param url: Request URL + :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) + to be sent as parameters in the query string of the new request. + `Params example + `_ + :param data: Dictionary, bytes, or file-like object to send in the body of the request + (optional) + :param json: Any json compatible python object, json and data parameters could not be used at the same time. + (optional) + :param kwargs: See `aiohttp request `_ + for further possible kwargs + :return: awaitable + """ + assert CLIENT is not None + return CLIENT.post(url, params=params, data=data, json=json, **kwargs) + + +def put(url: str, params: Optional[Mapping[str, str]] = None, + data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: + """http put request + + :param url: Request URL + :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) + to be sent as parameters in the query string of the new request. + `Params example + `_ + :param data: Dictionary, bytes, or file-like object to send in the body of the request + (optional) + :param json: Any json compatible python object, json and data parameters could not be used at the same time. + (optional) + :param kwargs: See `aiohttp request `_ + for further possible kwargs + :return: awaitable + """ + assert CLIENT is not None + return CLIENT.put(url, params=params, data=data, json=json, **kwargs) + + +def delete(url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ + -> aiohttp.client._RequestContextManager: + """http delete request + + :param url: Request URL + :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) + to be sent as parameters in the query string of the new request. + `Params example + `_ + :param kwargs: See `aiohttp request `_ + for further possible kwargs + :return: awaitable + """ + assert CLIENT is not None + return CLIENT.delete(url, params=params, **kwargs) + + +def get_client_session() -> aiohttp.ClientSession: + """Return the aiohttp + `client session object `_ + for use in aiohttp libraries + + :return: session object + """ + assert CLIENT is not None + return CLIENT diff --git a/src/HABApp/rule/interfaces/http.py b/src/HABApp/rule/interfaces/http.py deleted file mode 100644 index 20c7a8c5..00000000 --- a/src/HABApp/rule/interfaces/http.py +++ /dev/null @@ -1,91 +0,0 @@ -from typing import Any, Optional, Mapping - -import aiohttp - -from HABApp.core.const import loop -from HABApp.core.const.json import dump_json - - -class AsyncHttpConnection: - - def __init__(self): - self.__client: aiohttp.ClientSession = None - - async def create_client(self): - assert self.__client is None - - self.__client = aiohttp.ClientSession(json_serialize=dump_json, loop=loop) - - from HABApp.runtime import shutdown - shutdown.register_func(self.__client.close, msg='Closing generic http connection') - - def get(self, url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ - -> aiohttp.client._RequestContextManager: - """http get request - - :param url: Request URL - :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) - to be sent as parameters in the query string of the new request. - `Params example - `_ - :param data: Dictionary, bytes, or file-like object to send in the body of the request - (optional) - :param json: Any json compatible python object, json and data parameters could not be used at the same time. - (optional) - :param kwargs: See `aiohttp request `_ - for further possible kwargs - :return: awaitable - """ - return self.__client.get(url, params=params, **kwargs) - - def post(self, url: str, params: Optional[Mapping[str, str]] = None, - data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: - """http post request - - :param url: Request URL - :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) - to be sent as parameters in the query string of the new request. - `Params example - `_ - :param data: Dictionary, bytes, or file-like object to send in the body of the request - (optional) - :param json: Any json compatible python object, json and data parameters could not be used at the same time. - (optional) - :param kwargs: See `aiohttp request `_ - for further possible kwargs - :return: awaitable - """ - return self.__client.post(url, params=params, data=data, json=json, **kwargs) - - def put(self, url: str, params: Optional[Mapping[str, str]] = None, - data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: - """http put request - - :param url: Request URL - :param params: Mapping, iterable of tuple of key/value pairs (e.g. dict) - to be sent as parameters in the query string of the new request. - `Params example - `_ - :param data: Dictionary, bytes, or file-like object to send in the body of the request - (optional) - :param json: Any json compatible python object, json and data parameters could not be used at the same time. - (optional) - :param kwargs: See `aiohttp request `_ - for further possible kwargs - :return: awaitable - """ - return self.__client.put(url, params=params, data=data, json=json, **kwargs) - - def delete(self, url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ - -> aiohttp.client._RequestContextManager: - - return self.__client.delete(url, params=params, **kwargs) - - def get_client_session(self) -> aiohttp.ClientSession: - """Return the aiohttp - `client session object `_ - for use in aiohttp libraries - - :return: session object - """ - return self.__client diff --git a/src/HABApp/rule/interfaces/http_interface.py b/src/HABApp/rule/interfaces/http_interface.py new file mode 100644 index 00000000..6b29f1cc --- /dev/null +++ b/src/HABApp/rule/interfaces/http_interface.py @@ -0,0 +1 @@ +from HABApp.rule.interfaces._http import get, post, put, delete, get_client_session diff --git a/src/HABApp/rule/interfaces/rule_subprocess.py b/src/HABApp/rule/interfaces/rule_subprocess.py index 75ba0fd5..4b55eb17 100644 --- a/src/HABApp/rule/interfaces/rule_subprocess.py +++ b/src/HABApp/rule/interfaces/rule_subprocess.py @@ -20,7 +20,6 @@ async def async_subprocess_exec(callback, program: str, *args, capture_output=Tr proc = None stdout = None stderr = None - ret_code = None try: proc = await asyncio.create_subprocess_exec( diff --git a/src/HABApp/rule/rule.py b/src/HABApp/rule/rule.py index bf0abda0..3db7c28e 100644 --- a/src/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -2,11 +2,13 @@ import datetime import logging import random +import re import sys import traceback import typing import warnings import weakref +from typing import Iterable, Union import HABApp import HABApp.core @@ -14,9 +16,10 @@ import HABApp.rule_manager import HABApp.util from HABApp.core.events import AllEvents -from .interfaces import async_subprocess_exec +from HABApp.core.items.base_item import BaseItem, TYPE_ITEM, TYPE_ITEM_CLS +from HABApp.rule import interfaces from HABApp.rule.scheduler import HABAppSchedulerView as _HABAppSchedulerView - +from .interfaces import async_subprocess_exec log = logging.getLogger('HABApp.Rule') @@ -78,7 +81,7 @@ def __init__(self): self.rule_name: str = self.__rule_file.suggest_rule_name(self) # interfaces - self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = self.__runtime.async_http if not test else None + self.async_http = interfaces.http self.mqtt: HABApp.mqtt.interface = HABApp.mqtt.interface self.oh: HABApp.openhab.interface = HABApp.openhab.interface self.openhab: HABApp.openhab.interface = self.oh @@ -120,10 +123,10 @@ def post_event(self, name, event): event ) - def listen_event(self, name: typing.Union[HABApp.core.items.BaseValueItem, str], + def listen_event(self, name: Union[HABApp.core.items.BaseValueItem, str], callback: typing.Callable[[typing.Any], typing.Any], - event_type: typing.Union[typing.Type['HABApp.core.events.AllEvents'], - 'HABApp.core.events.EventFilter', typing.Any] = AllEvents + event_type: Union[typing.Type['HABApp.core.events.AllEvents'], + 'HABApp.core.events.EventFilter', typing.Any] = AllEvents ) -> HABApp.core.EventBusListener: """ Register an event listener @@ -167,7 +170,7 @@ def execute_subprocess(self, callback, program, *args, capture_output=True): HABApp.core.const.loop ) - def get_rule(self, rule_name: str) -> 'typing.Union[Rule, typing.List[Rule]]': + def get_rule(self, rule_name: str) -> 'Union[Rule, typing.List[Rule]]': assert rule_name is None or isinstance(rule_name, str), type(rule_name) return self.__runtime.rule_manager.get_rule(rule_name) @@ -189,11 +192,59 @@ def register_cancel_obj(self, obj): """ self.__cancel_objs.add(obj) + @staticmethod + def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = None, + name: Union[str, typing.Pattern[str]] = None, + tags: Union[str, Iterable[str]] = None, + groups: Union[str, Iterable[str]] = None + ) -> Union[typing.List[TYPE_ITEM], typing.List[BaseItem]]: + """Search the HABApp item registry and return the found items. + + :param type: item has to be an instance of this class + :param name: str (will be compiled) or regex that is used to search the Name + :param tags: item must have these tags (will return only instances of OpenhabItem) + :param groups: item must be a member of these groups (will return only instances of OpenhabItem) + :return: Items that match all the passed criteria + """ + + if name is not None: + if isinstance(name, str): + name = re.compile(name, re.IGNORECASE) + + _tags, _groups = None, None + if tags is not None: + _tags = set(tags) if not isinstance(tags, str) else {tags} + if groups is not None: + _groups = set(groups) if not isinstance(groups, str) else {groups} + + OpenhabItem = HABApp.openhab.items.OpenhabItem + if _tags or _groups: + if type is None: + type = OpenhabItem + if not issubclass(type, OpenhabItem): + raise ValueError('Searching for tags and groups only works for OpenhabItem or its Subclasses') + + ret = [] + for item in HABApp.core.Items.get_all_items(): # type: HABApp.core.items.base_valueitem.BaseItem + if type is not None and not isinstance(item, type): + continue + + if name is not None and not name.search(item.name): + continue + + if _tags is not None and not _tags.issubset(item.tags): + continue + + if _groups is not None and not _groups.issubset(item.groups): + continue + + ret.append(item) + return ret + # ----------------------------------------------------------------------------------------------------------------- # deprecated functions # ----------------------------------------------------------------------------------------------------------------- - def run_every(self, time, interval: typing.Union[int, datetime.timedelta], - callback, *args, **kwargs): + def run_every(self, time, interval: Union[int, datetime.timedelta], callback, *args, **kwargs): warnings.warn('self.run_every is deprecated. Please use self.run.every', DeprecationWarning) return self.run.every(time, interval, callback, *args, **kwargs) @@ -237,7 +288,7 @@ def run_at(self, date_time, callback, *args, **kwargs): warnings.warn('self.run_at is deprecated. Please use self.run.at', DeprecationWarning) return self.run.at(date_time, callback, *args, **kwargs) - def run_in(self, seconds: typing.Union[int, datetime.timedelta], callback, *args, **kwargs): + def run_in(self, seconds: Union[int, datetime.timedelta], callback, *args, **kwargs): warnings.warn('self.run_in is deprecated. Please use self.run.at', DeprecationWarning) return self.run.at(seconds, callback, *args, **kwargs) diff --git a/src/HABApp/rule/scheduler/habappschedulerview.py b/src/HABApp/rule/scheduler/habappschedulerview.py index 732c232f..be9e6557 100644 --- a/src/HABApp/rule/scheduler/habappschedulerview.py +++ b/src/HABApp/rule/scheduler/habappschedulerview.py @@ -1,17 +1,15 @@ import random from datetime import datetime as dt_datetime, time as dt_time, timedelta as dt_timedelta -from typing import Iterable, Union, TYPE_CHECKING +from typing import Iterable, Union -from HABApp.core import WrappedFunction -from HABApp.rule.scheduler.executor import WrappedFunctionExecutor -from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler from eascheduler import SchedulerView from eascheduler.jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ SunsetJob - -if TYPE_CHECKING: - import HABApp +import HABApp +from HABApp.core import WrappedFunction +from HABApp.rule.scheduler.executor import WrappedFunctionExecutor +from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler class HABAppSchedulerView(SchedulerView): diff --git a/src/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py index c51b2e9f..57642e9e 100644 --- a/src/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -1,3 +1,4 @@ +import asyncio from pathlib import Path import HABApp.config @@ -11,49 +12,57 @@ from HABApp.openhab import connection_logic as openhab_connection from HABApp.runtime import shutdown +import HABApp.rule.interfaces._http + class Runtime: def __init__(self): self.config: HABApp.config.Config = None - self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = HABApp.rule.interfaces.AsyncHttpConnection() - # Rule engine self.rule_manager: HABApp.rule_manager.RuleManager = None # Async Workers & shutdown callback shutdown.register_func(HABApp.core.WrappedFunction._WORKERS.shutdown, msg='Stopping workers') - @HABApp.core.wrapper.log_exception async def start(self, config_folder: Path): - HABApp.core.context.async_context.set('HABApp startup') + try: + HABApp.core.context.async_context.set('HABApp startup') - # setup exception handler for the scheduler - eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) + # setup exception handler for the scheduler + eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) - # Start Folder watcher! - HABApp.core.files.watcher.start() + # Start Folder watcher! + HABApp.core.files.watcher.start() - self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) + self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) - await HABApp.core.files.setup() + await HABApp.core.files.setup() - # MQTT - HABApp.mqtt.mqtt_connection.setup() - HABApp.mqtt.mqtt_connection.connect() + # generic HTTP + await HABApp.rule.interfaces._http.create_client() - # openhab - openhab_connection.setup() + # openhab + openhab_connection.setup() - # Parameter Files - await HABApp.parameters.parameter_files.setup_param_files() + # Parameter Files + await HABApp.parameters.parameter_files.setup_param_files() - # Rule engine - self.rule_manager = HABApp.rule_manager.RuleManager(self) - await self.rule_manager.setup() + # Rule engine + self.rule_manager = HABApp.rule_manager.RuleManager(self) + await self.rule_manager.setup() + + # MQTT + HABApp.mqtt.mqtt_connection.setup() + HABApp.mqtt.mqtt_connection.connect() - await self.async_http.create_client() - await openhab_connection.start() + await openhab_connection.start() - shutdown.register_func(HABApp.core.const.loop.stop, msg='Stopping asyncio loop') + shutdown.register_func(HABApp.core.const.loop.stop, msg='Stopping asyncio loop') + except asyncio.CancelledError: + pass + except Exception as e: + process_exception('Runtime.start', e) + await asyncio.sleep(1) # Sleep so we can do a graceful shutdown + shutdown.request_shutdown() diff --git a/src/HABApp/util/functions/__init__.py b/src/HABApp/util/functions/__init__.py index 4fba70f2..34b55cf9 100644 --- a/src/HABApp/util/functions/__init__.py +++ b/src/HABApp/util/functions/__init__.py @@ -1,2 +1,2 @@ -from .min_max import min, max from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb +from HABApp.util.functions.min_max import min, max diff --git a/tests/conftest.py b/tests/conftest.py index 718cd446..9befdcee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,11 +36,6 @@ def show_errors(monkeypatch): monkeypatch.setattr(HABApp.core.wrapper, 'ignore_exception', raise_err) monkeypatch.setattr(HABApp.core.wrapper, 'log_exception', raise_err) - # Delete all existing items/listener from previous tests - HABApp.core.EventBus.remove_all_listeners() - for name in HABApp.core.Items.get_all_item_names(): - HABApp.core.Items.pop_item(name) - @pytest.yield_fixture(autouse=True, scope='function') def use_dummy_cfg(monkeypatch): @@ -54,3 +49,13 @@ def use_dummy_cfg(monkeypatch): @pytest.yield_fixture(autouse=True, scope='function') def event_loop(): yield HABApp.core.const.loop + + +@pytest.yield_fixture(autouse=True, scope='function') +def cleanup_registry(): + yield + + # Delete all existing items/listener from previous tests + HABApp.core.EventBus.remove_all_listeners() + for name in HABApp.core.Items.get_all_item_names(): + HABApp.core.Items.pop_item(name) diff --git a/tests/test_core/test_items/test_item_search.py b/tests/test_core/test_items/test_item_search.py new file mode 100644 index 00000000..a1428a1c --- /dev/null +++ b/tests/test_core/test_items/test_item_search.py @@ -0,0 +1,62 @@ +import pytest + +from HABApp import Rule +from HABApp.core import Items +from HABApp.core.items import Item, BaseValueItem +from HABApp.openhab.items import OpenhabItem, SwitchItem + + +def test_search_type(): + item1 = BaseValueItem('item_1') + item2 = Item('item_2') + + assert Rule.get_items() == [] + + Items.add_item(item1) + Items.add_item(item2) + + assert Rule.get_items() == [item1, item2] + assert Rule.get_items(type=BaseValueItem) == [item1, item2] + assert Rule.get_items(type=(BaseValueItem, Item)) == [item1, item2] + + assert Rule.get_items(type=Item) == [item2] + + +def test_search_oh(): + item1 = OpenhabItem('oh_item_1', tags=frozenset(['tag1', 'tag2', 'tag3']), groups=frozenset(['grp1', 'grp2'])) + item2 = SwitchItem('oh_item_2', tags=frozenset(['tag1', 'tag2', 'tag4']), groups=frozenset(['grp2', 'grp3'])) + item3 = Item('item_2') + + assert Rule.get_items() == [] + + Items.add_item(item1) + Items.add_item(item2) + Items.add_item(item3) + + assert Rule.get_items() == [item1, item2, item3] + assert Rule.get_items(tags='tag2') == [item1, item2] + assert Rule.get_items(tags='tag4') == [item2] + + assert Rule.get_items(groups='grp1') == [item1] + assert Rule.get_items(groups='grp2') == [item1, item2] + + assert Rule.get_items(groups='grp1', tags='tag1') == [item1] + assert Rule.get_items(groups='grp2', tags='tag4') == [item2] + + +def test_classcheck(): + with pytest.raises(ValueError): + Rule.get_items(Item, tags='asdf') + + +def test_search_name(): + item1 = BaseValueItem('item_1a') + item2 = Item('item_2a') + + assert Rule.get_items() == [] + + Items.add_item(item1) + Items.add_item(item2) + + assert Rule.get_items() == [item1, item2] + assert Rule.get_items(name=r'\da') == [item1, item2] diff --git a/tests/test_mqtt/test_retain.py b/tests/test_mqtt/test_retain.py index 85fcccdb..a798292c 100644 --- a/tests/test_mqtt/test_retain.py +++ b/tests/test_mqtt/test_retain.py @@ -5,6 +5,7 @@ class MqttDummyMsg: def __init__(self, topic='', payload='', retain=False): self.topic = topic + self._topic = topic.encode('utf-8') self.payload = payload.encode('utf-8') self.retain = retain self.qos = 0 diff --git a/tests/test_mqtt/test_values.py b/tests/test_mqtt/test_values.py new file mode 100644 index 00000000..bcd26e84 --- /dev/null +++ b/tests/test_mqtt/test_values.py @@ -0,0 +1,27 @@ +import pytest +from paho.mqtt.client import MQTTMessage + +from HABApp.mqtt.mqtt_payload import get_msg_payload + + +@pytest.mark.parametrize( + 'payload, expected', ( + ('none', None), + ('None', None), + ('true', True), + ('True', True), + ('false', False), + ('False', False), + ('1', 1), + ('-1', -1), + ('0.1', 0.1), + ('-0.1', -0.1), + ('asdf', 'asdf'), + ('[asdf]', '[asdf]'), + (b'\x07\x07', '\x07\x07'), + ) +) +def test_value_cast(payload, expected): + msg = MQTTMessage(topic=b'test_topic') + msg.payload = payload.encode('utf-8') if not isinstance(payload, bytes) else payload + assert get_msg_payload(msg) == ('test_topic', expected) diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index 06941eea..d541bddd 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -41,6 +41,23 @@ def test_ItemAddedEvent2(): assert isinstance(event, ItemAddedEvent) assert event.name == 'TestColor_OFF' assert event.type == 'Color' + assert event.tags == frozenset() + assert event.groups == frozenset(["TestGroup"]) + assert str(event) == '' + + event = get_event({ + 'topic': 'openhab/items/TestColor_OFF/added', + 'payload': '{"type":"Color","name":"TestColor_OFF","tags":["test_tag","tag2"],"groupNames":["TestGroup"]}', + 'type': 'ItemAddedEvent' + }) + assert isinstance(event, ItemAddedEvent) + assert event.name == 'TestColor_OFF' + assert event.type == 'Color' + assert event.tags == frozenset(['test_tag', 'tag2']) + assert event.groups == frozenset(['TestGroup']) + + assert str(event) == '' def test_ItemUpdatedEvent(): @@ -53,6 +70,22 @@ def test_ItemUpdatedEvent(): assert isinstance(event, ItemUpdatedEvent) assert event.name == 'NameUpdated' assert event.type == 'Switch' + assert event.tags == frozenset() + assert event.groups == frozenset() + assert str(event) == '' + + event = get_event({ + 'topic': 'openhab/items/NameUpdated/updated', + 'payload': '[{"type":"Switch","name":"Test","tags":["tag5","tag1"],"groupNames":["def","abc"]},' + '{"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', + 'type': 'ItemUpdatedEvent' + }) + assert isinstance(event, ItemUpdatedEvent) + assert event.name == 'NameUpdated' + assert event.type == 'Switch' + assert event.tags == frozenset(['tag1', 'tag5']) + assert event.groups == frozenset(['abc', 'def']) + assert str(event) == '' def test_ItemStateChangedEvent1(): diff --git a/tests/test_openhab/test_items/test_image.py b/tests/test_openhab/test_items/test_image.py index 3eadf631..569c595c 100644 --- a/tests/test_openhab/test_items/test_image.py +++ b/tests/test_openhab/test_items/test_image.py @@ -7,7 +7,9 @@ def test_image_load(): i = map_item( 'localCurrentConditionIcon', 'Image', - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAD8CAYAAABTq8lnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFIGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTgtMDgtMTdUMTQ6MTc6NTAtMDQ6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjJiNzE4NDBmLTE2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" # noqa: E501 + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAD8CAYAAABTq8lnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFIGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTgtMDgtMTdUMTQ6MTc6NTAtMDQ6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjJiNzE4NDBmLTE2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", # noqa: E501 + tags=frozenset(), + groups=frozenset(), ) assert isinstance(i, ImageItem) diff --git a/tests/test_openhab/test_items/test_mapping.py b/tests/test_openhab/test_items/test_mapping.py index 87fe7f9c..30055f19 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -4,27 +4,27 @@ def test_exception(): - assert map_item('test', 'Number', 'asdf') is None + assert map_item('test', 'Number', 'asdf', frozenset(), frozenset()) is None def test_number_unit_of_measurement(): - assert map_item('test1', 'Number:Length', '1.0 m') == NumberItem('test', 1) - assert map_item('test2', 'Number:Temperature', '2.0 °C') == NumberItem('test', 2) - assert map_item('test3', 'Number:Pressure', '3.0 hPa') == NumberItem('test', 3) - assert map_item('test4', 'Number:Speed', '4.0 km/h') == NumberItem('test', 4) - assert map_item('test5', 'Number:Intensity', '5.0 W/m2') == NumberItem('test', 5) - assert map_item('test6', 'Number:Dimensionless', '6.0') == NumberItem('test', 6) - assert map_item('test7', 'Number:Angle', '7.0 °') == NumberItem('test', 7) + assert map_item('test1', 'Number:Length', '1.0 m', frozenset(), frozenset()) == NumberItem('test', 1) + assert map_item('test2', 'Number:Temperature', '2.0 °C', frozenset(), frozenset()) == NumberItem('test', 2) + assert map_item('test3', 'Number:Pressure', '3.0 hPa', frozenset(), frozenset()) == NumberItem('test', 3) + assert map_item('test4', 'Number:Speed', '4.0 km/h', frozenset(), frozenset()) == NumberItem('test', 4) + assert map_item('test5', 'Number:Intensity', '5.0 W/m2', frozenset(), frozenset()) == NumberItem('test', 5) + assert map_item('test6', 'Number:Dimensionless', '6.0', frozenset(), frozenset()) == NumberItem('test', 6) + assert map_item('test7', 'Number:Angle', '7.0 °', frozenset(), frozenset()) == NumberItem('test', 7) def test_datetime(): # Todo: remove this test once we go >= OH3.1 # Old format - assert map_item('test1', 'DateTime', '2018-11-19T09:47:38.284+0000') == \ + assert map_item('test1', 'DateTime', '2018-11-19T09:47:38.284+0000', frozenset(), frozenset()) == \ DatetimeItem('test', datetime(2018, 11, 19, 9, 47, 38, 284000)) or \ DatetimeItem('test', datetime(2018, 11, 19, 10, 47, 38, 284000)) # From >= OH3.1 - assert map_item('test1', 'DateTime', '2021-04-10T21:00:43.043996+0000') == \ + assert map_item('test1', 'DateTime', '2021-04-10T21:00:43.043996+0000', frozenset(), frozenset()) == \ DatetimeItem('test', datetime(2021, 4, 10, 21, 0, 43, 43996)) or \ DatetimeItem('test', datetime(2021, 4, 10, 23, 0, 43, 43996)) diff --git a/tests/test_openhab/test_rest/test_items.py b/tests/test_openhab/test_rest/test_items.py index b35f3e57..010009aa 100644 --- a/tests/test_openhab/test_rest/test_items.py +++ b/tests/test_openhab/test_rest/test_items.py @@ -23,7 +23,7 @@ def test_item_1(): "type": "Contact", "name": "Item1Name", "label": "Item1Label", - "tags": [], + "tags": ["Tag1"], "groupNames": ["Group1", "Group2"] } item = OpenhabItemDefinition.parse_obj(_in) # type: OpenhabItemDefinition @@ -32,6 +32,7 @@ def test_item_1(): assert item.label == 'Item1Label' assert item.state == 'CLOSED' assert item.transformed_state == 'zu' + assert item.tags == ["Tag1"] assert item.groups == ["Group1", "Group2"] diff --git a/tests/test_openhab/test_rest/test_links.py b/tests/test_openhab/test_rest/test_links.py index c12342b1..7cb7b57f 100644 --- a/tests/test_openhab/test_rest/test_links.py +++ b/tests/test_openhab/test_rest/test_links.py @@ -40,5 +40,6 @@ def test_creation(): assert o.dict(by_alias=True) == { "channelUID": "zwave:device:controller:node15:sensor_luminance", "itemName": "ZWaveItem1", - "configuration": {} + "configuration": {}, + 'editable': False, }