Skip to content

Commit

Permalink
Added option to wait for minimum openHAB uptime (default 60s)
Browse files Browse the repository at this point in the history
  • Loading branch information
spacemanspiff2007 committed Jan 31, 2024
1 parent fe5d4c5 commit 6f0762e
Show file tree
Hide file tree
Showing 21 changed files with 487 additions and 61 deletions.
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements_tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@
# -----------------------------------------------------------------------------
packaging == 23.2
pytest == 7.4.4
pytest-asyncio == 0.23.3
pytest-asyncio == 0.23.4
18 changes: 11 additions & 7 deletions src/HABApp/config/models/openhab.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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(
Expand All @@ -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.'
Expand All @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/HABApp/core/connections/base_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/HABApp/core/lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions src/HABApp/core/lib/priority_list.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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] = []
Expand All @@ -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)}>'
100 changes: 100 additions & 0 deletions src/HABApp/core/lib/timeout.py
Original file line number Diff line number Diff line change
@@ -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'<Timeout {self._timeout:.{decimals:d}f}s>'

time = monotonic() - self._started
if time >= self._timeout:
time = self._timeout
return f'<Timeout {time:.{decimals:d}f}/{self._timeout:.{decimals:d}f}s>'

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
54 changes: 54 additions & 0 deletions src/HABApp/core/lib/value_change.py
Original file line number Diff line number Diff line change
@@ -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}>'
3 changes: 1 addition & 2 deletions src/HABApp/openhab/connection/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,8 +34,6 @@ class OpenhabContext:
session: aiohttp.ClientSession
session_options: dict[str, Any]

workaround_small_floats: bool


CONTEXT_TYPE: TypeAlias = Optional[OpenhabContext]

Expand Down
4 changes: 1 addition & 3 deletions src/HABApp/openhab/connection/handler/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from HABApp.openhab.transformations._map import MAP_REGISTRY
from HABApp.openhab.transformations.base import TransformationRegistryBase, log


Items = uses_item_registry()


Expand Down
10 changes: 4 additions & 6 deletions src/HABApp/openhab/connection/plugins/out.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
23 changes: 12 additions & 11 deletions src/HABApp/openhab/connection/plugins/wait_for_restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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')
Loading

0 comments on commit 6f0762e

Please sign in to comment.