From 96535bb10fef4fc285ba705a2b04d0da6bac1d2a Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Fri, 29 Oct 2021 14:44:25 +0200 Subject: [PATCH] 0.31.1 (#239) * Added EventListenerGroup * - Z-Wave table doesn't show the linked items any more * add option to get metadata to async_get_items * Added support for item metadata * Add option to search for item metadata * fixes #238 --- _doc/util.rst | 61 +++++++ conf_testing/config.yml | 4 +- .../lib/HABAppTests/openhab_tmp_item.py | 11 +- .../rules/openhab/test_event_types.py | 2 +- conf_testing/rules/openhab/test_interface.py | 8 +- conf_testing/rules/openhab/test_items.py | 59 ++++++- readme.md | 5 + requirements.txt | 1 + requirements_setup.txt | 1 + src/HABApp/__version__.py | 2 +- src/HABApp/core/event_bus_listener.py | 5 +- src/HABApp/core/wrappedfunction.py | 3 +- .../openhab/connection_handler/func_async.py | 14 +- .../openhab/connection_handler/func_sync.py | 6 +- .../openhab/connection_handler/sse_handler.py | 97 ++++++----- .../connection_logic/plugin_load_items.py | 7 +- .../connection_logic/plugin_thing_overview.py | 20 ++- .../openhab/definitions/helpers/log_table.py | 70 ++++++-- src/HABApp/openhab/items/base_item.py | 13 +- src/HABApp/openhab/items/color_item.py | 11 +- src/HABApp/openhab/items/image_item.py | 11 +- src/HABApp/openhab/map_items.py | 50 +++--- src/HABApp/openhab/map_values.py | 5 + src/HABApp/rule/rule.py | 27 ++- src/HABApp/rule/scheduler/scheduler.py | 2 +- src/HABApp/runtime/runtime.py | 5 +- src/HABApp/util/__init__.py | 1 + src/HABApp/util/listener_groups.py | 156 +++++++++++++++++ .../test_events/test_from_dict.py | 6 + tests/test_openhab/test_helpers.py | 73 ++++++++ tests/test_openhab/test_items/test_image.py | 1 + tests/test_openhab/test_items/test_mapping.py | 50 ++++-- .../test_item_search.py | 16 +- tests/test_utils/test_listener_groups.py | 158 ++++++++++++++++++ 34 files changed, 815 insertions(+), 146 deletions(-) create mode 100644 src/HABApp/util/listener_groups.py create mode 100644 tests/test_openhab/test_helpers.py rename tests/{test_core/test_items => test_rule}/test_item_search.py (68%) create mode 100644 tests/test_utils/test_listener_groups.py diff --git a/_doc/util.rst b/_doc/util.rst index fc5195a8..447ec8a9 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -117,6 +117,67 @@ Documentation .. autoclass:: Statistics :members: +EventListenerGroup +------------------------------ +EventListenerGroup is a helper class which allows to subscribe to multiple items at once. +All subscriptions can be canceled together, too. +This is useful if e.g. something has to be done once after a sensor reports a value. + +Example +^^^^^^^^^^^^^^^^^^ +This is a rule which will turn on the lights once (!) in a room on the first movement in the morning. +The lights will only turn on after 4 and before 8 and two movement sensors are used to pick up movement. + + +.. exec_code:: + + # ------------ hide: start ------------ + import HABApp + from rule_runner import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + HABApp.core.Items.add_item(HABApp.openhab.items.SwitchItem('RoomLights')) + HABApp.core.Items.add_item(HABApp.openhab.items.NumberItem('MovementSensor1')) + HABApp.core.Items.add_item(HABApp.openhab.items.NumberItem('MovementSensor2')) + # ------------ hide: stop ------------- + from datetime import time + + from HABApp import Rule + from HABApp.core.events import ValueChangeEvent + from HABApp.openhab.items import SwitchItem, NumberItem + from HABApp.util import EventListenerGroup + + + class EventListenerGroupExample(Rule): + def __init__(self): + super().__init__() + self.lights = SwitchItem.get_item('RoomLights') + self.sensor_move_1 = NumberItem.get_item('MovementSensor1') + self.sensor_move_1 = NumberItem.get_item('MovementSensor2') + + # use the defaults so we don't have to pass the callback and event filter in add_listener + self.group = EventListenerGroup(default_callback=self.sensor_changed, default_event_filter=ValueChangeEvent).\ + add_listener(self.sensor_move_1).add_listener(self.sensor_move_1) + + self.run.on_every_day(time(4), self.listen_sensors) + self.run.on_every_day(time(8), self.sensors_cancel) + + def listen_sensors(self): + self.listeners.listen() + + def sensors_cancel(self): + self.listeners.cancel() + + def sensor_changed(self, event): + self.listeners.cancel() + self.lights.on() + + + EventListenerGroupExample() + + +.. autoclass:: EventListenerGroup + :members: MultiModeItem ------------------------------ diff --git a/conf_testing/config.yml b/conf_testing/config.yml index 6b48e14c..55d870cc 100644 --- a/conf_testing/config.yml +++ b/conf_testing/config.yml @@ -35,8 +35,8 @@ openhab: connection: host: localhost port: 8080 - user: '' - password: '' + user: 'asdf' + password: 'asdf' general: listen_only: false wait_for_openhab: true diff --git a/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/conf_testing/lib/HABAppTests/openhab_tmp_item.py index d7bfc7cf..667abad6 100644 --- a/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -1,6 +1,6 @@ import time from typing import List, Optional - +from functools import wraps import HABApp from . import get_random_name, EventWaiter @@ -9,6 +9,7 @@ class OpenhabTmpItem: @staticmethod def use(type: str, name: Optional[str] = None, arg_name: str = 'item'): def decorator(func): + @wraps(func) def new_func(*args, **kwargs): assert arg_name not in kwargs, f'arg {arg_name} already set' item = OpenhabTmpItem(type, name) @@ -21,10 +22,14 @@ def new_func(*args, **kwargs): return decorator @staticmethod - def create(type: str, name: Optional[str] = None): + def create(type: str, name: Optional[str] = None, arg_name: Optional[str] = None): def decorator(func): + @wraps(func) def new_func(*args, **kwargs): - with OpenhabTmpItem(type, name): + with OpenhabTmpItem(type, name) as f: + if arg_name is not None: + assert arg_name not in kwargs, f'arg {arg_name} already set' + kwargs[arg_name] = f return func(*args, **kwargs) return new_func return decorator diff --git a/conf_testing/rules/openhab/test_event_types.py b/conf_testing/rules/openhab/test_event_types.py index d141e3be..c1f3ec79 100644 --- a/conf_testing/rules/openhab/test_event_types.py +++ b/conf_testing/rules/openhab/test_event_types.py @@ -45,7 +45,7 @@ def test_quantity_type_events(self, dimension): 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]}') + self.openhab.post_update(item_name, f'{state} {unit_of_dimension[dimension]}'.strip()) event_watier.wait_for_event(value=state) item_waiter.wait_for_state(state) diff --git a/conf_testing/rules/openhab/test_interface.py b/conf_testing/rules/openhab/test_interface.py index a65e93c8..6955fcba 100644 --- a/conf_testing/rules/openhab/test_interface.py +++ b/conf_testing/rules/openhab/test_interface.py @@ -99,12 +99,12 @@ def test_post_update(self, oh_type, values): self.openhab.send_command(item, value) waiter.wait_for_state(value) - def test_umlaute(self): + @OpenhabTmpItem.use('String') + def test_umlaute(self, item: OpenhabTmpItem): LABEL = 'äöß' - NAME = 'TestUmlaute' - self.openhab.create_item('String', NAME, label=LABEL) - ret = self.openhab.get_item(NAME) + self.openhab.create_item('String', item.name, label=LABEL) + ret = self.openhab.get_item(item.name) assert ret.label == LABEL, f'"{LABEL}" != "{ret.label}"' def test_openhab_item_not_found(self): diff --git a/conf_testing/rules/openhab/test_items.py b/conf_testing/rules/openhab/test_items.py index 2c1f0294..65da1e1a 100644 --- a/conf_testing/rules/openhab/test_items.py +++ b/conf_testing/rules/openhab/test_items.py @@ -1,5 +1,15 @@ -from HABApp.openhab.items import StringItem, GroupItem -from HABAppTests import TestBaseRule, OpenhabTmpItem +# ---------------------------------------------------------------------------------------------------------------------- +# This rule requires the following item: +# String TestString (TestGroup) [TestTag] {meta1="test" [key="value"]} +# ---------------------------------------------------------------------------------------------------------------------- +import asyncio + +from immutables import Map + +from HABApp.core.const import loop +from HABApp.openhab.interface_async import async_get_items +from HABApp.openhab.items import GroupItem, StringItem +from HABAppTests import OpenhabTmpItem, TestBaseRule class OpenhabItems(TestBaseRule): @@ -10,10 +20,51 @@ def __init__(self): self.add_test('ApiDoc', self.test_api) self.add_test('MemberTags', self.test_tags) self.add_test('MemberGroups', self.test_groups) + self.add_test('TestExisting', self.test_existing) + + self.item_number = OpenhabTmpItem('Number') + self.item_switch = OpenhabTmpItem('Switch') + + self.item_group = OpenhabTmpItem('Group') + self.item_string = OpenhabTmpItem('String') + + def set_up(self): + self.item_number.create_item(label='No metadata') + + self.item_switch.create_item() + self.openhab.set_metadata( + self.item_switch.name, 'homekit', 'HeatingThresholdTemperature', {'minValue': 0.5, 'maxValue': 20}) + + self.item_group.create_item(label='MyGrpValue [%s]', category='text', tags=['DocItem'], + group_function='AND', group_function_params=['VALUE_TRUE', 'VALUE_FALSE']) + self.item_string.create_item(label='MyStrValue [%s]', category='text', tags=['DocItem'], + groups=[self.item_group.name]) + + self.openhab.set_metadata(self.item_string.name, 'ns1', 'v1', {'key11': 'value11', 'key12': 'value12'}) + self.openhab.set_metadata(self.item_string.name, 'ns2', 'v2', {'key2': 'value2'}) + self.openhab.set_metadata(self.item_group.name, 'ns3', 'v3', {}) + + def tear_down(self): + self.item_string.remove() + self.item_switch.remove() + + def test_existing(self): + item = StringItem.get_item('TestString') + assert item.tags == frozenset(['TestTag']) + assert item.groups == frozenset(['TestGroup']) + assert list(item.metadata.keys()) == ['meta1'] + assert item.metadata['meta1'].value == 'test' + assert item.metadata['meta1'].config == Map({'key': 'value'}) def test_api(self): - with OpenhabTmpItem('String') as item: - self.openhab.get_item(item.name) + self.openhab.get_item(self.item_string.name) + + self.openhab.get_item(self.item_number.name, all_metadata=True) + self.openhab.get_item(self.item_string.name, all_metadata=True) + self.openhab.get_item(self.item_switch.name, all_metadata=True) + + self.openhab.get_item(self.item_group.name, all_metadata=True) + asyncio.run_coroutine_threadsafe(async_get_items(all_metadata=True), loop).result() @OpenhabTmpItem.use('String', arg_name='oh_item') def test_tags(self, oh_item: OpenhabTmpItem): diff --git a/readme.md b/readme.md index af9c19d3..ac7a8b8e 100644 --- a/readme.md +++ b/readme.md @@ -102,6 +102,11 @@ MyOpenhabRule() ``` # Changelog +#### 0.31.1 (29.10.2021) +- Added support for item metadata +- Added possibility to search for items by metadata +- Added EventListenerGroup to subscribe/cancel multiple listeners at once + #### 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 diff --git a/requirements.txt b/requirements.txt index 7995b8e7..4e2d033d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ stackprinter==0.2.5 voluptuous==0.12.1 watchdog==2.1.2 ujson==4.0.2 +immutables==0.16 # Packages to run source tests pytest==6.2.4 diff --git a/requirements_setup.txt b/requirements_setup.txt index 9180ac65..58f3c0eb 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -10,3 +10,4 @@ stackprinter==0.2.5 voluptuous==0.12.1 watchdog==2.1.2 ujson==4.0.2 +immutables==0.16 diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index c3d10d7c..74f9490d 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.31.0' +__version__ = '0.31.1' diff --git a/src/HABApp/core/event_bus_listener.py b/src/HABApp/core/event_bus_listener.py index f736b2f2..3df71446 100644 --- a/src/HABApp/core/event_bus_listener.py +++ b/src/HABApp/core/event_bus_listener.py @@ -1,7 +1,8 @@ +from typing import Any, Optional + import HABApp +from HABApp.core import WrappedFunction from HABApp.core.events import AllEvents -from . import WrappedFunction -from typing import Optional, Any class EventBusListener: diff --git a/src/HABApp/core/wrappedfunction.py b/src/HABApp/core/wrappedfunction.py index 5a32239c..6e1668cf 100644 --- a/src/HABApp/core/wrappedfunction.py +++ b/src/HABApp/core/wrappedfunction.py @@ -66,13 +66,14 @@ def __format_traceback(self, e: Exception, *args, **kwargs): async def async_run(self, *args, **kwargs): - async_context.set('WrappedFunction') + token = async_context.set('WrappedFunction') try: await self._func(*args, **kwargs) except Exception as e: self.__format_traceback(e, *args, **kwargs) + async_context.reset(token) return None def __run(self, *args, **kwargs): diff --git a/src/HABApp/openhab/connection_handler/func_async.py b/src/HABApp/openhab/connection_handler/func_async.py index 529526fa..afcc571b 100644 --- a/src/HABApp/openhab/connection_handler/func_async.py +++ b/src/HABApp/openhab/connection_handler/func_async.py @@ -51,10 +51,17 @@ async def async_item_exists(item) -> bool: return ret.status == 200 -async def async_get_items(include_habapp_meta=False, disconnect_on_error=False) -> Optional[List[Dict[str, Any]]]: +async def async_get_items(include_habapp_meta=False, metadata: Optional[str] = None, all_metadata=False, + disconnect_on_error=False) -> Optional[List[Dict[str, Any]]]: params = None if include_habapp_meta: params = {'metadata': 'HABApp'} + if metadata is not None: + if params is not None: + raise ValueError('Use include_habapp_meta or metadata') + params = {'metadata': metadata} + if all_metadata: + params = {'metadata': '.+'} try: resp = await get('items', disconnect_on_error=disconnect_on_error, params=params) @@ -67,8 +74,11 @@ async def async_get_items(include_habapp_meta=False, disconnect_on_error=False) return None -async def async_get_item(item: str, metadata: Optional[str] = None) -> dict: +async def async_get_item(item: str, metadata: Optional[str] = None, all_metadata=False) -> dict: params = None if metadata is None else {'metadata': metadata} + if all_metadata: + params = {'metadata': '.+'} + ret = await get(f'items/{item:s}', params=params, log_404=False) if ret.status == 404: raise ItemNotFoundError.from_name(item) diff --git a/src/HABApp/openhab/connection_handler/func_sync.py b/src/HABApp/openhab/connection_handler/func_sync.py index bbccf949..3160ca73 100644 --- a/src/HABApp/openhab/connection_handler/func_sync.py +++ b/src/HABApp/openhab/connection_handler/func_sync.py @@ -115,11 +115,12 @@ def validate(_in): return fut.result() -def get_item(item_name: str, metadata: Optional[str] = None) -> OpenhabItemDefinition: +def get_item(item_name: str, metadata: Optional[str] = None, all_metadata=False) -> OpenhabItemDefinition: """Return the complete OpenHAB item definition :param item_name: name of the item or item :param metadata: metadata to include (optional, comma separated or search expression) + :param all_metadata: if true the result will include all item metadata :return: """ if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): @@ -131,7 +132,8 @@ def get_item(item_name: str, metadata: Optional[str] = None) -> OpenhabItemDefin if async_context.get(None) is not None: raise AsyncContextError(get_item) - fut = asyncio.run_coroutine_threadsafe(async_get_item(item_name, metadata=metadata), loop) + fut = asyncio.run_coroutine_threadsafe( + async_get_item(item_name, metadata=metadata, all_metadata=all_metadata), loop) data = fut.result() return OpenhabItemDefinition.parse_obj(data) diff --git a/src/HABApp/openhab/connection_handler/sse_handler.py b/src/HABApp/openhab/connection_handler/sse_handler.py index 80afcde5..6d0d90a8 100644 --- a/src/HABApp/openhab/connection_handler/sse_handler.py +++ b/src/HABApp/openhab/connection_handler/sse_handler.py @@ -1,76 +1,85 @@ +from asyncio import create_task +from typing import Union + import HABApp import HABApp.core import HABApp.openhab.events -from HABApp.core import Items, EventBus +from HABApp.core import EventBus, Items 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.core.wrapper import process_exception from HABApp.openhab.connection_handler import http_connection -from HABApp.openhab.events import ThingStatusInfoEvent, GroupItemStateChangedEvent, ItemRemovedEvent, ItemAddedEvent, \ - ItemUpdatedEvent +from HABApp.openhab.events import GroupItemStateChangedEvent, ItemAddedEvent, ItemRemovedEvent, ItemUpdatedEvent, \ + ThingStatusInfoEvent +from HABApp.openhab.item_to_reg import add_to_registry, remove_from_registry 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): + try: + # Lookup corresponding OpenHAB event + event = get_event(event_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 - # 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) + 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, ThingStatusInfoEvent): - __thing = Items.get_item(event.name) # type: HABApp.openhab.items.Thing - __thing.process_event(event) + if isinstance(event, ItemRemovedEvent): + remove_from_registry(event.name) 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) + # These events require that we query openhab because of the metadata so we have to do it in a task + # They also change the item registry + if isinstance(event, (ItemAddedEvent, ItemUpdatedEvent)): + create_task(item_event(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) + HABApp.core.EventBus.post_event(event.name, event) + except Exception as e: + process_exception(func=on_sse_event, e=e) 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 +async def item_event(event: Union[ItemAddedEvent, ItemUpdatedEvent]): + name = event.name - if isinstance(event, ItemUpdatedEvent): - new_item = map_item(event.name, event.type, None, event.tags, event.groups) - if new_item is None: - return None + # Since metadata is not part of the event we have to request it + cfg = await HABApp.openhab.interface_async.async_get_item(name, metadata='.+') - if new_item is not None: - add_to_registry(new_item) + new_item = map_item(name, event.type, None, event.tags, event.groups, metadata=cfg.get('metadata')) + if new_item is None: + return None + add_to_registry(new_item) # Send Event to Event Bus - HABApp.core.EventBus.post_event(event.name, event) + HABApp.core.EventBus.post_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 d778a130..7b99d9a3 100644 --- a/src/HABApp/openhab/connection_logic/plugin_load_items.py +++ b/src/HABApp/openhab/connection_logic/plugin_load_items.py @@ -15,7 +15,7 @@ class LoadAllOpenhabItems(OnConnectPlugin): @ignore_exception async def on_connect_function(self): - data = await async_get_items(disconnect_on_error=True) + data = await async_get_items(disconnect_on_error=True, all_metadata=True) if data is None: return None @@ -24,8 +24,9 @@ async def on_connect_function(self): found_items = len(data) for _dict in data: item_name = _dict['name'] - new_item = map_item(item_name, _dict['type'], _dict['state'], tuple(_dict['tags']), - tuple(_dict['groupNames'])) # type: HABApp.openhab.items.OpenhabItem + new_item = map_item(item_name, _dict['type'], _dict['state'], + frozenset(_dict['tags']), frozenset(_dict['groupNames']), + _dict.get('metadata', {})) # type: HABApp.openhab.items.OpenhabItem if new_item is None: continue add_to_registry(new_item, True) diff --git a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py b/src/HABApp/openhab/connection_logic/plugin_thing_overview.py index 04a3b51f..e36649a6 100644 --- a/src/HABApp/openhab/connection_logic/plugin_thing_overview.py +++ b/src/HABApp/openhab/connection_logic/plugin_thing_overview.py @@ -42,8 +42,8 @@ async def on_connect_function(self): zw_fw = zw_table.add_column('Firmware', '^') zw_type = zw_table.add_column('Thing type') zw_uid = zw_table.add_column('Thing UID') - zw_l_channels = zw_table.add_column('Linked channel types') - zw_u_channels = zw_table.add_column('Unlinked channel types') + # zw_l_channels = zw_table.add_column('Linked channel types') + # zw_u_channels = zw_table.add_column('Unlinked channel types') for node in thing_data: uid = node['UID'] @@ -66,7 +66,7 @@ async def on_connect_function(self): # optional properties which can be set props = node['properties'] - channels = node.get('channels', []) + # channels = node.get('channels', []) # Node-ID, e.g. 5 node_id = props.get('zwave_nodeid') @@ -75,12 +75,14 @@ async def on_connect_function(self): zw_model.add(props.get('modelId', '')) zw_fw.add(props.get('zwave_version', '')) - zw_l_channels.add( - ', '.join(map(lambda x: x.get('channelTypeUID', ''), filter(lambda x: x.get('linkedItems'), channels))) - ) - zw_u_channels.add(', '.join( - map(lambda x: x.get('channelTypeUID', ''), filter(lambda x: not x.get('linkedItems'), channels))) - ) + # This generates very long logs and doesn't look good + # zw_l_channels.add( + # tuple(map(lambda x: x.get('channelTypeUID', ''), filter(lambda x: x.get('linkedItems'), channels))) + # ) + # zw_u_channels.add( + # tuple(map(lambda x: x.get('channelTypeUID', ''), + # filter(lambda x: not x.get('linkedItems'), channels))) + # ) log = logging.getLogger('HABApp.openhab.things') for line in thing_table.get_lines(sort_columns=[thing_uid]): diff --git a/src/HABApp/openhab/definitions/helpers/log_table.py b/src/HABApp/openhab/definitions/helpers/log_table.py index 02eb0ffa..1bc0d91f 100644 --- a/src/HABApp/openhab/definitions/helpers/log_table.py +++ b/src/HABApp/openhab/definitions/helpers/log_table.py @@ -1,27 +1,59 @@ from collections import OrderedDict -from typing import Optional, Dict, List, Union +from typing import Optional, Dict, List, Union, Tuple, Any class Column: - def __init__(self, name: str, align: Optional[str] = None, alias: Optional[str] = None): + wrap: int = 80 + + def __init__(self, name: str, align: Optional[str] = None, alias: Optional[str] = None, wrap: Optional[int] = None): self.name: str = name self.alias: Optional[str] = alias - self.width: int = len(name) if alias is None else len(alias) self.align: Optional[str] = align + if wrap is not None: + self.wrap = wrap + + self.width: int = len(name) if alias is None else len(alias) + self.entries: List[Tuple[Any, ...]] = [] + + def get_lines(self, pos: int) -> int: + return len(self.entries[pos]) - self.entries = [] + def format_entry(self, pos: int, lines: int) -> List[str]: + ret = [] - def format_entry(self, pos: int) -> str: - val = self.entries[pos] - if isinstance(val, bool): - val = str(val) - f = f'{{:{""if self.align is None else self.align}{self.width:d}}}' - return f.format(val) + objs = self.entries[pos] + size = len(objs) + for i in range(lines): + if i >= size: + ret.append(self.width * ' ') + continue + val = objs[i] + if isinstance(val, bool): + val = str(val) + f = f'{{:{""if self.align is None else self.align}{self.width:d}}}' + ret.append(f.format(val)) + return ret def add(self, val): - self.width = max(self.width, len(str(val))) - self.entries.append(val) + _res = [] + if isinstance(val, (list, set, tuple)): + _len = 0 + _str = '' + for obj in val: + if _len >= self.wrap: + _len = 0 + _res.append(_str) + _str = '' + _str = f'{_str}, {obj}' if _str else f'{obj}' + _len = len(_str) + _res.append(_str) + else: + _res.append(val) + + for k in _res: + self.width = max(self.width, len(str(k))) + self.entries.append(tuple(_res)) class Table: @@ -29,15 +61,16 @@ def __init__(self, heading: str = ''): self.columns: Dict[str, Column] = OrderedDict() self.heading: str = heading - def add_column(self, name: str, align: Optional[str] = None, alias: Optional[str] = None) -> Column: - self.columns[name] = c = Column(name, align, alias) + def add_column(self, name: str, align: Optional[str] = None, alias: Optional[str] = None, + wrap: Optional[int] = None) -> Column: + self.columns[name] = c = Column(name, align, alias, wrap) return c def add_dict(self, _in: dict): for k, col in self.columns.items(): col.add(_in[k]) - def get_lines(self, sort_columns: List[Union[str, Column]] = None): + def get_lines(self, sort_columns: List[Union[str, Column]] = None) -> List[str]: # check if all tables have the same length vals = list(self.columns.values()) len1 = len(vals[0].entries) @@ -79,7 +112,12 @@ def get_lines(self, sort_columns: List[Union[str, Column]] = None): ret.append(line_sep) for t, i in sorted(lines_dict.items()): - ret.append('| ' + ' | '.join(map(lambda x: x.format_entry(i), self.columns.values())) + ' |') + lines = max(map(lambda x: x.get_lines(i), self.columns.values())) + + grid = tuple(map(lambda x: x.format_entry(i, lines), self.columns.values())) # type: Tuple[List[str], ...] + for col_i in range(lines): + cols = [obj[col_i] for obj in grid] + ret.append('| ' + ' | '.join(cols) + ' |') ret.append(line_sep) return ret diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index 8e9c0878..b08c05e3 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -1,20 +1,29 @@ import datetime -from typing import Any, FrozenSet, Optional +from typing import Any, FrozenSet, Mapping, NamedTuple, Optional + +from immutables import Map from HABApp.core.const import MISSING from HABApp.core.items.base_valueitem import BaseValueItem from HABApp.openhab.interface import get_persistence_data, post_update, send_command +class MetaData(NamedTuple): + value: str + config: Mapping[str, Any] = Map() + + class OpenhabItem(BaseValueItem): """Base class for items which exists in OpenHAB. """ def __init__(self, name: str, initial_value=None, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset()): + tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()): super().__init__(name, initial_value) self.tags: FrozenSet[str] = tags self.groups: FrozenSet[str] = groups + self.metadata: Mapping[str, MetaData] = metadata def oh_send_command(self, value: Any = MISSING): """Send a command to the openHAB item diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index e95fb821..6a66993b 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -1,7 +1,9 @@ -from typing import Optional, Tuple +from typing import FrozenSet, Mapping, Optional, Tuple + +from immutables import Map from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb -from HABApp.openhab.items.base_item import OpenhabItem +from HABApp.openhab.items.base_item import MetaData, OpenhabItem from HABApp.openhab.items.commands import OnOffCommand, PercentCommand from ..definitions import HSBValue, OnOffValue, PercentValue @@ -12,8 +14,9 @@ class ColorItem(OpenhabItem, OnOffCommand, PercentCommand): 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) + tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()): + super().__init__(name=name, initial_value=(h, s, b), tags=tags, groups=groups, metadata=metadata) 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/image_item.py b/src/HABApp/openhab/items/image_item.py index 418ef3d7..5932c5ad 100644 --- a/src/HABApp/openhab/items/image_item.py +++ b/src/HABApp/openhab/items/image_item.py @@ -1,7 +1,9 @@ from base64 import b64encode -from typing import FrozenSet, Optional +from typing import FrozenSet, Mapping, Optional -from HABApp.openhab.items.base_item import OpenhabItem +from immutables import Map + +from HABApp.openhab.items.base_item import OpenhabItem, MetaData from ..definitions import RawValue @@ -23,8 +25,9 @@ class ImageItem(OpenhabItem): """ImageItem which accepts and converts the data types from OpenHAB""" def __init__(self, name: str, initial_value=None, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset()): - super().__init__(name, initial_value, tags, groups) + tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()): + super().__init__(name, initial_value, tags, groups, metadata) # this item is unique because we also save the image type and thus have two states self.image_type: Optional[str] = None diff --git a/src/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py index b87f470e..6648d31b 100644 --- a/src/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -1,17 +1,21 @@ import datetime import logging -from typing import FrozenSet, Optional +from typing import Any, Dict, FrozenSet, Optional + +from immutables import Map 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, \ NumberItem, PlayerItem, RollershutterItem, StringItem, SwitchItem +from HABApp.openhab.items.base_item import MetaData log = logging.getLogger('HABApp.openhab') -def map_item(name: str, type: str, value: Optional[str], tags: FrozenSet[str], groups: FrozenSet[str]) -> \ +def map_item(name: str, type: str, value: Optional[str], + tags: FrozenSet[str], groups: FrozenSet[str], metadata: Optional[Dict[str, Dict[str, Any]]]) -> \ Optional['HABApp.openhab.items.OpenhabItem']: try: assert isinstance(type, str) @@ -20,6 +24,12 @@ def map_item(name: str, type: str, value: Optional[str], tags: FrozenSet[str], g if value == 'NULL' or value == 'UNDEF': value = None + # map Metadata + if metadata is not None: + meta = Map({k: MetaData(v['value'], Map(v.get('config', {}))) for k, v in metadata.items()}) + else: + meta = Map() + # 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 type: @@ -30,37 +40,37 @@ def map_item(name: str, type: str, value: Optional[str], tags: FrozenSet[str], g # Specific classes if type == "Switch": - return SwitchItem(name, value, tags=tags, groups=groups) + return SwitchItem(name, value, tags=tags, groups=groups, metadata=meta) if type == "String": - return StringItem(name, value, tags=tags, groups=groups) + return StringItem(name, value, tags=tags, groups=groups, metadata=meta) if type == "Contact": - return ContactItem(name, value, tags=tags, groups=groups) + return ContactItem(name, value, tags=tags, groups=groups, metadata=meta) if type == "Rollershutter": if value is None: - return RollershutterItem(name, value, tags=tags, groups=groups) - return RollershutterItem(name, float(value), tags=tags, groups=groups) + return RollershutterItem(name, value, tags=tags, groups=groups, metadata=meta) + return RollershutterItem(name, float(value), tags=tags, groups=groups, metadata=meta) if type == "Dimmer": if value is None: - return DimmerItem(name, value, tags=tags, groups=groups) - return DimmerItem(name, float(value), tags=tags, groups=groups) + return DimmerItem(name, value, tags=tags, groups=groups, metadata=meta) + return DimmerItem(name, float(value), tags=tags, groups=groups, metadata=meta) if type == "Number": if value is None: - return NumberItem(name, value, tags=tags, groups=groups) + return NumberItem(name, value, tags=tags, groups=groups, metadata=meta) # Number items can be int or float try: - return NumberItem(name, int(value), tags=tags, groups=groups) + return NumberItem(name, int(value), tags=tags, groups=groups, metadata=meta) except ValueError: - return NumberItem(name, float(value), tags=tags, groups=groups) + return NumberItem(name, float(value), tags=tags, groups=groups, metadata=meta) if type == "DateTime": if value is None: - return DatetimeItem(name, value) + return DatetimeItem(name, value, tags=tags, groups=groups, metadata=meta) # Todo: remove this once we go >= OH3.1 # Previous OH versions used a datetime string like this: # 2018-11-19T09:47:38.284+0100 @@ -73,28 +83,28 @@ def map_item(name: str, type: str, value: Optional[str], tags: FrozenSet[str], g # --> 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, tags=tags, groups=groups) + return DatetimeItem(name, dt, tags=tags, groups=groups, metadata=meta) if type == "Color": if value is None: - return ColorItem(name, tags=tags, groups=groups) - return ColorItem(name, *(float(k) for k in value.split(',')), tags=tags, groups=groups) + return ColorItem(name, tags=tags, groups=groups, metadata=meta) + return ColorItem(name, *(float(k) for k in value.split(',')), tags=tags, groups=groups, metadata=meta) if type == "Image": - img = ImageItem(name, tags=tags, groups=groups) + img = ImageItem(name, tags=tags, groups=groups, metadata=meta) if value is None: return img img.set_value(RawValue(value)) return img if type == "Group": - return GroupItem(name, value, tags=tags, groups=groups) + return GroupItem(name, value, tags=tags, groups=groups, metadata=meta) if type == "Location": - return LocationItem(name, value, tags=tags, groups=groups) + return LocationItem(name, value, tags=tags, groups=groups, metadata=meta) if type == "Player": - return PlayerItem(name, value, tags=tags, groups=groups) + return PlayerItem(name, value, tags=tags, groups=groups, metadata=meta) raise ValueError(f'Unknown Openhab type: {type} for {name}') diff --git a/src/HABApp/openhab/map_values.py b/src/HABApp/openhab/map_values.py index 79eec9bb..88b81778 100644 --- a/src/HABApp/openhab/map_values.py +++ b/src/HABApp/openhab/map_values.py @@ -5,6 +5,11 @@ def map_openhab_values(openhab_type: str, openhab_value: str): + # because we preprocess the string value can be None. + # Either remove the preprocessing or remove this here + if openhab_value is None: + return None + assert isinstance(openhab_type, str), type(openhab_type) assert isinstance(openhab_value, str), type(openhab_value) diff --git a/src/HABApp/rule/rule.py b/src/HABApp/rule/rule.py index 3db7c28e..7ca63493 100644 --- a/src/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -196,7 +196,9 @@ def register_cancel_obj(self, obj): 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 + groups: Union[str, Iterable[str]] = None, + metadata: Union[str, typing.Pattern[str]] = None, + metadata_value: Union[str, typing.Pattern[str]] = None, ) -> Union[typing.List[TYPE_ITEM], typing.List[BaseItem]]: """Search the HABApp item registry and return the found items. @@ -204,12 +206,18 @@ def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = Non :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) + :param metadata: str (will be compiled) or regex that is used to search the metadata (e.g. 'homekit') + :param metadata_value: str (will be compiled) or regex that is used to search the metadata value + (e.g. 'TargetTemperature') :return: Items that match all the passed criteria """ - if name is not None: - if isinstance(name, str): - name = re.compile(name, re.IGNORECASE) + if name is not None and isinstance(name, str): + name = re.compile(name, re.IGNORECASE) + if metadata is not None and isinstance(metadata, str): + metadata = re.compile(metadata, re.IGNORECASE) + if metadata_value is not None and isinstance(metadata_value, str): + metadata_value = re.compile(metadata_value, re.IGNORECASE) _tags, _groups = None, None if tags is not None: @@ -218,11 +226,11 @@ def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = Non _groups = set(groups) if not isinstance(groups, str) else {groups} OpenhabItem = HABApp.openhab.items.OpenhabItem - if _tags or _groups: + if _tags or _groups or metadata or metadata_value: 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') + raise ValueError('Searching for tags, groups and metadata only works for OpenhabItem or its Subclasses') ret = [] for item in HABApp.core.Items.get_all_items(): # type: HABApp.core.items.base_valueitem.BaseItem @@ -238,6 +246,13 @@ def get_items(type: Union[typing.Tuple[TYPE_ITEM_CLS, ...], TYPE_ITEM_CLS] = Non if _groups is not None and not _groups.issubset(item.groups): continue + if metadata is not None and not any(map(metadata.search, item.metadata)): + continue + + if metadata_value is not None and not any( + map(metadata_value.search, map(lambda x: x[0], item.metadata.values()))): + continue + ret.append(item) return ret diff --git a/src/HABApp/rule/scheduler/scheduler.py b/src/HABApp/rule/scheduler/scheduler.py index aa3b6670..0bc8c16b 100644 --- a/src/HABApp/rule/scheduler/scheduler.py +++ b/src/HABApp/rule/scheduler/scheduler.py @@ -14,7 +14,7 @@ def __init__(self): self.pause() async def _run_next(self): - async_context.set('HABAppSchedulerView') + async_context.set('HABAppScheduler') return await super()._run_next() async def __add_job(self, job: ScheduledJobBase): diff --git a/src/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py index 57642e9e..09151e31 100644 --- a/src/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -11,6 +11,7 @@ from HABApp.core.wrapper import process_exception from HABApp.openhab import connection_logic as openhab_connection from HABApp.runtime import shutdown +from HABApp.core.context import async_context import HABApp.rule.interfaces._http @@ -28,7 +29,7 @@ def __init__(self): async def start(self, config_folder: Path): try: - HABApp.core.context.async_context.set('HABApp startup') + token = async_context.set('HABApp startup') # setup exception handler for the scheduler eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) @@ -60,6 +61,8 @@ async def start(self, config_folder: Path): await openhab_connection.start() shutdown.register_func(HABApp.core.const.loop.stop, msg='Stopping asyncio loop') + + async_context.reset(token) except asyncio.CancelledError: pass except Exception as e: diff --git a/src/HABApp/util/__init__.py b/src/HABApp/util/__init__.py index d961d772..1760de8f 100644 --- a/src/HABApp/util/__init__.py +++ b/src/HABApp/util/__init__.py @@ -4,6 +4,7 @@ from .threshold import Threshold from .statistics import Statistics from . import multimode +from .listener_groups import EventListenerGroup # 27.04.2020 - this can be removed in some time from .multimode import MultiModeItem diff --git a/src/HABApp/util/listener_groups.py b/src/HABApp/util/listener_groups.py new file mode 100644 index 00000000..65d59f8c --- /dev/null +++ b/src/HABApp/util/listener_groups.py @@ -0,0 +1,156 @@ +from typing import Any, Callable, List, Optional, Union + +from HABApp.core.event_bus_listener import EventBusListener +from HABApp.core.events import AllEvents, EventFilter +from HABApp.core.items.base_valueitem import BaseItem + + +class EventListenerCreator: + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], event_filter: EventFilter): + self.item = item + self.callback = callback + self.event_filter = event_filter + + def listen(self) -> EventBusListener: + return self.item.listen_event(self.callback, self.event_filter) + + +class NoUpdateEventListenerCreator: + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: Union[int, float]): + self.item = item + self.callback = callback + self.secs = secs + + def listen(self) -> EventBusListener: + return self.item.watch_update(self.secs).listen_event(self.callback) + + +class NoChangeEventListenerCreator: + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: Union[int, float]): + self.item = item + self.callback = callback + self.secs = secs + + def listen(self) -> EventBusListener: + return self.item.watch_change(self.secs).listen_event(self.callback) + + +class EventListenerGroup: + """Helper to create/cancel multiple event listeners simultaneously + """ + def __init__(self, default_callback: Optional[Callable[[Any], Any]] = None, default_event_filter=AllEvents, + default_seconds: Optional[Union[int, float]] = None): + self._items: List[Union[EventListenerCreator, NoUpdateEventListenerCreator, NoChangeEventListenerCreator]] = [] + self._subs: List[EventBusListener] = [] + + self._is_active = False + + self._default_callback = default_callback + self._default_event_filter = default_event_filter + self._default_seconds = default_seconds + + @property + def active(self) -> bool: + """ + + :return: True if the listeners are currently active + """ + return self._is_active + + def listen(self): + """Create all event listeners. If the event listeners are already active this will do nothing. + """ + if self._is_active: + return None + self._is_active = True + + for obj in self._items: + self._subs.append(obj.listen()) + + def cancel(self): + """Cancel the active event listeners. If the event listeners are not active this will do nothing. + """ + if not self._is_active: + return None + self._is_active = False + + while self._subs: + self._subs.pop().cancel() + + def add_listener(self, item: BaseItem, callback: Optional[Callable[[Any], Any]] = None, + event_filter: Optional[EventFilter] = None) -> 'EventListenerGroup': + """Add an event listener to the group + + :param item: Item + :param callback: Callback or default callback if omitted + :param event_filter: Event filter of default event filter if omitted + :return: self + """ + if callback is None: + callback = self._default_callback + if callback is None: + raise ValueError('No callback passed and no default callback specified in __init__') + + if event_filter is None: + event_filter = self._default_event_filter + + obj = EventListenerCreator(item, callback, event_filter if event_filter is not None else AllEvents) + self._items.append(obj) + + if self._is_active: + self._subs.append(obj.listen()) + return self + + def add_no_update_watcher(self, item: BaseItem, callback: Optional[Callable[[Any], Any]] = None, + seconds: Optional[Union[int, float]] = None) -> 'EventListenerGroup': + """Add an no update watcher to the group. On ``listen`` this will create a no update watcher and + the corresponding event listener that will trigger the callback + + :param item: Item + :param callback: Callback or default callback if omitted + :param seconds: No update time for the no update watcher or default seconds if omitted + :return: self + """ + if callback is None: + callback = self._default_callback + if seconds is None: + seconds = self._default_seconds + + if callback is None: + raise ValueError('No callback passed and no default callback specified in __init__') + if seconds is None: + raise ValueError('No seconds passed and no default seconds specified in __init__') + + obj = NoUpdateEventListenerCreator(item, callback, seconds) + self._items.append(obj) + + if self._is_active: + self._subs.append(obj.listen()) + return self + + def add_no_change_watcher(self, item: BaseItem, callback: Optional[Callable[[Any], Any]] = None, + seconds: Optional[Union[int, float]] = None) -> 'EventListenerGroup': + """Add an no change watcher to the group. On ``listen`` this this will create a no change watcher and + the corresponding event listener that will trigger the callback + + :param item: Item + :param callback: Callback or default callback if omitted + :param seconds: No update time for the no change watcher or default seconds if omitted + :return: self + """ + if callback is None: + callback = self._default_callback + if seconds is None: + seconds = self._default_seconds + + if callback is None: + raise ValueError('No callback passed and no default callback specified in __init__') + if seconds is None: + raise ValueError('No seconds passed and no default seconds specified in __init__') + + obj = NoChangeEventListenerCreator(item, callback, seconds) + self._items.append(obj) + + if self._is_active: + self._subs.append(obj.listen()) + return self diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index d541bddd..5996e9c6 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -14,6 +14,12 @@ def test_ItemStateEvent(): assert event.name == 'Ping' assert event.value == '1' + event = get_event({'topic': 'openhab/items/my_item_name/state', + 'payload': '{"type":"String","value":"NONE"}', 'type': 'ItemStateEvent'}) + assert isinstance(event, ItemStateEvent) + assert event.name == 'my_item_name' + assert event.value is None + def test_ItemCommandEvent(): event = get_event({'topic': 'openhab/items/Ping/command', 'payload': '{"type":"String","value":"1"}', diff --git a/tests/test_openhab/test_helpers.py b/tests/test_openhab/test_helpers.py new file mode 100644 index 00000000..1e9d9a76 --- /dev/null +++ b/tests/test_openhab/test_helpers.py @@ -0,0 +1,73 @@ +from HABApp.openhab.definitions.helpers.log_table import Table, Column + + +def test_col(): + col = Column('my heading', wrap=40) + col.add(['asdf', 'def', '23456trhrethtre', 'ghdrhtrezertztre', 'adfsdsf']) + assert col.get_lines(0) == 2 + assert col.format_entry(0, 2) == [ + 'asdf, def, 23456trhrethtre, ghdrhtrezertztre', + 'adfsdsf ' + ] + + +def test_table(): + table = Table('my heading') + c1 = table.add_column('col1') + c2 = table.add_column('col2') + + c1.add('12') + c1.add(345) + c1.add(23) + + c2.add('asdf') + c2.add('ljkäöjio') + c2.add('jfhgf') + + assert table.get_lines() == [ + '+-----------------+', + '| my heading |', + '+------+----------+', + '| col1 | col2 |', + '+------+----------+', + '| 12 | asdf |', + '| 345 | ljkäöjio |', + '| 23 | jfhgf |', + '+------+----------+' + ] + + assert table.get_lines(sort_columns=['col2']) == [ + '+-----------------+', + '| my heading |', + '+------+----------+', + '| col1 | col2 |', + '+------+----------+', + '| 12 | asdf |', + '| 23 | jfhgf |', + '| 345 | ljkäöjio |', + '+------+----------+' + ] + + +def test_wrap(): + table = Table('my heading') + c1 = table.add_column('col1') + c2 = table.add_column('col2', wrap=20) + + c1.add('12') + c1.add(345) + + c2.add(['asdfasdfasdf', 'defdefdef', 'adfiopfafds', 'jkdfsj']) + c2.add('ljkäöjio') + + assert table.get_lines() == [ + '+--------------------------------+', + '| my heading |', + '+------+-------------------------+', + '| col1 | col2 |', + '+------+-------------------------+', + '| 12 | asdfasdfasdf, defdefdef |', + '| | adfiopfafds, jkdfsj |', + '| 345 | ljkäöjio |', + '+------+-------------------------+' + ] diff --git a/tests/test_openhab/test_items/test_image.py b/tests/test_openhab/test_items/test_image.py index 569c595c..0cea7411 100644 --- a/tests/test_openhab/test_items/test_image.py +++ b/tests/test_openhab/test_items/test_image.py @@ -10,6 +10,7 @@ def test_image_load(): "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAD8CAYAAABTq8lnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFIGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTgtMDgtMTdUMTQ6MTc6NTAtMDQ6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjE2LTA0OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjJiNzE4NDBmLTE2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", # noqa: E501 tags=frozenset(), groups=frozenset(), + metadata=None, ) 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 30055f19..2b410ad7 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -1,30 +1,56 @@ -from HABApp.openhab.map_items import map_item -from HABApp.openhab.items import NumberItem, DatetimeItem from datetime import datetime +from functools import partial + +from immutables import Map + +from HABApp.openhab.items import DatetimeItem, NumberItem +from HABApp.openhab.map_items import map_item def test_exception(): - assert map_item('test', 'Number', 'asdf', frozenset(), frozenset()) is None + assert map_item('test', 'Number', 'asdf', frozenset(), frozenset(), {}) is None + + +def test_metadata(): + make_number = partial(map_item, 'test', 'Number', None, frozenset(), frozenset()) + + item = make_number({'ns1': {'value': 'v1'}}) + assert isinstance(item.metadata, Map) + assert item.metadata['ns1'].value == 'v1' + assert isinstance(item.metadata['ns1'].config, Map) + assert item.metadata['ns1'].config == Map() + + item = make_number({'ns1': {'value': 'v1', 'config': {'c': 1}}}) + assert item.metadata['ns1'].value == 'v1' + assert isinstance(item.metadata['ns1'].config, Map) + assert item.metadata['ns1'].config == Map({'c': 1}) + + item = make_number({'ns1': {'value': 'v1'}, 'ns2': {'value': 12}}) + assert item.metadata['ns1'].value == 'v1' + assert item.metadata['ns1'].config == Map() + assert item.metadata['ns2'].value == 12 + assert item.metadata['ns2'].config == Map() def test_number_unit_of_measurement(): - 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) + make_item = partial(map_item, tags=frozenset(), groups=frozenset(), metadata={}) + assert make_item('test1', 'Number:Length', '1.0 m', ) == NumberItem('test', 1) + assert make_item('test2', 'Number:Temperature', '2.0 °C', ) == NumberItem('test', 2) + assert make_item('test3', 'Number:Pressure', '3.0 hPa', ) == NumberItem('test', 3) + assert make_item('test4', 'Number:Speed', '4.0 km/h', ) == NumberItem('test', 4) + assert make_item('test5', 'Number:Intensity', '5.0 W/m2', ) == NumberItem('test', 5) + assert make_item('test6', 'Number:Dimensionless', '6.0', ) == NumberItem('test', 6) + assert make_item('test7', 'Number:Angle', '7.0 °', ) == 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', frozenset(), frozenset()) == \ + 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', frozenset(), frozenset()) == \ + 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_core/test_items/test_item_search.py b/tests/test_rule/test_item_search.py similarity index 68% rename from tests/test_core/test_items/test_item_search.py rename to tests/test_rule/test_item_search.py index a1428a1c..75e0df65 100644 --- a/tests/test_core/test_items/test_item_search.py +++ b/tests/test_rule/test_item_search.py @@ -4,6 +4,7 @@ from HABApp.core import Items from HABApp.core.items import Item, BaseValueItem from HABApp.openhab.items import OpenhabItem, SwitchItem +from HABApp.openhab.items.base_item import MetaData def test_search_type(): @@ -23,8 +24,10 @@ def test_search_type(): 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'])) + item1 = OpenhabItem('oh_item_1', tags=frozenset(['tag1', 'tag2', 'tag3']), + groups=frozenset(['grp1', 'grp2']), metadata={'meta1': MetaData('meta_v1')}) + item2 = SwitchItem('oh_item_2', tags=frozenset(['tag1', 'tag2', 'tag4']), + groups=frozenset(['grp2', 'grp3']), metadata={'meta2': MetaData('meta_v2', config={'a': 'b'})}) item3 = Item('item_2') assert Rule.get_items() == [] @@ -43,6 +46,15 @@ def test_search_oh(): assert Rule.get_items(groups='grp1', tags='tag1') == [item1] assert Rule.get_items(groups='grp2', tags='tag4') == [item2] + assert Rule.get_items(metadata='meta1') == [item1] + assert Rule.get_items(metadata='meta2') == [item2] + assert Rule.get_items(metadata=r'meta\d') == [item1, item2] + + assert Rule.get_items(metadata_value='meta_v1') == [item1] + assert Rule.get_items(metadata_value='meta_v2') == [item2] + assert Rule.get_items(metadata_value=r'meta_v\d') == [item1, item2] + assert Rule.get_items(groups='grp1', metadata_value=r'meta_v\d') == [item1] + def test_classcheck(): with pytest.raises(ValueError): diff --git a/tests/test_utils/test_listener_groups.py b/tests/test_utils/test_listener_groups.py new file mode 100644 index 00000000..4db4c89f --- /dev/null +++ b/tests/test_utils/test_listener_groups.py @@ -0,0 +1,158 @@ +from unittest.mock import Mock + +from HABApp.core.events import AllEvents +from HABApp.util import EventListenerGroup + + +def test_listen(): + item1 = Mock() + item1.listen_event = Mock() + item2 = Mock() + item2.listen_event = Mock() + + cb = Mock(name='cb_mock') + + grp = EventListenerGroup(cb) + grp.add_listener(item1) + item1.listen_event.assert_not_called() + + # Assert that multiple calls will only create the listener once + for i in range(5): + grp.listen() + item1.listen_event.assert_called_once_with(cb, AllEvents) + + assert grp.active + + grp.add_listener(item2) + item1.listen_event.assert_called_once_with(cb, AllEvents) + item2.listen_event.assert_called_once_with(cb, AllEvents) + + objs = grp._subs.copy() + assert len(objs) == 2 + for o in objs: + assert 'cancel' not in o.__dir__() + + grp.cancel() + assert not grp.active + + for o in objs: + cancel = o.cancel + assert isinstance(cancel, Mock) + cancel.assert_called_once_with() + + assert grp._subs == [] + + +def test_change(): + item1 = Mock() + item1_ret = Mock() + item1.watch_change = Mock(return_value=item1_ret) + item2 = Mock() + item2_ret = Mock() + item2.watch_change = Mock(return_value=item2_ret) + + cb = Mock(name='cb_mock') + + grp = EventListenerGroup(cb, default_seconds=20) + grp.add_no_change_watcher(item1, seconds=30) + item1.watch_change.assert_not_called() + item2.watch_change.assert_not_called() + + # Assert that multiple calls will only create the listener once + for i in range(5): + grp.listen() + item1.watch_change.assert_called_once_with(30) + item1_ret.listen_event.assert_called_once_with(cb) + item2.watch_change.assert_not_called() + + assert grp.active + + # ensure that the watcher is added immediately when we are active + grp.add_no_change_watcher(item2) + item1.watch_change.assert_called_once_with(30) + item1_ret.listen_event.assert_called_once_with(cb) + item2.watch_change.assert_called_once_with(20) + item2_ret.listen_event.assert_called_once_with(cb) + + # check that the cancel gets cleaned properly + objs = grp._subs.copy() + assert len(objs) == 2 + for o in objs: + assert 'cancel' not in o.__dir__() + + grp.cancel() + assert not grp.active + + for o in objs: + cancel = o.cancel + assert isinstance(cancel, Mock) + cancel.assert_called_once_with() + + assert grp._subs == [] + + +def test_update(): + item1 = Mock() + item1_ret = Mock() + item1.watch_update = Mock(return_value=item1_ret) + item2 = Mock() + item2_ret = Mock() + item2.watch_update = Mock(return_value=item2_ret) + + cb = Mock(name='cb_mock') + + grp = EventListenerGroup(cb, default_seconds=20) + grp.add_no_update_watcher(item1, seconds=30) + item1.watch_update.assert_not_called() + item2.watch_update.assert_not_called() + + # Assert that multiple calls will only create the listener once + for i in range(5): + grp.listen() + item1.watch_update.assert_called_once_with(30) + item1_ret.listen_event.assert_called_once_with(cb) + item2.watch_update.assert_not_called() + + assert grp.active + + # ensure that the watcher is added immediately when we are active + grp.add_no_update_watcher(item2) + item1.watch_update.assert_called_once_with(30) + item1_ret.listen_event.assert_called_once_with(cb) + item2.watch_update.assert_called_once_with(20) + item2_ret.listen_event.assert_called_once_with(cb) + + # check that the cancel gets cleaned properly + objs = grp._subs.copy() + assert len(objs) == 2 + for o in objs: + assert 'cancel' not in o.__dir__() + + grp.cancel() + assert not grp.active + + for o in objs: + cancel = o.cancel + assert isinstance(cancel, Mock) + cancel.assert_called_once_with() + + assert grp._subs == [] + + +def test_overwrite_defaults(): + item1 = Mock() + item1.listen_event = Mock() + + default_cb = Mock(name='cb_default') + grp = EventListenerGroup(default_cb) + + cb, f = Mock(), Mock() + grp.add_listener(item1, cb, f) + + item1.listen_event.assert_not_called() + + # Assert that multiple calls will only create the listener once + for i in range(5): + grp.listen() + item1.listen_event.assert_called_once_with(cb, f) + assert grp.active