|
|
@@ -0,0 +1,162 @@
|
|
|
+import asyncio
|
|
|
+import time
|
|
|
+from aiohttp import web
|
|
|
+
|
|
|
+from config.logger import setup_logging
|
|
|
+from core.api.base_handler import BaseHandler
|
|
|
+from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
|
|
|
+from plugins_func.nav_copilot.service import _parse_fix, _pick_gnss_tool_name
|
|
|
+
|
|
|
+TAG = __name__
|
|
|
+
|
|
|
+
|
|
|
+class AppGpsHandler(BaseHandler):
|
|
|
+ def __init__(self, config: dict, ws_server):
|
|
|
+ super().__init__(config)
|
|
|
+ self.ws_server = ws_server
|
|
|
+ self.logger = setup_logging()
|
|
|
+ self._inflight_lock = asyncio.Lock()
|
|
|
+ self._inflight_tasks: dict[str, asyncio.Task] = {}
|
|
|
+ self._latest_payloads: dict[str, tuple[float, dict]] = {}
|
|
|
+ self._cache_ttl_s = 1.5
|
|
|
+ self._tool_timeout_s = 8
|
|
|
+
|
|
|
+ async def _resolve_conn(self, request):
|
|
|
+ device_id = request.query.get("device_id")
|
|
|
+ conn = await self.ws_server.get_connection(device_id=device_id)
|
|
|
+ if conn is None:
|
|
|
+ return None, "no_active_device"
|
|
|
+ if not hasattr(conn, "mcp_client") or conn.mcp_client is None:
|
|
|
+ return None, "device_mcp_not_initialized"
|
|
|
+ if not await conn.mcp_client.is_ready():
|
|
|
+ return None, "device_mcp_not_ready"
|
|
|
+ tool_name = _pick_gnss_tool_name(conn)
|
|
|
+ if not tool_name:
|
|
|
+ return None, "device_gnss_tool_not_found"
|
|
|
+ return (conn, tool_name), None
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _device_key(conn) -> str:
|
|
|
+ return str(getattr(conn, "device_id", "") or "default")
|
|
|
+
|
|
|
+ async def _fetch_fix_payload(self, conn, tool_name: str) -> dict:
|
|
|
+ raw = await call_mcp_tool(
|
|
|
+ conn,
|
|
|
+ conn.mcp_client,
|
|
|
+ tool_name,
|
|
|
+ "{}",
|
|
|
+ timeout=self._tool_timeout_s,
|
|
|
+ )
|
|
|
+
|
|
|
+ fix = _parse_fix(raw)
|
|
|
+ if not fix.ok:
|
|
|
+ return {
|
|
|
+ "ok": False,
|
|
|
+ "device_id": getattr(conn, "device_id", None),
|
|
|
+ "reason": "no_fix",
|
|
|
+ }
|
|
|
+
|
|
|
+ payload = {
|
|
|
+ "ok": True,
|
|
|
+ "device_id": getattr(conn, "device_id", None),
|
|
|
+ "lat": fix.lat,
|
|
|
+ "lon": fix.lon,
|
|
|
+ "speed_mps": fix.speed_mps,
|
|
|
+ "heading_deg": fix.heading_deg,
|
|
|
+ "timestamp": fix.t,
|
|
|
+ "source": "device_mcp_gnss",
|
|
|
+ }
|
|
|
+ self._latest_payloads[self._device_key(conn)] = (time.monotonic(), payload)
|
|
|
+ return payload
|
|
|
+
|
|
|
+ async def _get_or_start_inflight(self, device_key: str, conn, tool_name: str):
|
|
|
+ async with self._inflight_lock:
|
|
|
+ task = self._inflight_tasks.get(device_key)
|
|
|
+ if task is None or task.done():
|
|
|
+ task = asyncio.create_task(self._fetch_fix_payload(conn, tool_name))
|
|
|
+ self._inflight_tasks[device_key] = task
|
|
|
+ return task
|
|
|
+
|
|
|
+ async def _get_fix_payload(self, request):
|
|
|
+ resolved, error_code = await self._resolve_conn(request)
|
|
|
+ if error_code:
|
|
|
+ return None, error_code
|
|
|
+
|
|
|
+ conn, tool_name = resolved
|
|
|
+ device_key = self._device_key(conn)
|
|
|
+
|
|
|
+ cached = self._latest_payloads.get(device_key)
|
|
|
+ now = time.monotonic()
|
|
|
+ if cached and (now - cached[0]) <= self._cache_ttl_s:
|
|
|
+ return cached[1], None
|
|
|
+
|
|
|
+ try:
|
|
|
+ task = await self._get_or_start_inflight(device_key, conn, tool_name)
|
|
|
+ payload = await task
|
|
|
+ return payload, None
|
|
|
+ except Exception as exc:
|
|
|
+ self.logger.bind(tag=TAG).warning(f"app gps tool call failed: {exc}")
|
|
|
+ cached = self._latest_payloads.get(device_key)
|
|
|
+ if cached:
|
|
|
+ stale_payload = dict(cached[1])
|
|
|
+ stale_payload["stale"] = True
|
|
|
+ return stale_payload, None
|
|
|
+ return None, "device_gnss_call_failed"
|
|
|
+ finally:
|
|
|
+ async with self._inflight_lock:
|
|
|
+ task = self._inflight_tasks.get(device_key)
|
|
|
+ if task is not None and task.done():
|
|
|
+ self._inflight_tasks.pop(device_key, None)
|
|
|
+
|
|
|
+ def _json_response(self, payload: dict, status: int = 200):
|
|
|
+ response = web.json_response(payload, status=status)
|
|
|
+ self._add_cors_headers(response)
|
|
|
+ return response
|
|
|
+
|
|
|
+ async def handle_get_latest(self, request):
|
|
|
+ payload, error_code = await self._get_fix_payload(request)
|
|
|
+ if error_code:
|
|
|
+ return self._json_response(
|
|
|
+ {
|
|
|
+ "ok": False,
|
|
|
+ "error": error_code,
|
|
|
+ "active_devices": await self.ws_server.list_active_device_ids(),
|
|
|
+ },
|
|
|
+ status=503,
|
|
|
+ )
|
|
|
+ return self._json_response(payload)
|
|
|
+
|
|
|
+ async def handle_ws(self, request):
|
|
|
+ ws = web.WebSocketResponse(heartbeat=30)
|
|
|
+ await ws.prepare(request)
|
|
|
+
|
|
|
+ interval_s = 1.0
|
|
|
+ interval_arg = request.query.get("interval")
|
|
|
+ if interval_arg:
|
|
|
+ try:
|
|
|
+ interval_s = max(0.2, float(interval_arg))
|
|
|
+ except ValueError:
|
|
|
+ interval_s = 1.0
|
|
|
+
|
|
|
+ try:
|
|
|
+ while True:
|
|
|
+ payload, error_code = await self._get_fix_payload(request)
|
|
|
+ if error_code:
|
|
|
+ await ws.send_json(
|
|
|
+ {
|
|
|
+ "ok": False,
|
|
|
+ "error": error_code,
|
|
|
+ "active_devices": await self.ws_server.list_active_device_ids(),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ await ws.send_json(payload)
|
|
|
+ await asyncio.sleep(interval_s)
|
|
|
+ except asyncio.CancelledError:
|
|
|
+ raise
|
|
|
+ except Exception as exc:
|
|
|
+ self.logger.bind(tag=TAG).warning(f"app gps websocket closed: {exc}")
|
|
|
+ finally:
|
|
|
+ await ws.close()
|
|
|
+
|
|
|
+ return ws
|