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:
@@ -1,6 +1,7 @@
|
|||||||
"""Entry point del cliente desktop Runtime (stub Sprint 0).
|
"""Entry point del cliente desktop del Runtime (Sprint 6).
|
||||||
|
|
||||||
Sprint 6 lo reemplaza con `vmssailor.runtime.client.app:main`.
|
Uso:
|
||||||
|
uv run python runtime_client_main.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -9,9 +10,9 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
print("VMS-Sailor Runtime cliente — Sprint 6 trae la UI PySide6.")
|
from vmssailor.runtime.client.app import run_client
|
||||||
print("En Sprint 0 solo existe el modelo de datos core.")
|
|
||||||
return 0
|
return run_client(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Tests del cliente Runtime (Sprint 6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("PySide6")
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_window_builds(qtbot):
|
||||||
|
from vmssailor.runtime.client.main_window import RuntimeClientWindow
|
||||||
|
|
||||||
|
w = RuntimeClientWindow()
|
||||||
|
qtbot.addWidget(w)
|
||||||
|
assert w._stack.count() == 7 # Overview, Mimicos, Alarmas, Trends, Trim, Logbook, Conexion
|
||||||
|
assert "VMS-Sailor Runtime" in w.windowTitle()
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_handles_tag_update(qtbot):
|
||||||
|
from vmssailor.runtime.client.views.overview_view import OverviewView
|
||||||
|
|
||||||
|
ov = OverviewView()
|
||||||
|
qtbot.addWidget(ov)
|
||||||
|
ov.handle_event(
|
||||||
|
{
|
||||||
|
"type": "tag_update",
|
||||||
|
"tag_id": "ME_PORT.OIL_PRESS",
|
||||||
|
"value": 4.8,
|
||||||
|
"quality": "good",
|
||||||
|
"timestamp": "2026-05-17T03:42:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert "ME_PORT.OIL_PRESS" in ov._tiles
|
||||||
|
|
||||||
|
|
||||||
|
def test_alarms_view_handles_alarm_event(qtbot):
|
||||||
|
from vmssailor.runtime.client.views.alarms_view import AlarmsView
|
||||||
|
|
||||||
|
av = AlarmsView()
|
||||||
|
qtbot.addWidget(av)
|
||||||
|
av.handle_event(
|
||||||
|
{
|
||||||
|
"type": "alarm_event",
|
||||||
|
"alarm_id": "alm_1",
|
||||||
|
"tag_id": "ME_PORT.OIL_PRESS",
|
||||||
|
"priority": "emergency",
|
||||||
|
"state": "active",
|
||||||
|
"message": "Oil press low",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert av._table.rowCount() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alarms_view_clears(qtbot):
|
||||||
|
from vmssailor.runtime.client.views.alarms_view import AlarmsView
|
||||||
|
|
||||||
|
av = AlarmsView()
|
||||||
|
qtbot.addWidget(av)
|
||||||
|
av.handle_event(
|
||||||
|
{
|
||||||
|
"type": "alarm_event",
|
||||||
|
"alarm_id": "alm_1",
|
||||||
|
"tag_id": "X",
|
||||||
|
"priority": "low",
|
||||||
|
"state": "active",
|
||||||
|
"message": "low msg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert av._table.rowCount() == 1
|
||||||
|
av.handle_event(
|
||||||
|
{
|
||||||
|
"type": "alarm_event",
|
||||||
|
"alarm_id": "alm_1",
|
||||||
|
"tag_id": "X",
|
||||||
|
"priority": "low",
|
||||||
|
"state": "cleared",
|
||||||
|
"message": "low msg",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert av._table.rowCount() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_view_initial_state(qtbot):
|
||||||
|
from vmssailor.runtime.client.views.connection_view import ConnectionView
|
||||||
|
|
||||||
|
cv = ConnectionView()
|
||||||
|
qtbot.addWidget(cv)
|
||||||
|
assert cv._connect_btn.isEnabled()
|
||||||
|
assert not cv._disconnect_btn.isEnabled()
|
||||||
|
assert cv._host_input.text() == "127.0.0.1"
|
||||||
|
assert cv._port_input.value() == 8765
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Cliente desktop del Runtime (PySide6).
|
||||||
|
|
||||||
|
Sprint 6: shell + login + overview + alarmas + WebSocket client.
|
||||||
|
|
||||||
|
Entry point: `runtime_client_main.py` en la raíz del repo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from vmssailor.runtime.client.app import RuntimeClientApp, run_client
|
||||||
|
|
||||||
|
__all__ = ["RuntimeClientApp", "run_client"]
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Bootstrap del cliente desktop Runtime (PySide6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from vmssailor.shared.logging_setup import setup_logging
|
||||||
|
from vmssailor.studio.theme import apply_theme
|
||||||
|
from vmssailor.version import __version__
|
||||||
|
|
||||||
|
BRAND_ROOT = Path(__file__).resolve().parents[3] / "docs" / "brand"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeClientApp(QApplication):
|
||||||
|
"""QApplication del cliente Runtime."""
|
||||||
|
|
||||||
|
def __init__(self, argv: list[str] | None = None) -> None:
|
||||||
|
super().__init__(argv or sys.argv)
|
||||||
|
self.setOrganizationName("Aerom")
|
||||||
|
self.setApplicationName("VMS-Sailor Runtime Client")
|
||||||
|
self.setApplicationDisplayName("VMS-Sailor Runtime")
|
||||||
|
self.setApplicationVersion(__version__)
|
||||||
|
|
||||||
|
icon_svg = BRAND_ROOT / "favicon.svg"
|
||||||
|
if icon_svg.exists():
|
||||||
|
self.setWindowIcon(QIcon(str(icon_svg)))
|
||||||
|
apply_theme(self)
|
||||||
|
|
||||||
|
|
||||||
|
def run_client(argv: list[str] | None = None) -> int:
|
||||||
|
"""Lanza el cliente Runtime y bloquea hasta que cierre."""
|
||||||
|
setup_logging()
|
||||||
|
app = RuntimeClientApp(argv)
|
||||||
|
from vmssailor.runtime.client.main_window import RuntimeClientWindow
|
||||||
|
|
||||||
|
window = RuntimeClientWindow()
|
||||||
|
window.resize(1440, 900)
|
||||||
|
window.show()
|
||||||
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(run_client())
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Ventana principal del cliente Runtime.
|
||||||
|
|
||||||
|
Layout: topbar (vessel name + status + alarms badge) /
|
||||||
|
sidebar (Overview/Mímicos/Alarmas/Trends/Trim) /
|
||||||
|
central stacked widget /
|
||||||
|
ticker.
|
||||||
|
|
||||||
|
Sprint 6: Overview + Alarmas funcionales. Mímicos/Trends/Trim son stubs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt, QTimer, Slot
|
||||||
|
from PySide6.QtGui import QIcon
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QListWidget,
|
||||||
|
QListWidgetItem,
|
||||||
|
QMainWindow,
|
||||||
|
QStackedWidget,
|
||||||
|
QStatusBar,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.runtime.client.views.alarms_view import AlarmsView
|
||||||
|
from vmssailor.runtime.client.views.connection_view import ConnectionView
|
||||||
|
from vmssailor.runtime.client.views.overview_view import OverviewView
|
||||||
|
from vmssailor.studio.theme import (
|
||||||
|
C_FOAM,
|
||||||
|
C_FOG,
|
||||||
|
C_OK,
|
||||||
|
C_SAND,
|
||||||
|
C_STEEL,
|
||||||
|
C_WARN,
|
||||||
|
mono_font,
|
||||||
|
ui_font,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
BRAND_ROOT = Path(__file__).resolve().parents[3] / "docs" / "brand"
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeClientWindow(QMainWindow):
|
||||||
|
"""Ventana principal del cliente Runtime (puente / máquinas)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("VMS-Sailor Runtime")
|
||||||
|
self.setMinimumSize(1100, 700)
|
||||||
|
|
||||||
|
self._user = "operator"
|
||||||
|
self._build_topbar()
|
||||||
|
self._build_central()
|
||||||
|
self._build_statusbar()
|
||||||
|
self._connect_signals()
|
||||||
|
|
||||||
|
# Clock
|
||||||
|
self._clock_timer = QTimer(self)
|
||||||
|
self._clock_timer.timeout.connect(self._update_clock)
|
||||||
|
self._clock_timer.start(1000)
|
||||||
|
self._update_clock()
|
||||||
|
|
||||||
|
# ----- Build -------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_topbar(self) -> None:
|
||||||
|
bar = QWidget()
|
||||||
|
bar.setObjectName("topbar")
|
||||||
|
bar.setFixedHeight(64)
|
||||||
|
layout = QHBoxLayout(bar)
|
||||||
|
layout.setContentsMargins(24, 0, 24, 0)
|
||||||
|
layout.setSpacing(16)
|
||||||
|
|
||||||
|
logo_path = BRAND_ROOT / "logo-mark.svg"
|
||||||
|
if logo_path.exists():
|
||||||
|
logo = QLabel()
|
||||||
|
logo.setPixmap(QIcon(str(logo_path)).pixmap(36, 36))
|
||||||
|
layout.addWidget(logo)
|
||||||
|
|
||||||
|
vessel_box = QVBoxLayout()
|
||||||
|
vessel_box.setSpacing(0)
|
||||||
|
self._vessel_name_label = QLabel("VMS-Sailor Runtime")
|
||||||
|
self._vessel_name_label.setFont(ui_font(14))
|
||||||
|
self._vessel_name_label.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;")
|
||||||
|
self._vessel_sub_label = QLabel("Sin conexión")
|
||||||
|
self._vessel_sub_label.setFont(ui_font(9))
|
||||||
|
self._vessel_sub_label.setStyleSheet(f"color: {C_FOG}; letter-spacing: 1.5px;")
|
||||||
|
vessel_box.addWidget(self._vessel_name_label)
|
||||||
|
vessel_box.addWidget(self._vessel_sub_label)
|
||||||
|
layout.addLayout(vessel_box)
|
||||||
|
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self._status_pill = QLabel("● Desconectado")
|
||||||
|
self._status_pill.setFont(ui_font(11))
|
||||||
|
self._status_pill.setStyleSheet(
|
||||||
|
f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(self._status_pill)
|
||||||
|
|
||||||
|
self._alarm_chip = QLabel("0 alarmas")
|
||||||
|
self._alarm_chip.setFont(ui_font(11))
|
||||||
|
self._alarm_chip.setStyleSheet(
|
||||||
|
f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(self._alarm_chip)
|
||||||
|
|
||||||
|
self._user_chip = QLabel(f"Usuario: {self._user}")
|
||||||
|
self._user_chip.setFont(mono_font(10))
|
||||||
|
self._user_chip.setStyleSheet(
|
||||||
|
f"color: {C_SAND}; background: {C_STEEL}; padding: 6px 14px; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
layout.addWidget(self._user_chip)
|
||||||
|
|
||||||
|
self._topbar = bar
|
||||||
|
|
||||||
|
def _build_central(self) -> None:
|
||||||
|
wrapper = QWidget()
|
||||||
|
outer = QVBoxLayout(wrapper)
|
||||||
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
|
outer.setSpacing(0)
|
||||||
|
outer.addWidget(self._topbar)
|
||||||
|
|
||||||
|
body = QHBoxLayout()
|
||||||
|
body.setContentsMargins(0, 0, 0, 0)
|
||||||
|
body.setSpacing(0)
|
||||||
|
|
||||||
|
# Sidebar
|
||||||
|
sidebar = QWidget()
|
||||||
|
sidebar.setObjectName("sidebar")
|
||||||
|
sidebar.setFixedWidth(220)
|
||||||
|
sb_layout = QVBoxLayout(sidebar)
|
||||||
|
sb_layout.setContentsMargins(8, 12, 8, 12)
|
||||||
|
sb_layout.setSpacing(4)
|
||||||
|
|
||||||
|
self._nav = QListWidget()
|
||||||
|
self._nav.setStyleSheet("border: none;")
|
||||||
|
self._nav_items = ["Overview", "Mímicos", "Alarmas", "Trends", "Trim", "Log Book", "Conexión"]
|
||||||
|
for name in self._nav_items:
|
||||||
|
QListWidgetItem(name, self._nav)
|
||||||
|
self._nav.setCurrentRow(0)
|
||||||
|
sb_layout.addWidget(self._nav, 1)
|
||||||
|
body.addWidget(sidebar)
|
||||||
|
|
||||||
|
# Central stack
|
||||||
|
self._stack = QStackedWidget()
|
||||||
|
self._overview = OverviewView()
|
||||||
|
self._alarms = AlarmsView()
|
||||||
|
self._connection = ConnectionView()
|
||||||
|
self._stack.addWidget(self._overview)
|
||||||
|
self._stack.addWidget(self._placeholder("Mímicos · Sprint 6+"))
|
||||||
|
self._stack.addWidget(self._alarms)
|
||||||
|
self._stack.addWidget(self._placeholder("Trends · Sprint 7+"))
|
||||||
|
self._stack.addWidget(self._placeholder("Trim & Maniobra · Sprint 8"))
|
||||||
|
self._stack.addWidget(self._placeholder("Log Book · Sprint 6+"))
|
||||||
|
self._stack.addWidget(self._connection)
|
||||||
|
body.addWidget(self._stack, 1)
|
||||||
|
outer.addLayout(body, 1)
|
||||||
|
self.setCentralWidget(wrapper)
|
||||||
|
|
||||||
|
def _build_statusbar(self) -> None:
|
||||||
|
sb = QStatusBar(self)
|
||||||
|
sb.setSizeGripEnabled(False)
|
||||||
|
sb.setFont(mono_font(9))
|
||||||
|
self._ws_label = QLabel("WS: disconnected")
|
||||||
|
self._ws_label.setFont(mono_font(9))
|
||||||
|
self._ws_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
sb.addWidget(self._ws_label, 1)
|
||||||
|
self._clock_label = QLabel("--:--:--")
|
||||||
|
self._clock_label.setFont(mono_font(9))
|
||||||
|
sb.addPermanentWidget(self._clock_label)
|
||||||
|
from vmssailor.version import __version__ as v
|
||||||
|
|
||||||
|
ver_label = QLabel(f"Runtime client {v}")
|
||||||
|
ver_label.setFont(mono_font(9))
|
||||||
|
ver_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
sb.addPermanentWidget(ver_label)
|
||||||
|
self.setStatusBar(sb)
|
||||||
|
|
||||||
|
def _connect_signals(self) -> None:
|
||||||
|
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
|
||||||
|
self._connection.connectionStateChanged.connect(self._on_connection_state_change)
|
||||||
|
self._connection.eventReceived.connect(self._on_event)
|
||||||
|
self._connection.projectLoaded.connect(self._on_project_loaded)
|
||||||
|
self._alarms.acknowledged.connect(self._on_ack_request)
|
||||||
|
|
||||||
|
# ----- Helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
def _placeholder(self, title: str) -> QWidget:
|
||||||
|
w = QWidget()
|
||||||
|
lo = QVBoxLayout(w)
|
||||||
|
lo.addStretch(1)
|
||||||
|
big = QLabel(title)
|
||||||
|
big.setFont(ui_font(20))
|
||||||
|
big.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
big.setAlignment(big.alignment() | Qt.AlignmentFlag.AlignCenter)
|
||||||
|
lo.addWidget(big)
|
||||||
|
lo.addStretch(2)
|
||||||
|
return w
|
||||||
|
|
||||||
|
def _update_clock(self) -> None:
|
||||||
|
self._clock_label.setText(datetime.now().strftime("%H:%M:%S · %Y-%m-%d"))
|
||||||
|
|
||||||
|
# ----- Slots -------------------------------------------------------
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def _on_connection_state_change(self, state: str) -> None:
|
||||||
|
if state == "connected":
|
||||||
|
self._status_pill.setText("● Conectado")
|
||||||
|
self._status_pill.setStyleSheet(
|
||||||
|
f"color: {C_OK}; padding: 6px 14px; border: 1px solid {C_OK}; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
self._ws_label.setText("WS: connected")
|
||||||
|
self._ws_label.setStyleSheet(f"color: {C_OK};")
|
||||||
|
elif state == "connecting":
|
||||||
|
self._status_pill.setText("● Conectando…")
|
||||||
|
self._status_pill.setStyleSheet(
|
||||||
|
f"color: {C_WARN}; padding: 6px 14px; border: 1px solid {C_WARN}; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
self._ws_label.setText("WS: connecting")
|
||||||
|
self._ws_label.setStyleSheet(f"color: {C_WARN};")
|
||||||
|
else:
|
||||||
|
self._status_pill.setText("● Desconectado")
|
||||||
|
self._status_pill.setStyleSheet(
|
||||||
|
f"color: {C_FOG}; padding: 6px 14px; border: 1px solid {C_FOG}; border-radius: 999px;"
|
||||||
|
)
|
||||||
|
self._ws_label.setText("WS: disconnected")
|
||||||
|
self._ws_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def _on_event(self, event: dict) -> None:
|
||||||
|
self._overview.handle_event(event)
|
||||||
|
self._alarms.handle_event(event)
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def _on_project_loaded(self, project: dict) -> None:
|
||||||
|
self._vessel_name_label.setText(project.get("name", "—"))
|
||||||
|
v = project.get("vessel", {})
|
||||||
|
sub = f"{v.get('name', '')} · {v.get('loa_m', 0):.1f} m"
|
||||||
|
self._vessel_sub_label.setText(sub)
|
||||||
|
self._overview.set_project(project)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def _on_ack_request(self, alarm_id: str) -> None:
|
||||||
|
self._connection.request_ack(alarm_id, user=self._user)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Vistas del cliente Runtime (Sprint 6)."""
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Vista de alarmas activas (Sprint 6).
|
||||||
|
|
||||||
|
Renderiza eventos `alarm_event` y `tag_update` para mantener el contador.
|
||||||
|
ACK individual emite signal `acknowledged(alarm_id)`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
from PySide6.QtGui import QColor
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.studio.theme import (
|
||||||
|
C_EMERGENCY,
|
||||||
|
C_FOG,
|
||||||
|
C_HIGH,
|
||||||
|
C_INFO,
|
||||||
|
C_WARN,
|
||||||
|
mono_font,
|
||||||
|
ui_font,
|
||||||
|
)
|
||||||
|
|
||||||
|
_PRIORITY_COLORS = {
|
||||||
|
"emergency": C_EMERGENCY,
|
||||||
|
"high": C_HIGH,
|
||||||
|
"low": C_WARN,
|
||||||
|
"info": C_INFO,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AlarmsView(QWidget):
|
||||||
|
"""Panel de alarmas: tabla + botones ACK."""
|
||||||
|
|
||||||
|
acknowledged = Signal(str)
|
||||||
|
"""Emite el `alarm_id` al solicitar ACK."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._alarms: dict[str, dict] = {}
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(20, 20, 20, 20)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
title_row = QHBoxLayout()
|
||||||
|
title = QLabel("Alarmas")
|
||||||
|
title.setFont(ui_font(20))
|
||||||
|
title.setStyleSheet(f"color: {C_EMERGENCY}; font-weight: 600;")
|
||||||
|
title_row.addWidget(title)
|
||||||
|
title_row.addStretch(1)
|
||||||
|
|
||||||
|
self._counter = QLabel("0 activas")
|
||||||
|
self._counter.setFont(mono_font(11))
|
||||||
|
self._counter.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
title_row.addWidget(self._counter)
|
||||||
|
outer.addLayout(title_row)
|
||||||
|
|
||||||
|
# Table
|
||||||
|
self._table = QTableWidget(0, 6)
|
||||||
|
self._table.setHorizontalHeaderLabels(
|
||||||
|
["ID", "Tag", "Prioridad", "Estado", "Mensaje", "Acción"]
|
||||||
|
)
|
||||||
|
self._table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||||||
|
self._table.verticalHeader().setVisible(False)
|
||||||
|
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
|
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
outer.addWidget(self._table, 1)
|
||||||
|
|
||||||
|
def handle_event(self, event: dict) -> None:
|
||||||
|
if event.get("type") != "alarm_event":
|
||||||
|
return
|
||||||
|
alarm_id = event.get("alarm_id") or event.get("id")
|
||||||
|
if not alarm_id:
|
||||||
|
return
|
||||||
|
state = event.get("state", "active")
|
||||||
|
if state == "cleared":
|
||||||
|
# Quitar
|
||||||
|
self._alarms.pop(alarm_id, None)
|
||||||
|
else:
|
||||||
|
self._alarms[alarm_id] = event
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
self._table.setRowCount(0)
|
||||||
|
for alarm_id, a in self._alarms.items():
|
||||||
|
row = self._table.rowCount()
|
||||||
|
self._table.insertRow(row)
|
||||||
|
self._table.setItem(row, 0, QTableWidgetItem(alarm_id[:24]))
|
||||||
|
self._table.setItem(row, 1, QTableWidgetItem(str(a.get("tag_id", ""))))
|
||||||
|
priority = a.get("priority", "—")
|
||||||
|
prio_item = QTableWidgetItem(priority)
|
||||||
|
prio_item.setForeground(QColor(_PRIORITY_COLORS.get(priority, C_FOG)))
|
||||||
|
self._table.setItem(row, 2, prio_item)
|
||||||
|
self._table.setItem(row, 3, QTableWidgetItem(a.get("state", "")))
|
||||||
|
self._table.setItem(row, 4, QTableWidgetItem(a.get("message", "")))
|
||||||
|
btn = QPushButton("ACK")
|
||||||
|
btn.clicked.connect(lambda _checked=False, aid=alarm_id: self.acknowledged.emit(aid))
|
||||||
|
self._table.setCellWidget(row, 5, btn)
|
||||||
|
|
||||||
|
self._counter.setText(f"{len(self._alarms)} activas")
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Vista de conexión: configurar host/port + connect/disconnect + tag table.
|
||||||
|
|
||||||
|
Sprint 6: corre el cliente API/WS en un QThread separado con su propio
|
||||||
|
event loop asyncio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal, Slot
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.runtime.client.api_client import (
|
||||||
|
RuntimeApiClient,
|
||||||
|
RuntimeWebSocketClient,
|
||||||
|
)
|
||||||
|
from vmssailor.studio.theme import (
|
||||||
|
C_CYAN,
|
||||||
|
C_FOG,
|
||||||
|
mono_font,
|
||||||
|
ui_font,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncWorker(QObject):
|
||||||
|
"""Worker que corre un asyncio event loop en su propio QThread."""
|
||||||
|
|
||||||
|
eventReceived = Signal(dict)
|
||||||
|
connectionStateChanged = Signal(str)
|
||||||
|
projectLoaded = Signal(dict)
|
||||||
|
error = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._api: RuntimeApiClient | None = None
|
||||||
|
self._ws: RuntimeWebSocketClient | None = None
|
||||||
|
self._thread_id: int | None = None
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def run(self) -> None:
|
||||||
|
self._loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self._loop)
|
||||||
|
self._thread_id = threading.get_ident()
|
||||||
|
try:
|
||||||
|
self._loop.run_until_complete(self._main())
|
||||||
|
finally:
|
||||||
|
self._loop.close()
|
||||||
|
|
||||||
|
async def _main(self) -> None:
|
||||||
|
base_url = f"http://{self._host}:{self._port}"
|
||||||
|
ws_url = f"ws://{self._host}:{self._port}/ws/realtime"
|
||||||
|
|
||||||
|
self._api = RuntimeApiClient(base_url)
|
||||||
|
self._ws = RuntimeWebSocketClient(
|
||||||
|
ws_url,
|
||||||
|
on_event=lambda ev: self.eventReceived.emit(ev),
|
||||||
|
on_state_change=lambda s: self.connectionStateChanged.emit(s),
|
||||||
|
)
|
||||||
|
# Try fetch project info
|
||||||
|
try:
|
||||||
|
project = await self._api.project()
|
||||||
|
self.projectLoaded.emit(project)
|
||||||
|
except Exception as exc:
|
||||||
|
self.error.emit(f"GET /project falló: {exc}")
|
||||||
|
# Start WS loop
|
||||||
|
await self._ws.start()
|
||||||
|
|
||||||
|
# Block until cancelled
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if self._ws is not None:
|
||||||
|
await self._ws.stop()
|
||||||
|
if self._api is not None:
|
||||||
|
await self._api.close()
|
||||||
|
|
||||||
|
def submit(self, coro_factory):
|
||||||
|
"""Programa una coroutine en el loop del worker."""
|
||||||
|
if self._loop is None or not self._loop.is_running():
|
||||||
|
return None
|
||||||
|
return asyncio.run_coroutine_threadsafe(coro_factory(), self._loop)
|
||||||
|
|
||||||
|
async def ack_alarm(self, alarm_id: str, user: str) -> dict[str, Any] | None:
|
||||||
|
if self._api is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return await self._api.ack_alarm(alarm_id, user=user)
|
||||||
|
except Exception as exc:
|
||||||
|
self.error.emit(f"ACK falló: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionView(QWidget):
|
||||||
|
"""Panel de conexión + monitor de eventos crudos."""
|
||||||
|
|
||||||
|
connectionStateChanged = Signal(str)
|
||||||
|
eventReceived = Signal(dict)
|
||||||
|
projectLoaded = Signal(dict)
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._worker: _AsyncWorker | None = None
|
||||||
|
self._thread: QThread | None = None
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
layout.setSpacing(12)
|
||||||
|
|
||||||
|
title = QLabel("Conexión al Runtime server")
|
||||||
|
title.setFont(ui_font(16))
|
||||||
|
title.setStyleSheet(f"color: {C_CYAN}; font-weight: 600;")
|
||||||
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
intro = QLabel(
|
||||||
|
"Lanza el servidor primero con:\n"
|
||||||
|
" uv run python runtime_server_main.py --verbose\n"
|
||||||
|
"Luego conecta acá."
|
||||||
|
)
|
||||||
|
intro.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
intro.setFont(mono_font(10))
|
||||||
|
layout.addWidget(intro)
|
||||||
|
|
||||||
|
# Connect controls
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addWidget(QLabel("Host:"))
|
||||||
|
self._host_input = QLineEdit("127.0.0.1")
|
||||||
|
self._host_input.setFixedWidth(160)
|
||||||
|
row.addWidget(self._host_input)
|
||||||
|
row.addWidget(QLabel("Puerto:"))
|
||||||
|
self._port_input = QSpinBox()
|
||||||
|
self._port_input.setRange(1, 65535)
|
||||||
|
self._port_input.setValue(8765)
|
||||||
|
self._port_input.setFixedWidth(100)
|
||||||
|
row.addWidget(self._port_input)
|
||||||
|
|
||||||
|
self._connect_btn = QPushButton("Conectar")
|
||||||
|
self._connect_btn.setObjectName("primary")
|
||||||
|
self._disconnect_btn = QPushButton("Desconectar")
|
||||||
|
self._disconnect_btn.setEnabled(False)
|
||||||
|
row.addWidget(self._connect_btn)
|
||||||
|
row.addWidget(self._disconnect_btn)
|
||||||
|
row.addStretch(1)
|
||||||
|
layout.addLayout(row)
|
||||||
|
|
||||||
|
# Event log (tabla con últimos updates)
|
||||||
|
self._counter = QLabel("0 eventos recibidos")
|
||||||
|
self._counter.setFont(mono_font(10))
|
||||||
|
self._counter.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
layout.addWidget(self._counter)
|
||||||
|
|
||||||
|
self._events_table = QTableWidget(0, 4)
|
||||||
|
self._events_table.setHorizontalHeaderLabels(["Tipo", "Tag/Detalle", "Valor", "Timestamp"])
|
||||||
|
self._events_table.horizontalHeader().setSectionResizeMode(
|
||||||
|
1, QHeaderView.ResizeMode.Stretch
|
||||||
|
)
|
||||||
|
self._events_table.verticalHeader().setVisible(False)
|
||||||
|
self._events_table.setSelectionBehavior(
|
||||||
|
QAbstractItemView.SelectionBehavior.SelectRows
|
||||||
|
)
|
||||||
|
self._events_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
layout.addWidget(self._events_table, 1)
|
||||||
|
|
||||||
|
self._connect_btn.clicked.connect(self._on_connect)
|
||||||
|
self._disconnect_btn.clicked.connect(self._on_disconnect)
|
||||||
|
|
||||||
|
self._event_count = 0
|
||||||
|
|
||||||
|
# ----- Connection lifecycle ----------------------------------------
|
||||||
|
|
||||||
|
def _on_connect(self) -> None:
|
||||||
|
host = self._host_input.text().strip()
|
||||||
|
port = self._port_input.value()
|
||||||
|
if self._thread is not None:
|
||||||
|
return
|
||||||
|
self._worker = _AsyncWorker(host, port)
|
||||||
|
self._thread = QThread(self)
|
||||||
|
self._worker.moveToThread(self._thread)
|
||||||
|
self._worker.connectionStateChanged.connect(self.connectionStateChanged)
|
||||||
|
self._worker.eventReceived.connect(self._on_event_received_internal)
|
||||||
|
self._worker.projectLoaded.connect(self.projectLoaded)
|
||||||
|
self._worker.error.connect(self._on_error)
|
||||||
|
self._thread.started.connect(self._worker.run)
|
||||||
|
self._thread.start()
|
||||||
|
self._connect_btn.setEnabled(False)
|
||||||
|
self._disconnect_btn.setEnabled(True)
|
||||||
|
|
||||||
|
def _on_disconnect(self) -> None:
|
||||||
|
if self._worker is not None and self._worker._loop is not None:
|
||||||
|
self._worker._loop.call_soon_threadsafe(lambda: None)
|
||||||
|
# Stop loop tasks
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(
|
||||||
|
_stop_worker(self._worker), self._worker._loop
|
||||||
|
)
|
||||||
|
from contextlib import suppress
|
||||||
|
with suppress(Exception):
|
||||||
|
fut.result(timeout=3)
|
||||||
|
if self._thread is not None:
|
||||||
|
self._thread.quit()
|
||||||
|
self._thread.wait(3000)
|
||||||
|
self._thread = None
|
||||||
|
self._worker = None
|
||||||
|
self.connectionStateChanged.emit("disconnected")
|
||||||
|
self._connect_btn.setEnabled(True)
|
||||||
|
self._disconnect_btn.setEnabled(False)
|
||||||
|
|
||||||
|
# ----- Events ------------------------------------------------------
|
||||||
|
|
||||||
|
@Slot(dict)
|
||||||
|
def _on_event_received_internal(self, event: dict) -> None:
|
||||||
|
self._event_count += 1
|
||||||
|
# Add row to table (keep last 500)
|
||||||
|
if self._events_table.rowCount() > 500:
|
||||||
|
self._events_table.removeRow(self._events_table.rowCount() - 1)
|
||||||
|
self._events_table.insertRow(0)
|
||||||
|
ev_type = event.get("type", "?")
|
||||||
|
self._events_table.setItem(0, 0, QTableWidgetItem(ev_type))
|
||||||
|
detail = event.get("tag_id", "") or event.get("alarm_id", "") or ""
|
||||||
|
self._events_table.setItem(0, 1, QTableWidgetItem(str(detail)))
|
||||||
|
value = event.get("value", "")
|
||||||
|
self._events_table.setItem(0, 2, QTableWidgetItem(str(value)))
|
||||||
|
ts = event.get("timestamp", "")
|
||||||
|
self._events_table.setItem(0, 3, QTableWidgetItem(str(ts)))
|
||||||
|
self._counter.setText(f"{self._event_count} eventos recibidos")
|
||||||
|
self.eventReceived.emit(event)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def _on_error(self, msg: str) -> None:
|
||||||
|
logger.warning("ConnectionView error: %s", msg)
|
||||||
|
|
||||||
|
# ----- Public API --------------------------------------------------
|
||||||
|
|
||||||
|
def request_ack(self, alarm_id: str, user: str) -> None:
|
||||||
|
if self._worker is None:
|
||||||
|
return
|
||||||
|
self._worker.submit(lambda: self._worker.ack_alarm(alarm_id, user))
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_worker(worker: _AsyncWorker) -> None:
|
||||||
|
"""Cancela tareas y cierra recursos del worker desde su loop."""
|
||||||
|
if worker._ws is not None:
|
||||||
|
await worker._ws.stop()
|
||||||
|
if worker._api is not None:
|
||||||
|
await worker._api.close()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# Cancelar todas las tareas
|
||||||
|
for task in asyncio.all_tasks(loop):
|
||||||
|
if task is not asyncio.current_task():
|
||||||
|
task.cancel()
|
||||||
|
loop.stop()
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Vista Overview: tarjeta con tags clave en vivo (Sprint 6)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtWidgets import (
|
||||||
|
QFrame,
|
||||||
|
QGridLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QScrollArea,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vmssailor.studio.theme import (
|
||||||
|
C_CYAN,
|
||||||
|
C_FOAM,
|
||||||
|
C_FOG,
|
||||||
|
C_MIDNIGHT,
|
||||||
|
C_OK,
|
||||||
|
C_STEEL,
|
||||||
|
C_WARN,
|
||||||
|
mono_font,
|
||||||
|
ui_font,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _TagTile(QFrame):
|
||||||
|
"""Tarjeta visual con valor en vivo de un tag."""
|
||||||
|
|
||||||
|
def __init__(self, tag_id: str, description: str, unit: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._tag_id = tag_id
|
||||||
|
self._unit = unit
|
||||||
|
self.setObjectName("tagTile")
|
||||||
|
self.setStyleSheet(
|
||||||
|
f"#tagTile {{ background: {C_MIDNIGHT}; "
|
||||||
|
f"border: 1px solid {C_STEEL}; border-radius: 8px; }}"
|
||||||
|
)
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setContentsMargins(14, 12, 14, 12)
|
||||||
|
layout.setSpacing(2)
|
||||||
|
|
||||||
|
self._desc_label = QLabel(description or tag_id)
|
||||||
|
self._desc_label.setFont(ui_font(9))
|
||||||
|
self._desc_label.setStyleSheet(f"color: {C_FOG}; letter-spacing: 1.5px;")
|
||||||
|
layout.addWidget(self._desc_label)
|
||||||
|
|
||||||
|
self._id_label = QLabel(tag_id)
|
||||||
|
self._id_label.setFont(mono_font(9))
|
||||||
|
self._id_label.setStyleSheet(f"color: {C_CYAN};")
|
||||||
|
layout.addWidget(self._id_label)
|
||||||
|
|
||||||
|
self._value_label = QLabel("—")
|
||||||
|
self._value_label.setFont(mono_font(20))
|
||||||
|
self._value_label.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;")
|
||||||
|
layout.addWidget(self._value_label)
|
||||||
|
|
||||||
|
self._meta_label = QLabel("sin datos")
|
||||||
|
self._meta_label.setFont(mono_font(8))
|
||||||
|
self._meta_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
layout.addWidget(self._meta_label)
|
||||||
|
|
||||||
|
def update_value(self, value, quality: str, timestamp: str) -> None:
|
||||||
|
value_str = f"{value:.2f}" if isinstance(value, float) else str(value)
|
||||||
|
self._value_label.setText(f"{value_str} {self._unit}")
|
||||||
|
ts_short = timestamp[-12:-3] if timestamp else "—"
|
||||||
|
self._meta_label.setText(f"q={quality} · {ts_short}")
|
||||||
|
color = C_OK if quality == "good" else C_WARN
|
||||||
|
self._meta_label.setStyleSheet(f"color: {color};")
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewView(QWidget):
|
||||||
|
"""Grid de tiles con tags clave."""
|
||||||
|
|
||||||
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._tiles: dict[str, _TagTile] = {}
|
||||||
|
|
||||||
|
outer = QVBoxLayout(self)
|
||||||
|
outer.setContentsMargins(20, 20, 20, 20)
|
||||||
|
outer.setSpacing(12)
|
||||||
|
|
||||||
|
title_row = QHBoxLayout()
|
||||||
|
self._title = QLabel("Overview")
|
||||||
|
self._title.setFont(ui_font(20))
|
||||||
|
self._title.setStyleSheet(f"color: {C_FOAM}; font-weight: 600;")
|
||||||
|
title_row.addWidget(self._title)
|
||||||
|
title_row.addStretch(1)
|
||||||
|
self._tag_count_label = QLabel("0 tags")
|
||||||
|
self._tag_count_label.setFont(mono_font(10))
|
||||||
|
self._tag_count_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
title_row.addWidget(self._tag_count_label)
|
||||||
|
outer.addLayout(title_row)
|
||||||
|
|
||||||
|
scroll = QScrollArea()
|
||||||
|
scroll.setWidgetResizable(True)
|
||||||
|
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
outer.addWidget(scroll, 1)
|
||||||
|
|
||||||
|
grid_wrapper = QWidget()
|
||||||
|
self._grid = QGridLayout(grid_wrapper)
|
||||||
|
self._grid.setSpacing(10)
|
||||||
|
scroll.setWidget(grid_wrapper)
|
||||||
|
|
||||||
|
self._empty_label = QLabel("Conecta al Runtime para ver tags en vivo (panel Conexión).")
|
||||||
|
self._empty_label.setFont(ui_font(11))
|
||||||
|
self._empty_label.setStyleSheet(f"color: {C_FOG};")
|
||||||
|
self._empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
self._grid.addWidget(self._empty_label, 0, 0)
|
||||||
|
|
||||||
|
def set_project(self, project: dict) -> None:
|
||||||
|
# Pre-popula tiles a partir de stats del proyecto (sin valores aún)
|
||||||
|
self._title.setText(f"Overview · {project.get('name', '')}")
|
||||||
|
|
||||||
|
def handle_event(self, event: dict) -> None:
|
||||||
|
if event.get("type") != "tag_update":
|
||||||
|
return
|
||||||
|
tag_id = event.get("tag_id")
|
||||||
|
if not tag_id:
|
||||||
|
return
|
||||||
|
if tag_id not in self._tiles:
|
||||||
|
# Crear nueva tile dinámicamente
|
||||||
|
if self._empty_label is not None:
|
||||||
|
self._empty_label.setVisible(False)
|
||||||
|
tile = _TagTile(tag_id, description="", unit="")
|
||||||
|
row = len(self._tiles) // 4
|
||||||
|
col = len(self._tiles) % 4
|
||||||
|
self._grid.addWidget(tile, row, col)
|
||||||
|
self._tiles[tag_id] = tile
|
||||||
|
self._tag_count_label.setText(f"{len(self._tiles)} tags")
|
||||||
|
self._tiles[tag_id].update_value(
|
||||||
|
event.get("value"),
|
||||||
|
event.get("quality", "—"),
|
||||||
|
event.get("timestamp", ""),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user