From 6f0762e1bba233ed7a4d7dc1732a430bfac9be20 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:41:00 +0100 Subject: [PATCH] Added option to wait for minimum openHAB uptime (default 60s) --- requirements.txt | 2 +- requirements_tests.txt | 2 +- src/HABApp/config/models/openhab.py | 18 ++- .../core/connections/base_connection.py | 1 + src/HABApp/core/lib/__init__.py | 2 + src/HABApp/core/lib/priority_list.py | 6 +- src/HABApp/core/lib/timeout.py | 100 +++++++++++++ src/HABApp/core/lib/value_change.py | 54 +++++++ src/HABApp/openhab/connection/connection.py | 3 +- .../openhab/connection/handler/handler.py | 4 +- .../plugins/load_transformations.py | 1 + src/HABApp/openhab/connection/plugins/out.py | 10 +- .../connection/plugins/wait_for_restore.py | 23 +-- .../connection/plugins/wait_for_startlevel.py | 96 ++++++++++-- .../openhab/definitions/rest/systeminfo.py | 2 + tests/helpers/__init__.py | 1 + tests/helpers/mock_monotonic.py | 10 ++ tests/test_core/test_lib/test_timeout.py | 141 ++++++++++++++++++ tests/test_core/test_lib/test_value_change.py | 55 +++++++ .../test_plugins/test_load_items.py | 4 +- tests/test_utils/test_rate_limiter.py | 13 +- 21 files changed, 487 insertions(+), 61 deletions(-) create mode 100644 src/HABApp/core/lib/timeout.py create mode 100644 src/HABApp/core/lib/value_change.py create mode 100644 tests/helpers/mock_monotonic.py create mode 100644 tests/test_core/test_lib/test_timeout.py create mode 100644 tests/test_core/test_lib/test_value_change.py 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_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/src/HABApp/config/models/openhab.py b/src/HABApp/config/models/openhab.py index 95b1d959..f13ecb1a 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.' @@ -71,7 +74,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/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/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..c8db382d --- /dev/null +++ b/tests/test_core/test_lib/test_timeout.py @@ -0,0 +1,141 @@ +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() + elif isinstance(time, int): + assert t.remaining() == time + assert t.remaining_or_none() == time + 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(): + + 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_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()