service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  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, List, Optional, Set, Tuple
  8. from config.logger import setup_logging
  9. from core.providers.tts.dto.dto import ContentType
  10. from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
  11. from .geo import haversine_m, nearest_point_index
  12. from .storage import Hazard, NavDB, RoutePoint
  13. TAG = __name__
  14. logger = setup_logging()
  15. _HAZARD_LABELS: Dict[str, str] = {
  16. "sharp_turn": "急转弯",
  17. "bump": "大坑/起伏",
  18. "jump": "跳台",
  19. "slope": "陡坡",
  20. "water": "涉水",
  21. "obstacle": "障碍",
  22. "other": "风险点",
  23. }
  24. _HAZARD_SYNONYMS: Dict[str, str] = {
  25. "急转弯": "sharp_turn",
  26. "急弯": "sharp_turn",
  27. "弯": "sharp_turn",
  28. "大坑": "bump",
  29. "坑": "bump",
  30. "颠簸": "bump",
  31. "跳台": "jump",
  32. "陡坡": "slope",
  33. "涉水": "water",
  34. "水": "water",
  35. "障碍": "obstacle",
  36. }
  37. @dataclass
  38. class GnssFix:
  39. ok: bool
  40. lat: float
  41. lon: float
  42. speed_mps: float
  43. heading_deg: Optional[float] = None
  44. t: Optional[int] = None
  45. def _parse_fix(raw: Any) -> GnssFix:
  46. if raw is None:
  47. return GnssFix(False, 0.0, 0.0, 0.0)
  48. if isinstance(raw, str):
  49. raw = raw.strip()
  50. if not raw:
  51. return GnssFix(False, 0.0, 0.0, 0.0)
  52. try:
  53. raw = json.loads(raw)
  54. except Exception:
  55. return GnssFix(False, 0.0, 0.0, 0.0)
  56. if not isinstance(raw, dict):
  57. return GnssFix(False, 0.0, 0.0, 0.0)
  58. # Device firmware may wrap fields in {"gnss": {...}}
  59. if "gnss" in raw and isinstance(raw.get("gnss"), dict):
  60. raw = raw["gnss"]
  61. ok = bool(raw.get("ok", True))
  62. has_fix = raw.get("has_fix")
  63. if has_fix is not None:
  64. ok = ok and bool(has_fix)
  65. lat = raw.get("lat")
  66. lon = raw.get("lon")
  67. if lat is None or lon is None:
  68. lat = raw.get("latitude")
  69. lon = raw.get("longitude")
  70. try:
  71. lat_f = float(lat)
  72. lon_f = float(lon)
  73. except Exception:
  74. return GnssFix(False, 0.0, 0.0, 0.0)
  75. speed_mps = raw.get("speed_mps")
  76. if speed_mps is None:
  77. speed_knots = raw.get("speed_knots")
  78. if speed_knots is not None:
  79. try:
  80. speed_mps = float(speed_knots) * 0.514444
  81. except Exception:
  82. speed_mps = 0.0
  83. else:
  84. speed_kmh = raw.get("speed_kmh")
  85. if speed_kmh is not None:
  86. try:
  87. speed_mps = float(speed_kmh) / 3.6
  88. except Exception:
  89. speed_mps = 0.0
  90. else:
  91. speed_mps = raw.get("speed") or 0.0
  92. try:
  93. speed_mps_f = float(speed_mps)
  94. except Exception:
  95. speed_mps_f = 0.0
  96. heading = raw.get("heading_deg")
  97. if heading is None:
  98. heading = raw.get("course_deg")
  99. heading_f = None
  100. if heading is not None:
  101. try:
  102. heading_f = float(heading)
  103. except Exception:
  104. heading_f = None
  105. t = raw.get("t") or raw.get("timestamp") or raw.get("time")
  106. t_i = None
  107. if t is not None:
  108. try:
  109. t_i = int(t)
  110. except Exception:
  111. t_i = None
  112. # Optional: fix quality
  113. fix = raw.get("fix") or raw.get("fix_quality") or raw.get("quality")
  114. if fix is not None:
  115. try:
  116. ok = ok and int(fix) > 0
  117. except Exception:
  118. pass
  119. return GnssFix(ok, lat_f, lon_f, max(0.0, speed_mps_f), heading_f, t_i)
  120. def _pick_gnss_tool_name(conn) -> Optional[str]:
  121. if not hasattr(conn, "mcp_client") or not conn.mcp_client:
  122. return None
  123. # Prefer sanitized name (server side sanitizes tool names).
  124. preferred = "self_gnss_get_fix"
  125. if conn.mcp_client.has_tool(preferred):
  126. return preferred
  127. # Fallback heuristic
  128. for name in conn.mcp_client.tools.keys():
  129. if "gnss" in name.lower() and "fix" in name.lower():
  130. return name
  131. return None
  132. class NavCopilotService:
  133. def __init__(self, conn, db: NavDB):
  134. self.conn = conn
  135. self.db = db
  136. self.record_task: Optional[asyncio.Task] = None
  137. self.record_route_id: Optional[int] = None
  138. self._record_seq = 0
  139. self._record_cum_m = 0.0
  140. self._record_last_latlon: Optional[Tuple[float, float]] = None
  141. self._record_last_t = 0
  142. self._record_interval_s = 1.0
  143. self.run_task: Optional[asyncio.Task] = None
  144. self.run_route_id: Optional[int] = None
  145. self._run_interval_s = 0.2
  146. self._run_points: List[RoutePoint] = []
  147. self._run_hazards: List[Hazard] = []
  148. self._run_last_idx: Optional[int] = None
  149. self._run_announced: Set[int] = set()
  150. self._run_last_announce_at = 0.0
  151. self.announce_at_m = 50.0
  152. self.announce_window_m = 18.0
  153. self.announce_cooldown_s = 5.0
  154. async def _get_fix(self) -> GnssFix:
  155. tool_name = _pick_gnss_tool_name(self.conn)
  156. if not tool_name:
  157. return GnssFix(False, 0.0, 0.0, 0.0)
  158. try:
  159. raw = await call_mcp_tool(self.conn, self.conn.mcp_client, tool_name, "{}")
  160. return _parse_fix(raw)
  161. except Exception as e:
  162. logger.bind(tag=TAG).warning(f"GNSS fix failed: {e}")
  163. return GnssFix(False, 0.0, 0.0, 0.0)
  164. def _speak(self, text: str) -> None:
  165. try:
  166. self.conn.tts.tts_one_sentence(self.conn, ContentType.TEXT, content_detail=text)
  167. except Exception as e:
  168. logger.bind(tag=TAG).warning(f"TTS speak failed: {e}")
  169. async def start_recording(self, route_name: Optional[str] = None, sample_hz: int = 1) -> int:
  170. if self.record_task and not self.record_task.done():
  171. raise RuntimeError("已在录制中")
  172. if self.run_task and not self.run_task.done():
  173. raise RuntimeError("正在领航中,不能开始录制")
  174. if not _pick_gnss_tool_name(self.conn):
  175. raise RuntimeError("车载端暂未提供定位数据接口,请先在设备端启用定位数据上报。")
  176. name = (route_name or "").strip() or f"路线 {time.strftime('%Y-%m-%d %H:%M:%S')}"
  177. route_id = self.db.create_route(name)
  178. self.record_route_id = route_id
  179. self._record_seq = 0
  180. self._record_cum_m = 0.0
  181. self._record_last_latlon = None
  182. self._record_last_t = 0
  183. self._record_interval_s = 1.0 / max(1, int(sample_hz))
  184. self.record_task = asyncio.create_task(self._record_loop(), name=f"NavRecord:{route_id}")
  185. return route_id
  186. async def stop_recording(self) -> Optional[int]:
  187. rid = self.record_route_id
  188. if self.record_task and not self.record_task.done():
  189. self.record_task.cancel()
  190. try:
  191. await self.record_task
  192. except BaseException:
  193. pass
  194. self.record_task = None
  195. self.record_route_id = None
  196. if rid is not None:
  197. self.db.finish_route(rid)
  198. return rid
  199. async def _record_loop(self) -> None:
  200. assert self.record_route_id is not None
  201. route_id = self.record_route_id
  202. logger.bind(tag=TAG).info(f"Nav record loop started, route_id={route_id}")
  203. try:
  204. while not self.conn.stop_event.is_set():
  205. fix = await self._get_fix()
  206. now_t = int(fix.t or time.time())
  207. if fix.ok:
  208. latlon = (fix.lat, fix.lon)
  209. should_add = False
  210. if self._record_last_latlon is None:
  211. should_add = True
  212. else:
  213. moved_m = haversine_m(
  214. self._record_last_latlon[0],
  215. self._record_last_latlon[1],
  216. latlon[0],
  217. latlon[1],
  218. )
  219. if moved_m >= 1.0:
  220. should_add = True
  221. if (now_t - self._record_last_t) >= 2:
  222. should_add = True
  223. if should_add:
  224. if self._record_last_latlon is not None:
  225. self._record_cum_m += haversine_m(
  226. self._record_last_latlon[0],
  227. self._record_last_latlon[1],
  228. latlon[0],
  229. latlon[1],
  230. )
  231. self._record_last_latlon = latlon
  232. self._record_last_t = now_t
  233. self._record_seq += 1
  234. self.db.add_point(
  235. route_id=route_id,
  236. seq=self._record_seq,
  237. t=now_t,
  238. lat=latlon[0],
  239. lon=latlon[1],
  240. speed_mps=fix.speed_mps,
  241. cum_dist_m=self._record_cum_m,
  242. )
  243. await asyncio.sleep(self._record_interval_s)
  244. finally:
  245. if self.record_route_id == route_id:
  246. try:
  247. self.db.finish_route(route_id)
  248. except Exception:
  249. pass
  250. self.record_route_id = None
  251. self.record_task = None
  252. async def mark_hazard(self, hazard_type: str, note: str = "") -> int:
  253. hazard_type = (hazard_type or "").strip() or "other"
  254. if hazard_type in _HAZARD_SYNONYMS:
  255. hazard_type = _HAZARD_SYNONYMS[hazard_type]
  256. if hazard_type not in _HAZARD_LABELS:
  257. hazard_type = "other"
  258. route_id = self.record_route_id or self.run_route_id or self.db.get_active_route_id()
  259. if not route_id:
  260. raise RuntimeError("没有可用路线:先说“开始录制路线”")
  261. if not _pick_gnss_tool_name(self.conn):
  262. raise RuntimeError("车载端暂未提供定位数据接口,无法读取当前位置。")
  263. fix = await self._get_fix()
  264. if not fix.ok:
  265. raise RuntimeError("当前没有有效GPS定位,标记失败")
  266. now_t = int(fix.t or time.time())
  267. # Prefer current recording cum if we are recording
  268. if self.record_route_id == route_id and self._record_last_latlon is not None:
  269. seq = self._record_seq
  270. cum = self._record_cum_m
  271. else:
  272. points = self.db.load_route_points(route_id)
  273. if not points:
  274. raise RuntimeError("路线还没有轨迹点")
  275. idx, _ = nearest_point_index([(p.lat, p.lon) for p in points], fix.lat, fix.lon)
  276. seq = points[idx].seq
  277. cum = points[idx].cum_dist_m
  278. hid = self.db.add_hazard(
  279. route_id=route_id,
  280. seq=seq,
  281. t=now_t,
  282. lat=fix.lat,
  283. lon=fix.lon,
  284. cum_dist_m=cum,
  285. hazard_type=hazard_type,
  286. note=note or "",
  287. )
  288. # If we're running on this route, refresh hazards in memory
  289. if self.run_route_id == route_id and self.run_task and not self.run_task.done():
  290. self._run_hazards = self.db.load_hazards(route_id)
  291. return hid
  292. async def start_run(self, route_id: Optional[int] = None, tick_hz: int = 5) -> int:
  293. if self.run_task and not self.run_task.done():
  294. raise RuntimeError("已在领航中")
  295. if self.record_task and not self.record_task.done():
  296. raise RuntimeError("正在录制中,不能开始领航")
  297. if not _pick_gnss_tool_name(self.conn):
  298. raise RuntimeError("车载端暂未提供定位数据接口,请先在设备端启用定位数据上报。")
  299. rid = route_id or self.db.get_active_route_id()
  300. if not rid:
  301. raise RuntimeError("没有可用路线:先说“开始录制路线”并跑一遍")
  302. points = self.db.load_route_points(rid)
  303. hazards = self.db.load_hazards(rid)
  304. if len(points) < 2:
  305. raise RuntimeError("路线点太少,无法领航")
  306. self.run_route_id = rid
  307. self._run_points = points
  308. self._run_hazards = hazards
  309. self._run_last_idx = None
  310. self._run_announced = set()
  311. self._run_last_announce_at = 0.0
  312. self._run_interval_s = 1.0 / max(1, int(tick_hz))
  313. self.run_task = asyncio.create_task(self._run_loop(), name=f"NavRun:{rid}")
  314. return rid
  315. async def stop_run(self) -> Optional[int]:
  316. rid = self.run_route_id
  317. if self.run_task and not self.run_task.done():
  318. self.run_task.cancel()
  319. try:
  320. await self.run_task
  321. except BaseException:
  322. pass
  323. self.run_task = None
  324. self.run_route_id = None
  325. self._run_points = []
  326. self._run_hazards = []
  327. self._run_last_idx = None
  328. self._run_announced = set()
  329. return rid
  330. async def _run_loop(self) -> None:
  331. assert self.run_route_id is not None
  332. rid = self.run_route_id
  333. logger.bind(tag=TAG).info(f"Nav run loop started, route_id={rid}")
  334. points = self._run_points
  335. latlon_points = [(p.lat, p.lon) for p in points]
  336. try:
  337. while not self.conn.stop_event.is_set():
  338. fix = await self._get_fix()
  339. if not fix.ok:
  340. await asyncio.sleep(1.0)
  341. continue
  342. idx, _d = nearest_point_index(
  343. latlon_points,
  344. fix.lat,
  345. fix.lon,
  346. start_idx=self._run_last_idx,
  347. window=120,
  348. )
  349. self._run_last_idx = idx
  350. cur_cum = points[idx].cum_dist_m
  351. # Find next hazard ahead
  352. next_h: Optional[Hazard] = None
  353. next_dist = None
  354. for h in self._run_hazards:
  355. if h.id in self._run_announced:
  356. continue
  357. d = h.cum_dist_m - cur_cum
  358. if d < -15:
  359. # already passed
  360. self._run_announced.add(h.id)
  361. continue
  362. if d < 0:
  363. continue
  364. if next_dist is None or d < next_dist:
  365. next_h = h
  366. next_dist = d
  367. if next_h and next_dist is not None:
  368. now = time.time()
  369. if now - self._run_last_announce_at >= self.announce_cooldown_s:
  370. if (self.announce_at_m - self.announce_window_m) <= next_dist <= self.announce_at_m:
  371. speed_mps = max(0.5, float(fix.speed_mps or 0.0))
  372. speed_kmh = speed_mps * 3.6
  373. eta_s = int(round(next_dist / speed_mps))
  374. label = _HAZARD_LABELS.get(next_h.type, _HAZARD_LABELS["other"])
  375. dist_i = int(round(next_dist))
  376. speed_i = int(round(speed_kmh))
  377. text = f"注意前方{dist_i}米{label},当前{speed_i}公里每小时,预计{eta_s}秒抵达。"
  378. if next_h.note:
  379. text = f"{text}{next_h.note}"
  380. self._speak(text)
  381. self._run_last_announce_at = now
  382. self._run_announced.add(next_h.id)
  383. await asyncio.sleep(self._run_interval_s)
  384. finally:
  385. if self.run_route_id == rid:
  386. self.run_route_id = None
  387. self.run_task = None
  388. self._run_points = []
  389. self._run_hazards = []
  390. self._run_last_idx = None
  391. self._run_announced = set()
  392. def status_text(self) -> str:
  393. parts = []
  394. if self.record_route_id:
  395. parts.append(f"正在录制路线ID={self.record_route_id},点数={self._record_seq}")
  396. if self.run_route_id:
  397. parts.append(
  398. f"正在领航路线ID={self.run_route_id},障碍点={len(self._run_hazards)}"
  399. )
  400. if not parts:
  401. rid = self.db.get_active_route_id()
  402. if rid:
  403. parts.append(f"当前激活路线ID={rid}")
  404. else:
  405. parts.append("暂无路线,先说“开始录制路线”")
  406. return ";".join(parts)
  407. def get_service(conn) -> NavCopilotService:
  408. if not hasattr(conn, "nav_copilot_service") or conn.nav_copilot_service is None:
  409. conn.nav_copilot_service = NavCopilotService(conn, NavDB.default())
  410. return conn.nav_copilot_service