feat(server): ingest temps réel WS + GUI live + client PC

Serveur FastAPI reçoit le flux JSONL (sim ou ROV réel) sur /ws/ingest,
SLAM incrémental, rediffuse carte+poses sur /ws/live, GUI live et export PLY.
Déployé Docker sur caddy-net, exposé /moulin-live/. Client PC stream_client.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Flag
2026-06-06 20:27:17 +00:00
parent 06e198c7d9
commit 6e83bbd73f
15 changed files with 12675 additions and 0 deletions

228
server/app.py Normal file
View File

@@ -0,0 +1,228 @@
"""
app.py — Serveur FastAPI moulin-mapper.
Lance avec : uvicorn app:app --host 0.0.0.0 --port 8211
Déployé derrière préfixe /moulin-live/ via reverse-proxy Caddy.
"""
import asyncio
import json
import os
import time
from typing import Set
import numpy as np
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Form, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from slam_incremental import IncrementalSLAM
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
TOKEN = os.environ.get("MOULIN_TOKEN", "moulin-2026")
# ---------------------------------------------------------------------------
# État global de session
# ---------------------------------------------------------------------------
slam = IncrementalSLAM()
session_records: list = [] # tous les enregistrements bruts reçus
session_start_time: float = time.time()
debit_window: list = [] # timestamps récents pour calcul débit
# Clients /ws/live connectés
live_clients: Set[WebSocket] = set()
# ---------------------------------------------------------------------------
# App FastAPI (root_path vide — le préfixe est géré par Caddy côté client)
# ---------------------------------------------------------------------------
app = FastAPI(title="moulin-mapper live")
# ---------------------------------------------------------------------------
# Broadcast aux clients /ws/live
# ---------------------------------------------------------------------------
async def broadcast(payload: dict) -> None:
if not live_clients:
return
msg = json.dumps(payload)
dead = set()
for ws in list(live_clients):
try:
await ws.send_text(msg)
except Exception:
dead.add(ws)
live_clients.difference_update(dead)
# ---------------------------------------------------------------------------
# WS /ws/ingest — reçoit le flux du client ROV/sim
# ---------------------------------------------------------------------------
@app.websocket("/ws/ingest")
async def ws_ingest(websocket: WebSocket, token: str = ""):
if token != TOKEN:
await websocket.close(code=4001)
return
await websocket.accept()
try:
while True:
raw = await websocket.receive_text()
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
# Accepte un seul record ou une liste
records = data if isinstance(data, list) else [data]
sweep_completed = False
for rec in records:
session_records.append(rec)
debit_window.append(time.time())
# Nettoie la fenêtre (5 secondes)
cutoff = time.time() - 5.0
while debit_window and debit_window[0] < cutoff:
debit_window.pop(0)
completed = slam.add_record(rec)
if completed:
sweep_completed = True
if sweep_completed:
delta = slam.get_sweep_delta()
delta["debit"] = len(debit_window) / 5.0
await broadcast(delta)
except WebSocketDisconnect:
pass
except Exception as e:
print(f"[ingest] erreur: {e}")
# ---------------------------------------------------------------------------
# WS /ws/live — navigateur s'abonne
# ---------------------------------------------------------------------------
@app.websocket("/ws/live")
async def ws_live(websocket: WebSocket):
await websocket.accept()
live_clients.add(websocket)
# Envoie l'état complet courant dès la connexion
try:
snapshot = slam.get_state_snapshot()
snapshot["debit"] = len(debit_window) / 5.0
await websocket.send_text(json.dumps(snapshot))
# Garde la connexion ouverte jusqu'à déconnexion
while True:
try:
# ping keepalive (pas de timeout natif FastAPI WS)
await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
except asyncio.TimeoutError:
await websocket.send_text(json.dumps({"type": "ping"}))
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
live_clients.discard(websocket)
# ---------------------------------------------------------------------------
# POST /session/reset
# ---------------------------------------------------------------------------
@app.post("/session/reset")
async def session_reset(token: str = Form(...)):
if token != TOKEN:
raise HTTPException(status_code=403, detail="Token invalide")
slam.reset()
session_records.clear()
debit_window.clear()
await broadcast({"type": "reset"})
return {"status": "ok"}
# ---------------------------------------------------------------------------
# GET /healthz
# ---------------------------------------------------------------------------
@app.get("/healthz")
async def healthz():
return {
"status": "ok",
"records": slam.records_count,
"sweeps": slam.sweeps_done,
"map_points": len(slam.imap),
"loop_closures": slam.loop_closures,
"live_clients": len(live_clients),
}
# ---------------------------------------------------------------------------
# GET /cloud.ply — export nuage 3D (contour extrudé altitude/depth)
# ---------------------------------------------------------------------------
@app.get("/cloud.ply")
async def cloud_ply():
map_arr = slam.imap.get_array()
if len(map_arr) == 0:
raise HTTPException(status_code=204, detail="Carte vide")
# Extrude les points 2D sur l'intervalle [depth - altitude, depth]
# Utilise les dernières valeurs connues depuis les enregistrements
depth = 1.5
altitude = 0.5
if session_records:
last = session_records[-1]
depth = last.get("depth", 1.5)
altitude = last.get("altitude", 0.5)
z_top = -depth + altitude # fond (proche du bas)
z_bot = -depth # surface de l'eau (z=0)
pts_3d = []
for pt in map_arr:
pts_3d.append((pt[0], pt[1], z_top))
pts_3d.append((pt[0], pt[1], z_bot))
lines = [
"ply",
"format ascii 1.0",
f"element vertex {len(pts_3d)}",
"property float x",
"property float y",
"property float z",
"end_header",
]
for p in pts_3d:
lines.append(f"{p[0]:.4f} {p[1]:.4f} {p[2]:.4f}")
content = "\n".join(lines) + "\n"
from fastapi.responses import Response
return Response(
content=content,
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=moulin_cloud.ply"},
)
# ---------------------------------------------------------------------------
# Fichiers statiques + page principale
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def index():
static_path = os.path.join(os.path.dirname(__file__), "static", "index.html")
with open(static_path, "r", encoding="utf-8") as f:
return f.read()
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")