"""Module for sending get state requests to API in regular periods.""" import asyncio from contextlib import suppress from typing import TYPE_CHECKING from .api import GetState from .api.status_request import StatusRequest from .exception import PyVLXException from .log import PYVLXLOG from .opening_device import Blind, DualRollerShutter, OpeningDevice if TYPE_CHECKING: from pyvlx import PyVLX class Heartbeat: """Class for sending heartbeats to API.""" def __init__( self, pyvlx: "PyVLX", interval: int = 30, load_all_states: bool = True ): """Initialize Heartbeat object.""" PYVLXLOG.debug("Heartbeat: initialize") self.pyvlx = pyvlx self.interval = interval self.load_all_states = load_all_states self.heartbeat_task: asyncio.Task[None] | None = None self._lock = asyncio.Lock() async def _run(self) -> None: PYVLXLOG.debug("Heartbeat: started") while True: PYVLXLOG.debug("Heartbeat: sleeping") await asyncio.sleep(self.interval) PYVLXLOG.debug("Heartbeat: pulsing") try: await self.pulse() except (OSError, PyVLXException) as e: PYVLXLOG.debug("Heartbeat: pulsing failed: %s", e) async def start(self) -> None: """Start heartbeat. Does nothing if already running.""" async with self._lock: if self.heartbeat_task is not None and not self.heartbeat_task.done(): PYVLXLOG.debug("Heartbeat: already running") return if self.heartbeat_task is not None and self.heartbeat_task.done(): if not self.heartbeat_task.cancelled(): exc = self.heartbeat_task.exception() if exc is not None: PYVLXLOG.warning("Heartbeat: previous task died: %s", exc) PYVLXLOG.debug("Heartbeat: starting") self.heartbeat_task = asyncio.create_task(self._run()) @property def stopped(self) -> bool: """Return Heartbeat running state.""" return self.heartbeat_task is None or self.heartbeat_task.done() async def stop(self) -> None: """Stop heartbeat.""" async with self._lock: task = self.heartbeat_task self.heartbeat_task = None if task is None: PYVLXLOG.debug("Heartbeat: was not running") return task.cancel() with suppress(asyncio.CancelledError): await task PYVLXLOG.debug("Heartbeat: stopped") async def pulse(self) -> None: """Send get state request to API to keep the connection alive.""" PYVLXLOG.debug("Heartbeat: pulse") get_state = GetState(pyvlx=self.pyvlx) await get_state.do_api_call() if not get_state.success: raise PyVLXException("Unable to send get state.") # If nodes contain Blind or DualRollerShutter device, refresh orientation or upper/lower curtain positions because House Monitoring # delivers wrong values for FP1, FP2 and FP3 parameter for node in self.pyvlx.nodes: # A StatusRequest issued to a node mid-travel can make the # KLF200 emit a run_status = EXECUTION_COMPLETED notification # for the currently active command — sometimes with # status_reply = COMMAND_OVERRULED. Downstream consumers # cannot distinguish that from a genuine end-of-travel # completion, and on some actuators (gates, garage doors) the # gateway can additionally interrupt the motion. Skip polling # nodes that are currently moving; House Monitoring delivers # the in-flight position updates anyway. if isinstance(node, OpeningDevice) and ( node.is_opening or node.is_closing ): PYVLXLOG.debug( "Heartbeat: skipping node %s while it is in motion", node.name, ) continue if isinstance(node, (Blind, DualRollerShutter)) or self.load_all_states: status_request = StatusRequest(self.pyvlx, node.node_id) await status_request.do_api_call() # give user requests a chance await asyncio.sleep(0.5)