diff --git a/.github/workflows/publish-dockerhub.yml b/.github/workflows/publish-dockerhub.yml index ff3c08a6..01d70400 100644 --- a/.github/workflows/publish-dockerhub.yml +++ b/.github/workflows/publish-dockerhub.yml @@ -12,30 +12,30 @@ jobs: environment: release steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: ref: master - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: all - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: version: latest - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_HUB_USER }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Build and push release id: docker_build_release - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: push: true platforms: linux/amd64,linux/arm64,linux/arm/v7 @@ -46,7 +46,7 @@ jobs: - name: Build and push latest id: docker_build_latest - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: push: true platforms: linux/amd64,linux/arm64,linux/arm/v7 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 2aac5900..503f142a 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -13,12 +13,12 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: master - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/run-tox.yml b/.github/workflows/run-tox.yml index 17106d8b..43fba253 100644 --- a/.github/workflows/run-tox.yml +++ b/.github/workflows/run-tox.yml @@ -7,10 +7,10 @@ jobs: name: pre-commit runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.11' - uses: pre-commit/action@v3.0.0 @@ -23,9 +23,9 @@ jobs: python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 048861d2..eb3b23b6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-ast - id: check-builtin-literals @@ -22,7 +22,7 @@ repos: - repo: https://github.com/PyCQA/flake8 - rev: '6.1.0' + rev: '7.0.0' hooks: - id: flake8 # additional_dependencies: diff --git a/.ruff.toml b/.ruff.toml index 29f7cd3b..e44eb57d 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -8,7 +8,10 @@ src = ["src", "test"] # https://docs.astral.sh/ruff/settings/#ignore-init-module-imports ignore-init-module-imports = true -extend-exclude = ["__init__.py"] +extend-exclude = [ + "__init__.py", + "src/__test_*.py" +] select = [ "E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w @@ -62,6 +65,7 @@ builtins-ignorelist = ["id", "input"] [lint.per-file-ignores] "docs/conf.py" = ["INP001", "A001"] "setup.py" = ["PTH123"] +"run/**" = ["INP001"] [lint.isort] diff --git a/docs/conf.py b/docs/conf.py index 30cc030f..365ecc03 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ from docutils.nodes import Node, Text from sphinx.addnodes import desc_signature + IS_RTD_BUILD = os.environ.get('READTHEDOCS', '-').lower() == 'true' IS_CI = os.environ.get('CI', '-') == 'true' diff --git a/docs/configuration.rst b/docs/configuration.rst index 09360962..03e6546b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -54,7 +54,7 @@ Example mqtt: connection: - client_id: HABApp + identifier: HABApp host: '' port: 8883 user: '' diff --git a/docs/requirements.txt b/docs/requirements.txt index 8032d1f4..1f74dc8f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ # Packages required to build the documentation sphinx == 7.2.6 -sphinx-autodoc-typehints == 1.25.2 +sphinx-autodoc-typehints == 1.25.3 sphinx_rtd_theme == 2.0.0 -sphinx-exec-code == 0.10 +sphinx-exec-code == 0.12 autodoc_pydantic == 2.0.1 sphinx-copybutton == 0.5.2 diff --git a/readme.md b/readme.md index ca924f6a..a05052ac 100644 --- a/readme.md +++ b/readme.md @@ -127,6 +127,11 @@ MyOpenhabRule() ``` # Changelog +#### 24.02.0 (2024-XX-XX) +- For openHAB >= 4.1 it's possible to wait for a minimum openHAB uptime before connecting (defaults to 60s) +- Renamed config entry mqtt.connection.client_id to identifier (backwards compatible) +- Updated dependencies + #### 24.01.0 (2024-01-08) - Added HABApp.util.RateLimiter - Added CompressedMidnightRotatingFileHandler diff --git a/requirements.txt b/requirements.txt index 57c6b701..eedef308 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ # Packages for source formatting # ----------------------------------------------------------------------------- pre-commit == 3.5.0 # 3.6.0 requires python >= 3.10 -ruff == 0.1.11 +ruff == 0.1.15 # ----------------------------------------------------------------------------- # Packages for other developement tasks diff --git a/requirements_setup.txt b/requirements_setup.txt index f635f60b..f2c00d95 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,14 +1,14 @@ -aiohttp == 3.9.1 -pydantic == 2.5.3 -msgspec == 0.18.5 +aiohttp == 3.9.3 +pydantic == 2.6.0 +msgspec == 0.18.6 bidict == 0.22.1 watchdog == 3.0.0 ujson == 5.9.0 -aiomqtt == 1.2.1 +aiomqtt == 2.0.0 immutables == 0.20 eascheduler == 0.1.11 -easyconfig == 0.3.1 +easyconfig == 0.3.2 pendulum == 2.1.2 stack_data == 0.6.3 colorama == 0.4.6 diff --git a/requirements_tests.txt b/requirements_tests.txt index 9451f3ee..3d25d953 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -8,4 +8,4 @@ # ----------------------------------------------------------------------------- packaging == 23.2 pytest == 7.4.4 -pytest-asyncio == 0.23.3 +pytest-asyncio == 0.23.4 diff --git a/run/conf/config.yml b/run/conf/config.yml index 106f139d..a4286b14 100644 --- a/run/conf/config.yml +++ b/run/conf/config.yml @@ -12,7 +12,7 @@ location: mqtt: connection: - client_id: HABAppConf + identifier: HABAppConf host: localhost password: '' port: 1883 diff --git a/run/conf/rules/logging_rule.py b/run/conf/rules/logging_rule.py index 697461c4..16c3e4f5 100644 --- a/run/conf/rules/logging_rule.py +++ b/run/conf/rules/logging_rule.py @@ -2,6 +2,7 @@ import HABApp + log = logging.getLogger('MyRule') diff --git a/run/conf/rules/openhab_rule.py b/run/conf/rules/openhab_rule.py index c7bf58c4..fe3318ef 100644 --- a/run/conf/rules/openhab_rule.py +++ b/run/conf/rules/openhab_rule.py @@ -1,7 +1,7 @@ import HABApp -from HABApp.core.events import ValueUpdateEvent, ValueChangeEvent -from HABApp.openhab.events import ItemStateEvent, ItemCommandEvent, ItemStateChangedEvent -from HABApp.openhab.items import SwitchItem, ContactItem, DatetimeItem +from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.openhab.events import ItemCommandEvent, ItemStateChangedEvent, ItemStateEvent +from HABApp.openhab.items import ContactItem, DatetimeItem, SwitchItem class MyOpenhabRule(HABApp.Rule): diff --git a/run/conf/rules/openhab_things.py b/run/conf/rules/openhab_things.py index 623ecf2a..675a239f 100644 --- a/run/conf/rules/openhab_things.py +++ b/run/conf/rules/openhab_things.py @@ -1,7 +1,7 @@ from HABApp import Rule +from HABApp.core.events import EventFilter from HABApp.openhab.events import ThingStatusInfoChangedEvent from HABApp.openhab.items import Thing -from HABApp.core.events import EventFilter class CheckAllThings(Rule): diff --git a/run/conf/rules/openhab_to_mqtt_rule.py b/run/conf/rules/openhab_to_mqtt_rule.py index 294d328b..5ebe21a5 100644 --- a/run/conf/rules/openhab_to_mqtt_rule.py +++ b/run/conf/rules/openhab_to_mqtt_rule.py @@ -1,5 +1,5 @@ import HABApp -from HABApp.openhab.events import ItemStateUpdatedEventFilter, ItemStateEvent +from HABApp.openhab.events import ItemStateEvent, ItemStateUpdatedEventFilter from HABApp.openhab.items import OpenhabItem diff --git a/run/conf/rules/time_rule.py b/run/conf/rules/time_rule.py index 5914c6c8..cc003540 100644 --- a/run/conf/rules/time_rule.py +++ b/run/conf/rules/time_rule.py @@ -1,4 +1,5 @@ -from datetime import time, timedelta, datetime +from datetime import datetime, time, timedelta + from HABApp import Rule diff --git a/run/conf_listen/config.yml b/run/conf_listen/config.yml index cb831b10..7b158d2f 100644 --- a/run/conf_listen/config.yml +++ b/run/conf_listen/config.yml @@ -12,7 +12,7 @@ location: mqtt: connection: - client_id: HABAppTesting + identifier: HABAppTesting host: localhost user: '' password: '' diff --git a/run/conf_testing/config.yml b/run/conf_testing/config.yml index ac30233d..cf645909 100644 --- a/run/conf_testing/config.yml +++ b/run/conf_testing/config.yml @@ -12,7 +12,7 @@ location: mqtt: connection: - client_id: HABAppTesting + identifier: HABAppTesting host: 'localhost' port: 1883 user: '' diff --git a/run/conf_testing/lib/HABAppTests/event_waiter.py b/run/conf_testing/lib/HABAppTests/event_waiter.py index bc01d292..e4e4fe54 100644 --- a/run/conf_testing/lib/HABAppTests/event_waiter.py +++ b/run/conf_testing/lib/HABAppTests/event_waiter.py @@ -1,15 +1,21 @@ import logging import time -from typing import TypeVar, Dict, Any -from typing import Union +from typing import Any, Dict, TypeVar, Union from HABApp.core.events.filter import EventFilter -from HABApp.core.internals import EventBusListener, wrap_func, EventFilterBase, HINT_EVENT_FILTER_OBJ, \ - get_current_context +from HABApp.core.internals import ( + HINT_EVENT_FILTER_OBJ, + EventBusListener, + EventFilterBase, + get_current_context, + wrap_func, +) from HABApp.core.items import BaseValueItem from HABAppTests.errors import TestCaseFailed + from .compare_values import get_equal_text, get_value_text + log = logging.getLogger('HABApp.Tests') EVENT_TYPE = TypeVar('EVENT_TYPE') @@ -52,8 +58,8 @@ def wait_for_event(self, **kwargs) -> EVENT_TYPE: if time.time() > start + self.timeout: expected_values = "with " + ", ".join([f"{__k}={__v}" for __k, __v in kwargs.items()]) if kwargs else "" - raise TestCaseFailed(f'Timeout while waiting for {self.event_filter.describe()} ' - f'for {self.name} {expected_values}') + msg = f'Timeout while waiting for {self.event_filter.describe()} for {self.name} {expected_values}' + raise TestCaseFailed(msg) if not self._received_events: continue diff --git a/run/conf_testing/lib/HABAppTests/item_waiter.py b/run/conf_testing/lib/HABAppTests/item_waiter.py index 09522e73..d2ccbe7f 100644 --- a/run/conf_testing/lib/HABAppTests/item_waiter.py +++ b/run/conf_testing/lib/HABAppTests/item_waiter.py @@ -5,6 +5,7 @@ from HABAppTests.compare_values import get_equal_text from HABAppTests.errors import TestCaseFailed + log = logging.getLogger('HABApp.Tests') @@ -35,9 +36,8 @@ def wait_for_attribs(self, **kwargs): for name, target in kwargs.items() ] failed_msg = "\n".join(failed) - raise TestCaseFailed(f'Timeout waiting for {self.item.name}!\n{failed_msg}') - - raise ValueError() + msg = f'Timeout waiting for {self.item.name}!\n{failed_msg}' + raise TestCaseFailed(msg) def wait_for_state(self, state=None): return self.wait_for_attribs(value=state) diff --git a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py index 48574966..ef7e3613 100644 --- a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -4,17 +4,18 @@ import HABApp from HABApp.openhab.definitions.topics import TOPIC_ITEMS -from . import get_random_name, EventWaiter + +from . import EventWaiter, get_random_name class OpenhabTmpItem: @staticmethod - def use(type: str, name: Optional[str] = None, arg_name: str = 'item'): + def use(item_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) + item = OpenhabTmpItem(item_type, name) try: kwargs[arg_name] = item return func(*args, **kwargs) @@ -24,11 +25,11 @@ def new_func(*args, **kwargs): return decorator @staticmethod - def create(type: str, name: Optional[str] = None, arg_name: Optional[str] = None): + def create(item_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) as f: + with OpenhabTmpItem(item_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 @@ -69,7 +70,8 @@ def create_item(self, label="", category="", tags: List[str] = [], groups: List[ while not HABApp.core.Items.item_exists(self.name): time.sleep(0.01) if time.time() > stop: - raise TimeoutError(f'Item {self.name} was not found!') + msg = f'Item {self.name} was not found!' + raise TimeoutError(msg) return HABApp.openhab.items.OpenhabItem.get_item(self.name) diff --git a/run/conf_testing/rules/test_mqtt.py b/run/conf_testing/rules/test_mqtt.py index ee1f78db..7052431a 100644 --- a/run/conf_testing/rules/test_mqtt.py +++ b/run/conf_testing/rules/test_mqtt.py @@ -1,12 +1,14 @@ import logging import time +from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule, run_coro + import HABApp from HABApp.core.connections import Connections, ConnectionStatus from HABApp.core.events import ValueUpdateEventFilter from HABApp.mqtt.events import MqttValueUpdateEventFilter from HABApp.mqtt.items import MqttItem, MqttPairItem -from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule, run_coro + log = logging.getLogger('HABApp.MqttTestEvents') @@ -73,10 +75,10 @@ def test_mqtt_item_creation(self): run_coro(self.trigger_reconnect()) - time.sleep(0.2) connection = Connections.get('mqtt') while not connection.is_online: time.sleep(0.2) + assert HABApp.core.Items.item_exists(topic) is True HABApp.core.Items.pop_item(topic) diff --git a/src/HABApp/__cmd_args__.py b/src/HABApp/__cmd_args__.py index 9a79db7a..c46e37cd 100644 --- a/src/HABApp/__cmd_args__.py +++ b/src/HABApp/__cmd_args__.py @@ -6,6 +6,7 @@ import typing from pathlib import Path + # Global var if we want to run the benchmark DO_BENCH = False DO_DEBUG = False diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index a97afa18..da28a9a7 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -10,4 +10,4 @@ # Development versions contain the DEV-COUNTER postfix: # - 24.01.0.DEV-1 -__version__ = '24.01.0' +__version__ = '24.02.0.DEV-1' diff --git a/src/HABApp/config/models/habapp.py b/src/HABApp/config/models/habapp.py index f4b845a4..df50819d 100644 --- a/src/HABApp/config/models/habapp.py +++ b/src/HABApp/config/models/habapp.py @@ -1,6 +1,5 @@ -from pydantic import Field, conint - from easyconfig import BaseModel +from pydantic import Field, conint class ThreadPoolConfig(BaseModel): diff --git a/src/HABApp/config/models/mqtt.py b/src/HABApp/config/models/mqtt.py index e9146309..73b05501 100644 --- a/src/HABApp/config/models/mqtt.py +++ b/src/HABApp/config/models/mqtt.py @@ -1,13 +1,13 @@ +import logging import random import string from pathlib import Path -from typing import Literal -from typing import Optional, Tuple +from typing import Literal, Optional, Tuple import pydantic +from easyconfig.models import BaseModel from pydantic import Field -from easyconfig.models import BaseModel QOS = Literal[0, 1, 2] @@ -21,14 +21,24 @@ class TLSSettings(BaseModel): class Connection(BaseModel): - 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.') + identifier: str = Field('HABApp-' + ''.join(random.choices(string.ascii_letters, k=13)), + description='Identifier 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 = '' password: str = '' tls: TLSSettings = Field(default_factory=TLSSettings) + @pydantic.model_validator(mode='before') + @classmethod + def _migrate_client_id(cls, data): + if isinstance(data, dict) and 'client_id' in data: + log = logging.getLogger('HABApp.Config') + log.warning('"client_id" in mqtt.connection has been renamed to "identifier"') + if 'identifier' not in data: + data['identifier'] = data.pop('client_id') + return data + class Subscribe(BaseModel): qos: QOS = Field(default=0, description='Default QoS for subscribing') diff --git a/src/HABApp/config/models/openhab.py b/src/HABApp/config/models/openhab.py index 95b1d959..466fd67b 100644 --- a/src/HABApp/config/models/openhab.py +++ b/src/HABApp/config/models/openhab.py @@ -1,8 +1,7 @@ from typing import Union -from pydantic import AnyHttpUrl, ByteSize, Field, field_validator, TypeAdapter - from easyconfig.models import BaseModel +from pydantic import AnyHttpUrl, ByteSize, Field, TypeAdapter, field_validator class Ping(BaseModel): @@ -28,6 +27,12 @@ class General(BaseModel): description='Minimum openHAB start level to load items and listen to events', ) + # Minimum uptime + min_uptime: int = Field( + 60, ge=0, le=3600, in_file=False, + description='Minimum openHAB uptime in seconds to load items and listen to events', + ) + class Connection(BaseModel): url: str = Field( @@ -45,10 +50,8 @@ class Connection(BaseModel): topic_filter: str = Field( 'openhab/items/*,' # Item updates - 'openhab/channels/*,' # Channel update - # Thing events - don't listen to updated events - # todo: check if this might be a good filter: 'openhab/things/*', - 'openhab/things/*', + 'openhab/channels/*,' # Channel updates + 'openhab/things/*', # Thing updates alias='topic filter', in_file=False, description='Topic filter for subscribing to openHAB. This filter is processed by openHAB and only events ' 'matching this filter will be sent to HABApp.' @@ -56,7 +59,8 @@ class Connection(BaseModel): @field_validator('url') def validate_url(cls, value: str): - TypeAdapter(AnyHttpUrl).validate_python(value) + if value: + TypeAdapter(AnyHttpUrl).validate_python(value) return value @field_validator('buffer') @@ -71,7 +75,8 @@ def validate_see_buffer(cls, value: ByteSize): if value == ByteSize._validate(_v, None): return value - raise ValueError(f'Value must be one of {", ".join(valid_values)}') + msg = f'Value must be one of {", ".join(valid_values)}' + raise ValueError(msg) class OpenhabConfig(BaseModel): diff --git a/src/HABApp/core/connections/base_connection.py b/src/HABApp/core/connections/base_connection.py index 884c655b..d906ee20 100644 --- a/src/HABApp/core/connections/base_connection.py +++ b/src/HABApp/core/connections/base_connection.py @@ -209,6 +209,7 @@ def status_configuration_changed(self): def on_application_shutdown(self): if self.status.shutdown: return None + self.log.debug('Requesting shutdown') self.status.shutdown = True diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py index bfc1d5b7..f2ed5876 100644 --- a/src/HABApp/core/lib/__init__.py +++ b/src/HABApp/core/lib/__init__.py @@ -5,3 +5,5 @@ from .rgb_hsv import hsb_to_rgb, rgb_to_hsb from .exceptions import format_exception, HINT_EXCEPTION from .priority_list import PriorityList +from .timeout import Timeout, TimeoutNotRunningError +from .value_change import ValueChange diff --git a/src/HABApp/core/lib/priority_list.py b/src/HABApp/core/lib/priority_list.py index fbd9601d..af3e0421 100644 --- a/src/HABApp/core/lib/priority_list.py +++ b/src/HABApp/core/lib/priority_list.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import Generic, TypeVar, Literal, Union, Iterator, Tuple +from typing import Generic, Iterator, Literal, Tuple, TypeVar, Union from HABApp.core.const.const import PYTHON_310 + if PYTHON_310: from typing import TypeAlias else: @@ -23,6 +24,7 @@ def sort_func(obj: T_ENTRY): return prio.get(key, 1), key +# Todo: Move this to the connection class PriorityList(Generic[T]): def __init__(self): self._objs: list[T_ENTRY] = [] @@ -48,4 +50,4 @@ def reversed(self) -> Iterator[T]: yield o def __repr__(self): - return f'<{self.__class__.__name__} {[o for o in self]}>' + return f'<{self.__class__.__name__} {list(self)}>' diff --git a/src/HABApp/core/lib/timeout.py b/src/HABApp/core/lib/timeout.py new file mode 100644 index 00000000..28f2cc78 --- /dev/null +++ b/src/HABApp/core/lib/timeout.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from time import monotonic + + +class TimeoutNotRunningError(Exception): + pass + + +class Timeout: + __slots__ = ('_timeout', '_started') + + def __init__(self, timeout: float, *, start: bool = True): + self._timeout: float = timeout + if self._timeout <= 0: + raise ValueError() + + self._started: float | None = None if not start else monotonic() + + def __repr__(self): + + decimals = 1 if self._timeout < 10 else 0 + + if self._started is None: + return f'' + + time = monotonic() - self._started + if time >= self._timeout: + time = self._timeout + return f'' + + def reset(self): + """Reset the timeout if it is running""" + if self._started is not None: + self._started = monotonic() + return self + + def start(self): + """Start the timeout if it is not running""" + if self._started is None: + self._started = monotonic() + return self + + def stop(self): + """Stop the timeout""" + self._started = None + return self + + def set_timeout(self, timeout: float): + """Set the timeout + + :param timeout: Timeout in seconds + """ + if self._timeout <= 0: + raise ValueError() + self._timeout = timeout + return self + + def is_running(self) -> bool: + """ Return whether the timeout is running. + + :return: True if running or False + """ + return self._started is not None + + def is_expired(self) -> bool: + """Return whether the timeout is expired, raises an exception if the timeout is not running + + :return: True if expired else False + """ + if self._started is None: + raise TimeoutNotRunningError() + return monotonic() - self._started >= self._timeout + + def is_running_and_expired(self) -> bool: + """Return whether the timeout is running and expired + + :return: True if expired else False + """ + return self._started is not None and monotonic() - self._started >= self._timeout + + def remaining(self) -> float: + """Return the remaining seconds. Raises an exception if the timeout is not running + + :return: Remaining time in seconds or 0 if expired + """ + if self._started is None: + raise TimeoutNotRunningError() + remaining = self._timeout - (monotonic() - self._started) + return 0 if remaining <= 0 else remaining + + def remaining_or_none(self) -> float | None: + """Return the remaining seconds. Raises an exception if the timeout is not running + + :return: Remaining time in seconds, 0 if expired or None if not running + """ + if self._started is None: + return None + remaining = self._timeout - (monotonic() - self._started) + return 0 if remaining <= 0 else remaining diff --git a/src/HABApp/core/lib/value_change.py b/src/HABApp/core/lib/value_change.py new file mode 100644 index 00000000..16ec02fa --- /dev/null +++ b/src/HABApp/core/lib/value_change.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from typing import Generic, TypeVar + +from HABApp.core.const.const import MISSING, _MissingType + + +T = TypeVar('T') + + +class ValueChange(Generic[T]): + __slots__ = ('_value', 'changed') + + def __init__(self): + self._value: T | _MissingType = MISSING + self.changed: bool = False + + def set_value(self, value: T): + current = self._value + + if value is MISSING and current is MISSING: + self.changed = False + return self + + if value is MISSING and current is not MISSING or value is not MISSING and current is MISSING: + self._value = value + self.changed = True + return self + + if value != current: + self._value = value + self.changed = True + return self + + self.changed = False + return self + + def set_missing(self): + self.set_value(MISSING) + return self + + @property + def is_missing(self) -> bool: + return self._value is MISSING + + @property + def value(self) -> T: + if self._value is MISSING: + raise ValueError() + return self._value + + def __repr__(self): + now = self._value if self._value is not MISSING else repr(MISSING) + return f'<{self.__class__.__name__} value: {now} changed: {self.changed}>' diff --git a/src/HABApp/mqtt/connection/connection.py b/src/HABApp/mqtt/connection/connection.py index 74577778..f159770f 100644 --- a/src/HABApp/mqtt/connection/connection.py +++ b/src/HABApp/mqtt/connection/connection.py @@ -71,4 +71,4 @@ async def _mqtt_wrap_task(self): except AlreadyHandledException: pass finally: - log.debug(f'{self.task.name} task stop') + log.debug(f'{self.task.name} task finished') diff --git a/src/HABApp/mqtt/connection/handler.py b/src/HABApp/mqtt/connection/handler.py index 4c0be234..92c692aa 100644 --- a/src/HABApp/mqtt/connection/handler.py +++ b/src/HABApp/mqtt/connection/handler.py @@ -5,10 +5,10 @@ from HABApp.config import CONFIG from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.connections._definitions import CONNECTION_HANDLER_NAME - -from HABApp.core.internals import uses_post_event, uses_get_item, uses_item_registry +from HABApp.core.internals import uses_get_item, uses_item_registry, uses_post_event from HABApp.mqtt.connection.connection import CONTEXT_TYPE, MqttConnection + post_event = uses_post_event() get_item = uses_get_item() Items = uses_item_registry() @@ -53,7 +53,7 @@ async def on_setup(self, connection: MqttConnection): connection.context = Client( hostname=config.host, port=config.port, username=config.user if config.user else None, password=config.password if config.password else None, - client_id=config.client_id, + identifier=config.identifier, tls_insecure=tls_insecure, tls_params=None if not tls_enabled else TLSParameters(ca_certs=tls_ca_cert), @@ -66,15 +66,16 @@ async def on_connecting(self, connection: MqttConnection, context: CONTEXT_TYPE) connection.log.info(f'Connecting to {context._hostname}:{context._port}') await context.__aenter__() + + # TODO: remove once https://github.com/sbtinstruments/aiomqtt/issues/268 has been fixed + context.messages = context._messages() + connection.log.info('Connection successful') async def on_disconnected(self, connection: MqttConnection, context: CONTEXT_TYPE): assert context is not None connection.log.info('Disconnected') - # remove this check when https://github.com/sbtinstruments/aiomqtt/pull/249 gets merged - if not context._lock.locked(): - await context._lock.acquire() await context.__aexit__(None, None, None) diff --git a/src/HABApp/mqtt/connection/subscribe.py b/src/HABApp/mqtt/connection/subscribe.py index 9b3c5e78..84f05f70 100644 --- a/src/HABApp/mqtt/connection/subscribe.py +++ b/src/HABApp/mqtt/connection/subscribe.py @@ -151,17 +151,15 @@ async def mqtt_task(self): client = self.plugin_connection.context assert client is not None - async with client.messages() as messages: - async for message in messages: - - try: - topic, payload = get_msg_payload(message) - if topic is None: - continue - - msg_to_event(topic, payload, message.retain) - except Exception as e: - process_exception('mqtt payload handling', e, logger=self.plugin_connection.log) + async for message in client.messages: + try: + topic, payload = get_msg_payload(message) + if topic is None: + continue + + msg_to_event(topic, payload, message.retain) + except Exception as e: + process_exception('mqtt payload handling', e, logger=self.plugin_connection.log) post_event = uses_post_event() diff --git a/src/HABApp/openhab/connection/connection.py b/src/HABApp/openhab/connection/connection.py index 54bf6ee5..3db61cd6 100644 --- a/src/HABApp/openhab/connection/connection.py +++ b/src/HABApp/openhab/connection/connection.py @@ -23,6 +23,7 @@ class OpenhabContext: version: tuple[int, int, int] is_oh3: bool + is_oh41: bool # true when we waited during connect waited_for_openhab: bool @@ -33,8 +34,6 @@ class OpenhabContext: session: aiohttp.ClientSession session_options: dict[str, Any] - workaround_small_floats: bool - CONTEXT_TYPE: TypeAlias = Optional[OpenhabContext] diff --git a/src/HABApp/openhab/connection/handler/handler.py b/src/HABApp/openhab/connection/handler/handler.py index 6958547c..27d6b4e4 100644 --- a/src/HABApp/openhab/connection/handler/handler.py +++ b/src/HABApp/openhab/connection/handler/handler.py @@ -169,12 +169,10 @@ async def on_connecting(self, connection: OpenhabConnection): log.warning('HABApp requires at least openHAB version 3.3!') connection.context = OpenhabContext( - version=vers, is_oh3=vers < (4, 0), + version=vers, is_oh3=vers < (4, 0), is_oh41=vers >= (4, 1), waited_for_openhab=False, created_items={}, created_things={}, session=self.session, session_options=self.options, - - workaround_small_floats=vers < (4, 1) ) # during startup we get OpenhabCredentialsInvalidError even though credentials are correct diff --git a/src/HABApp/openhab/connection/plugins/load_transformations.py b/src/HABApp/openhab/connection/plugins/load_transformations.py index f1a69b28..631e8d0d 100644 --- a/src/HABApp/openhab/connection/plugins/load_transformations.py +++ b/src/HABApp/openhab/connection/plugins/load_transformations.py @@ -8,6 +8,7 @@ from HABApp.openhab.transformations._map import MAP_REGISTRY from HABApp.openhab.transformations.base import TransformationRegistryBase, log + Items = uses_item_registry() diff --git a/src/HABApp/openhab/connection/plugins/out.py b/src/HABApp/openhab/connection/plugins/out.py index 21a2c2e3..20538b64 100644 --- a/src/HABApp/openhab/connection/plugins/out.py +++ b/src/HABApp/openhab/connection/plugins/out.py @@ -1,9 +1,7 @@ from __future__ import annotations -from asyncio import Queue, QueueEmpty -from asyncio import sleep -from typing import Any -from typing import Final +from asyncio import Queue, QueueEmpty, sleep +from typing import Any, Final from HABApp.core.asyncio import run_func_from_async from HABApp.core.connections import BaseConnectionPlugin @@ -75,7 +73,7 @@ async def queue_worker(self): queue: Final = self.queue to_str: Final = convert_to_oh_type - scientific_floats = not self.plugin_connection.context.workaround_small_floats + scientific_floats = self.plugin_connection.context.is_oh41 while True: try: @@ -92,7 +90,7 @@ async def queue_worker(self): await post(f'/rest/items/{item:s}', data=state) else: await put(f'/rest/items/{item:s}/state', data=state) - except Exception as e: + except Exception as e: # noqa: PERF203 self.plugin_connection.process_exception(e, 'Outgoing queue worker') def async_post_update(self, item: str | ItemRegistryItem, state: Any): diff --git a/src/HABApp/openhab/connection/plugins/wait_for_restore.py b/src/HABApp/openhab/connection/plugins/wait_for_restore.py index 20e8512c..d43b250a 100644 --- a/src/HABApp/openhab/connection/plugins/wait_for_restore.py +++ b/src/HABApp/openhab/connection/plugins/wait_for_restore.py @@ -2,14 +2,16 @@ import logging from asyncio import sleep -from time import monotonic from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.internals import uses_item_registry +from HABApp.core.lib import ValueChange +from HABApp.core.lib.timeout import Timeout from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext from HABApp.openhab.items import OpenhabItem from HABApp.runtime import shutdown + log = logging.getLogger('HABApp.openhab.startup') item_registry = uses_item_registry() @@ -26,26 +28,25 @@ def count_none_items() -> int: class WaitForPersistenceRestore(BaseConnectionPlugin[OpenhabConnection]): async def on_connected(self, context: OpenhabContext): - if context.waited_for_openhab: + if not context.waited_for_openhab: log.debug('Openhab has already been running -> complete') return None + none_items: ValueChange[int] = ValueChange() + # if we find None items check if they are still getting initialized (e.g. from persistence) - if this_count := count_none_items(): + if none_items.set_value(count_none_items()).value: log.debug('Some items are still None - waiting for initialisation') - last_count = -1 - start = monotonic() - - while not shutdown.requested and last_count != this_count: - await sleep(2) + timeout = Timeout(4 * 60) + while not shutdown.requested and none_items.changed: + await sleep(3) # timeout so we start eventually - if monotonic() - start >= 180: + if timeout.is_expired(): log.debug('Timeout while waiting for initialisation') break - last_count = this_count - this_count = count_none_items() + none_items.set_value(count_none_items()) log.debug('complete') diff --git a/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py index 16722be2..0fbfbd8c 100644 --- a/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py +++ b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py @@ -1,30 +1,90 @@ from __future__ import annotations import asyncio -from time import monotonic import HABApp import HABApp.core import HABApp.openhab.events from HABApp.core.connections import BaseConnectionPlugin +from HABApp.core.lib import Timeout, ValueChange from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext from HABApp.openhab.connection.handler.func_async import async_get_system_info -async def _start_level_reached() -> tuple[bool, None | int]: - start_level_min = HABApp.CONFIG.openhab.general.min_start_level +class WaitForStartlevelPlugin(BaseConnectionPlugin[OpenhabConnection]): - if (system_info := await async_get_system_info()) is None: - return False, None + def __init__(self, name: str | None = None): + super().__init__(name) - start_level_is = system_info.start_level + async def on_connected(self, context: OpenhabContext, connection: OpenhabConnection): + if not context.is_oh41: + return await self.__on_connected_old(context, connection) - return start_level_is >= start_level_min, start_level_is + return await self.__on_connected_new(context, connection) + async def __on_connected_new(self, context: OpenhabContext, connection: OpenhabConnection): + oh_general = HABApp.CONFIG.openhab.general -class WaitForStartlevelPlugin(BaseConnectionPlugin[OpenhabConnection]): + if (system_info := await async_get_system_info()) is not None: # noqa: SIM102 + # If openHAB is already running we have a fast exit path here + if system_info.uptime >= oh_general.min_uptime and system_info.start_level >= oh_general.min_start_level: + context.waited_for_openhab = False + return None + + log = connection.log + log.info('Waiting for openHAB startup to be complete') + context.waited_for_openhab = True + + timeout_start_at_level = 70 + timeout = Timeout(10 * 60, start=False) + + level_change: ValueChange[int] = ValueChange() + + sleep_secs = 1 + + while not HABApp.runtime.shutdown.requested: + await asyncio.sleep(sleep_secs) + sleep_secs = 1 + + if (system_info := await async_get_system_info()) is None: + if level_change.set_missing().changed: + log.debug('Start level: not received!') + continue + + level = system_info.start_level + + # Wait for min uptime + if system_info.uptime < (min_uptime := oh_general.min_uptime): + sleep_secs = min_uptime - system_info.uptime + log.debug(f'Waiting {sleep_secs:d} secs until openHAB uptime of {min_uptime:d} secs is reached') + continue + + # timeout is running + if timeout.is_running_and_expired(): + log.warning(f'Starting even though openHAB is not ready yet (start level: {level})') + break + + # log only when level changed, so we don't spam the log + if level_change.set_value(level).changed: + + log.debug(f'Start level: {level:d}') + if level >= oh_general.min_start_level: + break + + # Wait but start eventually because sometimes we have a bad configured thing or an offline gateway + # that prevents the start level from advancing + # This is a safety net, so we properly start e.g. after a power outage + # When starting manually one should fix the blocking thing + if level >= timeout_start_at_level: + timeout.start() + log.debug('Starting start level timeout') + + if HABApp.runtime.shutdown.requested: + return None + log.info('openHAB startup complete') + + async def __on_connected_old(self, context: OpenhabContext, connection: OpenhabConnection): - async def on_connected(self, context: OpenhabContext, connection: OpenhabConnection): level_reached, level = await _start_level_reached() if level_reached: @@ -38,9 +98,8 @@ async def on_connected(self, context: OpenhabContext, connection: OpenhabConnect last_level: int = -100 - timeout_duration = 10 * 60 timeout_start_at_level = 70 - timeout_timestamp = 0 + timeout = Timeout(10 * 60, start=False) while not level_reached: await asyncio.sleep(1) @@ -60,11 +119,11 @@ async def on_connected(self, context: OpenhabContext, connection: OpenhabConnect # This is a safety net, so we properly start e.g. after a power outage # When starting manually one should fix the blocking thing if level >= timeout_start_at_level: - timeout_timestamp = monotonic() + timeout.start() log.debug('Starting start level timeout') # timeout is running - if timeout_timestamp and monotonic() - timeout_timestamp > timeout_duration: + if timeout.is_running_and_expired(): log.warning(f'Starting even though openHAB is not ready yet (start level: {level})') break @@ -72,3 +131,14 @@ async def on_connected(self, context: OpenhabContext, connection: OpenhabConnect last_level = level log.info('openHAB startup complete') + + +async def _start_level_reached() -> tuple[bool, None | int]: + start_level_min = HABApp.CONFIG.openhab.general.min_start_level + + if (system_info := await async_get_system_info()) is None: + return False, None + + start_level_is = system_info.start_level + + return start_level_is >= start_level_min, start_level_is diff --git a/src/HABApp/openhab/definitions/rest/systeminfo.py b/src/HABApp/openhab/definitions/rest/systeminfo.py index 4aeec7f1..cf73e0de 100644 --- a/src/HABApp/openhab/definitions/rest/systeminfo.py +++ b/src/HABApp/openhab/definitions/rest/systeminfo.py @@ -21,6 +21,8 @@ class SystemInfoResp(Struct, rename='camel', kw_only=True): total_memory: int start_level: int + uptime: int = -1 # todo: remove default if we go OH4.1 only + class SystemInfoRootResp(Struct, rename='camel'): system_info: SystemInfoResp diff --git a/src/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py index e61ec185..8550e9a5 100644 --- a/src/HABApp/openhab/items/group_item.py +++ b/src/HABApp/openhab/items/group_item.py @@ -1,8 +1,9 @@ -from typing import TYPE_CHECKING, Optional, FrozenSet, Mapping, Tuple, Any +from typing import TYPE_CHECKING, Any, FrozenSet, Mapping, Optional, Tuple from HABApp.core.events import ComplexEventValue from HABApp.openhab.item_to_reg import get_members -from HABApp.openhab.items.base_item import OpenhabItem, MetaData +from HABApp.openhab.items.base_item import MetaData, OpenhabItem + if TYPE_CHECKING: Any = Any diff --git a/src/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py index 4b46969d..3ec37fe3 100644 --- a/src/HABApp/openhab/items/number_item.py +++ b/src/HABApp/openhab/items/number_item.py @@ -1,8 +1,9 @@ -from typing import Optional, FrozenSet, Mapping, Union, TYPE_CHECKING +from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union + +from HABApp.core.errors import InvalidItemValue, ItemValueIsNoneError +from HABApp.openhab.definitions import QuantityValue +from HABApp.openhab.items.base_item import MetaData, OpenhabItem -from HABApp.openhab.items.base_item import OpenhabItem, MetaData -from ..definitions import QuantityValue -from ...core.errors import ItemValueIsNoneError, InvalidItemValue if TYPE_CHECKING: Union = Union diff --git a/src/HABApp/parameters/parameters.py b/src/HABApp/parameters/parameters.py index f2ccbcd6..ecd6eb4a 100644 --- a/src/HABApp/parameters/parameters.py +++ b/src/HABApp/parameters/parameters.py @@ -44,7 +44,7 @@ def set_file_validator(filename: str, validator: typing.Any, allow_extra_keys=Tr validator, required=True, extra=(voluptuous.ALLOW_EXTRA if allow_extra_keys else voluptuous.PREVENT_EXTRA) ) - # todo: move this to file handling so we get the extension + # TODO: move this to file handling so we get the extension if old_validator != new_validator: reload_param_file(filename) diff --git a/src/HABApp/rule_manager/rule_file.py b/src/HABApp/rule_manager/rule_file.py index 341fe6bd..05deaee3 100644 --- a/src/HABApp/rule_manager/rule_file.py +++ b/src/HABApp/rule_manager/rule_file.py @@ -5,8 +5,9 @@ from pathlib import Path import HABApp -from HABApp.rule.rule_hook import HABAppRuleHook from HABApp.core.internals import get_current_context +from HABApp.rule.rule_hook import HABAppRuleHook + log = logging.getLogger('HABApp.Rules') diff --git a/src/HABApp/rule_manager/rule_manager.py b/src/HABApp/rule_manager/rule_manager.py index aaa1a649..2eb0fb8f 100644 --- a/src/HABApp/rule_manager/rule_manager.py +++ b/src/HABApp/rule_manager/rule_manager.py @@ -6,17 +6,18 @@ import HABApp import HABApp.__cmd_args__ as cmd_args +from HABApp.core.connections import Connections from HABApp.core.files.errors import AlreadyHandledFileError from HABApp.core.files.file import HABAppFile from HABApp.core.files.folders import add_folder as add_habapp_folder from HABApp.core.files.watcher import AggregatingAsyncEventHandler +from HABApp.core.internals import uses_item_registry +from HABApp.core.internals.wrapped_function import run_function from HABApp.core.logger import log_warning from HABApp.core.wrapper import log_exception +from HABApp.rule_manager.rule_file import RuleFile from HABApp.runtime import shutdown -from .rule_file import RuleFile -from ..core.internals import uses_item_registry -from HABApp.core.internals.wrapped_function import run_function -from HABApp.core.connections import Connections + log = logging.getLogger('HABApp.Rules') diff --git a/src/HABApp/runtime/shutdown.py b/src/HABApp/runtime/shutdown.py index 8e843040..5355dfee 100644 --- a/src/HABApp/runtime/shutdown.py +++ b/src/HABApp/runtime/shutdown.py @@ -1,9 +1,9 @@ import itertools import logging import logging.handlers +import signal import traceback import typing -import signal from asyncio import iscoroutinefunction, sleep from dataclasses import dataclass from types import FunctionType, MethodType @@ -30,7 +30,7 @@ def register_func(func, last=False, msg: str = ''): assert last is True or last is False, last assert isinstance(msg, str) - _FUNCS.append(ShutdownInfo(func, f'{func.__module__}.{func.__name__}' if not msg else msg, last)) + _FUNCS.append(ShutdownInfo(func, msg if msg else f'{func.__module__}.{func.__name__}', last)) def register_signal_handler(): diff --git a/src/HABApp/util/listener_groups/listener_groups.py b/src/HABApp/util/listener_groups/listener_groups.py index a7a7abf5..193384b3 100644 --- a/src/HABApp/util/listener_groups/listener_groups.py +++ b/src/HABApp/util/listener_groups/listener_groups.py @@ -1,15 +1,21 @@ from typing import Any, Callable, Dict, Iterable, Optional, Union from HABApp.core.internals import HINT_EVENT_FILTER_OBJ -from HABApp.core.items import BaseItem, HINT_ITEM_OBJ - +from HABApp.core.items import HINT_ITEM_OBJ, BaseItem from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF -from .listener_creator import ListenerCreatorBase, EventListenerCreator, \ - NoChangeEventListenerCreator, NoUpdateEventListenerCreator + +from .listener_creator import ( + EventListenerCreator, + ListenerCreatorBase, + NoChangeEventListenerCreator, + NoUpdateEventListenerCreator, +) class ListenerCreatorNotFoundError(Exception): - pass + @classmethod + def from_name(cls, name: str): + return cls(f'ListenerCreator for "{name}" not found!') class EventListenerGroup: @@ -57,7 +63,7 @@ def activate_listener(self, name: str): try: obj = self._items[name] except KeyError: - raise ListenerCreatorNotFoundError(f'ListenerCreator for "{name}" not found!') from None + raise ListenerCreatorNotFoundError.from_name(name) from None if obj.active: return False @@ -76,7 +82,7 @@ def deactivate_listener(self, name: str, cancel_if_active=True): try: obj = self._items[name] except KeyError: - raise ListenerCreatorNotFoundError(f'ListenerCreator for "{name}" not found!') from None + raise ListenerCreatorNotFoundError.from_name(name) from None if not obj.active: return False @@ -135,7 +141,7 @@ def add_no_update_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OB def add_no_change_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], seconds: TH_POSITIVE_TIME_DIFF, alias: Optional[str] = None ) -> 'EventListenerGroup': - """Add an no change watcher to the group. On ``listen`` this this will create a no change watcher and + """Add a no change watcher to the group. On ``listen`` this will create a no change watcher and the corresponding event listener that will trigger the callback :param item: Single or multiple items diff --git a/src/HABApp/util/multimode/item.py b/src/HABApp/util/multimode/item.py index 16a98b61..01753561 100644 --- a/src/HABApp/util/multimode/item.py +++ b/src/HABApp/util/multimode/item.py @@ -6,6 +6,7 @@ from .mode_base import HINT_BASE_MODE, BaseMode + LOCK = Lock() diff --git a/tests/conftest.py b/tests/conftest.py index 223588be..5414a6c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,11 @@ import pytest import HABApp -import tests from HABApp.core.asyncio import async_context -from HABApp.core.const.topics import TOPIC_ERRORS -from HABApp.core.internals import setup_internals, EventBus, ItemRegistry -from tests.helpers import params, parent_rule, sync_worker, eb, get_dummy_cfg, LogCollector -from tests.helpers.log.log_matcher import LogLevelMatcher, AsyncDebugWarningMatcher +from HABApp.core.internals import EventBus, ItemRegistry, setup_internals +from tests.helpers import LogCollector, eb, get_dummy_cfg, params, parent_rule, sync_worker +from tests.helpers.log.log_matcher import AsyncDebugWarningMatcher, LogLevelMatcher + if typing.TYPE_CHECKING: parent_rule = parent_rule @@ -48,7 +47,7 @@ def use_dummy_cfg(monkeypatch): monkeypatch.setattr(HABApp, 'CONFIG', cfg) monkeypatch.setattr(HABApp.config, 'CONFIG', cfg) monkeypatch.setattr(HABApp.config.config, 'CONFIG', cfg) - yield cfg + return cfg @pytest.fixture(autouse=True, scope='session') @@ -62,8 +61,7 @@ def event_loop(): @pytest.fixture(scope='function') def ir(): - ir = ItemRegistry() - yield ir + return ItemRegistry() @pytest.fixture(autouse=True, scope='function') diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 03cb79cd..10b234d6 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -3,6 +3,7 @@ from .parameters import params from .event_bus import eb, TestEventBus from .mock_file import MockFile +from .mock_monotonic import MockedMonotonic from .habapp_config import get_dummy_cfg from .log import LogCollector diff --git a/tests/helpers/mock_monotonic.py b/tests/helpers/mock_monotonic.py new file mode 100644 index 00000000..c957faff --- /dev/null +++ b/tests/helpers/mock_monotonic.py @@ -0,0 +1,10 @@ +class MockedMonotonic: + def __init__(self): + self.time = 0 + + def get_time(self): + return self.time + + def __iadd__(self, other): + self.time += other + return self diff --git a/tests/test_core/test_lib/test_timeout.py b/tests/test_core/test_lib/test_timeout.py new file mode 100644 index 00000000..221646c5 --- /dev/null +++ b/tests/test_core/test_lib/test_timeout.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import pytest + +from HABApp.core.lib import timeout as timeout_module +from HABApp.core.lib.timeout import Timeout, TimeoutNotRunningError +from tests.helpers import MockedMonotonic + + +@pytest.fixture() +def time(monkeypatch) -> MockedMonotonic: + m = MockedMonotonic() + monkeypatch.setattr(timeout_module, 'monotonic', m.get_time) + return m + + +def assert_remaining(t: Timeout, time: float | None): + if time is None: + assert t.remaining_or_none() is None + with pytest.raises(TimeoutNotRunningError): + t.remaining() + else: + # prevent rounding errors + assert abs(t.remaining() - time) < 0.000_000_1 + assert abs(t.remaining_or_none() - time) < 0.000_000_1 + + +def test_timeout_init(time): + + t = Timeout(5, start=False) + with pytest.raises(TimeoutNotRunningError): + assert not t.is_expired() + assert not t.is_running_and_expired() + assert not t.is_running() + + assert_remaining(t, None) + + t = Timeout(5) + assert not t.is_expired() + assert not t.is_running_and_expired() + assert t.is_running() + assert_remaining(t, 5) + + +def test_running_expired(time): + t = Timeout(5) + assert t.is_running() + assert not t.is_running_and_expired() + assert not t.is_expired() + assert_remaining(t, 5) + + time += 4.9 + assert t.is_running() + assert not t.is_expired() + assert_remaining(t, 0.1) + + time += 0.1 + assert t.is_running() + assert t.is_expired() + assert_remaining(t, 0) + + time += 5 + assert t.is_running() + assert t.is_expired() + assert_remaining(t, 0) + + t.stop() + assert not t.is_running() + assert not t.is_running_and_expired() + with pytest.raises(TimeoutNotRunningError): + assert not t.is_expired() + assert_remaining(t, None) + + +def test_start_stop_reset(time): + t = Timeout(5, start=False) + assert not t.is_running() + assert_remaining(t, None) + + t.start() + assert t.is_running() + assert_remaining(t, 5) + + time += 2 + t.start() + assert t.is_running() + assert_remaining(t, 3) + + t.set_timeout(7) + assert t.is_running() + assert_remaining(t, 5) + + t.reset() + assert t.is_running() + assert_remaining(t, 7) + + t.stop() + assert not t.is_running() + assert_remaining(t, None) + + # reset will only reset a running timeout + t.reset() + assert not t.is_running() + assert_remaining(t, None) + + t.start() + assert t.is_running() + assert_remaining(t, 7) + + +def test_repr(time): + assert str(Timeout(5, start=False)) == '' + assert str(Timeout(10, start=False)) == '' + assert str(Timeout(100, start=False)) == '' + + t = Timeout(5, start=True) + assert str(t) == '' + time += 0.1 + assert str(t) == '' + time += 4.8 + assert str(t) == '' + time += 0.1 + assert str(t) == '' + time += 99 + assert str(t) == '' + + t = Timeout(10, start=True) + assert str(t) == '' + time += 0.1 + assert str(t) == '' + time += 0.9 + assert str(t) == '' + time += 8 + assert str(t) == '' + time += 1 + assert str(t) == '' + time += 99 + assert str(t) == '' diff --git a/tests/test_core/test_lib/test_value_change.py b/tests/test_core/test_lib/test_value_change.py new file mode 100644 index 00000000..a77a181a --- /dev/null +++ b/tests/test_core/test_lib/test_value_change.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from HABApp.core.lib import ValueChange + + +def test_change(): + assert not ValueChange().changed + + c = ValueChange[int]() + assert not c.changed + + assert c.set_value(1).changed + assert c.value == 1 + for _ in range(100): + assert not c.set_value(1).changed + assert c.value == 1 + + assert c.set_value(2).changed + assert c.value == 2 + for _ in range(100): + assert not c.set_value(2).changed + assert c.value == 2 + + +def test_missing(): + c = ValueChange[int]() + assert c.set_value(1) + + assert c.set_missing().changed + assert c.is_missing + + for _ in range(100): + assert not c.set_missing().changed + assert c.is_missing + + assert c.set_value(1).changed + assert not c.is_missing + assert c.value == 1 + + +def test_repr(): + c = ValueChange[int]() + assert str(c) == ' changed: False>' + + c.set_value(1) + assert str(c) == '' + + c.set_value(1) + assert str(c) == '' + + c.set_missing() + assert str(c) == ' changed: True>' + + c.set_missing() + assert str(c) == ' changed: False>' diff --git a/tests/test_openhab/test_items/test_commands.py b/tests/test_openhab/test_items/test_commands.py index 99de6c8c..c4668f54 100644 --- a/tests/test_openhab/test_items/test_commands.py +++ b/tests/test_openhab/test_items/test_commands.py @@ -13,7 +13,7 @@ def test_OnOff(cls): c = cls('item_name') assert not c.is_on() - if not __version__.startswith('24.01.0'): + if not __version__.startswith('24.02.0'): assert not c.is_off() c.set_value(OnOffValue('ON')) diff --git a/tests/test_openhab/test_plugins/test_load_items.py b/tests/test_openhab/test_plugins/test_load_items.py index d548da7d..249c3b86 100644 --- a/tests/test_openhab/test_plugins/test_load_items.py +++ b/tests/test_openhab/test_plugins/test_load_items.py @@ -74,13 +74,11 @@ async def test_item_sync(monkeypatch, ir: ItemRegistry, test_logs): monkeypatch.setattr(load_items_module, 'async_get_things', _mock_get_things) context = OpenhabContext( - version=(1, 0, 0), is_oh3=False, + version=(1, 0, 0), is_oh3=False, is_oh41=False, waited_for_openhab=False, created_items={}, created_things={}, session=None, session_options=None, - - workaround_small_floats=False ) # initial item create await LoadOpenhabItemsPlugin().on_connected(context) diff --git a/tests/test_utils/test_rate_limiter.py b/tests/test_utils/test_rate_limiter.py index 844bb72d..a43d4b63 100644 --- a/tests/test_utils/test_rate_limiter.py +++ b/tests/test_utils/test_rate_limiter.py @@ -14,18 +14,7 @@ parse_limit, ) from HABApp.util.rate_limiter.parser import LIMIT_REGEX - - -class MockedMonotonic: - def __init__(self): - self.time = 0 - - def get_time(self): - return self.time - - def __iadd__(self, other): - self.time += other - return self +from tests.helpers import MockedMonotonic @pytest.fixture() diff --git a/tools/prettify_json.py b/tools/prettify_json.py index d6d15d94..c65cbd3c 100644 --- a/tools/prettify_json.py +++ b/tools/prettify_json.py @@ -2,6 +2,7 @@ from json import dumps, loads + INCLUDE_CHANNELS = False diff --git a/tox.ini b/tox.ini index 4f8e4380..5f1003f3 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ envlist = python = 3.8: py38 3.9: py39 - 3.10: py310, flake, docs + 3.10: py310, docs 3.11: py311 3.12: py312 @@ -25,9 +25,9 @@ deps = commands = python -m pytest -# CI from github -pass_env = CI +# Environment variable CI from github actions +pass_env = CI [testenv:docs]