Files
desungongpai/app/websocket_manager.py
default 1d06cc5415 feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤
- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT)
- 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段
- 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索
- 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState)
- 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖
- TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播
- 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板
- 新增fence_attendance WebSocket topic

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-27 13:04:11 +00:00

90 lines
2.9 KiB
Python

"""
WebSocket Manager - WebSocket 连接管理器
Manages client connections, topic subscriptions, and broadcasting.
"""
import asyncio
import json
import logging
from app.config import now_cst
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# Maximum concurrent WebSocket connections
MAX_CONNECTIONS = 100
# Valid topics
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth", "fence_attendance"}
class WebSocketManager:
"""Manages WebSocket connections with topic-based subscriptions."""
def __init__(self):
# {websocket: set_of_topics}
self.active_connections: dict[WebSocket, set[str]] = {}
@property
def connection_count(self) -> int:
return len(self.active_connections)
async def connect(self, websocket: WebSocket, topics: set[str]) -> bool:
"""Accept and register a WebSocket connection. Returns False if limit reached."""
if self.connection_count >= MAX_CONNECTIONS:
await websocket.close(code=1013, reason="Max connections reached")
return False
await websocket.accept()
filtered = topics & VALID_TOPICS
self.active_connections[websocket] = filtered if filtered else VALID_TOPICS
logger.info(
"WebSocket connected (%d total), topics: %s",
self.connection_count,
self.active_connections[websocket],
)
return True
def disconnect(self, websocket: WebSocket):
"""Remove a WebSocket connection."""
self.active_connections.pop(websocket, None)
logger.info("WebSocket disconnected (%d remaining)", self.connection_count)
async def broadcast(self, topic: str, data: dict):
"""Broadcast a message to all subscribers of the given topic."""
if topic not in VALID_TOPICS:
return
message = json.dumps(
{"topic": topic, "data": data, "timestamp": now_cst().isoformat()},
default=str,
ensure_ascii=False,
)
disconnected = []
# Snapshot dict to avoid RuntimeError from concurrent modification
for ws, topics in list(self.active_connections.items()):
if topic in topics:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
self.active_connections.pop(ws, None)
def broadcast_nonblocking(self, topic: str, data: dict):
"""Fire-and-forget broadcast (used from TCP handler context)."""
asyncio.create_task(self._safe_broadcast(topic, data))
async def _safe_broadcast(self, topic: str, data: dict):
try:
await self.broadcast(topic, data)
except Exception:
logger.exception("WebSocket broadcast error for topic %s", topic)
# Singleton instance
ws_manager = WebSocketManager()