Implement bridge-backed Home Assistant integration
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user