591 lines
23 KiB
Python
591 lines
23 KiB
Python
import bcrypt as _bcrypt
|
|
if not hasattr(_bcrypt, "__about__"):
|
|
import types; _bcrypt.__about__ = types.SimpleNamespace(__version__=_bcrypt.__version__ or "4.0.0")
|
|
|
|
from datetime import datetime
|
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from contextlib import asynccontextmanager
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
|
|
|
|
from database import engine, SessionLocal, ensure_column
|
|
from models.aid import Aid
|
|
from models.vessel import Vessel, VesselTrack, RecordingEvent
|
|
from models.lamp import Lamp
|
|
from models.contact import Contact, AlertReport
|
|
import models.aid
|
|
import models.vessel
|
|
import models.user
|
|
import models.lamp
|
|
import models.contact
|
|
from routers import aids
|
|
from routers.auth import seed_users, get_current_user
|
|
from models.user import User
|
|
from fastapi import Depends
|
|
from database import get_db
|
|
from sqlalchemy.orm import Session
|
|
from routers import auth as auth_router
|
|
from routers import charts as charts_router
|
|
from routers import equipment as equipment_router
|
|
from routers import lamps as lamps_router
|
|
from routers import contacts as contacts_router
|
|
from services.ais_simulator import run_simulator, MIAMI_AIDS
|
|
from services.alert_engine import evaluate_vessel, evaluate_aid_movement, aid_alert_state
|
|
from services.gps_reader import GPSReader
|
|
from services.aton_decoder import decode_type21, decode_type8_aton, process_aton_message, aton_state
|
|
from services import settings_store
|
|
import services.ais_catcher as _ais_catcher
|
|
from services.ais_udp_reader import run_udp_listener
|
|
import services.ais_udp_reader as _ais_udp
|
|
|
|
# Crear tablas
|
|
models.aid.Base.metadata.create_all(bind=engine)
|
|
models.vessel.Base.metadata.create_all(bind=engine)
|
|
models.user.Base.metadata.create_all(bind=engine)
|
|
models.lamp.Base.metadata.create_all(bind=engine)
|
|
models.contact.Base.metadata.create_all(bind=engine)
|
|
# Additive migrations for columns added after first install
|
|
ensure_column("aids", "lamp_id", "TEXT")
|
|
ensure_column("users", "prefs_json", "TEXT")
|
|
|
|
connected_clients: list[WebSocket] = []
|
|
# Source of truth for runtime config — mutated via POST /settings.
|
|
config = settings_store.SETTINGS
|
|
|
|
# Background AIS reader task handle (lets us stop/restart on source change)
|
|
_ais_task: asyncio.Task | None = None
|
|
|
|
# Last-known battery alert state per ATON to avoid re-emitting every msg.
|
|
# Values: None | 'WARN' | 'ALARM'
|
|
_battery_alert_state: dict[str, str | None] = {}
|
|
|
|
# Estado en memoria (posiciones actuales)
|
|
vessels_state: dict = {}
|
|
aids_state: dict = {}
|
|
|
|
def seed_contacts():
|
|
"""Pre-load the operator company (INSM) as an OWNER contact so the
|
|
REPORT flow has someone to notify out of the box."""
|
|
db = SessionLocal()
|
|
try:
|
|
from models.contact import Contact
|
|
existing = db.query(Contact).filter(Contact.id == "insm-default").first()
|
|
if not existing:
|
|
db.add(Contact(
|
|
id="insm-default",
|
|
role="OWNER",
|
|
name="INSM — Ingeniería Naval y Señalización Marítima",
|
|
company_name="INSM",
|
|
email="tecnicoinsm@gmail.com",
|
|
preferred_channel="EMAIL",
|
|
notes="Operator of this AidsMonitoring deployment.",
|
|
))
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def seed_aids():
|
|
db = SessionLocal()
|
|
for a in MIAMI_AIDS:
|
|
if not db.query(Aid).filter(Aid.id == a["id"]).first():
|
|
aid = Aid(
|
|
id=a["id"],
|
|
nombre=a["nombre"],
|
|
categoria=a["categoria"],
|
|
tipo=a["tipo"],
|
|
tipo_ais=a["tipo_ais"],
|
|
mmsi=a.get("mmsi"),
|
|
lat_nominal=a["lat_nominal"],
|
|
lon_nominal=a["lon_nominal"],
|
|
radio_borneo_m=a.get("radio_borneo_m", 10.0),
|
|
caracteristica_luz=a.get("caracteristica_luz"),
|
|
alcance_nm=a.get("alcance_nm"),
|
|
fuente_posicion="MANUAL",
|
|
)
|
|
db.add(aid)
|
|
db.commit()
|
|
db.close()
|
|
|
|
async def broadcast(message: dict):
|
|
data = json.dumps(message)
|
|
dead = []
|
|
for ws in connected_clients:
|
|
try:
|
|
await ws.send_text(data)
|
|
except Exception:
|
|
dead.append(ws)
|
|
for ws in dead:
|
|
connected_clients.remove(ws)
|
|
|
|
async def _persist_recording(db, alert: dict):
|
|
"""Save or close a RecordingEvent row when auto-recording triggers."""
|
|
from models.vessel import RecordingEvent
|
|
import uuid as _uuid
|
|
if alert["tipo"] == "GRABACION_INICIADA":
|
|
rec = RecordingEvent(
|
|
id=str(_uuid.uuid4()),
|
|
mmsi=alert["mmsi"],
|
|
aid_id=alert["aid_id"],
|
|
inicio=datetime.utcnow(),
|
|
trigger=alert.get("trigger", "PROXIMIDAD"),
|
|
)
|
|
db.add(rec)
|
|
db.commit()
|
|
elif alert["tipo"] == "GRABACION_FINALIZADA":
|
|
from sqlalchemy import and_
|
|
rec = db.query(RecordingEvent).filter(
|
|
and_(
|
|
RecordingEvent.mmsi == alert["mmsi"],
|
|
RecordingEvent.aid_id == alert["aid_id"],
|
|
RecordingEvent.cerrado == False,
|
|
)
|
|
).order_by(RecordingEvent.inicio.desc()).first()
|
|
if rec:
|
|
rec.fin = datetime.utcnow()
|
|
rec.distancia_min_m = alert.get("distancia_min_m")
|
|
rec.cerrado = True
|
|
db.commit()
|
|
|
|
async def process_message(msg: dict):
|
|
db = SessionLocal()
|
|
try:
|
|
if msg["type"] == "vessel":
|
|
vessels_state[msg["mmsi"]] = msg
|
|
aids_list = list(aids_state.values())
|
|
alerts = evaluate_vessel(msg, aids_list, config)
|
|
await broadcast(msg)
|
|
for alert in alerts:
|
|
await broadcast({"type": "alert", **alert})
|
|
if alert["tipo"] in ("GRABACION_INICIADA", "GRABACION_FINALIZADA"):
|
|
await _persist_recording(db, alert)
|
|
|
|
elif msg["type"] == "aton":
|
|
# Real AIS Type 21 / Type 8 from hardware receiver
|
|
entry = process_aton_message(msg)
|
|
if entry:
|
|
aton_state[entry["mmsi"]] = entry
|
|
await broadcast({"type": "aton", **entry})
|
|
|
|
mmsi = entry["mmsi"]
|
|
|
|
# Auto-upsert Aid row on first sight (Type 21 carries name + position).
|
|
# The user must then assign a lamp via the right panel.
|
|
if msg.get("msg_type") == 21 and entry.get("lat") is not None:
|
|
aid = db.query(Aid).filter(Aid.mmsi == mmsi).first()
|
|
if not aid:
|
|
from models.aid import Aid as _Aid
|
|
import uuid as _uuid
|
|
aid = _Aid(
|
|
id=str(_uuid.uuid4()),
|
|
nombre=entry.get("name") or f"AIS AtoN {mmsi}",
|
|
categoria="FLOTANTE",
|
|
tipo="BOYA_LATERAL",
|
|
tipo_ais="ATON_21",
|
|
mmsi=mmsi,
|
|
lat_nominal=entry["lat"],
|
|
lon_nominal=entry["lon"],
|
|
fuente_posicion="AIS",
|
|
)
|
|
db.add(aid); db.commit()
|
|
await broadcast({
|
|
"type": "alert",
|
|
"id": str(_uuid.uuid4()),
|
|
"tipo": "ALERTA_AMARILLA",
|
|
"subtipo": "AYUDA_NUEVA_SIN_CONFIGURAR",
|
|
"mmsi": mmsi,
|
|
"aid_id": aid.id,
|
|
"aid_nombre": aid.nombre,
|
|
"mensaje": "Nueva ayuda detectada — falta asignar lámpara",
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
})
|
|
|
|
# Battery threshold check — per-aid lamp values if assigned,
|
|
# otherwise the global defaults from settings_store.
|
|
v = entry.get("voltage_v")
|
|
if v is not None:
|
|
aid = db.query(Aid).filter(Aid.mmsi == mmsi).first()
|
|
lamp = None
|
|
if aid and aid.lamp_id:
|
|
lamp = db.query(Lamp).filter(Lamp.id == aid.lamp_id).first()
|
|
if lamp:
|
|
rng = lamp.voltage_max - lamp.voltage_min
|
|
warn_v = lamp.voltage_min + rng * 0.20
|
|
alarm_v = lamp.voltage_min + rng * 0.10
|
|
threshold_source = f"lamp:{lamp.manufacturer} {lamp.model}"
|
|
else:
|
|
warn_v = config["battery_warn_v"]
|
|
alarm_v = config["battery_alarm_v"]
|
|
threshold_source = "global"
|
|
prev = _battery_alert_state.get(mmsi)
|
|
new_state = None
|
|
if v <= alarm_v: new_state = "ALARM"
|
|
elif v <= warn_v: new_state = "WARN"
|
|
if new_state and new_state != prev:
|
|
import uuid as _uuid
|
|
await broadcast({
|
|
"type": "alert",
|
|
"id": str(_uuid.uuid4()),
|
|
"tipo": "ALERTA_ROJA" if new_state == "ALARM" else "ALERTA_AMARILLA",
|
|
"subtipo": "BATERIA_BAJA",
|
|
"mmsi": mmsi,
|
|
"aid_nombre": entry.get("name"),
|
|
"voltage_v": v,
|
|
"umbral": alarm_v if new_state == "ALARM" else warn_v,
|
|
"fuente_umbral": threshold_source,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
})
|
|
_battery_alert_state[mmsi] = new_state
|
|
|
|
elif msg["type"] == "aid_position":
|
|
aid_id = msg["id"]
|
|
if aid_id in aids_state:
|
|
aids_state[aid_id].update(msg)
|
|
aid = db.query(Aid).filter(Aid.id == aid_id).first()
|
|
if aid:
|
|
alert_list = evaluate_aid_movement(
|
|
aid_id, msg["lat_actual"], msg["lon_actual"],
|
|
aid.lat_nominal, aid.lon_nominal, config
|
|
)
|
|
aid.lat_actual = msg["lat_actual"]
|
|
aid.lon_actual = msg["lon_actual"]
|
|
aid.desplazamiento_m = msg["desplazamiento_m"]
|
|
aid.en_posicion = msg["en_posicion"]
|
|
db.commit()
|
|
await broadcast(msg)
|
|
for alert in alert_list:
|
|
await broadcast({"type": "alert", **alert})
|
|
finally:
|
|
db.close()
|
|
|
|
async def _start_ais_source():
|
|
"""Start the AIS reader task matching the current config['ais_source']."""
|
|
src = config.get("ais_source", "SIMULATOR").upper()
|
|
if src == "SIMULATOR":
|
|
return asyncio.create_task(run_simulator(process_message))
|
|
if src == "SDR":
|
|
# Auto-launch AIS-catcher if hardware is present and not already running
|
|
if _ais_catcher.exe_exists() and not _ais_catcher.is_running():
|
|
result = _ais_catcher.launch()
|
|
if result["ok"]:
|
|
print(f"[ais] AIS-catcher launched (pid {result.get('pid')})")
|
|
else:
|
|
print(f"[ais] AIS-catcher launch failed: {result.get('error')}")
|
|
return asyncio.create_task(run_udp_listener(process_message, port=10110))
|
|
return None
|
|
|
|
|
|
async def _stop_ais_source():
|
|
global _ais_task
|
|
if _ais_task and not _ais_task.done():
|
|
_ais_task.cancel()
|
|
try: await _ais_task
|
|
except asyncio.CancelledError: pass
|
|
_ais_task = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
settings_store.init()
|
|
seed_aids()
|
|
seed_users()
|
|
seed_contacts()
|
|
db = SessionLocal()
|
|
for aid in db.query(Aid).all():
|
|
aids_state[aid.id] = {
|
|
"id": aid.id, "nombre": aid.nombre, "categoria": aid.categoria,
|
|
"tipo": aid.tipo, "tipo_ais": aid.tipo_ais, "mmsi": aid.mmsi,
|
|
"lat_nominal": aid.lat_nominal, "lon_nominal": aid.lon_nominal,
|
|
"radio_borneo_m": aid.radio_borneo_m,
|
|
"lat_actual": aid.lat_actual, "lon_actual": aid.lon_actual,
|
|
"desplazamiento_m": aid.desplazamiento_m or 0,
|
|
"en_posicion": aid.en_posicion, "en_movimiento": aid.en_movimiento,
|
|
"caracteristica_luz": aid.caracteristica_luz, "alcance_nm": aid.alcance_nm,
|
|
"lamp_id": aid.lamp_id,
|
|
"puerto_responsable": aid.puerto_responsable,
|
|
"empresa_responsable": aid.empresa_responsable,
|
|
"ultima_senal": str(aid.ultima_senal) if aid.ultima_senal else None,
|
|
}
|
|
db.close()
|
|
|
|
global _ais_task
|
|
_ais_task = await _start_ais_source()
|
|
|
|
# GPS — settings_store now holds the port (mirrored from .env on first run)
|
|
gps_port = config.get("gps_port") or None
|
|
gps_baud = config.get("gps_baud") or None
|
|
gps = GPSReader(broadcast, forced_port=gps_port, forced_baud=gps_baud)
|
|
await gps.start()
|
|
app.state.gps = gps
|
|
|
|
yield
|
|
|
|
app = FastAPI(title="AidsMonitoring", lifespan=lifespan)
|
|
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
|
allow_methods=["*"], allow_headers=["*"])
|
|
|
|
app.include_router(auth_router.router)
|
|
app.include_router(aids.router)
|
|
app.include_router(charts_router.router)
|
|
app.include_router(equipment_router.router)
|
|
app.include_router(lamps_router.router)
|
|
app.include_router(contacts_router.router)
|
|
|
|
@app.post("/recordings/start/{mmsi}")
|
|
async def manual_start_recording(mmsi: str, aid_id: str = "MANUAL"):
|
|
from services.alert_engine import active_recordings
|
|
from models.vessel import RecordingEvent
|
|
import uuid as _uuid
|
|
key = f"{mmsi}_{aid_id}"
|
|
if key in active_recordings:
|
|
return {"ok": False, "detail": "Already recording"}
|
|
active_recordings[key] = {
|
|
"mmsi": mmsi, "aid_id": aid_id, "aid_nombre": aid_id,
|
|
"inicio": datetime.utcnow().isoformat(),
|
|
"trigger": "MANUAL", "min_dist": 0, "pre_buffer": [],
|
|
}
|
|
db = SessionLocal()
|
|
try:
|
|
rec = RecordingEvent(
|
|
id=str(_uuid.uuid4()), mmsi=mmsi, aid_id=aid_id,
|
|
inicio=datetime.utcnow(), trigger="MANUAL",
|
|
)
|
|
db.add(rec); db.commit()
|
|
finally:
|
|
db.close()
|
|
vessel = vessels_state.get(mmsi, {})
|
|
await broadcast({"type": "alert", "tipo": "GRABACION_INICIADA",
|
|
"mmsi": mmsi, "aid_id": aid_id, "aid_nombre": aid_id,
|
|
"distancia_m": 0, "trigger": "MANUAL",
|
|
"timestamp": datetime.utcnow().isoformat()})
|
|
return {"ok": True}
|
|
|
|
@app.post("/recordings/stop/{mmsi}")
|
|
async def manual_stop_recording(mmsi: str, aid_id: str = "MANUAL"):
|
|
from services.alert_engine import active_recordings
|
|
from models.vessel import RecordingEvent
|
|
from sqlalchemy import and_
|
|
key = f"{mmsi}_{aid_id}"
|
|
rec_mem = active_recordings.pop(key, None)
|
|
min_dist = round(rec_mem["min_dist"], 1) if rec_mem else None
|
|
db = SessionLocal()
|
|
try:
|
|
rec = db.query(RecordingEvent).filter(
|
|
and_(RecordingEvent.mmsi == mmsi, RecordingEvent.aid_id == aid_id,
|
|
RecordingEvent.cerrado == False)
|
|
).order_by(RecordingEvent.inicio.desc()).first()
|
|
if rec:
|
|
rec.fin = datetime.utcnow()
|
|
rec.distancia_min_m = min_dist
|
|
rec.cerrado = True
|
|
db.commit()
|
|
finally:
|
|
db.close()
|
|
await broadcast({"type": "alert", "tipo": "GRABACION_FINALIZADA",
|
|
"mmsi": mmsi, "aid_id": aid_id,
|
|
"distancia_min_m": min_dist,
|
|
"timestamp": datetime.utcnow().isoformat()})
|
|
return {"ok": True}
|
|
|
|
@app.get("/debug")
|
|
async def debug_state():
|
|
from services.alert_engine import aid_alert_state, aid_position_history
|
|
return {
|
|
"alert_states": dict(aid_alert_state),
|
|
"history_sizes": {k: len(v) for k, v in aid_position_history.items()},
|
|
"aids_in_state": list(aids_state.keys()),
|
|
"vessels_tracked": len(vessels_state),
|
|
}
|
|
|
|
@app.get("/settings")
|
|
async def get_settings(current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Return system defaults merged with this user's saved preferences."""
|
|
import json as _json
|
|
base = settings_store.get_all()
|
|
try:
|
|
user_row = db.query(User).filter(User.id == current_user.id).first()
|
|
prefs = _json.loads(user_row.prefs_json or '{}') if user_row else {}
|
|
except Exception:
|
|
prefs = {}
|
|
return {**base, **prefs}
|
|
|
|
|
|
@app.post("/settings")
|
|
async def update_settings(payload: dict,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)):
|
|
"""Save user-specific preferences to the DB. System-level keys (GPS port,
|
|
AIS source) are also applied to the runtime config so they take effect
|
|
without a restart. Returns {ok, settings, applied: [...]}."""
|
|
import json as _json
|
|
global _ais_task
|
|
|
|
# Split: system keys go to settings_store; everything stays in user prefs
|
|
SYSTEM_KEYS = {"ais_source","ais_serial_port","ais_baud","ais_net_addr",
|
|
"gps_port","gps_baud","smtp_host","smtp_port","smtp_user",
|
|
"smtp_password","smtp_from","smtp_from_name","smtp_use_tls"}
|
|
|
|
system_patch = {k: v for k, v in (payload or {}).items() if k in SYSTEM_KEYS}
|
|
prev = settings_store.get_all()
|
|
if system_patch:
|
|
settings_store.apply_patch(system_patch)
|
|
new = settings_store.get_all()
|
|
applied = []
|
|
|
|
# AIS source change
|
|
if system_patch.get("ais_source") and new["ais_source"] != prev["ais_source"]:
|
|
await _stop_ais_source()
|
|
_ais_task = await _start_ais_source()
|
|
applied.append(f"ais_source → {new['ais_source']}")
|
|
|
|
# GPS port change
|
|
if "gps_port" in system_patch or "gps_baud" in system_patch:
|
|
gps: GPSReader = getattr(app.state, "gps", None)
|
|
if gps:
|
|
gps._forced_port = new["gps_port"] or None
|
|
gps._forced_baud = new["gps_baud"] or None
|
|
gps._port = None
|
|
gps.status = "SEARCHING"
|
|
gps.reconnect()
|
|
applied.append(f"gps_port → {new['gps_port'] or 'auto'} @ {new['gps_baud']}")
|
|
|
|
# Save full payload as this user's preferences
|
|
try:
|
|
user_row = db.query(User).filter(User.id == current_user.id).first()
|
|
if user_row:
|
|
existing = _json.loads(user_row.prefs_json or '{}')
|
|
existing.update(payload or {})
|
|
user_row.prefs_json = _json.dumps(existing)
|
|
db.commit()
|
|
except Exception:
|
|
pass
|
|
|
|
return {"ok": True, "settings": {**new, **(payload or {})}, "applied": applied}
|
|
|
|
|
|
@app.get("/gps/status")
|
|
async def gps_status(request: Request):
|
|
gps: GPSReader = getattr(request.app.state, "gps", None)
|
|
if not gps:
|
|
return {"status": "DISABLED", "port": None, "last_fix": None}
|
|
return {
|
|
"status": gps.status,
|
|
"port": gps._port or gps._forced_port,
|
|
"forced_port": gps._forced_port,
|
|
"baud": gps._baud,
|
|
"last_fix": gps.last_fix,
|
|
}
|
|
|
|
@app.post("/gps/port")
|
|
async def set_gps_port(request: Request, port: str = "", baud: int = 9600):
|
|
"""Hot-change GPS port without server restart."""
|
|
gps: GPSReader = getattr(request.app.state, "gps", None)
|
|
if not gps:
|
|
return {"ok": False, "detail": "GPS reader not running"}
|
|
gps._forced_port = port.strip() or None
|
|
gps._forced_baud = baud or None
|
|
gps._port = None # force reconnect on next loop iteration
|
|
gps.status = "SEARCHING"
|
|
gps.reconnect() # close active serial so _read_loop exits and re-probes
|
|
# Persist to .env for next restart
|
|
env_path = os.path.join(os.path.dirname(__file__), '..', '.env')
|
|
try:
|
|
lines = open(env_path).readlines()
|
|
def _set(lines, key, val):
|
|
for i, l in enumerate(lines):
|
|
if l.startswith(f"{key}="):
|
|
lines[i] = f"{key}={val}\n"; return lines
|
|
lines.append(f"{key}={val}\n"); return lines
|
|
lines = _set(lines, "GPS_PORT", port.strip())
|
|
lines = _set(lines, "GPS_BAUD", str(baud))
|
|
open(env_path, 'w').writelines(lines)
|
|
except Exception as e:
|
|
pass # non-fatal — in-memory change already applied
|
|
return {"ok": True, "port": gps._forced_port, "baud": baud}
|
|
|
|
@app.get("/ais/status")
|
|
async def ais_status():
|
|
"""Return AIS-catcher process status and SDR hardware info."""
|
|
return _ais_catcher.status()
|
|
|
|
|
|
@app.get("/ais/stats")
|
|
async def ais_stats():
|
|
"""Return live UDP listener stats: sentence counts, last receive time."""
|
|
import time as _time
|
|
udp = _ais_udp.get_stats()
|
|
catcher = _ais_catcher.status()
|
|
last_ts = udp.get("last_sentence_ts")
|
|
seconds_since = round(_time.time() - last_ts, 1) if last_ts else None
|
|
return {
|
|
**udp,
|
|
"seconds_since_last": seconds_since,
|
|
"signal": last_ts is not None and seconds_since < 60,
|
|
"catcher_running": catcher["running"],
|
|
"sdr_devices": catcher["sdr_devices"],
|
|
"ais_source": config.get("ais_source", "SIMULATOR"),
|
|
}
|
|
|
|
|
|
@app.get("/ais/raw")
|
|
async def ais_raw():
|
|
"""Return last 30 raw NMEA sentences received."""
|
|
return {"sentences": _ais_udp.get_raw()}
|
|
|
|
|
|
@app.post("/ais/launch")
|
|
async def ais_launch(udp_port: int = 10110, web_port: int = 8100):
|
|
"""Launch AIS-catcher.exe (auto-switches ais_source to SDR)."""
|
|
global _ais_task
|
|
result = _ais_catcher.launch(udp_port=udp_port, web_port=web_port)
|
|
# Always sync source to SDR after a successful launch (or already-running),
|
|
# otherwise the catcher streams to UDP:10110 with no listener attached.
|
|
if result["ok"] and config.get("ais_source", "SIMULATOR").upper() != "SDR":
|
|
settings_store.apply_patch({"ais_source": "SDR"})
|
|
await _stop_ais_source()
|
|
_ais_task = await _start_ais_source()
|
|
return result
|
|
|
|
|
|
@app.post("/ais/stop")
|
|
async def ais_stop():
|
|
"""Stop AIS-catcher.exe."""
|
|
return _ais_catcher.stop()
|
|
|
|
|
|
@app.websocket("/ws")
|
|
async def websocket_endpoint(ws: WebSocket):
|
|
await ws.accept()
|
|
connected_clients.append(ws)
|
|
await ws.send_text(json.dumps({"type": "init", "aids": list(aids_state.values()), "vessels": list(vessels_state.values()), "atons": list(aton_state.values())}))
|
|
# Re-emit any active aid alerts so new clients see current state
|
|
from datetime import datetime as _dt
|
|
for aid_id, state in aid_alert_state.items():
|
|
if state == 'RED':
|
|
await ws.send_text(json.dumps({"type": "alert", "tipo": "ALERTA_ROJA",
|
|
"subtipo": "AYUDA_EN_MOVIMIENTO", "aid_id": aid_id,
|
|
"mensaje": "Movimiento continuo detectado",
|
|
"timestamp": _dt.utcnow().isoformat()}))
|
|
elif state == 'YELLOW':
|
|
aid = aids_state.get(aid_id, {})
|
|
await ws.send_text(json.dumps({"type": "alert", "tipo": "ALERTA_AMARILLA",
|
|
"subtipo": "AYUDA_FUERA_POSICION", "aid_id": aid_id,
|
|
"desplazamiento_m": round(aid.get("desplazamiento_m", 0), 1),
|
|
"timestamp": _dt.utcnow().isoformat()}))
|
|
try:
|
|
while True:
|
|
await ws.receive_text()
|
|
except WebSocketDisconnect:
|
|
if ws in connected_clients:
|
|
connected_clients.remove(ws)
|
|
|
|
app.mount("/", StaticFiles(directory=os.path.join(os.path.dirname(__file__), '..', 'frontend'), html=True), name="frontend")
|