"""Utilities module.""" from __future__ import annotations import asyncio from collections.abc import Callable from datetime import datetime, time, timedelta, timezone from typing import Any from .exceptions import SpaMessageError MESSAGE_DELIMETER_BYTE = b"~" MESSAGE_DELIMETER = MESSAGE_DELIMETER_BYTE[0] def byte_parser( value: int, offset: int = 0, count: int = 8, bits: int = 1, fn: Callable[[int], int] = lambda _: _, # pylint: disable=invalid-name ) -> list[int]: """Parse a byte.""" return [ fn(value >> i * bits + offset & int("0b" + "1" * bits, 2)) for i in range(count) ] def calculate_checksum(data: bytes) -> int: """Calculate the checksum byte for a message.""" crc = 0xB5 for _, cur in enumerate(data): for i in range(8): bit = crc & 0x80 crc = ((crc << 1) & 0xFF) | ((cur >> (7 - i)) & 0x01) if bit: crc = crc ^ 0x07 crc &= 0xFF for i in range(8): bit = crc & 0x80 crc = (crc << 1) & 0xFF if bit: crc ^= 0x07 return crc ^ 0x02 def calculate_time(base_time: time | None, duration: timedelta | None) -> time | None: """Calculate the time after adding a duration to a base time.""" if base_time is None: return None duration = duration or timedelta() return (datetime.combine(datetime.now(), base_time) + duration).time() def calculate_time_difference(start: time, end: time) -> int: """Calculate the difference (in minutes) between a start and end time.""" return ((end.hour - start.hour) * 60 + (end.minute - start.minute)) % (24 * 60) async def cancel_task(task: asyncio.Task | None) -> None: """Cancel a task.""" if task is not None and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass def default(value: Any, default_value: Any | Callable[[], Any]) -> Any: """Return value if not None, else default.""" if value is not None: return value return default_value() if callable(default_value) else default_value async def read_one_message(reader: asyncio.StreamReader, timeout: int = 15) -> bytes: """Read one message.""" data = await asyncio.wait_for(reader.readexactly(2), timeout) if data[0] != MESSAGE_DELIMETER or data[1] == 0: # something went wrong reading a message, so # read to the next delimeter and discard data += await asyncio.wait_for( reader.readuntil(MESSAGE_DELIMETER_BYTE), timeout ) raise SpaMessageError(f"Invalid message: {data.hex()}") data = data[1:] + (await reader.readexactly(data[1]))[:-1] if data[0] != len(data): raise SpaMessageError(f"Incomplete message: {data.hex()}") if calculate_checksum(data[:-1]) != data[-1]: raise SpaMessageError(f"Invalid checksum: {data.hex()}") return data def to_celsius(fahrenheit: float) -> float: """Convert a Fahrenheit temperature to Celsius.""" return 0.5 * round(((fahrenheit - 32) / 1.8) / 0.5) def utcnow() -> datetime: """Get now in UTC time.""" return datetime.now(timezone.utc)