Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added ability to listen to all events #379

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ build:
# Optionally set the version of Python and requirements required to build your docs
python:
install:
- requirements: requirements_setup.txt
- requirements: requirements_tests.txt # We currently still need pytest for the rule runner
- requirements: docs/requirements.txt
- method: setuptools
path: .
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
include LICENSE
include requirements_setup.txt
include py.typed
19 changes: 13 additions & 6 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,12 @@
regex_path = re.compile(r"^\w+Path\('([^']+)'\)")
assert regex_path.search('WindowsPath(\'lib\')').group(1) == 'lib'

# nicer type values
TYPE_REPLACEMENTS = {
'_MissingType.MISSING': '<MISSING>',
'ConstrainedIntValue': 'int'
}


def replace_node_contents(node: Node):
"""Find nodes with given `tag_matches` and `text_matches`. Recursively
Expand All @@ -265,12 +271,12 @@ def replace_node_contents(node: Node):
parent: Node = node.parent
node_text: str = node.astext()

replacement = None
replacement = TYPE_REPLACEMENTS.get(node_text)

# Replace default value
# WindowsPath('config') -> 'config'
if node_text.endswith(')') and (m := regex_path.search(node_text)) is not None:
replacement = Text(f"'{m.group(1)}'")
replacement = f"'{m.group(1)}'"

# # Type hints
# tag_matches = {"pending_xref", "pending_xref_condition"}
Expand All @@ -281,16 +287,17 @@ def replace_node_contents(node: Node):

# put replacement in place
if replacement is not None:
replacement.parent = parent
replacement_obj = Text(replacement)
replacement_obj.parent = parent
pos = parent.children.index(node)
parent.children[pos] = replacement
parent.children[pos] = replacement_obj

return matched_nodes


def transform_desc(app, domain, objtype: str, contentnode):
if objtype != 'pydantic_field':
return None
# if objtype != 'pydantic_field':
# return None

replace_node_contents(node=contentnode.parent)

Expand Down
5 changes: 1 addition & 4 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,5 @@
sphinx >= 6.1, < 7
sphinx-autodoc-typehints >= 1.22, < 2
sphinx_rtd_theme == 1.2.0
sphinx-exec-code == 0.9
sphinx-exec-code == 0.10
autodoc_pydantic >= 1.8, < 1.9

# we use monkeypatch in the RuleRunner which is part of pytest
pytest >= 7.2, < 8
21 changes: 21 additions & 0 deletions docs/troubleshooting.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ The script can e.g. print the result as a json which HABApp can read and load ag
If this warning only appears now and then it can be ignored.


One or more UoM items configured
--------------------------------------

The state of UoM item may arbitrarily change the scale of the item state depending on state updates.
E.g. a state ``3.5kWh`` which is interpreted in HABApp as ``3.5`` can change to ``3500Wh``
which is interpreted as ``3500`` and thus wrongly triggering rules.

With persistence it's different:
The persisted value ``3.5kWh`` is dependent on the item state description and/or on the system default.
E.g. it's impossible to say how an item with the state ``"Length [%.1f]"`` will be persisted because it will depend
on the system locale and the openHAB version. ``"Length [%.1f ft]"`` will persist the length in feet but it's very
confusing and error prone because a change of the state description might persist the values in a different scale
leading to broken graphs and data.

The lack of internal normalisation makes it impossible to use UoM items with external systems or they only work by
chance (e.g. no event with different scale is received).
There was a `big push to change it for OH4.0 <https://github.com/openhab/openhab-core/issues/3282>`_ but
unfortunately no consensus was reached.
Therefore the recommendation is to not use UoM items since a consistent behavior can not be ensured.


Errors
======================================

Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# -----------------------------------------------------------------------------
# Packages for source formatting
# -----------------------------------------------------------------------------
pre-commit >= 2.17, < 2.18
pre-commit >= 3.2, < 3.3

# -----------------------------------------------------------------------------
# Packages for other developement tasks
Expand Down
8 changes: 4 additions & 4 deletions requirements_setup.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
aiohttp >= 3.8, < 3.9
pydantic >= 1.10, < 1.11
aiohttp >= 3.8.4, < 3.9
pydantic >= 1.10.7, < 1.11
pendulum >= 2.1.2, < 2.2
bidict >= 0.22, < 0.23
watchdog >= 2.2, < 2.3
watchdog >= 3.0, < 3.1
ujson >= 5.7, < 5.8
paho-mqtt >= 1.6, < 1.7

Expand All @@ -14,5 +14,5 @@ colorama == 0.4.6

voluptuous == 0.13.1

typing-extensions >= 4.1, < 5
typing-extensions >= 4.5, < 5
aiohttp-sse-client == 0.2.1
4 changes: 2 additions & 2 deletions requirements_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
# -----------------------------------------------------------------------------
# Packages to run source tests
# -----------------------------------------------------------------------------
pytest >= 7.2, < 8
pytest-asyncio >= 0.20, < 0.21
pytest >= 7.3, < 8
pytest-asyncio >= 0.21, < 0.22
4 changes: 2 additions & 2 deletions run/conf_testing/rules/openhab/test_event_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def __init__(self):

# test the states
for oh_type in get_openhab_test_types():
self.add_test(f'{oh_type} events', self.test_events, oh_type, get_openhab_test_events(oh_type))
self.add_test(f'{oh_type} events', self.test_events, oh_type, get_openhab_test_events(oh_type) + [None])

dimensions = {
'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°',
Expand All @@ -32,7 +32,7 @@ def test_events(self, item_type, test_values):
waiter.wait_for_event(value=value)

# Contact does not support commands
if item_type != 'Contact':
if item_type != 'Contact' and value is not None:
self.openhab.send_command(item_name, value)
waiter.wait_for_event(value=value)

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def load_req() -> typing.List[str]:
},
package_dir={'': 'src'},
packages=find_packages('src', exclude=['tests*']),
package_data={'HABApp': ['py.typed']},
install_requires=load_req(),
python_requires='>=3.8',
classifiers=[
Expand Down
2 changes: 1 addition & 1 deletion src/HABApp/config/logging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ def rotate_files():


def inject_log_buffer(cfg: dict, log: BufferedLogger):
from HABApp.core.const.topics import TOPIC_EVENTS
from HABApp.core.const.log import TOPIC_EVENTS

handler_cfg = cfg.setdefault('handlers', {})

Expand Down
60 changes: 35 additions & 25 deletions src/HABApp/config/logging/queue_handler.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
from queue import SimpleQueue, Empty
from threading import Thread
from threading import Thread, Lock
from time import sleep
from typing import Optional, Final

Expand All @@ -10,6 +10,9 @@
log = logging.getLogger('HABApp.logging')


LOCK = Lock()


class HABAppQueueHandler:
FLUSH_DELAY: float = CONFIG.habapp.logging.flush_every

Expand All @@ -21,46 +24,53 @@ def __init__(self, queue: SimpleQueue, handler_name: str, thread_name: str):
self._thread: Optional[Thread] = None

def start(self) -> None:
if self._thread is not None:
raise RuntimeError('Thread can only be started once!')
with LOCK:
if self._thread is not None:
raise RuntimeError('Thread can only be started once!')

# resolve handler
self._handler = logging._handlers[self._handler_name]

# resolve handler
self._handler = logging._handlers[self._handler_name]
self._thread = thread = Thread(target=self._worker, name=f'HABApp_{self._name}')

self._thread = Thread(target=self._worker, name=f'HABApp_{self._name}')
self._thread.start()
thread.start()

def signal_stop(self):
self._queue.put_nowait(None)

def stop(self) -> None:
if self._thread is None:
return None
thread = self._thread
with LOCK:
if (thread := self._thread) is None:
return None

self.signal_stop()
thread.join()

def _worker(self):
try:
assert self._handler is not None
while True:
sleep(self.FLUSH_DELAY)
if self.process_queue():
break
log.debug(f'{self._name} thread running')

log.debug(f'{self._name} thread stopped')
except Exception as e:
HABApp.core.wrapper.process_exception(self._worker, e)
try:
assert self._handler is not None
while True:
sleep(self.FLUSH_DELAY)
if self.process_queue():
break

# clean up queue
try:
while True:
self._queue.get_nowait()
except Empty:
pass
except Exception as e:
HABApp.core.wrapper.process_exception(self._worker, e)

self._thread = None
# clean up queue
try:
while True:
self._queue.get_nowait()
except Empty:
pass

log.debug(f'{self._name} thread stopped')
finally:
with LOCK:
self._thread = None

def process_queue(self) -> bool:
q = self._queue
Expand Down
42 changes: 38 additions & 4 deletions src/HABApp/core/asyncio.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
from asyncio import Future as _Future
from asyncio import run_coroutine_threadsafe as _run_coroutine_threadsafe
from contextvars import ContextVar as _ContextVar
from typing import Any as _Any
from typing import Any as _Any, Callable
from typing import Callable as _Callable
from typing import Coroutine as _Coroutine
from typing import Optional as _Optional
from typing import TypeVar as _TypeVar

from HABApp.core.const import loop
from HABApp.core.const.const import PYTHON_310

if PYTHON_310:
from typing import ParamSpec as _ParamSpec
else:
from typing_extensions import ParamSpec as _ParamSpec


async_context = _ContextVar('async_ctx')

Expand All @@ -21,11 +27,19 @@ def __str__(self):
return f'Function "{self.func.__name__}" may not be called from an async context!'


_tasks = set()


def create_task(coro: _Coroutine, name: _Optional[str] = None) -> _Future:
# https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task
if async_context.get(None) is None:
return _run_coroutine_threadsafe(coro, loop)
f = _run_coroutine_threadsafe(coro, loop)
f.add_done_callback(_tasks.discard)
return f
else:
return loop.create_task(coro, name=name)
t = loop.create_task(coro, name=name)
t.add_done_callback(_tasks.discard)
return t


_CORO_RET = _TypeVar('_CORO_RET')
Expand All @@ -38,3 +52,23 @@ def run_coro_from_thread(coro: _Coroutine[_Any, _Any, _CORO_RET], calling: _Call

fut = _run_coroutine_threadsafe(coro, loop)
return fut.result()


P = _ParamSpec('P')
T = _TypeVar('T')


def run_func_from_async(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
# we already have an async context
if async_context.get(None) is not None:
return func(*args, **kwargs)

future = _run_coroutine_threadsafe(_run_func_from_async_helper(func, *args, **kwargs), loop)
return future
# Doc build fails if we enable this
# Todo: Fix the Rule Runner
# return future.result()


async def _run_func_from_async_helper(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return func(*args, **kwargs)
11 changes: 11 additions & 0 deletions src/HABApp/core/const/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,14 @@ def __repr__(self):
PYTHON_39: Final = sys.version_info >= (3, 9)
PYTHON_310: Final = sys.version_info >= (3, 10)
PYTHON_311: Final = sys.version_info >= (3, 11)
PYTHON_312: Final = sys.version_info >= (3, 12)


# In python 3.11 there were changes to MyEnum(str, Enum), so we have to use the StrEnum
# https://docs.python.org/3/library/enum.html#enum.StrEnum
if PYTHON_311:
# noinspection PyUnresolvedReferences
from enum import StrEnum
else:
class StrEnum(str, Enum):
pass
3 changes: 3 additions & 0 deletions src/HABApp/core/const/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import Final

TOPIC_EVENTS: Final = 'HABApp.EventBus'
6 changes: 2 additions & 4 deletions src/HABApp/core/const/topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
TOPIC_INFOS: Final = 'HABApp.Infos'
TOPIC_WARNINGS: Final = 'HABApp.Warnings'
TOPIC_ERRORS: Final = 'HABApp.Errors'
TOPIC_ANY: Final = 'HABApp.Any'

TOPIC_FILES: Final = 'HABApp.Files'


ALL_TOPICS: Tuple[str, ...] = (
TOPIC_INFOS, TOPIC_WARNINGS, TOPIC_ERRORS,
TOPIC_INFOS, TOPIC_WARNINGS, TOPIC_ERRORS, TOPIC_ANY,

TOPIC_FILES
)


TOPIC_EVENTS: Final = 'HABApp.EventBus'
6 changes: 4 additions & 2 deletions src/HABApp/core/events/filter/event.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from HABApp.core.const import MISSING
from HABApp.core.const.hints import HINT_ANY_CLASS
from HABApp.core.internals import EventFilterBase
Expand All @@ -12,9 +14,9 @@ def __init__(self, event_class: HINT_ANY_CLASS, **kwargs):
self.event_class = event_class

# Property filters
self.attr_name1 = None
self.attr_name1: Optional[str] = None
self.attr_value1 = None
self.attr_name2 = None
self.attr_name2: Optional[str] = None
self.attr_value2 = None

for arg, value in kwargs.items():
Expand Down
1 change: 1 addition & 0 deletions src/HABApp/core/internals/context/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def remove_obj(self, obj: HINT_CONTEXT_BOUND_OBJ):

def link(self, obj: HINT_CONTEXT_BOUND_OBJ) -> HINT_CONTEXT_BOUND_OBJ:
assert isinstance(obj, ContextBoundObj)
# noinspection PyProtectedMember
obj._ctx_link(self)
return obj

Expand Down
Loading