Files

558 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API FastAPI del Runtime server.
- WebSocket: `/ws/realtime` — pub/sub de tag updates + alarm events
- REST: `/tags`, `/tags/{id}/history`, `/alarms`, `/health`, `/project`
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager, suppress
from datetime import datetime
from typing import Any
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, Response
from vmssailor.runtime.server.runtime_app import RuntimeApp
from vmssailor.version import __version__
logger = logging.getLogger(__name__)
def create_app(runtime: RuntimeApp) -> FastAPI:
"""Construye la FastAPI app y monta endpoints."""
@asynccontextmanager
async def lifespan(_app: FastAPI):
await runtime.start()
try:
yield
finally:
await runtime.stop()
app = FastAPI(
title="VMS-Sailor Runtime API",
version=__version__,
description="On-board Runtime API: tags, history, alarms, control.",
lifespan=lifespan,
)
app.state.runtime = runtime
# ----- Root + favicon (UI amigable de bienvenida) -----------------
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
def root() -> str:
return _RENDER_ROOT_HTML(runtime)
@app.get("/favicon.ico", include_in_schema=False)
def favicon() -> Response:
# SVG inline para evitar dependencias en disco
svg = _FAVICON_SVG.encode("utf-8")
return Response(content=svg, media_type="image/svg+xml")
# ----- Health ------------------------------------------------------
@app.get("/health")
def health() -> dict[str, Any]:
return {
"status": "ok",
"vmssailor_version": __version__,
"tag_store": runtime.tag_store.stats(),
"historian": runtime.historian.stats(),
"active_alarms": len(runtime.alarm_engine.active_alarms()),
"project": {
"id": runtime.project.id,
"name": runtime.project.name,
"vessel": runtime.project.vessel.name,
},
}
# ----- Project ----------------------------------------------------
@app.get("/project")
def project() -> dict[str, Any]:
p = runtime.project
return {
"id": p.id,
"name": p.name,
"customer": p.customer,
"vessel": {
"id": p.vessel.id,
"name": p.vessel.name,
"type": p.vessel.type.value,
"loa_m": p.vessel.length_overall_m,
"beam_m": p.vessel.beam_max_m,
"draft_m": p.vessel.draft_m,
},
"systems_enabled": [s.value for s in p.systems_enabled],
"stats": p.stats(),
}
# ----- Tags --------------------------------------------------------
@app.get("/tags")
def list_tags() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for tag_id, tag in runtime.tag_store.all_tags().items():
tv = runtime.tag_store.get(tag_id)
out.append(
{
"id": tag_id,
"description": tag.description,
"unit_si": tag.unit_si.value,
"controllable": tag.controllable,
"value": tv.value if tv else None,
"quality": tv.quality.value if tv else None,
"timestamp": tv.timestamp.isoformat() if tv else None,
"range_normal_min": tag.range_normal_min,
"range_normal_max": tag.range_normal_max,
}
)
return out
@app.get("/tags/{tag_id}")
def get_tag(tag_id: str) -> dict[str, Any]:
tag = runtime.tag_store.get_tag(tag_id)
tv = runtime.tag_store.get(tag_id)
if tag is None or tv is None:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
return {
"id": tag_id,
"description": tag.description,
"unit_si": tag.unit_si.value,
"value": tv.value,
"quality": tv.quality.value,
"timestamp": tv.timestamp.isoformat(),
"alarms": [a.model_dump(mode="json") for a in tag.alarms],
}
@app.get("/tags/{tag_id}/history")
def history(
tag_id: str,
since: str | None = None,
until: str | None = None,
limit: int = 1000,
) -> list[dict[str, Any]]:
if tag_id not in runtime.tag_store:
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
try:
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
except ValueError:
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit)
# ----- Alarms ------------------------------------------------------
@app.get("/alarms")
def alarms(state: str | None = None) -> list[dict[str, Any]]:
active = runtime.alarm_engine.active_alarms()
if state == "active":
return [a.model_dump(mode="json") for a in active]
all_events = runtime.alarm_events
return [a.model_dump(mode="json") for a in all_events]
@app.post("/alarms/{alarm_id}/ack")
def ack_alarm(alarm_id: str, user: str = "anonymous") -> dict[str, Any]:
acked = runtime.alarm_engine.ack(alarm_id, user=user)
if acked is None:
raise HTTPException(status_code=404, detail=f"Alarma '{alarm_id}' no activa.")
return acked.model_dump(mode="json")
# ----- Log Book ----------------------------------------------------
@app.get("/logbook")
def logbook_entries(
kind: str | None = None,
since: str | None = None,
until: str | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
from vmssailor.runtime.server.logbook import LogEntryKind
try:
kind_enum = LogEntryKind(kind) if kind else None
except ValueError:
raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}")
try:
since_dt = datetime.fromisoformat(since) if since else None
until_dt = datetime.fromisoformat(until) if until else None
except ValueError:
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
entries = runtime.logbook.query(
kind=kind_enum, since=since_dt, until=until_dt, limit=limit
)
return [
{
"id": e.id,
"kind": e.kind.value,
"timestamp": e.timestamp.isoformat(),
"summary": e.summary,
"payload": e.payload,
"user": e.user,
"hash": e.hash[:16] + "…",
}
for e in entries
]
@app.post("/logbook")
def logbook_append_manual(
kind: str = "manual",
summary: str = "",
user: str = "operator",
) -> dict[str, Any]:
import asyncio
from vmssailor.runtime.server.logbook import LogEntryKind
try:
kind_enum = LogEntryKind(kind)
except ValueError as err:
raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}") from err
if not summary:
raise HTTPException(status_code=400, detail="summary requerido")
async def _do_append():
return await runtime.logbook.append(kind_enum, summary, user=user)
entry = asyncio.run_coroutine_threadsafe(_do_append(), asyncio.get_event_loop()).result(timeout=2)
return {
"id": entry.id,
"kind": entry.kind.value,
"timestamp": entry.timestamp.isoformat(),
"hash": entry.hash,
}
@app.get("/logbook/verify")
def logbook_verify() -> dict[str, Any]:
ok, broken = runtime.logbook.verify_chain()
return {"ok": ok, "broken_entry_ids": broken}
# ----- WebSocket ---------------------------------------------------
@app.websocket("/ws/realtime")
async def ws_realtime(ws: WebSocket) -> None:
await ws.accept()
q = runtime.tag_store.subscribe(maxsize=512)
# Push current snapshot first
for tv in runtime.tag_store.all_values().values():
await ws.send_json(tv.to_event_dict())
try:
while True:
try:
tv = await asyncio.wait_for(q.get(), timeout=30.0)
await ws.send_json(tv.to_event_dict())
except TimeoutError:
await ws.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
pass
finally:
runtime.tag_store.unsubscribe(q)
with suppress(Exception):
await ws.close()
return app
# ===========================================================================
# Root HTML (UI amigable de bienvenida con dashboard vivo)
# ===========================================================================
_FAVICON_SVG = """<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#04111F"/>
<circle cx="32" cy="32" r="22" fill="none" stroke="#00D9FF" stroke-width="2.2"/>
<path d="M 32 10 L 30 6 L 34 6 Z" fill="#00D9FF"/>
<g transform="translate(32,33) scale(0.55)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="#E6EAF0"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</svg>"""
def _RENDER_ROOT_HTML(runtime: RuntimeApp) -> str:
project = runtime.project
tag_stats = runtime.tag_store.stats()
active_alarms = len(runtime.alarm_engine.active_alarms())
return f"""<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VMS-Sailor Runtime · {project.name}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
<style>
:root {{
--abyss: #04111F; --midnight: #0A1A2E; --steel: #1A2B42; --iron: #2C3E5C;
--fog: #7C8B9F; --sand: #E6EAF0; --foam: #F2F5F9;
--cyan: #00D9FF; --cyan-deep: #1B7FB5; --horizon: #5BC0EB;
--ok: #00E08A; --warn: #FFB020; --emergency: #FF3B47;
}}
*, *::before, *::after {{ box-sizing: border-box; }}
html, body {{
margin: 0; padding: 0; min-height: 100vh;
background: linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%);
color: var(--sand);
font-family: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
font-size: 14px;
}}
.container {{ max-width: 1280px; margin: 0 auto; padding: 32px; }}
.hero {{
display: flex; align-items: center; gap: 24px;
padding: 24px 32px;
background: linear-gradient(135deg, rgba(0,217,255,0.10), rgba(0,217,255,0.02));
border: 1px solid rgba(0,217,255,0.25);
border-radius: 16px;
margin-bottom: 24px;
}}
.hero-logo {{
width: 84px; height: 84px;
display: flex; align-items: center; justify-content: center;
filter: drop-shadow(0 8px 24px rgba(0,217,255,0.4));
flex-shrink: 0;
}}
.hero h1 {{
margin: 0;
font-size: 32px; font-weight: 700; letter-spacing: -1px;
color: var(--foam);
}}
.hero h1 .accent {{ color: var(--cyan); font-weight: 300; }}
.hero .subtitle {{
color: var(--horizon); margin-top: 4px;
font-size: 13px; letter-spacing: 2px; text-transform: uppercase;
}}
.hero .vessel-info {{
color: var(--sand); margin-top: 8px;
font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px;
}}
.hero .pill {{
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; margin-left: auto;
background: rgba(0,224,138,0.15); border: 1px solid rgba(0,224,138,0.4);
border-radius: 999px; color: var(--ok);
font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
}}
.pill .dot {{
width: 8px; height: 8px; border-radius: 50%;
background: var(--ok); box-shadow: 0 0 10px var(--ok);
animation: pulse 2s ease-in-out infinite;
}}
@keyframes pulse {{ 50% {{ opacity: 0.5; transform: scale(1.4); }} }}
.stats-row {{
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
margin-bottom: 24px;
}}
.stat {{
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
}}
.stat .label {{
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
}}
.stat .value {{
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 28px; font-weight: 600; color: var(--foam);
margin-top: 6px; letter-spacing: -0.5px;
}}
.stat .value.alarm {{ color: var(--emergency); }}
.stat .sub {{ font-size: 11px; color: var(--fog); margin-top: 2px; }}
h2 {{
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
color: var(--fog); font-weight: 700;
margin: 24px 0 12px 0;
}}
.actions {{
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px; margin-bottom: 24px;
}}
.action {{
display: block; text-decoration: none;
padding: 16px 18px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 10px;
transition: all 160ms;
}}
.action:hover {{
border-color: var(--cyan); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 16px rgba(0,217,255,0.3);
}}
.action .title {{
color: var(--foam); font-weight: 600; font-size: 14px;
display: flex; align-items: center; gap: 8px;
}}
.action .title::after {{ content: "→"; color: var(--cyan); margin-left: auto; }}
.action .desc {{
color: var(--fog); font-size: 12px; margin-top: 4px;
}}
.action .url {{
color: var(--cyan); font-family: "JetBrains Mono", Consolas, monospace;
font-size: 11px; margin-top: 6px;
}}
#tags-grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 8px;
}}
.tag-tile {{
padding: 10px 12px;
background: var(--midnight); border: 1px solid var(--steel);
border-radius: 8px;
font-family: "JetBrains Mono", Consolas, monospace;
}}
.tag-tile .tid {{ color: var(--cyan); font-size: 10px; }}
.tag-tile .val {{
color: var(--foam); font-size: 18px; font-weight: 600;
margin-top: 4px;
}}
.tag-tile .meta {{
color: var(--fog); font-size: 9px; margin-top: 2px;
}}
footer {{
margin-top: 32px; padding-top: 16px;
border-top: 1px solid var(--steel);
color: var(--fog); font-size: 11px;
font-family: "JetBrains Mono", Consolas, monospace;
display: flex; justify-content: space-between;
}}
</style>
</head>
<body>
<div class="container">
<div class="hero">
<div class="hero-logo">
<svg viewBox="0 0 120 120" width="84" height="84">
<circle cx="60" cy="60" r="46" fill="none" stroke="#00D9FF" stroke-width="3"/>
<path d="M 60 18 L 56 8 L 64 8 Z" fill="#00D9FF"/>
<g transform="translate(60,60)">
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z"
fill="#E6EAF0" stroke="#04111F" stroke-width="0.8"/>
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.6"/>
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.5"/>
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
</g>
</svg>
</div>
<div>
<h1>VMS<span class="accent"> · </span>Sailor</h1>
<div class="subtitle">Vessel · Management · System</div>
<div class="vessel-info">
{project.vessel.name} · {project.vessel.length_overall_m:.1f} m × {project.vessel.beam_max_m:.1f} m ·
proyecto: {project.id}
</div>
</div>
<span class="pill"><span class="dot"></span>RUNTIME ACTIVO</span>
</div>
<div class="stats-row">
<div class="stat">
<div class="label">Tags activos</div>
<div class="value">{tag_stats['total_tags']}</div>
<div class="sub">{tag_stats['by_quality'].get('good', 0)} good · simulator vivo</div>
</div>
<div class="stat">
<div class="label">Alarmas activas</div>
<div class="value {'alarm' if active_alarms > 0 else ''}">{active_alarms}</div>
<div class="sub">{'requieren ACK' if active_alarms else 'sin alarmas'}</div>
</div>
<div class="stat">
<div class="label">Sistemas</div>
<div class="value">{len(project.systems_enabled)}</div>
<div class="sub">habilitados</div>
</div>
<div class="stat">
<div class="label">Equipos</div>
<div class="value">{len(project.equipment)}</div>
<div class="sub">en el proyecto</div>
</div>
</div>
<h2>API Endpoints</h2>
<div class="actions">
<a class="action" href="/docs">
<div class="title">Documentación interactiva (Swagger UI)</div>
<div class="desc">Probar todos los endpoints de la API en el navegador.</div>
<div class="url">/docs</div>
</a>
<a class="action" href="/health">
<div class="title">Health</div>
<div class="desc">Estado del runtime, tag store, historian, alarmas.</div>
<div class="url">/health</div>
</a>
<a class="action" href="/project">
<div class="title">Project</div>
<div class="desc">Info del proyecto cargado (buque, sistemas, stats).</div>
<div class="url">/project</div>
</a>
<a class="action" href="/tags">
<div class="title">Tags (snapshot)</div>
<div class="desc">Lista completa de tags con valores actuales del simulator.</div>
<div class="url">/tags</div>
</a>
<a class="action" href="/alarms">
<div class="title">Alarms</div>
<div class="desc">Histórico de eventos de alarma desde el arranque.</div>
<div class="url">/alarms</div>
</a>
<a class="action" href="/logbook">
<div class="title">Log Book</div>
<div class="desc">Bitácora naval con arranques de motor, snapshots, alarmas.</div>
<div class="url">/logbook</div>
</a>
</div>
<h2>Tags en vivo (actualizando cada 1 s)</h2>
<div id="tags-grid">Cargando...</div>
<footer>
<span>VMS-Sailor Runtime · v{__version__}</span>
<span>Para conectar el cliente desktop: <code>uv run python runtime_client_main.py</code></span>
</footer>
</div>
<script>
async function refreshTags() {{
try {{
const r = await fetch('/tags');
const data = await r.json();
const grid = document.getElementById('tags-grid');
grid.innerHTML = data.map(t => {{
const val = typeof t.value === 'number' ? t.value.toFixed(2) : String(t.value ?? '—');
const unit = t.unit_si && t.unit_si !== 'none' ? ' ' + t.unit_si : '';
const q = t.quality || '—';
const color = q === 'good' ? '#00E08A' : '#FFB020';
return `<div class="tag-tile">
<div class="tid">${{t.id}}</div>
<div class="val">${{val}}${{unit}}</div>
<div class="meta" style="color:${{color}}">q=${{q}}</div>
</div>`;
}}).join('');
}} catch (e) {{
const msg = document.createTextNode('Error de conexión: ' + String(e));
const div = document.createElement('div');
div.style.color = 'var(--emergency)';
div.appendChild(msg);
const grid = document.getElementById('tags-grid');
grid.innerHTML = '';
grid.appendChild(div);
}}
}}
refreshTags();
setInterval(refreshTags, 1000);
</script>
</body>
</html>"""