From decea19e5d4a54fdcdbbb61edfe5ff96ab068e6a Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Tue, 9 Aug 2022 12:53:09 +0200 Subject: [PATCH] 1.0.3 --- .gitattributes | 6 + .pre-commit-config.yaml | 2 +- docs/getting_started.rst | 71 +++++++++-- docs/troubleshooting.rst | 22 ++++ mypy.ini | 6 +- readme.md | 21 +++- requirements_setup.txt | 2 +- requirements_tests.txt | 2 +- .../test_rule/test_case/test_case.py | 3 +- .../lib/HABAppTests/test_rule/test_rule.py | 4 +- run/conf_testing/rules/openhab/test_things.py | 13 ++ src/HABApp/__version__.py | 2 +- src/HABApp/config/logging/config.py | 2 +- src/HABApp/config/models/mqtt.py | 5 +- src/HABApp/core/asyncio.py | 2 +- src/HABApp/core/const/hints.py | 7 +- src/HABApp/core/files/folders/folders.py | 8 +- src/HABApp/core/internals/context/context.py | 4 +- src/HABApp/core/internals/proxy/proxy_obj.py | 8 +- .../core/internals/wrapped_function/base.py | 4 +- src/HABApp/core/items/base_item_times.py | 13 +- src/HABApp/core/items/item.py | 5 +- src/HABApp/core/items/item_aggregation.py | 3 +- src/HABApp/core/items/item_color.py | 6 +- src/HABApp/core/lib/exceptions/format.py | 2 +- src/HABApp/mqtt/mqtt_connection.py | 1 + .../openhab/connection_handler/func_async.py | 15 +++ .../openhab/connection_handler/func_sync.py | 16 ++- .../plugin_things/items_file.py | 4 +- src/HABApp/openhab/interface.py | 2 +- src/HABApp/openhab/interface_async.py | 2 +- src/HABApp/openhab/items/color_item.py | 6 +- src/HABApp/openhab/items/thing_item.py | 11 +- .../rule/scheduler/habappschedulerview.py | 47 ++++---- src/HABApp/rule_ctx/rule_ctx.py | 4 +- src/HABApp/util/multimode/item.py | 113 +++++++++--------- src/HABApp/util/multimode/mode_base.py | 15 +-- src/HABApp/util/multimode/mode_switch.py | 2 +- src/HABApp/util/multimode/mode_value.py | 12 +- tests/test_openhab/test_interface_sync.py | 3 +- tests/test_utils/test_multivalue.py | 54 ++++++++- tox.ini | 1 + 42 files changed, 369 insertions(+), 162 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..676d56e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto + +*.py text +*.rst text +*.yml text +*.yaml text diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffac85d8..97a79ddc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,6 @@ repos: # name: isort (python) - repo: https://gitlab.com/PyCQA/flake8 - rev: '3.9.1' + rev: '4.0.1' hooks: - id: flake8 diff --git a/docs/getting_started.rst b/docs/getting_started.rst index d9651887..41578d05 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -89,6 +89,9 @@ a list of openHAB items and adds them to the internal registry. Rules and HABApp derived libraries may add additional local items which can be used to share states across rules and/or files. +Access +"""""""""""""""""""""""""""""""""""""" + An item is created and added to the item registry through the corresponding class factory method .. exec_code:: @@ -104,12 +107,14 @@ An item is created and added to the item registry through the corresponding clas # This will create an item in the local (HABApp) item registry item = Item.get_create_item("an-item-name", "a value") +Values +"""""""""""""""""""""""""""""""""""""" + Posting values from the item will automatically create the events on the event bus. 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 `). .. exec_code:: - :caption: Output # ------------ hide: start ------------ import logging @@ -158,9 +163,56 @@ To access items from openHAB use the correct openHAB item type (see :ref:`the op # ------------ hide: stop ------------- +Timestamps +"""""""""""""""""""""""""""""""""""""" + +All items have two additional timestamps set which can be used to simplify rule logic. + +* The time when the item was last updated +* The time when the item was last changed. + + +.. exec_code:: + + # ------------ hide: start ------------ + from pendulum import DateTime + from HABApp.core.items import Item + from rule_runner import SimpleRuleRunner + + runner = SimpleRuleRunner() + runner.set_up() + + item = Item.get_create_item('Item_Name', initial_value='old_value') + item._last_update.dt = DateTime(2022, 8, 20, 12, 16) + item._last_change.dt = DateTime(2022, 8, 20, 10, 30) + + # ------------ hide: stop ------------- + import HABApp + from HABApp.core.items import Item + + class TimestampRule(HABApp.Rule): + def __init__(self): + super().__init__() + # This item was created by another rule, that's why "get_item" is used + self.my_item = Item.get_item('Item_Name') + + # Access of timestamps + print(f'Last update: {self.my_item.last_update}') + print(f'Last change: {self.my_item.last_change}') + + TimestampRule() + + # ------------ hide: start ------------ + runner.tear_down() + # ------------ hide: stop ------------- + + + Watch items for events ------------------------------ It is possible to watch items for changes or updates. +The ``listen_event`` function takes an instance of ``EventFilter`` which describes the kind of event that will be +passed to the callback. .. exec_code:: @@ -186,25 +238,26 @@ It is possible to watch items for changes or updates. # Run this function whenever the item receives an ValueUpdateEvent self.listen_event(self.my_item, self.item_updated, ValueUpdateEventFilter()) - # Run this function whenever the item receives an ValueChangeEvent - self.listen_event(self.my_item, self.item_changed, ValueChangeEventFilter()) - # If you already have an item you can use the more convenient method of the item - # This is the recommended way to use event listener + # This is the recommended way to use the event listener + self.my_item.listen_event(self.item_updated, ValueUpdateEventFilter()) + + # Run this function whenever the item receives an ValueChangeEvent self.my_item.listen_event(self.item_changed, ValueChangeEventFilter()) # the function has 1 argument which is the event + def item_updated(self, event: ValueUpdateEvent): + print(f'{event.name} updated value: "{event.value}"') + print(f'Last update of {self.my_item.name}: {self.my_item.last_update}') + def item_changed(self, event: ValueChangeEvent): print(f'{event.name} changed from "{event.old_value}" to "{event.value}"') print(f'Last change of {self.my_item.name}: {self.my_item.last_change}') - def item_updated(self, event: ValueUpdateEvent): - print(f'{event.name} updated value: "{event.value}"') - print(f'Last update of {self.my_item.name}: {self.my_item.last_update}') MyFirstRule() # ------------ hide: start ------------ - i = Item.get_create_item('Item_Name') + i = Item.get_item('Item_Name') i.post_value('Changed value') runner.process_events() runner.tear_down() diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index b0968b1d..98e13ff8 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -2,6 +2,28 @@ Troubleshooting ************************************** +Warnings +====================================== + +Starting of took too long. +-------------------------------------- + +This warning appears in the HABApp log, e.g.:: + + Starting of MyRule.my_func took too long: 0.08s. Maybe there are not enough threads? + +It means that the duration from when the event was received to the start of the execution of the function +took longer than expected. + +This can be the case if suddenly many events are received at once. +Another reason for this warning might be that currently running function calls take too long to finish and thus no free +workers are available. This can either be the case for complex calculations, +but most of the time it's blocking function calls or a ``time.sleep`` call. + +If these warnings pile up in the log it's an indicator that the worker is congested. +Make sure there is no use of long sleeps and instead the scheduler is used. + +If this warning only appears now and then it can be ignored. Errors ====================================== diff --git a/mypy.ini b/mypy.ini index f4053b42..8503b827 100644 --- a/mypy.ini +++ b/mypy.ini @@ -18,9 +18,6 @@ ignore_missing_imports = True [mypy-aiohttp_sse_client] ignore_missing_imports = True -[mypy-EasyCo] -ignore_missing_imports = True - [mypy-ruamel] ignore_missing_imports = True @@ -48,6 +45,9 @@ ignore_missing_imports = True [mypy-watchdog.observers] ignore_missing_imports = True +[mypy-stack_data] +ignore_missing_imports = True + #------------------------------------------------------------------------------ # Test libraries #------------------------------------------------------------------------------ diff --git a/readme.md b/readme.md index c3bc6938..baa00b87 100644 --- a/readme.md +++ b/readme.md @@ -31,7 +31,8 @@ import datetime import random import HABApp -from HABApp.core.events import ValueUpdateEvent, ValueChangeEventFilter +from HABApp.mqtt.items import MqttItem +from HABApp.core.events import ValueChangeEvent, ValueChangeEventFilter, ValueUpdateEvent, ValueUpdateEventFilter class ExampleMqttTestRule(HABApp.Rule): @@ -44,7 +45,13 @@ class ExampleMqttTestRule(HABApp.Rule): callback=self.publish_rand_value ) - self.listen_event('test/test', self.topic_updated, ValueChangeEventFilter()) + # this will trigger every time a message is received under "test/test" + self.listen_event('test/test', self.topic_updated, ValueUpdateEventFilter()) + + # This will create an item which will store the payload of the topic so it can be accessed later. + self.item = MqttItem.get_create_item('test/value_stored') + # Since the payload is now stored we can trigger only if the value has changed + self.item.listen_event(self.item_topic_updated, ValueChangeEventFilter()) def publish_rand_value(self): print('test mqtt_publish') @@ -54,6 +61,10 @@ class ExampleMqttTestRule(HABApp.Rule): assert isinstance(event, ValueUpdateEvent), type(event) print( f'mqtt topic "test/test" updated to {event.value}') + def item_topic_updated(self, event: ValueChangeEvent): + print(self.item.value) # will output the current item value + print( f'mqtt topic "test/value_stored" changed from {event.old_value} to {event.value}') + ExampleMqttTestRule() ``` @@ -106,6 +117,12 @@ MyOpenhabRule() ``` # Changelog +#### 1.0.3 (09.08.2022) +- OpenHAB Thing can now be enabled/disabled with ``thing.set_enabled()`` +- ClientID for MQTT should now be unique for every HABApp installation +- Reworked MultiModeItem, now a default value is possible when no mode is active +- Added some type hints and updated documentation + #### 1.0.2 (29.07.2022) - Fixed setup issues - Fixed unnecessary long tracebacks diff --git a/requirements_setup.txt b/requirements_setup.txt index 20aeaf3e..16758aca 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -7,7 +7,7 @@ ujson >= 5.4, < 5.5 paho-mqtt >= 1.6, < 1.7 immutables == 0.18 -eascheduler == 0.1.6 +eascheduler == 0.1.7 easyconfig == 0.2.4 stack_data == 0.3.0 diff --git a/requirements_tests.txt b/requirements_tests.txt index 0f6a6fbd..7b22dfdf 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -7,4 +7,4 @@ # Packages to run source tests # ----------------------------------------------------------------------------- pytest >= 7.1, < 8 -pytest-asyncio >= 0.18.3, < 0.19 +pytest-asyncio >= 0.19, < 0.20 diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py index f7782566..c3c4526c 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py @@ -1,11 +1,12 @@ import time +from typing import Callable 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={}): + def __init__(self, name: str, func: Callable, args=[], kwargs={}): self.name = name self.func = func self.args = args diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py index 38766f7e..05b9c709 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py @@ -1,5 +1,5 @@ import logging -from typing import Dict +from typing import Dict, Callable from typing import List import HABApp @@ -48,7 +48,7 @@ def set_up(self): def tear_down(self): pass - def add_test(self, name, func: callable, *args, **kwargs): + 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 diff --git a/run/conf_testing/rules/openhab/test_things.py b/run/conf_testing/rules/openhab/test_things.py index bb1f6eec..a5e5939b 100644 --- a/run/conf_testing/rules/openhab/test_things.py +++ b/run/conf_testing/rules/openhab/test_things.py @@ -1,5 +1,6 @@ from HABAppTests import TestBaseRule from HABAppTests.utils import find_astro_sun_thing +from HABApp.openhab.items import Thing class OpenhabThings(TestBaseRule): @@ -7,9 +8,21 @@ class OpenhabThings(TestBaseRule): def __init__(self): super().__init__() self.add_test('ApiDoc', self.test_api) + self.add_test('Enable(API)', self.test_enabled_api) + self.add_test('Enable(Obj)', self.test_enabled_obj) def test_api(self): self.openhab.get_thing(find_astro_sun_thing()) + def test_enabled_api(self): + uid = find_astro_sun_thing() + assert self.oh.set_thing_enabled(uid, False) == 200 + assert self.oh.set_thing_enabled(uid, True) == 200 + + def test_enabled_obj(self): + thing = Thing.get_item(find_astro_sun_thing()) + assert thing.set_enabled(False) == 200 + assert thing.set_enabled(True) == 200 + OpenhabThings() diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index a6221b3d..3f6fab60 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' diff --git a/src/HABApp/config/logging/config.py b/src/HABApp/config/logging/config.py index 07a23e02..74435db8 100644 --- a/src/HABApp/config/logging/config.py +++ b/src/HABApp/config/logging/config.py @@ -148,7 +148,7 @@ def inject_log_buffer(cfg: dict, log: BufferedLogger): q_handlers: List[HABAppQueueHandler] = [] for handler_name, buffered_handler_name in buffered_handlers.items(): - q = SimpleQueue() + q: SimpleQueue = SimpleQueue() handler_cfg[buffered_handler_name] = {'class': 'logging.handlers.QueueHandler', 'queue': q} qh = HABAppQueueHandler(q, handler_name, f'LogBuffer{handler_name:s}') diff --git a/src/HABApp/config/models/mqtt.py b/src/HABApp/config/models/mqtt.py index ac00cff9..d89b0952 100644 --- a/src/HABApp/config/models/mqtt.py +++ b/src/HABApp/config/models/mqtt.py @@ -1,3 +1,5 @@ +import random +import string import sys from pathlib import Path from typing import Optional, Tuple @@ -25,7 +27,8 @@ class TLSSettings(BaseModel): class Connection(BaseModel): - client_id: str = 'HABApp' + client_id: str = Field('HABApp-' + ''.join(random.choices(string.ascii_letters, k=13)), + description='ClientId that is used to uniquely identify this client on the mqtt broker.') host: str = Field('', description='Connect to this host. Empty string ("") disables the connection.') port: int = 1883 user: str = '' diff --git a/src/HABApp/core/asyncio.py b/src/HABApp/core/asyncio.py index d2b6d8c7..619441f1 100644 --- a/src/HABApp/core/asyncio.py +++ b/src/HABApp/core/asyncio.py @@ -32,7 +32,7 @@ def create_task(coro: _Coroutine, name: _Optional[str] = None) -> _Future: def run_coro_from_thread(coro: _Coroutine[_Any, _Any, _CORO_RET], calling: _Callable) -> _CORO_RET: - # This function call is blocking so it can't be called in the async context + # This function call is blocking, so it can't be called in the async context if async_context.get(None) is not None: raise AsyncContextError(calling) diff --git a/src/HABApp/core/const/hints.py b/src/HABApp/core/const/hints.py index 27e8455f..4b6e9c0e 100644 --- a/src/HABApp/core/const/hints.py +++ b/src/HABApp/core/const/hints.py @@ -3,17 +3,14 @@ from typing import Callable as __Callable from typing import Type as __Type -from .const import PYTHON_310 as __IS_GT_PYTHON_310 +from .const import PYTHON_310 as __IS_GE_PYTHON_310 -if __IS_GT_PYTHON_310: +if __IS_GE_PYTHON_310: from typing import TypeAlias else: from typing import Final as TypeAlias - HINT_ANY_CLASS: TypeAlias = __Type[object] HINT_FUNC_ASYNC: TypeAlias = __Callable[..., __Awaitable[__Any]] HINT_EVENT_CALLBACK: TypeAlias = __Callable[[__Any], __Any] - -HINT_SCHEDULER_CALLBACK: TypeAlias = __Callable[[], __Any] diff --git a/src/HABApp/core/files/folders/folders.py b/src/HABApp/core/files/folders/folders.py index 6f3afda9..8700853d 100644 --- a/src/HABApp/core/files/folders/folders.py +++ b/src/HABApp/core/files/folders/folders.py @@ -55,13 +55,13 @@ def add_folder(prefix: str, folder: Path, priority: int) -> ConfiguredFolder: def get_name(path: Path) -> str: - path = path.as_posix() + path_str = path.as_posix() for prefix, cfg in sorted(FOLDERS.items(), key=lambda x: len(x[0]), reverse=True): folder = cfg.folder.as_posix() - if path.startswith(folder): - return prefix + path[len(folder) + 1:] + if path_str.startswith(folder): + return prefix + path_str[len(folder) + 1:] - raise ValueError(f'Path "{path}" is not part of the configured folders!') + raise ValueError(f'Path "{path_str}" is not part of the configured folders!') def get_path(name: str) -> Path: diff --git a/src/HABApp/core/internals/context/context.py b/src/HABApp/core/internals/context/context.py index f45f777c..3f9f625b 100644 --- a/src/HABApp/core/internals/context/context.py +++ b/src/HABApp/core/internals/context/context.py @@ -1,4 +1,4 @@ -from typing import Set, Optional +from typing import Set, Optional, Callable from typing import TypeVar from HABApp.core.errors import ContextBoundObjectIsAlreadyLinkedError, ContextBoundObjectIsAlreadyUnlinkedError @@ -47,7 +47,7 @@ def link(self, obj: HINT_CONTEXT_BOUND_OBJ) -> HINT_CONTEXT_BOUND_OBJ: obj._ctx_link(self) return obj - def get_callback_name(self, callback: callable) -> Optional[str]: + def get_callback_name(self, callback: Callable) -> Optional[str]: raise NotImplementedError() diff --git a/src/HABApp/core/internals/proxy/proxy_obj.py b/src/HABApp/core/internals/proxy/proxy_obj.py index 23b63c8b..73816b28 100644 --- a/src/HABApp/core/internals/proxy/proxy_obj.py +++ b/src/HABApp/core/internals/proxy/proxy_obj.py @@ -1,5 +1,5 @@ import sys -from typing import Dict, List, Optional, Final +from typing import Dict, List, Optional, Final, Callable from HABApp.core.errors import ProxyObjHasNotBeenReplacedError @@ -31,8 +31,8 @@ def to_replace_name(self) -> str: class StartUpProxyObj(ProxyObjBase): - def __init__(self, to_replace: callable, globals: dict): - self.to_replace: Optional[callable] = to_replace + def __init__(self, to_replace: Callable, globals: dict): + self.to_replace: Optional[Callable] = to_replace self.globals: Optional[dict] = globals PROXIES.append(self) @@ -59,7 +59,7 @@ def replace(self, replacements: Dict[object, object], final: bool): self.to_replace = None -def create_proxy(to_replace: callable) -> StartUpProxyObj: +def create_proxy(to_replace: Callable) -> StartUpProxyObj: frm = sys._getframe(2) return StartUpProxyObj(to_replace, frm.f_globals) diff --git a/src/HABApp/core/internals/wrapped_function/base.py b/src/HABApp/core/internals/wrapped_function/base.py index a6c5e695..f641b170 100644 --- a/src/HABApp/core/internals/wrapped_function/base.py +++ b/src/HABApp/core/internals/wrapped_function/base.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, TypeVar +from typing import Optional, TypeVar, Callable from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS from HABApp.core.events.habapp_events import HABAppException @@ -13,7 +13,7 @@ class WrappedFunctionBase(ContextProvidingObj): - def __init__(self, func: callable, name: Optional[str] = None, logger: Optional[logging.Logger] = None, + def __init__(self, func: Callable, name: Optional[str] = None, logger: Optional[logging.Logger] = None, context: Optional[HINT_CONTEXT_OBJ] = None): # Allow setting of the rule context diff --git a/src/HABApp/core/items/base_item_times.py b/src/HABApp/core/items/base_item_times.py index 92df82d2..28c4344c 100644 --- a/src/HABApp/core/items/base_item_times.py +++ b/src/HABApp/core/items/base_item_times.py @@ -1,6 +1,5 @@ import logging -import typing -from typing import Generic, TypeVar, List +from typing import Generic, TypeVar, List, Union, Type from pendulum import DateTime @@ -10,16 +9,16 @@ log = logging.getLogger('HABApp') -WATCH_TYPE = TypeVar("WATCH_TYPE", bound=BaseWatch) +WATCH_OBJ = TypeVar("WATCH_OBJ", bound=BaseWatch) -class ItemTimes(Generic[WATCH_TYPE]): - WATCH: typing.Union[typing.Type[ItemNoUpdateWatch], typing.Type[ItemNoChangeWatch]] +class ItemTimes(Generic[WATCH_OBJ]): + WATCH: Union[Type[ItemNoUpdateWatch], Type[ItemNoChangeWatch]] def __init__(self, name: str, dt: DateTime): self.name: str = name self.dt: DateTime = dt - self.tasks: List[WATCH_TYPE] = [] + self.tasks: List[WATCH_OBJ] = [] def set(self, dt: DateTime, events=True): self.dt = dt @@ -30,7 +29,7 @@ def set(self, dt: DateTime, events=True): create_task(self.schedule_events()) return None - def add_watch(self, secs: typing.Union[int, float]) -> WATCH_TYPE: + def add_watch(self, secs: Union[int, float]) -> WATCH_OBJ: # don't add the watch two times for t in self.tasks: if not t.fut.is_canceled and t.fut.secs == secs: diff --git a/src/HABApp/core/items/item.py b/src/HABApp/core/items/item.py index e2cbe6d7..6e9da4a2 100644 --- a/src/HABApp/core/items/item.py +++ b/src/HABApp/core/items/item.py @@ -2,7 +2,6 @@ from HABApp.core.internals import uses_item_registry, uses_get_item from HABApp.core.items import BaseValueItem - get_item = uses_get_item() item_registry = uses_item_registry() @@ -11,12 +10,12 @@ class Item(BaseValueItem): """Simple item, used to store values in HABApp""" @classmethod - def get_create_item(cls, name: str, initial_value=None): + def get_create_item(cls, name: str, initial_value=None) -> 'Item': """Creates a new item in HABApp and returns it or returns the already existing one with the given name :param name: item name :param initial_value: state the item will have if it gets created - :return: item + :return: The item """ assert isinstance(name, str), type(name) diff --git a/src/HABApp/core/items/item_aggregation.py b/src/HABApp/core/items/item_aggregation.py index 4e0100a8..1b68109d 100644 --- a/src/HABApp/core/items/item_aggregation.py +++ b/src/HABApp/core/items/item_aggregation.py @@ -22,7 +22,8 @@ class AggregationItem(BaseValueItem): @classmethod def get_create_item(cls, name: str): - """Creates a new AggregationItem in HABApp and returns it or returns the already existing one with the given name + """Creates a new AggregationItem in HABApp and returns it or returns the + already existing item with the given name :param name: item name :return: item diff --git a/src/HABApp/core/items/item_color.py b/src/HABApp/core/items/item_color.py index fbd33235..5f01e432 100644 --- a/src/HABApp/core/items/item_color.py +++ b/src/HABApp/core/items/item_color.py @@ -43,7 +43,8 @@ def set_value(self, hue=0.0, saturation=0.0, brightness=0.0): return super().set_value(new_value=(self.hue, self.saturation, self.brightness)) def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): - """Set a new value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) + """Set a new value and post appropriate events on the HABApp event bus + (``ValueUpdateEvent``, ``ValueChangeEvent``) :param hue: hue (in °) :param saturation: saturation (in %) @@ -79,7 +80,8 @@ def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'Co return self def post_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': - """Set a new rgb value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) + """Set a new rgb value and post appropriate events on the HABApp event bus + (``ValueUpdateEvent``, ``ValueChangeEvent``) :param r: red value :param g: green value diff --git a/src/HABApp/core/lib/exceptions/format.py b/src/HABApp/core/lib/exceptions/format.py index c83d8d47..614a13f2 100644 --- a/src/HABApp/core/lib/exceptions/format.py +++ b/src/HABApp/core/lib/exceptions/format.py @@ -34,7 +34,7 @@ def fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: def format_exception(e: Union[Exception, Tuple[Any, Any, Any]]) -> List[str]: - tb = [] + tb: List[str] = [] try: all_frames = tuple(FrameInfo.stack_data(e[2] if isinstance(e, tuple) else e.__traceback__, DEFAULT_OPTIONS)) diff --git a/src/HABApp/mqtt/mqtt_connection.py b/src/HABApp/mqtt/mqtt_connection.py index 7b56df15..86a8daca 100644 --- a/src/HABApp/mqtt/mqtt_connection.py +++ b/src/HABApp/mqtt/mqtt_connection.py @@ -64,6 +64,7 @@ def connect(): ) if config.connection.tls.enabled: + log.debug("TLS enabled") # add option to specify tls certificate ca_cert = config.connection.tls.ca_cert if ca_cert is not None and ca_cert.name: diff --git a/src/HABApp/openhab/connection_handler/func_async.py b/src/HABApp/openhab/connection_handler/func_async.py index 83cb2c91..3b3b4cbe 100644 --- a/src/HABApp/openhab/connection_handler/func_async.py +++ b/src/HABApp/openhab/connection_handler/func_async.py @@ -213,6 +213,21 @@ async def async_set_thing_cfg(uid: str, cfg: typing.Dict[str, typing.Any]): return ret.status +async def async_set_thing_enabled(uid: str, enabled: bool): + ret = await put(f'/rest/things/{uid:s}/enable', data='true' if enabled else 'false') + if ret is None: + return None + + if ret.status == 404: + raise ThingNotFoundError.from_uid(uid) + elif ret.status == 409: + raise ThingNotEditableError.from_uid(uid) + elif ret.status >= 300: + raise ValueError('Something went wrong') + + return ret.status + + # --------------------------------------------------------------------------------------------------------------------- # Link handling is experimental # --------------------------------------------------------------------------------------------------------------------- diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py index 00476089..122827e7 100644 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ b/src/HABApp/openhab/connection_handler/func_sync.py @@ -7,7 +7,8 @@ from HABApp.core.asyncio import run_coro_from_thread, create_task from HABApp.core.items import BaseValueItem 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, \ +from .func_async import async_post_update, async_send_command, async_create_item, async_get_item, \ + async_get_thing, async_set_thing_enabled, \ async_set_metadata, async_remove_metadata, async_get_channel_link, async_create_channel_link, \ async_remove_channel_link, async_channel_link_exists, \ async_remove_item, async_item_exists, async_get_persistence_data, async_set_persistence_data @@ -193,6 +194,19 @@ def remove_metadata(item_name: str, namespace: str): return run_coro_from_thread(async_remove_metadata(item=item_name, namespace=namespace), calling=remove_metadata) +def set_thing_enabled(thing_name: str, enabled: bool = True): + """ + Enable/disable a thing + + :param thing_name: name of the thing or the thing object + :param enabled: True to enable thing, False to disable thing + """ + if isinstance(thing_name, BaseValueItem): + thing_name = thing_name.name + + return run_coro_from_thread(async_set_thing_enabled(uid=thing_name, enabled=enabled), calling=set_thing_enabled) + + def get_persistence_data(item_name: str, persistence: Optional[str], start_time: Optional[datetime.datetime], end_time: Optional[datetime.datetime]) -> OpenhabPersistenceData: diff --git a/src/HABApp/openhab/connection_logic/plugin_things/items_file.py b/src/HABApp/openhab/connection_logic/plugin_things/items_file.py index b13e0504..1d78ba0d 100644 --- a/src/HABApp/openhab/connection_logic/plugin_things/items_file.py +++ b/src/HABApp/openhab/connection_logic/plugin_things/items_file.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List +from typing import Dict, List, Optional import re from .cfg_validator import UserItem @@ -99,7 +99,7 @@ def create_items_file(path: Path, items_dict: Dict[str, UserItem]): 'bracket_close': '', } - grouped_items = {None: []} + grouped_items: Dict[Optional[str], List[Dict[str, str]]] = {None: []} for _name, _item in items_dict.items(): m = RE_GROUP_NAMES.match(_name) grp = grouped_items.setdefault(m.group(1) if m is not None else None, []) diff --git a/src/HABApp/openhab/interface.py b/src/HABApp/openhab/interface.py index b875be53..0a4e8f95 100644 --- a/src/HABApp/openhab/interface.py +++ b/src/HABApp/openhab/interface.py @@ -1,7 +1,7 @@ from HABApp.openhab.connection_handler.func_sync import \ post_update, send_command, \ get_item, item_exists, remove_item, create_item, \ - get_thing, \ + get_thing, set_thing_enabled, \ get_persistence_data, \ remove_metadata, set_metadata, \ get_channel_link, remove_channel_link, channel_link_exists, create_channel_link diff --git a/src/HABApp/openhab/interface_async.py b/src/HABApp/openhab/interface_async.py index d7850f2a..a69d65c6 100644 --- a/src/HABApp/openhab/interface_async.py +++ b/src/HABApp/openhab/interface_async.py @@ -1,7 +1,7 @@ from HABApp.openhab.connection_handler.func_async import \ async_post_update, async_send_command, \ async_get_items, async_get_item, async_item_exists, async_remove_item, async_create_item, \ - async_get_things, async_get_thing, async_set_thing_cfg, \ + async_get_things, async_get_thing, async_set_thing_cfg, async_set_thing_enabled, \ async_remove_metadata, async_set_metadata, \ async_get_persistence_data, \ async_get_channel_link, async_remove_channel_link, async_channel_link_exists, async_create_channel_link diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index 19e2ad1d..68ad6e80 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -74,7 +74,8 @@ def set_value(self, hue=0.0, saturation=0.0, brightness=0.0): return super().set_value(new_value=(self.hue, self.saturation, self.brightness)) def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): - """Set a new value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) + """Set a new value and post appropriate events on the HABApp event bus + (``ValueUpdateEvent``, ``ValueChangeEvent``) :param hue: hue (in °) :param saturation: saturation (in %) @@ -110,7 +111,8 @@ def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'Co return self def post_rgb(self, r, g, b, max_rgb_value=255) -> 'ColorItem': - """Set a new rgb value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) + """Set a new rgb value and post appropriate events on the HABApp event bus + (``ValueUpdateEvent``, ``ValueChangeEvent``) :param r: red value :param g: green value diff --git a/src/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py index 551f87e3..d01bd74e 100644 --- a/src/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -6,7 +6,8 @@ from pendulum import now as pd_now from HABApp.core.items import BaseItem -from ..events import ThingStatusInfoEvent, ThingUpdatedEvent +from HABApp.openhab.events import ThingStatusInfoEvent, ThingUpdatedEvent +from HABApp.openhab.interface import set_thing_enabled class Thing(BaseItem): @@ -58,3 +59,11 @@ def process_event(self, event): ) return None + + def set_enabled(self, enable: bool = True): + """Enable/disable the thing + + :param enable: True to enable, False to disable the thing + :return: + """ + return set_thing_enabled(self.name, enable) diff --git a/src/HABApp/rule/scheduler/habappschedulerview.py b/src/HABApp/rule/scheduler/habappschedulerview.py index 661cde06..93f17cfb 100644 --- a/src/HABApp/rule/scheduler/habappschedulerview.py +++ b/src/HABApp/rule/scheduler/habappschedulerview.py @@ -1,18 +1,25 @@ import random from datetime import datetime as dt_datetime, time as dt_time, timedelta as dt_timedelta -from typing import Iterable, Union +from typing import Iterable, Union, Callable, Any -from eascheduler import SchedulerView -from eascheduler.jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ - SunsetJob - -import HABApp import HABApp.rule_ctx -from HABApp.core.const.hints import HINT_SCHEDULER_CALLBACK +from HABApp.core.const.const import PYTHON_310 +from HABApp.core.internals import ContextProvidingObj, HINT_CONTEXT_OBJ from HABApp.core.internals import wrap_func from HABApp.rule.scheduler.executor import WrappedFunctionExecutor from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler -from HABApp.core.internals import ContextProvidingObj, HINT_CONTEXT_OBJ +from eascheduler import SchedulerView +from eascheduler.jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, \ + SunsetJob + +if PYTHON_310: + from typing import TypeAlias, ParamSpec +else: + from typing_extensions import TypeAlias, ParamSpec + + +HINT_CB_P = ParamSpec('HINT_CB_P') +HINT_CB: TypeAlias = Callable[HINT_CB_P, Any] class HABAppSchedulerView(SchedulerView, ContextProvidingObj): @@ -21,28 +28,28 @@ def __init__(self, context: 'HABApp.rule_ctx.HABAppRuleContext'): self._habapp_rule_ctx: HINT_CONTEXT_OBJ = context def at(self, time: Union[None, dt_datetime, dt_timedelta, dt_time, int], - callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> OneTimeJob: + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> OneTimeJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().at(time, callback, *args, **kwargs) def countdown(self, expire_time: Union[dt_timedelta, float, int], - callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> CountdownJob: + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> CountdownJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().countdown(expire_time, callback, *args, **kwargs) def every(self, start_time: Union[None, dt_datetime, dt_timedelta, dt_time, int], interval: Union[int, float, dt_timedelta], - callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().every(start_time, interval, callback, *args, **kwargs) def on_day_of_week(self, time: Union[dt_time, dt_datetime], weekdays: Union[str, Iterable[Union[str, int]]], - callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DayOfWeekJob: + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DayOfWeekJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_day_of_week(time, weekdays, callback, *args, **kwargs) def on_every_day(self, time: Union[dt_time, dt_datetime], - callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DayOfWeekJob: + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DayOfWeekJob: """Create a job that will run at a certain time of day :param time: Time when the job will run @@ -53,23 +60,23 @@ def on_every_day(self, time: Union[dt_time, dt_datetime], callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_day_of_week(time, 'all', callback, *args, **kwargs) - def on_sunrise(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> SunriseJob: + def on_sunrise(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> SunriseJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sunrise(callback, *args, **kwargs) - def on_sunset(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> SunsetJob: + def on_sunset(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> SunsetJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sunset(callback, *args, **kwargs) - def on_sun_dawn(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DawnJob: + def on_sun_dawn(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DawnJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sun_dawn(callback, *args, **kwargs) - def on_sun_dusk(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> DuskJob: + def on_sun_dusk(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DuskJob: callback = wrap_func(callback, context=self._habapp_rule_ctx) return super().on_sun_dusk(callback, *args, **kwargs) - def soon(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> OneTimeJob: + def soon(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> OneTimeJob: """ Run the callback as soon as possible. @@ -79,7 +86,7 @@ def soon(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> OneTimeJob """ return self.at(None, callback, *args, **kwargs) - def every_minute(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: + def every_minute(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: """Picks a random second and runs the callback every minute :param callback: |param_scheduled_cb| @@ -90,7 +97,7 @@ def every_minute(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> Re interval = dt_timedelta(seconds=60) return self.every(start, interval, callback, *args, **kwargs) - def every_hour(self, callback: HINT_SCHEDULER_CALLBACK, *args, **kwargs) -> ReoccurringJob: + def every_hour(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: """Picks a random minute and second and run the callback every hour :param callback: |param_scheduled_cb| diff --git a/src/HABApp/rule_ctx/rule_ctx.py b/src/HABApp/rule_ctx/rule_ctx.py index 04fe2853..871c8cf4 100644 --- a/src/HABApp/rule_ctx/rule_ctx.py +++ b/src/HABApp/rule_ctx/rule_ctx.py @@ -1,5 +1,5 @@ import logging -from typing import Optional +from typing import Optional, Callable import HABApp from HABApp.core.const.topics import ALL_TOPICS @@ -18,7 +18,7 @@ def __init__(self, rule: 'HABApp.rule.Rule'): super().__init__() self.rule: Optional['HABApp.rule.Rule'] = rule - def get_callback_name(self, callback: callable) -> Optional[str]: + def get_callback_name(self, callback: Callable) -> Optional[str]: return f'{self.rule.rule_name}.{callback.__name__}' if self.rule.rule_name else None def add_event_listener(self, listener: HINT_EVENT_BUS_LISTENER) -> HINT_EVENT_BUS_LISTENER: diff --git a/src/HABApp/util/multimode/item.py b/src/HABApp/util/multimode/item.py index f9af7ee5..17d0774b 100644 --- a/src/HABApp/util/multimode/item.py +++ b/src/HABApp/util/multimode/item.py @@ -1,11 +1,9 @@ -import datetime -import typing -import warnings from threading import Lock +from typing import Optional, Dict, Any, Tuple, List -import HABApp +from HABApp.core.const import MISSING from HABApp.core.items import Item -from .mode_base import BaseMode +from .mode_base import HINT_BASE_MODE, BaseMode LOCK = Lock() @@ -15,48 +13,61 @@ class MultiModeItem(Item): """ @classmethod - def get_create_item(cls, name: str, logger=None, initial_value=None): - # added 20.04.2020, van be removed in some time - if logger is not None: - warnings.warn("'logger' is deprecated, set logger on the mode instead!", DeprecationWarning, 2) + def get_create_item(cls, name: str, initial_value=None, default_value=MISSING) -> 'MultiModeItem': + """Creates a new item in HABApp and returns it or returns the already existing one with the given name - return super().get_create_item(name, initial_value) + :param name: item name + :param initial_value: state the item will have if it gets created + :param default_value: Default value that will be sent if no mode is active + :return: The created or existing item + """ + item = super().get_create_item(name, initial_value) # type: MultiModeItem + item._default_value = default_value + return item - def __init__(self, name: str, initial_value=None): + def __init__(self, name: str, initial_value=None, default_value=MISSING): super().__init__(name=name, initial_value=initial_value) - self.__values_by_prio: typing.Dict[int, BaseMode] = {} - self.__values_by_name: typing.Dict[str, BaseMode] = {} + self.__values_by_prio: Dict[int, HINT_BASE_MODE] = {} + self.__values_by_name: Dict[str, HINT_BASE_MODE] = {} + + self._default_value = default_value def __remove_mode(self, name: str) -> bool: # Check if the mode exists - found = self.__values_by_name.pop(name, None) - if found is None: + mode_remove = self.__values_by_name.pop(name, None) + if mode_remove is None: return False # Remove parent mapping, so we at least crash if someone attempts to do something with the mode - found.parent = None + mode_remove.parent = None - # remove value entry, too - for i, f in self.__values_by_prio.items(): - if f is found: - self.__values_by_prio.pop(i) + # remove priority entry, too + for prio, mode in self.__values_by_prio.items(): + if mode is mode_remove: + self.__values_by_prio.pop(prio) return True - raise RuntimeError() + + raise RuntimeError(f'Mode {name} is missing!') def __sort_modes(self): - # make the lower priority known to the mode - low = None - for _, child in sorted(self.__values_by_prio.items()): # type: int, BaseMode - child._set_mode_lower_prio(low) - low = child + # sort by priority and make lower prio known to the mode + modes = sorted(self.__values_by_prio.items()) + self.__values_by_prio.clear() + + lower_mode: Optional[HINT_BASE_MODE] = None + for prio, mode in modes: + self.__values_by_prio[prio] = mode + mode._set_mode_lower_prio(lower_mode) + lower_mode = mode + return None def remove_mode(self, name: str) -> bool: """Remove mode if it exists - :param name: name of the mode (case insensitive) + :param name: name of the mode (case-insensitive) :return: True if something was removed, False if nothing was found """ assert isinstance(name, str), type(name) @@ -66,7 +77,7 @@ def remove_mode(self, name: str) -> bool: self.__sort_modes() return found - def add_mode(self, priority: int, mode: BaseMode) -> 'MultiModeItem': + def add_mode(self, priority: int, mode: HINT_BASE_MODE) -> 'MultiModeItem': """Add a new mode to the item, if it already exists it will be overwritten :param priority: priority of the mode @@ -82,22 +93,22 @@ def add_mode(self, priority: int, mode: BaseMode) -> 'MultiModeItem': self.__remove_mode(name) # add new mode - mode.parent = self self.__values_by_prio[priority] = mode self.__values_by_name[name] = mode + mode.parent = self # resort self.__sort_modes() return self - def all_modes(self) -> typing.List[typing.Tuple[int, BaseMode]]: + def all_modes(self) -> List[Tuple[int, HINT_BASE_MODE]]: """Returns a sorted list containing tuples with the priority and the mode :return: List with priorities and modes """ - return sorted(self.__values_by_prio.items()) + return list(self.__values_by_prio.items()) - def get_mode(self, name: str) -> BaseMode: + def get_mode(self, name: str) -> HINT_BASE_MODE: """Returns a created mode :param name: name of the mode (case insensitive) @@ -108,41 +119,29 @@ def get_mode(self, name: str) -> BaseMode: except KeyError: raise KeyError(f'Unknown mode "{name}"! Available: {", ".join(self.__values_by_name.keys())}') from None - def calculate_value(self) -> typing.Any: - """Recalculate the output value and post the state to the event bus (if it is not None) + def calculate_value(self) -> Any: + """Recalculate the value. If the new value is not ``MISSING`` the calculated value will be set as the item + state and the corresponding events will be generated. :return: new value """ # recalculate value - new_value = None - with LOCK: - for priority, child in sorted(self.__values_by_prio.items()): - new_value = child.calculate_value(new_value) + new_value = MISSING + for priority, child in self.__values_by_prio.items(): + new_value = child.calculate_value(new_value) - if new_value is not None: - self.post_value(new_value) - return new_value + # if nothing is set try the default + if new_value is MISSING: + new_value = self._default_value - def create_mode( - self, name: str, priority: int, initial_value: typing.Optional[typing.Any] = None, - auto_disable_after: typing.Optional[datetime.timedelta] = None, - auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] = None, - calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] = None - ): - warnings.warn("'create_mode' is deprecated, create a mode and pass it to 'add_mode' instead!", - DeprecationWarning, 2) - - m = HABApp.util.multimode.ValueMode(name=name, initial_value=initial_value) - m.auto_disable_after = auto_disable_after - m.auto_disable_func = auto_disable_func - m.calc_value_func = calc_value_func + if new_value is not MISSING: + self.post_value(new_value) - self.add_mode(priority, m) - return m + return new_value def _on_item_removed(self): - for name, mode in self.all_modes(): + for p, mode in self.all_modes(): mode.cancel() super()._on_item_removed() diff --git a/src/HABApp/util/multimode/mode_base.py b/src/HABApp/util/multimode/mode_base.py index ce49ad8f..28f0c9be 100644 --- a/src/HABApp/util/multimode/mode_base.py +++ b/src/HABApp/util/multimode/mode_base.py @@ -1,5 +1,6 @@ -import typing +from typing import TYPE_CHECKING, TypeVar, Optional, Any +import HABApp from HABApp.core.internals import AutoContextBoundObj @@ -9,20 +10,20 @@ def __init__(self, name: str): super(BaseMode, self).__init__() self.name: str = name - self.__mode_lower_prio: typing.Optional[BaseMode] = None + self.__mode_lower_prio: Optional[BaseMode] = None - self.parent: MultiModeItem + self.parent: HABApp.util.multimode.MultiModeItem # Otherwise the missing assignment shows an error - if typing.TYPE_CHECKING: - self.parent = MultiModeItem('TYPE_CHECKING') + if TYPE_CHECKING: + self.parent = HABApp.util.multimode.MultiModeItem('TYPE_CHECKING') return def _set_mode_lower_prio(self, mode_lower_prio): assert isinstance(mode_lower_prio, BaseMode) or mode_lower_prio is None, type(mode_lower_prio) self.__lower_priority_mode = mode_lower_prio - def calculate_value(self, lower_prio_value: typing.Any) -> typing.Any: + def calculate_value(self, lower_prio_value: Any) -> Any: raise NotImplementedError() def cancel(self): @@ -33,4 +34,4 @@ def cancel(self): self.parent = None -from .item import MultiModeItem # noqa: E402 +HINT_BASE_MODE = TypeVar('HINT_BASE_MODE', bound=BaseMode) diff --git a/src/HABApp/util/multimode/mode_switch.py b/src/HABApp/util/multimode/mode_switch.py index d0dec953..8eb29f68 100644 --- a/src/HABApp/util/multimode/mode_switch.py +++ b/src/HABApp/util/multimode/mode_switch.py @@ -63,7 +63,7 @@ def __init__(self, name: str, # prevent direct calling def set_enabled(self, value: bool, only_on_change: bool = False): - """""" # so it doesn't show in Sphinx + """""" # Empty docstring so this function doesn't show up in Sphinx raise PermissionError('Enabled is controlled through the switch item!') def __switch_changed(self, event): diff --git a/src/HABApp/util/multimode/mode_value.py b/src/HABApp/util/multimode/mode_value.py index 82881f60..221278c3 100644 --- a/src/HABApp/util/multimode/mode_value.py +++ b/src/HABApp/util/multimode/mode_value.py @@ -124,22 +124,16 @@ def set_enabled(self, value: bool, only_on_change: bool = False): return None def calculate_lower_priority_value(self) -> typing.Any: - - self.__low_prio_value = True - # Trigger recalculation, this way we keep the output of MultiModeValue synchronized - # in case some mode gets enabled/disabled/etc + # in case some mode get enabled/disabled (e.g. by time) self.parent.calculate_value() - val = self.__low_prio_value - self.__low_prio_value = None - return val + return self.__low_prio_value def calculate_value(self, value_with_lower_priority: typing.Any) -> typing.Any: # helper for self.calculate_lower_priority_value - if self.__low_prio_value is not None: - self.__low_prio_value = value_with_lower_priority + self.__low_prio_value = value_with_lower_priority # so we don't spam the log if we are already disabled if not self.__enabled: diff --git a/tests/test_openhab/test_interface_sync.py b/tests/test_openhab/test_interface_sync.py index 6ecaa53f..0b7efda8 100644 --- a/tests/test_openhab/test_interface_sync.py +++ b/tests/test_openhab/test_interface_sync.py @@ -7,7 +7,7 @@ from HABApp.openhab.interface import \ post_update, send_command, \ get_item, item_exists, remove_item, create_item, \ - get_thing, get_persistence_data, \ + get_thing, get_persistence_data, set_thing_enabled, \ remove_metadata, set_metadata, \ get_channel_link, remove_channel_link, channel_link_exists, create_channel_link @@ -24,6 +24,7 @@ def test_all_imported(func: Callable): (send_command, ('name', 'value')), (get_item, ('name', )), (get_thing, ('name', )), + (set_thing_enabled, ('name', True)), (item_exists, ('name', )), (remove_item, ('name', )), (create_item, ('String', 'name')), diff --git a/tests/test_utils/test_multivalue.py b/tests/test_utils/test_multivalue.py index 3bbe399b..03e88045 100644 --- a/tests/test_utils/test_multivalue.py +++ b/tests/test_utils/test_multivalue.py @@ -1,5 +1,6 @@ import pytest +from HABApp.core.const import MISSING from HABApp.util.multimode import BaseMode, ValueMode, MultiModeItem from ..test_core import ItemTests from tests.helpers.parent_rule import DummyRule @@ -31,12 +32,13 @@ def test_diff_prio(parent_rule: DummyRule): def test_calculate_lower_priority_value(parent_rule: DummyRule): - p = MultiModeItem('TestItem') + p = MultiModeItem('TestItem', default_value=99) m1 = ValueMode('modea', '1234') m2 = ValueMode('modeb', '4567') p.add_mode(1, m1).add_mode(2, m2) - assert m1.calculate_lower_priority_value() is None + assert p.value is None + assert m1.calculate_lower_priority_value() is MISSING assert m2.calculate_lower_priority_value() == '1234' m1.set_value('asdf') @@ -101,3 +103,51 @@ def test_remove(parent_rule: DummyRule): p.remove_mode('m1') assert p.all_modes() == [(1, m2)] + + +def test_overwrite(parent_rule: DummyRule): + p = MultiModeItem('asdf') + m1 = BaseMode('m1') + m2 = BaseMode('m1') + m3 = BaseMode('m3') + + p.add_mode(99, m1).add_mode(1, m2).add_mode(5, m3) + + assert p.all_modes() == [(1, m2), (5, m3)] + + +def test_order(parent_rule: DummyRule): + p = MultiModeItem('asdf') + m1 = BaseMode('m1') + m2 = BaseMode('m2') + m3 = BaseMode('m3') + + p.add_mode(99, m1) + p.add_mode(1, m2) + p.add_mode(5, m3) + + assert p.all_modes() == [(1, m2), (5, m3), (99, m1)] + + +def test_disable_no_default(parent_rule: DummyRule): + + # No default_value is set -> we don't send anything if all modes are disabled + p1 = ValueMode('modea', '1234') + p = MultiModeItem('TestItem').add_mode(1, p1) + + p1.set_enabled(True) + assert p.value == '1234' + p1.set_enabled(False) + assert p.value == '1234' + + +def test_disable_with_default(parent_rule: DummyRule): + + # We have default_value set -> send it when all modes are disabled + a1 = ValueMode('modea', '1234') + a = MultiModeItem('TestItem', default_value=None).add_mode(1, a1) + + a1.set_enabled(True) + assert a.value == '1234' + a1.set_enabled(False) + assert a.value is None diff --git a/tox.ini b/tox.ini index b6b1ccc3..91753174 100644 --- a/tox.ini +++ b/tox.ini @@ -44,6 +44,7 @@ ignore = D107, D100, D101, D104, D102 [pytest] asyncio_mode = auto + norecursedirs = run docs markers = no_internals: Does not set up the item registry and event bus