service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. import time
  5. import uuid
  6. from dataclasses import dataclass
  7. from typing import Any, Dict, Optional, Tuple
  8. from config.logger import setup_logging
  9. from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
  10. from core.providers.tts.dto.dto import ContentType, SentenceType, TTSMessageDTO
  11. from .geo import (
  12. convert_from_wgs84,
  13. convert_to_wgs84,
  14. crosses_alert_line,
  15. haversine_m,
  16. normalize_coord_type,
  17. )
  18. from .storage import LineAlert, NavDB
  19. TAG = __name__
  20. logger = setup_logging()
  21. MONITOR_TICK_HZ = 5
  22. MIN_MOVEMENT_M = 1.5
  23. LINE_BUFFER_M = 2.5
  24. TRIGGER_COOLDOWN_S = 15.0
  25. NOTE_MAX_LEN = 120
  26. ALERT_ECHO_TTL_S = 12.0
  27. @dataclass
  28. class GnssFix:
  29. ok: bool
  30. lat: float
  31. lon: float
  32. speed_mps: float
  33. heading_deg: Optional[float] = None
  34. t: Optional[int] = None
  35. def _parse_jsonish(raw: Any) -> Optional[dict]:
  36. if raw is None:
  37. return None
  38. if isinstance(raw, str):
  39. raw = raw.strip()
  40. if not raw:
  41. return None
  42. try:
  43. raw = json.loads(raw)
  44. except Exception:
  45. return None
  46. if not isinstance(raw, dict):
  47. return None
  48. return raw
  49. def _parse_fix(raw: Any) -> GnssFix:
  50. payload = _parse_jsonish(raw)
  51. if payload is None:
  52. return GnssFix(False, 0.0, 0.0, 0.0)
  53. if "gnss" in payload and isinstance(payload.get("gnss"), dict):
  54. payload = payload["gnss"]
  55. try:
  56. coord_type = normalize_coord_type(payload.get("coord_type"), default="WGS84")
  57. except ValueError:
  58. return GnssFix(False, 0.0, 0.0, 0.0)
  59. ok = bool(payload.get("ok", True))
  60. has_fix = payload.get("has_fix")
  61. if has_fix is not None:
  62. ok = ok and bool(has_fix)
  63. lat = payload.get("lat", payload.get("latitude"))
  64. lon = payload.get("lon", payload.get("longitude"))
  65. try:
  66. lat_f = float(lat)
  67. lon_f = float(lon)
  68. lat_f, lon_f = convert_to_wgs84(lat_f, lon_f, coord_type)
  69. except Exception:
  70. return GnssFix(False, 0.0, 0.0, 0.0)
  71. speed_mps = payload.get("speed_mps")
  72. if speed_mps is None:
  73. speed_knots = payload.get("speed_knots")
  74. if speed_knots is not None:
  75. try:
  76. speed_mps = float(speed_knots) * 0.514444
  77. except Exception:
  78. speed_mps = 0.0
  79. else:
  80. speed_kmh = payload.get("speed_kmh")
  81. if speed_kmh is not None:
  82. try:
  83. speed_mps = float(speed_kmh) / 3.6
  84. except Exception:
  85. speed_mps = 0.0
  86. else:
  87. speed_mps = payload.get("speed") or 0.0
  88. try:
  89. speed_mps_f = float(speed_mps)
  90. except Exception:
  91. speed_mps_f = 0.0
  92. heading = payload.get("heading_deg", payload.get("course_deg"))
  93. heading_f = None
  94. if heading is not None:
  95. try:
  96. heading_f = float(heading)
  97. except Exception:
  98. heading_f = None
  99. t = payload.get("t") or payload.get("timestamp") or payload.get("time")
  100. t_i = None
  101. if t is not None:
  102. try:
  103. t_i = int(float(t))
  104. except Exception:
  105. t_i = None
  106. fix = payload.get("fix") or payload.get("fix_quality") or payload.get("quality")
  107. if fix is not None:
  108. try:
  109. ok = ok and int(fix) > 0
  110. except Exception:
  111. pass
  112. return GnssFix(ok, lat_f, lon_f, max(0.0, speed_mps_f), heading_f, t_i)
  113. def _pick_tool_name(conn, preferred: str, keywords: Tuple[str, ...]) -> Optional[str]:
  114. if not hasattr(conn, "mcp_client") or not conn.mcp_client:
  115. return None
  116. if conn.mcp_client.has_tool(preferred):
  117. return preferred
  118. for name in conn.mcp_client.tools.keys():
  119. lowered = name.lower()
  120. if all(keyword in lowered for keyword in keywords):
  121. return name
  122. return None
  123. def _pick_gnss_tool_name(conn) -> Optional[str]:
  124. return _pick_tool_name(conn, "self_gnss_get_fix", ("gnss", "fix"))
  125. def normalize_note(note: str) -> str:
  126. normalized = " ".join((note or "").split()).strip()
  127. if not normalized:
  128. raise ValueError("note_required")
  129. if len(normalized) > NOTE_MAX_LEN:
  130. raise ValueError("note_too_long")
  131. return normalized
  132. def normalize_latlon(lat: Any, lon: Any) -> Tuple[float, float]:
  133. lat_f = float(lat)
  134. lon_f = float(lon)
  135. if not (-90.0 <= lat_f <= 90.0 and -180.0 <= lon_f <= 180.0):
  136. raise ValueError("invalid_coordinate")
  137. return lat_f, lon_f
  138. class NavCopilotService:
  139. def __init__(self, conn, db: NavDB):
  140. self.conn = conn
  141. self.db = db
  142. self.monitor_task: Optional[asyncio.Task] = None
  143. self._shared_gps_cache: Optional[Dict[str, Any]] = None
  144. self._last_fix_wgs84: Optional[Tuple[float, float]] = None
  145. self._last_trigger_at: Dict[int, float] = {}
  146. def device_id(self) -> str:
  147. return str(getattr(self.conn, "device_id", "") or "")
  148. async def recover_monitoring_if_needed(self) -> Optional[int]:
  149. device_id = self.device_id()
  150. if not device_id:
  151. return None
  152. count = self.db.count_line_alerts(device_id)
  153. if count <= 0:
  154. return None
  155. await self.ensure_monitoring()
  156. return count
  157. def _remember_spoken_alert(self, text: str) -> None:
  158. if not text:
  159. return
  160. now = time.monotonic()
  161. recent = getattr(self.conn, "_recent_nav_alert_notes", None)
  162. if not isinstance(recent, dict):
  163. recent = {}
  164. recent[text] = now
  165. expired = [
  166. note
  167. for note, spoken_at in recent.items()
  168. if now - float(spoken_at) > ALERT_ECHO_TTL_S
  169. ]
  170. for note in expired:
  171. recent.pop(note, None)
  172. self.conn._recent_nav_alert_notes = recent
  173. def _speak(self, text: str) -> None:
  174. text = str(text or "").strip()
  175. if not text:
  176. return
  177. try:
  178. self.conn.client_abort = True
  179. self.conn.clear_queues()
  180. self.conn.clearSpeakStatus()
  181. self.conn.client_abort = False
  182. self.conn.sentence_id = uuid.uuid4().hex
  183. self.conn.tts.store_tts_text(self.conn.sentence_id, text)
  184. self.conn.tts.tts_text_queue.put(
  185. TTSMessageDTO(
  186. sentence_id=self.conn.sentence_id,
  187. sentence_type=SentenceType.FIRST,
  188. content_type=ContentType.ACTION,
  189. )
  190. )
  191. self.conn.tts.tts_one_sentence(
  192. self.conn,
  193. ContentType.TEXT,
  194. content_detail=text,
  195. )
  196. self.conn.tts.tts_text_queue.put(
  197. TTSMessageDTO(
  198. sentence_id=self.conn.sentence_id,
  199. sentence_type=SentenceType.LAST,
  200. content_type=ContentType.ACTION,
  201. )
  202. )
  203. self._remember_spoken_alert(text)
  204. except Exception as exc:
  205. logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
  206. async def _get_fix(self) -> GnssFix:
  207. server = getattr(self.conn, "server", None)
  208. device_id = getattr(self.conn, "device_id", None)
  209. if server is not None and device_id:
  210. try:
  211. payload, _ = await server.get_latest_gps(
  212. str(device_id), max_age_s=3.0, allow_stale=False
  213. )
  214. if payload is not None:
  215. return _parse_fix(payload)
  216. except Exception as exc:
  217. logger.bind(tag=TAG).debug(f"read live telemetry gps failed: {exc}")
  218. tool_name = _pick_gnss_tool_name(self.conn)
  219. if not tool_name:
  220. return GnssFix(False, 0.0, 0.0, 0.0)
  221. try:
  222. raw = await call_mcp_tool(
  223. self.conn,
  224. self.conn.mcp_client,
  225. tool_name,
  226. "{}",
  227. timeout=5,
  228. )
  229. return _parse_fix(raw)
  230. except Exception as exc:
  231. logger.bind(tag=TAG).warning(f"GNSS fix failed: {exc}")
  232. return GnssFix(False, 0.0, 0.0, 0.0)
  233. def update_shared_gps(self, fix: GnssFix) -> None:
  234. if not fix.ok:
  235. return
  236. self._shared_gps_cache = {
  237. "ok": True,
  238. "device_id": getattr(self.conn, "device_id", None),
  239. "lat": fix.lat,
  240. "lon": fix.lon,
  241. "speed_mps": fix.speed_mps,
  242. "heading_deg": fix.heading_deg,
  243. "timestamp": fix.t,
  244. "coord_type": "WGS84",
  245. "source": "nav_copilot_shared",
  246. }
  247. def get_cached_gps(self) -> Optional[Dict[str, Any]]:
  248. return self._shared_gps_cache
  249. def is_monitoring_active(self) -> bool:
  250. return self.monitor_task is not None and not self.monitor_task.done()
  251. def list_line_alerts(self, device_id: str) -> list[LineAlert]:
  252. return self.db.list_line_alerts(device_id)
  253. async def create_line_alert(
  254. self,
  255. *,
  256. device_id: str,
  257. start_lat: float,
  258. start_lon: float,
  259. end_lat: float,
  260. end_lon: float,
  261. coord_type: str = "BD09",
  262. note: str,
  263. ) -> LineAlert:
  264. start_lat, start_lon = normalize_latlon(start_lat, start_lon)
  265. end_lat, end_lon = normalize_latlon(end_lat, end_lon)
  266. coord_type = normalize_coord_type(coord_type, default="BD09")
  267. note = normalize_note(note)
  268. if haversine_m(start_lat, start_lon, end_lat, end_lon) < 1.0:
  269. raise ValueError("line_too_short")
  270. alert_id = self.db.create_line_alert(
  271. device_id=device_id,
  272. start_lat=start_lat,
  273. start_lon=start_lon,
  274. end_lat=end_lat,
  275. end_lon=end_lon,
  276. coord_type=coord_type,
  277. note=note,
  278. )
  279. alert = self.db.get_line_alert(alert_id, device_id=device_id)
  280. assert alert is not None
  281. if device_id == self.device_id():
  282. await self.ensure_monitoring()
  283. return alert
  284. async def delete_line_alert(self, device_id: str, alert_id: int) -> bool:
  285. deleted = self.db.delete_line_alert(alert_id, device_id=device_id)
  286. if deleted and device_id == self.device_id() and self.db.count_line_alerts(device_id) <= 0:
  287. await self.stop_monitoring()
  288. return deleted
  289. async def ensure_monitoring(self) -> bool:
  290. device_id = self.device_id()
  291. if not device_id:
  292. return False
  293. if self.db.count_line_alerts(device_id) <= 0:
  294. return False
  295. if self.is_monitoring_active():
  296. return True
  297. self.monitor_task = asyncio.create_task(
  298. self._monitor_loop(),
  299. name=f"LineAlertMonitor:{device_id}",
  300. )
  301. return True
  302. async def stop_monitoring(self) -> None:
  303. task = self.monitor_task
  304. if task is not None and not task.done():
  305. task.cancel()
  306. try:
  307. await task
  308. except BaseException:
  309. pass
  310. self.monitor_task = None
  311. self._last_fix_wgs84 = None
  312. self._last_trigger_at = {}
  313. async def _monitor_loop(self) -> None:
  314. device_id = self.device_id()
  315. logger.bind(tag=TAG).info(f"Line alert monitor started: device_id={device_id}")
  316. try:
  317. while not self.conn.stop_event.is_set():
  318. if self.db.count_line_alerts(device_id) <= 0:
  319. break
  320. fix = await self._get_fix()
  321. if not fix.ok:
  322. await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
  323. continue
  324. self.update_shared_gps(fix)
  325. current_fix_wgs84 = (fix.lat, fix.lon)
  326. last_fix_wgs84 = self._last_fix_wgs84
  327. self._last_fix_wgs84 = current_fix_wgs84
  328. if last_fix_wgs84 is None:
  329. await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
  330. continue
  331. movement_m = haversine_m(
  332. last_fix_wgs84[0],
  333. last_fix_wgs84[1],
  334. current_fix_wgs84[0],
  335. current_fix_wgs84[1],
  336. )
  337. if movement_m < MIN_MOVEMENT_M:
  338. await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
  339. continue
  340. for alert in self.db.list_line_alerts(device_id):
  341. prev_alert_lat, prev_alert_lon = convert_from_wgs84(
  342. last_fix_wgs84[0],
  343. last_fix_wgs84[1],
  344. alert.coord_type,
  345. )
  346. curr_alert_lat, curr_alert_lon = convert_from_wgs84(
  347. current_fix_wgs84[0],
  348. current_fix_wgs84[1],
  349. alert.coord_type,
  350. )
  351. if not crosses_alert_line(
  352. prev_alert_lat,
  353. prev_alert_lon,
  354. curr_alert_lat,
  355. curr_alert_lon,
  356. alert.start_lat,
  357. alert.start_lon,
  358. alert.end_lat,
  359. alert.end_lon,
  360. buffer_m=LINE_BUFFER_M,
  361. ):
  362. continue
  363. now = time.monotonic()
  364. last_trigger_at = self._last_trigger_at.get(alert.id, 0.0)
  365. if now - last_trigger_at < TRIGGER_COOLDOWN_S:
  366. continue
  367. logger.bind(tag=TAG).info(
  368. "Line alert triggered: "
  369. f"device_id={device_id}, alert_id={alert.id}, note={alert.note!r}"
  370. )
  371. self._speak(alert.note)
  372. self._last_trigger_at[alert.id] = now
  373. await asyncio.sleep(1.0 / MONITOR_TICK_HZ)
  374. except asyncio.CancelledError:
  375. raise
  376. finally:
  377. logger.bind(tag=TAG).info(f"Line alert monitor stopped: device_id={device_id}")
  378. self.monitor_task = None
  379. self._last_fix_wgs84 = None
  380. self._last_trigger_at = {}
  381. def status_text(self) -> str:
  382. device_id = self.device_id()
  383. if not device_id:
  384. return "当前没有可用设备连接。"
  385. count = self.db.count_line_alerts(device_id)
  386. if count <= 0:
  387. return "当前没有通过手机 App 配置的风险线。"
  388. if self.is_monitoring_active():
  389. return f"当前设备已启用风险线预警,数量 {count}。"
  390. return f"当前设备已有风险线 {count} 条,等待开始监控。"
  391. def get_service(conn) -> NavCopilotService:
  392. if not hasattr(conn, "nav_copilot_service") or conn.nav_copilot_service is None:
  393. conn.nav_copilot_service = NavCopilotService(conn, NavDB.default())
  394. return conn.nav_copilot_service