"""Asynchronous client for the vendor-neutral Bridge API v1.""" from __future__ import annotations import asyncio import json from collections.abc import AsyncIterator from typing import Any from urllib.parse import quote, urlsplit import aiohttp from .const import API_VERSION, MIN_TOKEN_LENGTH from .models import ( BridgeDataError, BridgeInfo, BridgeSnapshot, CommandResult, DomainEvent, ) class BridgeError(Exception): """Base bridge client error.""" class BridgeConnectionError(BridgeError): """Raised when the bridge cannot be reached.""" class BridgeAuthenticationError(BridgeError): """Raised when the local bridge token is rejected.""" class BridgeVersionError(BridgeError): """Raised when the bridge API version is unsupported.""" class BridgeResponseError(BridgeError): """Raised when the bridge returns an invalid response.""" def normalize_base_url(host: str, port: int) -> str: """Build a normalized local bridge base URL.""" candidate = host.strip() if not candidate: raise ValueError("host must not be empty") if "://" not in candidate: candidate = f"http://{candidate}" parsed = urlsplit(candidate) if parsed.scheme not in {"http", "https"} or not parsed.hostname: raise ValueError("host must be a valid HTTP or HTTPS address") if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: raise ValueError("host must not contain a path, query, or fragment") effective_port = parsed.port or port host_part = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname return f"{parsed.scheme}://{host_part}:{effective_port}" class BridgeClient: """Client for Bridge API v1.""" def __init__( self, session: aiohttp.ClientSession, *, host: str, port: int, token: str, ) -> None: """Initialize the client without logging credentials.""" if len(token) < MIN_TOKEN_LENGTH: raise ValueError("token is too short") self._session = session self._base_url = normalize_base_url(host, port) self._headers = {"Authorization": f"Bearer {token}"} @property def base_url(self) -> str: """Return the normalized non-secret endpoint.""" return self._base_url async def async_get_info(self) -> BridgeInfo: """Return bridge metadata and enforce API compatibility.""" info = BridgeInfo.from_dict(await self._async_request_json("GET", "/v1/info")) if info.api_version != API_VERSION: raise BridgeVersionError( f"unsupported Bridge API version {info.api_version}" ) return info async def async_get_snapshot(self) -> BridgeSnapshot: """Return the current normalized snapshot.""" return BridgeSnapshot.from_dict( await self._async_request_json("GET", "/v1/snapshot") ) async def async_get_diagnostics(self) -> dict[str, Any]: """Return diagnostics already sanitized by the bridge.""" return await self._async_request_json("GET", "/v1/diagnostics") async def async_arm_area(self, object_id: str, mode: str) -> CommandResult: """Arm an area using a bridge-reported mode capability.""" return await self._async_command( f"/v1/areas/{quote(object_id, safe='')}/arm", {"mode": mode} ) async def async_disarm_area(self, object_id: str) -> CommandResult: """Disarm an area.""" return await self._async_command( f"/v1/areas/{quote(object_id, safe='')}/disarm" ) async def async_set_output(self, object_id: str, state: str) -> CommandResult: """Set an output state.""" return await self._async_command( f"/v1/outputs/{quote(object_id, safe='')}/set", {"state": state} ) async def async_run_command(self, command: str) -> CommandResult: """Run a named capability reported by the bridge.""" return await self._async_command(f"/v1/commands/{quote(command, safe='')}") async def async_events(self) -> AsyncIterator[DomainEvent]: """Yield normalized server-sent events until disconnected.""" try: async with self._session.get( f"{self._base_url}/v1/events", headers=self._headers, timeout=aiohttp.ClientTimeout( total=None, sock_connect=10, sock_read=None ), ) as response: self._raise_for_status(response.status) data_lines: list[str] = [] async for raw_line in response.content: line = raw_line.decode("utf-8").rstrip("\r\n") if not line: if data_lines: yield self._parse_event("\n".join(data_lines)) data_lines.clear() continue if line.startswith("data:"): data_lines.append(line[5:].lstrip()) if data_lines: yield self._parse_event("\n".join(data_lines)) raise BridgeConnectionError("bridge event stream ended") except (TimeoutError, aiohttp.ClientError, UnicodeDecodeError) as err: raise BridgeConnectionError("bridge event stream is unavailable") from err async def _async_command( self, path: str, payload: dict[str, Any] | None = None ) -> CommandResult: result = CommandResult.from_dict( await self._async_request_json("POST", path, payload) ) if not result.accepted: raise BridgeResponseError("bridge rejected the command") return result async def _async_request_json( self, method: str, path: str, request_payload: dict[str, Any] | None = None, ) -> dict[str, Any]: try: async with asyncio.timeout(10): async with self._session.request( method, f"{self._base_url}{path}", headers=self._headers, json=request_payload, ) as response: self._raise_for_status(response.status) response_payload = await response.json(content_type=None) except (TimeoutError, aiohttp.ClientError) as err: raise BridgeConnectionError("bridge is unavailable") from err except ValueError as err: raise BridgeResponseError("bridge returned invalid JSON") from err if not isinstance(response_payload, dict): raise BridgeResponseError("bridge response must be an object") return response_payload @staticmethod def _raise_for_status(status: int) -> None: if status in {401, 403}: raise BridgeAuthenticationError("bridge authentication failed") if status >= 400: raise BridgeResponseError(f"bridge request failed with HTTP {status}") @staticmethod def _parse_event(payload: str) -> DomainEvent: try: value = json.loads(payload) if not isinstance(value, dict): raise BridgeDataError("event must be an object") return DomainEvent.from_dict(value) except ValueError as err: raise BridgeResponseError("bridge returned an invalid event") from err