"""Schiffsverkehr-Collector: AISStream.io WebSocket (global, Echtzeit).""" import asyncio import json import logging import os import time import websockets from fastapi import APIRouter from milship_db import get_country_from_mmsi, classify_military_ship logger = logging.getLogger("globe.ships") router = APIRouter() _AISSTREAM_KEY = os.getenv("AISSTREAM_KEY", "1a56b078db829727abd4d617937bae51c6f9973e") _AISSTREAM_URL = "wss://stream.aisstream.io/v0/stream" # {mmsi: {mmsi, lat, lon, sog, cog, heading, name, ts}} _store: dict[int, dict] = {} _connected = False _task = None async def _listener(): """Dauerhafter WebSocket-Client fuer AISStream.""" global _connected while True: try: logger.info("AISStream: Verbinde...") async with websockets.connect( _AISSTREAM_URL, ping_interval=30, ping_timeout=10, close_timeout=5 ) as ws: sub = { "APIKey": _AISSTREAM_KEY, "BoundingBoxes": [[[-90, -180], [90, 180]]], "FilterMessageTypes": ["PositionReport"], } await ws.send(json.dumps(sub)) _connected = True logger.info("AISStream: Verbunden") async for raw in ws: try: text = raw.decode("utf-8") if isinstance(raw, bytes) else raw msg = json.loads(text) meta = msg.get("MetaData", {}) mmsi = meta.get("MMSI") if not mmsi: continue pos = msg.get("Message", {}).get("PositionReport", {}) lat = meta.get("latitude") or pos.get("Latitude") lon = meta.get("longitude") or pos.get("Longitude") if not lat or not lon or not (-90 <= lat <= 90 and -180 <= lon <= 180): continue ship_type = meta.get("ShipType", 0) # Vorherige Position fuer Dark-Ship-Erkennung merken prev = _store.get(mmsi) prev_positions = (prev.get("track", []) if prev else [])[-10:] prev_positions.append({"lat": round(lat, 5), "lon": round(lon, 5), "ts": time.time()}) _store[mmsi] = { "mmsi": mmsi, "lat": round(lat, 5), "lon": round(lon, 5), "sog": round(pos.get("Sog", 0), 1), "cog": round(pos.get("Cog", 0), 1), "heading": pos.get("TrueHeading", 0), "name": (meta.get("ShipName") or "").strip(), "ship_type": ship_type, "ts": time.time(), "track": prev_positions, } # Stale-Cleanup alle 1000 Updates if len(_store) % 1000 == 0: cutoff = time.time() - 900 stale = [k for k, v in _store.items() if v["ts"] < cutoff] for k in stale: del _store[k] if len(_store) % 5000 == 0: logger.info(f"AISStream: {len(_store)} Schiffe") except Exception: continue except Exception as e: _connected = False logger.warning(f"AISStream Fehler: {e}. Reconnect in 10s...") await asyncio.sleep(10) def start_ais_collector(): global _task if _task is None or _task.done(): _task = asyncio.create_task(_listener()) logger.info("AIS collector gestartet") def _classify_ship(ship_type): """AIS Ship Type zu Kategorie.""" if not ship_type: return "unknown" t = int(ship_type) if 20 <= t <= 29: return "wing_in_ground" if 30 <= t <= 39: return "fishing" if t == 30 else "towing" if t in (31,32) else "military" if t == 35 else "sailing" if t == 36 else "other" if 40 <= t <= 49: return "hsc" if 50 <= t <= 59: return "pilot" if t == 50 else "sar" if t == 51 else "tug" if t == 52 else "port" if t == 53 else "medical" if t == 58 else "other" if 60 <= t <= 69: return "passenger" if 70 <= t <= 79: return "cargo" if 80 <= t <= 89: return "tanker" return "other" def _detect_dark_ships(): """Schiffe die laenger als 10 Minuten kein Update hatten aber vorher aktiv waren.""" now = time.time() dark = [] for mmsi, s in _store.items(): age = now - s["ts"] track = s.get("track", []) # Aktiv gewesen (>3 Positionen) aber seit >10min kein Update und SOG war > 1 if age > 600 and len(track) >= 3 and s.get("sog", 0) > 0.5: dark.append({ "mmsi": mmsi, "name": s.get("name", ""), "last_lat": s["lat"], "last_lon": s["lon"], "last_sog": s["sog"], "last_cog": s["cog"], "silent_minutes": round(age / 60, 1), "ship_type": _classify_ship(s.get("ship_type")), }) return dark @router.get("/ships") async def get_ships(): ships_out = [] for s in _store.values(): ship = dict(s) ship["category"] = _classify_ship(s.get("ship_type")) # Track auf letzte 5 Positionen kuerzen fuer API ship["track"] = ship.get("track", [])[-5:] ships_out.append(ship) return {"ships": ships_out, "total": len(ships_out), "connected": _connected} @router.get("/ships/military") async def get_military_ships(): """Alle Militaerschiffe mit Klassifizierung und Bildern.""" mil_ships = [] for s in _store.values(): if s.get("ship_type") == 35 or (isinstance(s.get("ship_type"), int) and 30 <= s["ship_type"] <= 39): info = classify_military_ship(s.get("mmsi"), s.get("name","")) mil_ships.append({ "mmsi": s["mmsi"], "name": s.get("name", ""), "lat": s["lat"], "lon": s["lon"], "sog": s["sog"], "cog": s["cog"], "country": info["country"], "ship_class": info["class"], "ship_type_detail": info["type"], "image": info["image"], }) return {"ships": mil_ships, "total": len(mil_ships)} @router.get("/ships/dark") async def get_dark_ships(): dark = _detect_dark_ships() return {"dark_ships": dark, "total": len(dark)}