Neue Datei milship_db.py: - MMSI-Laenderzuordnung (200+ Laendercodes) - Schiffsklassen-Datenbank (Nimitz, Arleigh-Burke, Gorshkov, Type 052D, Queen Elizabeth, F125, Charles de Gaulle) - Bilder aus Wikimedia Commons (frei lizenziert) - Klassifizierung nach MMSI-Prefix + Schiffsname Klick auf Militaerschiff zeigt: - Foto der Schiffsklasse (wenn verfuegbar) - Klassenname und Schiffstyp - Herkunftsland (aus MMSI) - MMSI, SOG, COG, Heading API: GET /api/ships/military liefert alle Militaerschiffe mit Klassifizierung und Bild-URLs.
162 Zeilen
6.5 KiB
Python
162 Zeilen
6.5 KiB
Python
"""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)}
|