service.py 16 KB

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