release: bump version to 0.52.0
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core.time import to_iso8601_utc
|
||||
from app.core.websocket.manager import manager
|
||||
@@ -15,6 +15,8 @@ class DataBroadcaster:
|
||||
def __init__(self):
|
||||
self.running = False
|
||||
self.tasks: Dict[str, asyncio.Task] = {}
|
||||
self._pending_vessel_updates: Dict[str, Dict[str, Any]] = {}
|
||||
self._vessel_flush_interval = 1.0
|
||||
|
||||
async def get_dashboard_stats(self) -> Dict[str, Any]:
|
||||
"""Get dashboard statistics"""
|
||||
@@ -68,6 +70,9 @@ class DataBroadcaster:
|
||||
|
||||
async def broadcast_custom(self, channel: str, data: Dict[str, Any]):
|
||||
"""Broadcast custom data to a specific channel"""
|
||||
if channel == "vessels":
|
||||
self.enqueue_vessel_update(data)
|
||||
return
|
||||
await manager.broadcast(
|
||||
{
|
||||
"type": "data_frame",
|
||||
@@ -78,6 +83,58 @@ class DataBroadcaster:
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
def enqueue_vessel_update(self, data: Dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list):
|
||||
return
|
||||
source = data.get("source")
|
||||
action = data.get("action") or "upsert"
|
||||
created = data.get("created")
|
||||
for vessel in vessels:
|
||||
if not isinstance(vessel, dict):
|
||||
continue
|
||||
mmsi = vessel.get("mmsi")
|
||||
if mmsi in (None, ""):
|
||||
continue
|
||||
self._pending_vessel_updates[str(mmsi)] = {
|
||||
**vessel,
|
||||
"_source": source,
|
||||
"_action": action,
|
||||
"_created": created,
|
||||
}
|
||||
|
||||
async def flush_vessel_updates(self):
|
||||
if not self._pending_vessel_updates:
|
||||
return
|
||||
pending = self._pending_vessel_updates
|
||||
self._pending_vessel_updates = {}
|
||||
vessels = []
|
||||
for item in pending.values():
|
||||
vessel = dict(item)
|
||||
source = vessel.pop("_source", None)
|
||||
action = vessel.pop("_action", "upsert")
|
||||
created = vessel.pop("_created", None)
|
||||
vessel["source"] = source
|
||||
vessel["action"] = action
|
||||
vessel["created"] = created
|
||||
vessels.append(vessel)
|
||||
await manager.broadcast_vessels(
|
||||
{
|
||||
"action": "upsert",
|
||||
"source": "mixed",
|
||||
"created": None,
|
||||
"vessels": vessels,
|
||||
}
|
||||
)
|
||||
|
||||
async def broadcast_vessels_periodically(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self.flush_vessel_updates()
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(self._vessel_flush_interval)
|
||||
|
||||
async def broadcast_datasource_task_update(self, data: Dict[str, Any]):
|
||||
"""Broadcast datasource task progress updates to connected clients."""
|
||||
await manager.broadcast(
|
||||
@@ -95,6 +152,7 @@ class DataBroadcaster:
|
||||
if not self.running:
|
||||
self.running = True
|
||||
self.tasks["dashboard"] = asyncio.create_task(self.broadcast_stats(5))
|
||||
self.tasks["vessels"] = asyncio.create_task(self.broadcast_vessels_periodically())
|
||||
|
||||
def stop(self):
|
||||
"""Stop all broadcasters"""
|
||||
@@ -102,6 +160,7 @@ class DataBroadcaster:
|
||||
for task in self.tasks.values():
|
||||
task.cancel()
|
||||
self.tasks.clear()
|
||||
self._pending_vessel_updates.clear()
|
||||
|
||||
|
||||
broadcaster = DataBroadcaster()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""WebSocket Connection Manager"""
|
||||
|
||||
from typing import Dict, Set, Optional
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, Set, Optional
|
||||
from fastapi import WebSocket
|
||||
import redis.asyncio as redis
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
MAX_VESSEL_SUBSCRIPTION_LIMIT = 5000
|
||||
MAX_VESSEL_WS_MESSAGE_ITEMS = 1000
|
||||
MAX_VESSEL_BBOX_AREA = 2500.0
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections"""
|
||||
@@ -14,6 +19,7 @@ class ConnectionManager:
|
||||
self.active_connections: Dict[str, Set[WebSocket]] = {} # user_id -> connections
|
||||
self.channel_subscriptions: Dict[str, Set[WebSocket]] = {}
|
||||
self.websocket_channels: Dict[WebSocket, Set[str]] = {}
|
||||
self.vessel_subscriptions: Dict[WebSocket, dict[str, Any]] = {}
|
||||
self.redis_client: Optional[redis.Redis] = None
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str):
|
||||
@@ -72,6 +78,50 @@ class ConnectionManager:
|
||||
channels = list(self.websocket_channels.get(websocket, set()))
|
||||
if channels:
|
||||
self.unsubscribe(websocket, channels)
|
||||
self.vessel_subscriptions.pop(websocket, None)
|
||||
|
||||
def subscribe_vessels(self, websocket: WebSocket, config: dict[str, Any]) -> dict[str, Any]:
|
||||
subscription = self._normalize_vessel_subscription(config)
|
||||
self.channel_subscriptions.setdefault("vessels", set()).add(websocket)
|
||||
self.websocket_channels.setdefault(websocket, set()).add("vessels")
|
||||
self.vessel_subscriptions[websocket] = subscription
|
||||
return subscription
|
||||
|
||||
def _normalize_vessel_subscription(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
bbox = config.get("bbox")
|
||||
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
|
||||
raise ValueError("vessels subscription requires bbox=[lon_min,lat_min,lon_max,lat_max]")
|
||||
try:
|
||||
lon_min, lat_min, lon_max, lat_max = [float(value) for value in bbox]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("bbox values must be numbers") from exc
|
||||
if lat_min > lat_max:
|
||||
lat_min, lat_max = lat_max, lat_min
|
||||
if lon_min > lon_max:
|
||||
lon_min, lon_max = lon_max, lon_min
|
||||
if not (-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
|
||||
raise ValueError("bbox longitude values must be between -180 and 180")
|
||||
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90):
|
||||
raise ValueError("bbox latitude values must be between -90 and 90")
|
||||
if (lon_max - lon_min) * (lat_max - lat_min) > MAX_VESSEL_BBOX_AREA:
|
||||
raise ValueError("bbox is too large; zoom in or request a smaller viewport")
|
||||
|
||||
zoom = int(config.get("zoom") or 1)
|
||||
if zoom < 1 or zoom > 20:
|
||||
raise ValueError("zoom must be between 1 and 20")
|
||||
limit = min(max(int(config.get("limit") or 1000), 1), MAX_VESSEL_SUBSCRIPTION_LIMIT)
|
||||
vessel_types = {
|
||||
str(item).strip().lower()
|
||||
for item in str(config.get("type") or "").split(",")
|
||||
if str(item).strip()
|
||||
}
|
||||
return {
|
||||
"bbox": (lon_min, lat_min, lon_max, lat_max),
|
||||
"zoom": zoom,
|
||||
"limit": limit,
|
||||
"type": vessel_types,
|
||||
"last_sent_at": None,
|
||||
}
|
||||
|
||||
async def send_personal_message(self, message: dict, user_id: str):
|
||||
if user_id in self.active_connections:
|
||||
@@ -92,6 +142,58 @@ class ConnectionManager:
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
async def broadcast_vessels(self, data: dict[str, Any]):
|
||||
vessels = data.get("vessels") if isinstance(data, dict) else None
|
||||
if not isinstance(vessels, list) or not vessels:
|
||||
return
|
||||
|
||||
for connection, subscription in list(self.vessel_subscriptions.items()):
|
||||
matched = [
|
||||
vessel
|
||||
for vessel in vessels
|
||||
if self._vessel_matches_subscription(vessel, subscription)
|
||||
][: min(subscription["limit"], MAX_VESSEL_WS_MESSAGE_ITEMS)]
|
||||
if not matched:
|
||||
continue
|
||||
subscription["last_sent_at"] = datetime.now(UTC)
|
||||
message = {
|
||||
"type": "data_frame",
|
||||
"channel": "vessels",
|
||||
"timestamp": subscription["last_sent_at"].isoformat(),
|
||||
"payload": {
|
||||
**data,
|
||||
"vessels": matched,
|
||||
"subscription": {
|
||||
"bbox": list(subscription["bbox"]),
|
||||
"zoom": subscription["zoom"],
|
||||
"limit": subscription["limit"],
|
||||
},
|
||||
},
|
||||
}
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
self.unsubscribe_all(connection)
|
||||
|
||||
def _vessel_matches_subscription(
|
||||
self,
|
||||
vessel: dict[str, Any],
|
||||
subscription: dict[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
lon = float(vessel.get("lon"))
|
||||
lat = float(vessel.get("lat"))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
lon_min, lat_min, lon_max, lat_max = subscription["bbox"]
|
||||
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
|
||||
return False
|
||||
requested_types = subscription.get("type") or set()
|
||||
if not requested_types:
|
||||
return True
|
||||
type_name = str(vessel.get("vessel_type_name") or "").lower()
|
||||
return any(requested_type in type_name for requested_type in requested_types)
|
||||
|
||||
async def close_all(self):
|
||||
for user_id in self.active_connections:
|
||||
for connection in self.active_connections[user_id]:
|
||||
@@ -99,6 +201,7 @@ class ConnectionManager:
|
||||
self.active_connections.clear()
|
||||
self.channel_subscriptions.clear()
|
||||
self.websocket_channels.clear()
|
||||
self.vessel_subscriptions.clear()
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
Reference in New Issue
Block a user