"""Cliente HTTP + WebSocket contra el Runtime server. Wrappers async-friendly de httpx + websockets para uso desde el cliente PySide6 (que corre asyncio via qasync o thread workers). """ from __future__ import annotations import asyncio import json import logging from collections.abc import Callable from contextlib import suppress from typing import Any import httpx import websockets logger = logging.getLogger(__name__) class RuntimeApiClient: """Cliente REST contra `http://host:port`.""" def __init__(self, base_url: str = "http://127.0.0.1:8765", timeout_s: float = 5.0) -> None: self._base = base_url.rstrip("/") self._timeout = timeout_s self._client = httpx.AsyncClient(base_url=self._base, timeout=timeout_s) async def close(self) -> None: await self._client.aclose() async def health(self) -> dict[str, Any]: r = await self._client.get("/health") r.raise_for_status() return r.json() async def project(self) -> dict[str, Any]: r = await self._client.get("/project") r.raise_for_status() return r.json() async def tags(self) -> list[dict[str, Any]]: r = await self._client.get("/tags") r.raise_for_status() return r.json() async def tag(self, tag_id: str) -> dict[str, Any]: r = await self._client.get(f"/tags/{tag_id}") r.raise_for_status() return r.json() async def history( self, tag_id: str, *, since: str | None = None, until: str | None = None, limit: int = 1000, ) -> list[dict[str, Any]]: params: dict[str, Any] = {"limit": limit} if since: params["since"] = since if until: params["until"] = until r = await self._client.get(f"/tags/{tag_id}/history", params=params) r.raise_for_status() return r.json() async def alarms(self, state: str | None = None) -> list[dict[str, Any]]: params = {"state": state} if state else None r = await self._client.get("/alarms", params=params) r.raise_for_status() return r.json() async def ack_alarm(self, alarm_id: str, user: str) -> dict[str, Any]: r = await self._client.post( f"/alarms/{alarm_id}/ack", params={"user": user} ) r.raise_for_status() return r.json() async def logbook(self, *, kind: str | None = None, limit: int = 200) -> list[dict[str, Any]]: params: dict[str, Any] = {"limit": limit} if kind: params["kind"] = kind r = await self._client.get("/logbook", params=params) r.raise_for_status() return r.json() class RuntimeWebSocketClient: """Cliente WebSocket que mantiene conexión y emite eventos por callback.""" def __init__( self, url: str = "ws://127.0.0.1:8765/ws/realtime", *, on_event: Callable[[dict[str, Any]], None] | None = None, on_state_change: Callable[[str], None] | None = None, reconnect_delay_s: float = 5.0, ) -> None: self._url = url self._on_event = on_event or (lambda _e: None) self._on_state_change = on_state_change or (lambda _s: None) self._reconnect_delay_s = reconnect_delay_s self._stop = False self._task: asyncio.Task | None = None self._state = "disconnected" @property def state(self) -> str: return self._state async def start(self) -> None: self._task = asyncio.create_task(self._loop()) async def stop(self) -> None: self._stop = True if self._task is not None: self._task.cancel() with suppress(asyncio.CancelledError): await self._task self._set_state("disconnected") def _set_state(self, state: str) -> None: self._state = state self._on_state_change(state) async def _loop(self) -> None: while not self._stop: try: self._set_state("connecting") async with websockets.connect(self._url, ping_interval=20) as ws: self._set_state("connected") async for raw in ws: try: event = json.loads(raw) self._on_event(event) except json.JSONDecodeError: logger.warning("Invalid JSON from WS: %s", raw[:200]) except asyncio.CancelledError: break except Exception as exc: logger.warning("WebSocket connection failed: %s", exc) self._set_state("disconnected") with suppress(asyncio.CancelledError): await asyncio.sleep(self._reconnect_delay_s)