"""Helper methods to handle the time in Home Assistant.""" from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Mapping import datetime as dt import logging from logging import Logger import random import ssl from typing import Any, TypedDict, TypeVar import ciso8601 from icmplib import Host, ICMPLibError, SocketPermissionError, async_multiping import jwt from .exceptions import NabuCasaBaseError CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name UTC = dt.UTC _LOGGER = logging.getLogger(__name__) class CheckLatencyError(NabuCasaBaseError): """Error to indicate a ping failure.""" class CheckLatencyInsufficientPrivileges(CheckLatencyError): """Error to indicate insufficient privileges for pinging.""" class CheckLatencyHostResult(TypedDict): """Result of a latency check for a single host.""" address: str avg_rtt: float is_alive: bool max_rtt: float min_rtt: float def utcnow() -> dt.datetime: """Get now in UTC time.""" return dt.datetime.now(UTC) def utc_from_timestamp(timestamp: float) -> dt.datetime: """Return a UTC time from a timestamp.""" return dt.datetime.fromtimestamp(timestamp, UTC) def parse_date(dt_str: str) -> dt.date | None: """Convert a date string to a date object.""" try: return ciso8601.parse_datetime(dt_str).date() except ValueError: # If dt_str did not match our format return None def seconds_as_dhms(seconds: float) -> str: """Convert seconds to a DDd:HHh:MMm:SSs string.""" days, seconds = divmod(int(seconds), 86400) hours, seconds = divmod(seconds, 3600) minutes, seconds = divmod(seconds, 60) parts = [] if days > 0: parts.append(f"{days}d") if hours > 0 or (parts and (minutes > 0 or seconds > 0)): parts.append(f"{hours}h") if minutes > 0 or (parts and seconds > 0): parts.append(f"{minutes}m") if seconds > 0 or not parts: parts.append(f"{seconds}s") return ":".join(parts) def expiration_from_token(token: str | None) -> int | None: """Return the expiration time from a token.""" if not token: return None try: decoded_token: Mapping[str, Any] = jwt.decode( token, options={"verify_signature": False}, ) return int(decoded_token["exp"]) except jwt.DecodeError, KeyError: return None def server_context_modern() -> ssl.SSLContext: """ Return an SSL context following the Mozilla recommendations. TLS configuration follows the best-practice guidelines specified here: https://wiki.mozilla.org/Security/Server_Side_TLS Modern guidelines are followed. """ context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member context.options |= ( ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_CIPHER_SERVER_PREFERENCE ) if hasattr(ssl, "OP_NO_COMPRESSION"): context.options |= ssl.OP_NO_COMPRESSION context.set_ciphers( "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256", ) return context def next_midnight() -> float: """Return the seconds till next local midnight.""" midnight = dt.datetime.now().replace( hour=0, minute=0, second=0, microsecond=0, ) + dt.timedelta(days=1) return (midnight - dt.datetime.now()).total_seconds() async def async_check_latency( addresses: list[str], *, count: int = 1, ping_timeout: float = 5, privileged: bool = True, ) -> list[CheckLatencyHostResult]: """Check latency to a list of IP addresses and return them. Args: addresses: List of IP addresses to ping. count: Number of ping packets to send to each address. ping_timeout: Timeout in seconds for each ping. privileged: Whether to use privileged (raw socket) mode. Returns: List of CheckLatencyHostResult dicts. """ if not addresses: raise CheckLatencyError("No addresses provided") hosts: list[Host] try: hosts = await async_multiping( addresses=addresses, count=count, timeout=ping_timeout, privileged=privileged, ) except SocketPermissionError as err: if not privileged: raise CheckLatencyInsufficientPrivileges( "Insufficient privileges to perform ICMP ping." ) from err _LOGGER.info( "Ping failed due to insufficient privileges, " "retrying without privileged mode" ) return await async_check_latency( addresses, count=count, ping_timeout=ping_timeout, privileged=False, ) except ICMPLibError as err: raise CheckLatencyError("ICMP ping failed") from err return [ CheckLatencyHostResult( address=host.address, is_alive=host.is_alive, avg_rtt=host.avg_rtt, max_rtt=host.max_rtt, min_rtt=host.min_rtt, ) for host in hosts ] def jitter(minimum: float, maximum: float) -> float: """Return a random float between minimum and maximum for backoff jitter.""" return random.uniform(minimum, maximum) async def gather_callbacks( logger: Logger, name: str, callbacks: list[Callable[[], Awaitable[None]]], ) -> None: """Gather callbacks and log exceptions.""" results = await asyncio.gather(*[cb() for cb in callbacks], return_exceptions=True) for result, callback in zip(results, callbacks, strict=False): if not isinstance(result, Exception): continue logger.error( "Unexpected error in %s callback %s", name, callback, exc_info=result ) class Registry(dict): """Registry of items.""" def register(self, name: str) -> Callable[[CALLABLE_T], CALLABLE_T]: """Return decorator to register item with a specific name.""" def decorator(func: CALLABLE_T) -> CALLABLE_T: """Register decorated function.""" self[name] = func return func return decorator