sprint-6: Runtime desktop client (PySide6 + WebSocket + REST)
Cliente desktop completo conectado al Runtime server por HTTP+WebSocket.
vmssailor/runtime/client/api_client.py
- RuntimeApiClient: wrapper async httpx contra /health, /project, /tags,
/tags/{id}, /tags/{id}/history, /alarms, /logbook, /alarms/{id}/ack
- RuntimeWebSocketClient: conexion ws://host:port/ws/realtime con
reconexion automatica + heartbeats + on_event/on_state_change callbacks
vmssailor/runtime/client/app.py
- RuntimeClientApp QApplication con tema Deep Ocean (compartido con Studio)
- run_client() entry point
vmssailor/runtime/client/main_window.py
- Topbar: logo + vessel name + status pill (connecting/connected/disconnected)
+ alarm chip + user chip
- Sidebar lista: Overview, Mimicos, Alarmas, Trends, Trim, Log Book, Conexion
- QStackedWidget central conmuta vista segun sidebar
- Reloj live + statusbar con estado WS + version
- Sprint 6 trae 3 vistas funcionales (Overview, Alarmas, Conexion); resto stubs
vmssailor/runtime/client/views/connection_view.py
- _AsyncWorker QObject corre asyncio event loop en QThread separado
- Conecta API + WS, emite signals connectionStateChanged / eventReceived / projectLoaded
- Tabla con ultimos 500 eventos crudos del WebSocket
- Conecta/desconecta sin congelar la UI
vmssailor/runtime/client/views/overview_view.py
- Grid de tiles dinamicas que se crean al recibir tag_update events
- Cada tile muestra: descripcion, tag_id, valor formateado, quality, timestamp
- Layout 4 columnas con QScrollArea
vmssailor/runtime/client/views/alarms_view.py
- Tabla de alarmas activas con priority coloreada (emergency/high/low/info)
- Boton ACK por fila emite signal acknowledged(alarm_id)
- handle_event mantiene set vivo segun state=active/cleared
runtime_client_main.py
- Entry point: uv run python runtime_client_main.py
Tests (tests/runtime/test_client.py, 5 nuevos, total 157/157):
- main window builds with 7 stack pages
- overview handles tag_update
- alarms view shows + clears on state change
- connection view initial state
Para correr el sistema completo en vivo:
# Terminal 1 — servidor:
uv run python runtime_server_main.py --verbose
# Terminal 2 — cliente:
uv run python runtime_client_main.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user