service.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  1. from __future__ import annotations
  2. import asyncio
  3. import json
  4. import time
  5. from dataclasses import dataclass
  6. from typing import Any, Dict, List, Optional, Set, Tuple
  7. from config.logger import setup_logging
  8. from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
  9. from core.providers.tts.dto.dto import ContentType
  10. from .geo import haversine_m, nearest_point_index
  11. from .storage import Hazard, NavDB, RoutePoint
  12. TAG = __name__
  13. logger = setup_logging()
  14. COUNTER_MAX_VALUE = 256
  15. COUNTER_WRAP_THRESHOLD = 128
  16. _HAZARD_LABELS: Dict[str, str] = {
  17. "sharp_turn": "急转弯",
  18. "bump": "大坑",
  19. "jump": "跳台",
  20. "slope": "陡坡",
  21. "water": "涉水",
  22. "obstacle": "障碍",
  23. "other": "风险点",
  24. }
  25. _HAZARD_SYNONYMS: Dict[str, str] = {
  26. "急转弯": "sharp_turn",
  27. "急弯": "sharp_turn",
  28. "大坑": "bump",
  29. "坑": "bump",
  30. "跳台": "jump",
  31. "陡坡": "slope",
  32. "涉水": "water",
  33. "障碍": "obstacle",
  34. }
  35. @dataclass
  36. class GnssFix:
  37. ok: bool
  38. lat: float
  39. lon: float
  40. speed_mps: float
  41. heading_deg: Optional[float] = None
  42. t: Optional[int] = None
  43. @dataclass
  44. class CounterState:
  45. ok: bool
  46. value: int
  47. absolute: int
  48. cycle: int
  49. t: Optional[int] = None
  50. @dataclass(frozen=True)
  51. class HazardMarkResult:
  52. hazard_id: int
  53. route_id: int
  54. mode: str
  55. counter_value: Optional[int] = None
  56. counter_absolute: Optional[int] = None
  57. @dataclass(frozen=True)
  58. class StopRecordingResult:
  59. route_id: int
  60. mode: str
  61. hazard_count: int
  62. monitor_started: bool
  63. def _parse_jsonish(raw: Any) -> Optional[dict]:
  64. if raw is None:
  65. return None
  66. if isinstance(raw, str):
  67. raw = raw.strip()
  68. if not raw:
  69. return None
  70. try:
  71. raw = json.loads(raw)
  72. except Exception:
  73. return None
  74. if not isinstance(raw, dict):
  75. return None
  76. return raw
  77. def _parse_fix(raw: Any) -> GnssFix:
  78. payload = _parse_jsonish(raw)
  79. if payload is None:
  80. return GnssFix(False, 0.0, 0.0, 0.0)
  81. if "gnss" in payload and isinstance(payload.get("gnss"), dict):
  82. payload = payload["gnss"]
  83. ok = bool(payload.get("ok", True))
  84. has_fix = payload.get("has_fix")
  85. if has_fix is not None:
  86. ok = ok and bool(has_fix)
  87. lat = payload.get("lat", payload.get("latitude"))
  88. lon = payload.get("lon", payload.get("longitude"))
  89. try:
  90. lat_f = float(lat)
  91. lon_f = float(lon)
  92. except Exception:
  93. return GnssFix(False, 0.0, 0.0, 0.0)
  94. speed_mps = payload.get("speed_mps")
  95. if speed_mps is None:
  96. speed_knots = payload.get("speed_knots")
  97. if speed_knots is not None:
  98. try:
  99. speed_mps = float(speed_knots) * 0.514444
  100. except Exception:
  101. speed_mps = 0.0
  102. else:
  103. speed_kmh = payload.get("speed_kmh")
  104. if speed_kmh is not None:
  105. try:
  106. speed_mps = float(speed_kmh) / 3.6
  107. except Exception:
  108. speed_mps = 0.0
  109. else:
  110. speed_mps = payload.get("speed") or 0.0
  111. try:
  112. speed_mps_f = float(speed_mps)
  113. except Exception:
  114. speed_mps_f = 0.0
  115. heading = payload.get("heading_deg", payload.get("course_deg"))
  116. heading_f = None
  117. if heading is not None:
  118. try:
  119. heading_f = float(heading)
  120. except Exception:
  121. heading_f = None
  122. t = payload.get("t") or payload.get("timestamp") or payload.get("time")
  123. t_i = None
  124. if t is not None:
  125. try:
  126. t_i = int(t)
  127. except Exception:
  128. t_i = None
  129. fix = payload.get("fix") or payload.get("fix_quality") or payload.get("quality")
  130. if fix is not None:
  131. try:
  132. ok = ok and int(fix) > 0
  133. except Exception:
  134. pass
  135. return GnssFix(ok, lat_f, lon_f, max(0.0, speed_mps_f), heading_f, t_i)
  136. def _parse_counter_state(raw: Any) -> CounterState:
  137. payload = _parse_jsonish(raw)
  138. if payload is None:
  139. return CounterState(False, 0, 0, 0)
  140. if "counter" in payload and isinstance(payload.get("counter"), dict):
  141. payload = payload["counter"]
  142. ok = bool(payload.get("ok", True))
  143. has_value = payload.get("has_value")
  144. if has_value is not None:
  145. ok = ok and bool(has_value)
  146. value = (
  147. payload.get("value")
  148. or payload.get("counter_value")
  149. or payload.get("counter")
  150. or payload.get("raw")
  151. )
  152. absolute = payload.get("absolute") or payload.get("counter_absolute")
  153. cycle = payload.get("cycle", 0)
  154. t = payload.get("t") or payload.get("timestamp") or payload.get("time")
  155. try:
  156. value_i = int(value)
  157. except Exception:
  158. return CounterState(False, 0, 0, 0)
  159. if value_i < 1 or value_i > COUNTER_MAX_VALUE:
  160. return CounterState(False, 0, 0, 0)
  161. try:
  162. absolute_i = int(absolute) if absolute is not None else value_i
  163. except Exception:
  164. absolute_i = value_i
  165. try:
  166. cycle_i = int(cycle)
  167. except Exception:
  168. cycle_i = 0
  169. try:
  170. t_i = int(t) if t is not None else None
  171. except Exception:
  172. t_i = None
  173. return CounterState(ok, value_i, max(value_i, absolute_i), max(0, cycle_i), t_i)
  174. def _pick_tool_name(conn, preferred: str, keywords: Tuple[str, ...]) -> Optional[str]:
  175. if not hasattr(conn, "mcp_client") or not conn.mcp_client:
  176. return None
  177. if conn.mcp_client.has_tool(preferred):
  178. return preferred
  179. for name in conn.mcp_client.tools.keys():
  180. lowered = name.lower()
  181. if all(keyword in lowered for keyword in keywords):
  182. return name
  183. return None
  184. def _pick_gnss_tool_name(conn) -> Optional[str]:
  185. return _pick_tool_name(conn, "self_gnss_get_fix", ("gnss", "fix"))
  186. def _pick_counter_tool_name(conn) -> Optional[str]:
  187. return _pick_tool_name(conn, "self_nav_counter_get_state", ("counter", "state"))
  188. class NavCopilotService:
  189. def __init__(self, conn, db: NavDB):
  190. self.conn = conn
  191. self.db = db
  192. self.record_task: Optional[asyncio.Task] = None
  193. self.record_route_id: Optional[int] = None
  194. self.record_mode: Optional[str] = None
  195. self._record_seq = 0
  196. self._record_cum_m = 0.0
  197. self._record_last_latlon: Optional[Tuple[float, float]] = None
  198. self._record_last_t = 0
  199. self._record_interval_s = 0.1
  200. self._record_counter_start_absolute: Optional[int] = None
  201. self._record_counter_last_absolute: Optional[int] = None
  202. self._record_counter_hazards: List[Hazard] = []
  203. self._record_counter_announced: Set[Tuple[int, int, str]] = set()
  204. self._record_last_announce_at = 0.0
  205. self.run_task: Optional[asyncio.Task] = None
  206. self.run_route_id: Optional[int] = None
  207. self.run_mode: Optional[str] = None
  208. self._run_interval_s = 0.1
  209. self._run_points: List[RoutePoint] = []
  210. self._run_hazards: List[Hazard] = []
  211. self._run_last_idx: Optional[int] = None
  212. self._run_announced: Set[int] = set()
  213. self._run_counter_announced: Set[Tuple[int, int, str]] = set()
  214. self._run_last_announce_at = 0.0
  215. self.announce_at_m = 50.0
  216. self.announce_window_m = 18.0
  217. self.announce_cooldown_s = 5.0
  218. self.counter_announce_ahead = 6
  219. self.counter_announce_cooldown_s = 1.5
  220. def _counter_input_hint(self) -> str:
  221. return (
  222. "请确认 STM32 已接到 RXD1/GND,波特率 115200,"
  223. "并且按带分隔符的文本格式发送,例如 1\\n 2\\n 3\\n ... 256\\n。"
  224. )
  225. def _speak(self, text: str) -> None:
  226. try:
  227. self.conn.tts.tts_one_sentence(
  228. self.conn,
  229. ContentType.TEXT,
  230. content_detail=text,
  231. )
  232. except Exception as exc:
  233. logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
  234. def _normalize_mode(self, mode: str | None) -> str:
  235. normalized = (mode or "auto").strip().lower()
  236. if normalized not in {"auto", "gps", "counter"}:
  237. return "auto"
  238. return normalized
  239. def _resolve_mode(self, requested_mode: str | None, route_id: int | None = None) -> str:
  240. mode = self._normalize_mode(requested_mode)
  241. if route_id:
  242. stored_mode = self.db.get_route_mode(route_id)
  243. if stored_mode in {"gps", "counter"}:
  244. return stored_mode
  245. if mode == "gps":
  246. if not _pick_gnss_tool_name(self.conn):
  247. raise RuntimeError("设备端没有可用的 GNSS 定位工具")
  248. return "gps"
  249. if mode == "counter":
  250. if not _pick_counter_tool_name(self.conn):
  251. raise RuntimeError("设备端没有可用的串口计数工具")
  252. return "counter"
  253. if _pick_counter_tool_name(self.conn):
  254. return "counter"
  255. if _pick_gnss_tool_name(self.conn):
  256. return "gps"
  257. raise RuntimeError("设备端既没有 GNSS 工具,也没有串口计数工具")
  258. async def _get_fix(self) -> GnssFix:
  259. tool_name = _pick_gnss_tool_name(self.conn)
  260. if not tool_name:
  261. return GnssFix(False, 0.0, 0.0, 0.0)
  262. try:
  263. raw = await call_mcp_tool(
  264. self.conn,
  265. self.conn.mcp_client,
  266. tool_name,
  267. "{}",
  268. timeout=5,
  269. )
  270. return _parse_fix(raw)
  271. except Exception as exc:
  272. logger.bind(tag=TAG).warning(f"GNSS fix failed: {exc}")
  273. return GnssFix(False, 0.0, 0.0, 0.0)
  274. async def _get_counter_state(self) -> CounterState:
  275. tool_name = _pick_counter_tool_name(self.conn)
  276. if not tool_name:
  277. return CounterState(False, 0, 0, 0)
  278. try:
  279. raw = await call_mcp_tool(
  280. self.conn,
  281. self.conn.mcp_client,
  282. tool_name,
  283. "{}",
  284. timeout=5,
  285. )
  286. return _parse_counter_state(raw)
  287. except Exception as exc:
  288. logger.bind(tag=TAG).warning(f"Counter state failed: {exc}")
  289. return CounterState(False, 0, 0, 0)
  290. def _counter_distance(self, current_value: int, target_value: int) -> int:
  291. delta = target_value - current_value
  292. if delta < 0:
  293. delta += COUNTER_MAX_VALUE
  294. return delta
  295. def _prune_counter_announced(
  296. self,
  297. announced: Set[Tuple[int, int, str]],
  298. current_cycle: int,
  299. ) -> None:
  300. stale = {
  301. item for item in announced if item[1] < max(0, current_cycle - 1)
  302. }
  303. announced.difference_update(stale)
  304. def _maybe_announce_counter_hazard(
  305. self,
  306. sample: CounterState,
  307. hazards: List[Hazard],
  308. announced: Set[Tuple[int, int, str]],
  309. *,
  310. last_announce_at: float,
  311. ) -> float:
  312. if not sample.ok or not hazards:
  313. return last_announce_at
  314. self._prune_counter_announced(announced, sample.cycle)
  315. for hazard in hazards:
  316. if hazard.counter_value <= 0:
  317. continue
  318. arrive_key = (hazard.id, sample.cycle, "arrived")
  319. if sample.value != hazard.counter_value or arrive_key in announced:
  320. continue
  321. label = _HAZARD_LABELS.get(hazard.type, _HAZARD_LABELS["other"])
  322. text = f"当前{label}"
  323. if hazard.note:
  324. text = f"{text},{hazard.note}"
  325. self._speak(text)
  326. announced.add(arrive_key)
  327. return time.time()
  328. next_hazard: Optional[Hazard] = None
  329. next_distance: Optional[int] = None
  330. for hazard in hazards:
  331. if hazard.counter_value <= 0:
  332. continue
  333. distance = self._counter_distance(sample.value, hazard.counter_value)
  334. if distance == 0:
  335. continue
  336. ahead_key = (hazard.id, sample.cycle, "ahead")
  337. if ahead_key in announced:
  338. continue
  339. if next_distance is None or distance < next_distance:
  340. next_hazard = hazard
  341. next_distance = distance
  342. if next_hazard is None or next_distance is None:
  343. return last_announce_at
  344. if not (1 <= next_distance <= self.counter_announce_ahead):
  345. return last_announce_at
  346. now = time.time()
  347. if now - last_announce_at < self.counter_announce_cooldown_s:
  348. return last_announce_at
  349. label = _HAZARD_LABELS.get(next_hazard.type, _HAZARD_LABELS["other"])
  350. text = f"注意{label}"
  351. if next_hazard.counter_value > 0:
  352. text = f"{text},目标点 {next_hazard.counter_value}"
  353. if next_hazard.note:
  354. text = f"{text},{next_hazard.note}"
  355. self._speak(text)
  356. announced.add((next_hazard.id, sample.cycle, "ahead"))
  357. return now
  358. def _pick_counter_point(self, points: List[RoutePoint], sample: CounterState) -> RoutePoint:
  359. if not points:
  360. raise RuntimeError("路线里还没有计数点")
  361. return min(
  362. points,
  363. key=lambda point: min(
  364. self._counter_distance(sample.value, point.counter_value or 1),
  365. self._counter_distance(point.counter_value or 1, sample.value),
  366. ),
  367. )
  368. async def start_recording(
  369. self,
  370. route_name: Optional[str] = None,
  371. sample_hz: int = 10,
  372. mode: str | None = None,
  373. ) -> int:
  374. if self.record_route_id is not None:
  375. raise RuntimeError("当前已经在记录路线")
  376. if self.run_route_id is not None:
  377. raise RuntimeError("当前正在预警,不能同时开始记录")
  378. route_mode = self._resolve_mode(mode)
  379. name = (route_name or "").strip() or f"路线 {time.strftime('%Y-%m-%d %H:%M:%S')}"
  380. route_id = self.db.create_route(name, route_mode)
  381. self.record_route_id = route_id
  382. self.record_mode = route_mode
  383. self._record_seq = 0
  384. self._record_cum_m = 0.0
  385. self._record_last_latlon = None
  386. self._record_last_t = 0
  387. self._record_counter_start_absolute = None
  388. self._record_counter_last_absolute = None
  389. self._record_counter_hazards = []
  390. self._record_counter_announced = set()
  391. self._record_last_announce_at = 0.0
  392. hz = max(1, int(sample_hz or 10))
  393. if route_mode == "counter":
  394. hz = max(5, hz)
  395. self._record_interval_s = 1.0 / hz
  396. if route_mode == "counter":
  397. self.record_task = None
  398. else:
  399. self.record_task = asyncio.create_task(
  400. self._record_gps_loop(),
  401. name=f"NavRecordGps:{route_id}",
  402. )
  403. return route_id
  404. async def stop_recording(self) -> Optional[StopRecordingResult]:
  405. route_id = self.record_route_id
  406. if route_id is None:
  407. return None
  408. route_mode = self.record_mode or self.db.get_route_mode(route_id)
  409. if self.record_task and not self.record_task.done():
  410. self.record_task.cancel()
  411. try:
  412. await self.record_task
  413. except BaseException:
  414. pass
  415. self.record_task = None
  416. self.record_route_id = None
  417. self.record_mode = None
  418. self._record_counter_hazards = []
  419. self._record_counter_announced = set()
  420. self.db.finish_route(route_id)
  421. hazard_count = len(self.db.load_hazards(route_id))
  422. monitor_started = False
  423. if route_mode == "counter" and hazard_count > 0:
  424. await self.start_run(route_id=route_id, tick_hz=10, mode="counter")
  425. monitor_started = True
  426. return StopRecordingResult(
  427. route_id=route_id,
  428. mode=route_mode,
  429. hazard_count=hazard_count,
  430. monitor_started=monitor_started,
  431. )
  432. async def _record_gps_loop(self) -> None:
  433. assert self.record_route_id is not None
  434. route_id = self.record_route_id
  435. logger.bind(tag=TAG).info(f"GPS record loop started, route_id={route_id}")
  436. try:
  437. while not self.conn.stop_event.is_set():
  438. fix = await self._get_fix()
  439. now_t = int(fix.t or time.time())
  440. if fix.ok:
  441. latlon = (fix.lat, fix.lon)
  442. should_add = False
  443. if self._record_last_latlon is None:
  444. should_add = True
  445. else:
  446. moved_m = haversine_m(
  447. self._record_last_latlon[0],
  448. self._record_last_latlon[1],
  449. latlon[0],
  450. latlon[1],
  451. )
  452. if moved_m >= 1.0:
  453. should_add = True
  454. if (now_t - self._record_last_t) >= 2:
  455. should_add = True
  456. if should_add:
  457. if self._record_last_latlon is not None:
  458. self._record_cum_m += haversine_m(
  459. self._record_last_latlon[0],
  460. self._record_last_latlon[1],
  461. latlon[0],
  462. latlon[1],
  463. )
  464. self._record_last_latlon = latlon
  465. self._record_last_t = now_t
  466. self._record_seq += 1
  467. self.db.add_point(
  468. route_id=route_id,
  469. seq=self._record_seq,
  470. t=now_t,
  471. lat=latlon[0],
  472. lon=latlon[1],
  473. speed_mps=fix.speed_mps,
  474. cum_dist_m=self._record_cum_m,
  475. )
  476. await asyncio.sleep(self._record_interval_s)
  477. finally:
  478. if self.record_route_id == route_id:
  479. try:
  480. self.db.finish_route(route_id)
  481. except Exception:
  482. pass
  483. self.record_route_id = None
  484. self.record_mode = None
  485. self.record_task = None
  486. async def _record_counter_loop(self) -> None:
  487. assert self.record_route_id is not None
  488. route_id = self.record_route_id
  489. logger.bind(tag=TAG).info(f"Counter record loop started, route_id={route_id}")
  490. try:
  491. while not self.conn.stop_event.is_set():
  492. sample = await self._get_counter_state()
  493. now_t = int(sample.t or time.time())
  494. if sample.ok:
  495. if self._record_counter_start_absolute is None:
  496. self._record_counter_start_absolute = sample.absolute
  497. should_add = (
  498. self._record_counter_last_absolute is None
  499. or sample.absolute != self._record_counter_last_absolute
  500. )
  501. if should_add:
  502. self._record_seq += 1
  503. self._record_cum_m = float(
  504. max(0, sample.absolute - self._record_counter_start_absolute)
  505. )
  506. self._record_counter_last_absolute = sample.absolute
  507. self._record_last_t = now_t
  508. self.db.add_point(
  509. route_id=route_id,
  510. seq=self._record_seq,
  511. t=now_t,
  512. lat=0.0,
  513. lon=0.0,
  514. speed_mps=0.0,
  515. cum_dist_m=self._record_cum_m,
  516. counter_value=sample.value,
  517. counter_absolute=sample.absolute,
  518. )
  519. self._record_last_announce_at = self._maybe_announce_counter_hazard(
  520. sample,
  521. self._record_counter_hazards,
  522. self._record_counter_announced,
  523. last_announce_at=self._record_last_announce_at,
  524. )
  525. await asyncio.sleep(self._record_interval_s)
  526. finally:
  527. if self.record_route_id == route_id:
  528. try:
  529. self.db.finish_route(route_id)
  530. except Exception:
  531. pass
  532. self.record_route_id = None
  533. self.record_mode = None
  534. self.record_task = None
  535. self._record_counter_hazards = []
  536. self._record_counter_announced = set()
  537. async def mark_hazard(self, hazard_type: str, note: str = "") -> HazardMarkResult:
  538. hazard_type = (hazard_type or "").strip() or "other"
  539. if hazard_type in _HAZARD_SYNONYMS:
  540. hazard_type = _HAZARD_SYNONYMS[hazard_type]
  541. if hazard_type not in _HAZARD_LABELS:
  542. hazard_type = "other"
  543. route_id = self.record_route_id or self.run_route_id or self.db.get_active_route_id()
  544. if not route_id:
  545. raise RuntimeError("没有可用路线,请先开始记录")
  546. route_mode = self.db.get_route_mode(route_id)
  547. now_t = int(time.time())
  548. if route_mode == "counter":
  549. sample = await self._get_counter_state()
  550. if not sample.ok:
  551. raise RuntimeError(
  552. "当前没有可用的串口计数值,无法标记风险点。"
  553. + self._counter_input_hint()
  554. )
  555. points = self.db.load_route_points(route_id)
  556. if points:
  557. point = self._pick_counter_point(points, sample)
  558. seq = point.seq
  559. cum = point.cum_dist_m
  560. else:
  561. seq = len(self.db.load_hazards(route_id)) + 1
  562. cum = float(max(0, seq - 1))
  563. hazard_id = self.db.add_hazard(
  564. route_id=route_id,
  565. seq=seq,
  566. t=int(sample.t or now_t),
  567. lat=0.0,
  568. lon=0.0,
  569. cum_dist_m=cum,
  570. hazard_type=hazard_type,
  571. note=note or "",
  572. counter_value=sample.value,
  573. counter_absolute=sample.absolute,
  574. )
  575. if self.record_route_id == route_id and self.record_mode == "counter":
  576. self._record_counter_hazards = self.db.load_hazards(route_id)
  577. if self.run_route_id == route_id and self.run_mode == "counter":
  578. self._run_hazards = self.db.load_hazards(route_id)
  579. return HazardMarkResult(
  580. hazard_id=hazard_id,
  581. route_id=route_id,
  582. mode="counter",
  583. counter_value=sample.value,
  584. counter_absolute=sample.absolute,
  585. )
  586. fix = await self._get_fix()
  587. if not fix.ok:
  588. raise RuntimeError("当前没有有效 GPS 定位,无法标记风险点")
  589. if self.record_route_id == route_id and self._record_last_latlon is not None:
  590. seq = self._record_seq
  591. cum = self._record_cum_m
  592. else:
  593. points = self.db.load_route_points(route_id)
  594. if not points:
  595. raise RuntimeError("路线里还没有轨迹点")
  596. idx, _ = nearest_point_index([(point.lat, point.lon) for point in points], fix.lat, fix.lon)
  597. seq = points[idx].seq
  598. cum = points[idx].cum_dist_m
  599. hazard_id = self.db.add_hazard(
  600. route_id=route_id,
  601. seq=seq,
  602. t=int(fix.t or now_t),
  603. lat=fix.lat,
  604. lon=fix.lon,
  605. cum_dist_m=cum,
  606. hazard_type=hazard_type,
  607. note=note or "",
  608. )
  609. if self.run_route_id == route_id and self.run_mode == "gps":
  610. self._run_hazards = self.db.load_hazards(route_id)
  611. return HazardMarkResult(
  612. hazard_id=hazard_id,
  613. route_id=route_id,
  614. mode="gps",
  615. )
  616. async def start_run(
  617. self,
  618. route_id: Optional[int] = None,
  619. tick_hz: int = 10,
  620. mode: str | None = None,
  621. ) -> int:
  622. if self.run_route_id is not None:
  623. raise RuntimeError("当前已经在预警")
  624. if self.record_route_id is not None:
  625. raise RuntimeError("当前正在记录路线,不能同时开始预警")
  626. route_id = route_id or self.db.get_active_route_id()
  627. if not route_id:
  628. raise RuntimeError("没有可用路线,请先开始记录路线")
  629. route_mode = self._resolve_mode(mode, route_id=route_id)
  630. points = self.db.load_route_points(route_id)
  631. hazards = self.db.load_hazards(route_id)
  632. if route_mode == "gps" and len(points) < 2:
  633. raise RuntimeError("GPS 路线点太少,无法开始预警")
  634. if route_mode == "counter" and not hazards:
  635. raise RuntimeError("这条计数路线还没有任何标记点")
  636. self.run_route_id = route_id
  637. self.run_mode = route_mode
  638. self._run_points = points
  639. self._run_hazards = hazards
  640. self._run_last_idx = None
  641. self._run_announced = set()
  642. self._run_counter_announced = set()
  643. self._run_last_announce_at = 0.0
  644. hz = max(1, int(tick_hz or 10))
  645. if route_mode == "counter":
  646. hz = max(5, hz)
  647. self._run_interval_s = 1.0 / hz
  648. if route_mode == "counter":
  649. self.run_task = asyncio.create_task(
  650. self._run_counter_loop(),
  651. name=f"NavRunCounter:{route_id}",
  652. )
  653. else:
  654. self.run_task = asyncio.create_task(
  655. self._run_gps_loop(),
  656. name=f"NavRunGps:{route_id}",
  657. )
  658. return route_id
  659. async def stop_run(self) -> Optional[int]:
  660. route_id = self.run_route_id
  661. if self.run_task and not self.run_task.done():
  662. self.run_task.cancel()
  663. try:
  664. await self.run_task
  665. except BaseException:
  666. pass
  667. self.run_task = None
  668. self.run_route_id = None
  669. self.run_mode = None
  670. self._run_points = []
  671. self._run_hazards = []
  672. self._run_last_idx = None
  673. self._run_announced = set()
  674. self._run_counter_announced = set()
  675. return route_id
  676. async def _run_gps_loop(self) -> None:
  677. assert self.run_route_id is not None
  678. route_id = self.run_route_id
  679. logger.bind(tag=TAG).info(f"GPS run loop started, route_id={route_id}")
  680. points = self._run_points
  681. latlon_points = [(point.lat, point.lon) for point in points]
  682. try:
  683. while not self.conn.stop_event.is_set():
  684. fix = await self._get_fix()
  685. if not fix.ok:
  686. await asyncio.sleep(1.0)
  687. continue
  688. idx, _ = nearest_point_index(
  689. latlon_points,
  690. fix.lat,
  691. fix.lon,
  692. start_idx=self._run_last_idx,
  693. window=120,
  694. )
  695. self._run_last_idx = idx
  696. cur_cum = points[idx].cum_dist_m
  697. next_hazard: Optional[Hazard] = None
  698. next_distance: Optional[float] = None
  699. for hazard in self._run_hazards:
  700. if hazard.id in self._run_announced:
  701. continue
  702. distance = hazard.cum_dist_m - cur_cum
  703. if distance < -15:
  704. self._run_announced.add(hazard.id)
  705. continue
  706. if distance < 0:
  707. continue
  708. if next_distance is None or distance < next_distance:
  709. next_hazard = hazard
  710. next_distance = distance
  711. if next_hazard and next_distance is not None:
  712. now = time.time()
  713. if now - self._run_last_announce_at >= self.announce_cooldown_s:
  714. if (
  715. self.announce_at_m - self.announce_window_m
  716. <= next_distance
  717. <= self.announce_at_m
  718. ):
  719. speed_mps = max(0.5, float(fix.speed_mps or 0.0))
  720. label = _HAZARD_LABELS.get(
  721. next_hazard.type,
  722. _HAZARD_LABELS["other"],
  723. )
  724. eta_s = int(round(next_distance / speed_mps))
  725. dist_i = int(round(next_distance))
  726. text = f"注意前方 {dist_i} 米{label},预计 {eta_s} 秒到达"
  727. if next_hazard.note:
  728. text = f"{text},{next_hazard.note}"
  729. self._speak(text)
  730. self._run_last_announce_at = now
  731. self._run_announced.add(next_hazard.id)
  732. await asyncio.sleep(self._run_interval_s)
  733. finally:
  734. if self.run_route_id == route_id:
  735. self.run_route_id = None
  736. self.run_mode = None
  737. self.run_task = None
  738. self._run_points = []
  739. self._run_hazards = []
  740. self._run_last_idx = None
  741. self._run_announced = set()
  742. self._run_counter_announced = set()
  743. async def _run_counter_loop(self) -> None:
  744. assert self.run_route_id is not None
  745. route_id = self.run_route_id
  746. logger.bind(tag=TAG).info(f"Counter run loop started, route_id={route_id}")
  747. try:
  748. while not self.conn.stop_event.is_set():
  749. sample = await self._get_counter_state()
  750. if sample.ok:
  751. self._run_last_announce_at = self._maybe_announce_counter_hazard(
  752. sample,
  753. self._run_hazards,
  754. self._run_counter_announced,
  755. last_announce_at=self._run_last_announce_at,
  756. )
  757. await asyncio.sleep(self._run_interval_s)
  758. finally:
  759. if self.run_route_id == route_id:
  760. self.run_route_id = None
  761. self.run_mode = None
  762. self.run_task = None
  763. self._run_points = []
  764. self._run_hazards = []
  765. self._run_last_idx = None
  766. self._run_announced = set()
  767. self._run_counter_announced = set()
  768. def status_text(self) -> str:
  769. parts: List[str] = []
  770. if self.record_route_id:
  771. if self.record_mode == "counter":
  772. parts.append(
  773. f"正在准备录制计数路线 ID={self.record_route_id},已标记风险点={len(self.db.load_hazards(self.record_route_id))}"
  774. )
  775. else:
  776. parts.append(
  777. f"正在记录路线 ID={self.record_route_id},模式={self.record_mode or 'unknown'},点数={self._record_seq}"
  778. )
  779. if self.run_route_id:
  780. if self.run_mode == "counter":
  781. parts.append(
  782. f"正在监听预警路线 ID={self.run_route_id},风险点={len(self._run_hazards)}"
  783. )
  784. else:
  785. parts.append(
  786. f"正在预警路线 ID={self.run_route_id},模式={self.run_mode or 'unknown'},风险点={len(self._run_hazards)}"
  787. )
  788. if not parts:
  789. route_id = self.db.get_active_route_id()
  790. if route_id:
  791. parts.append(
  792. f"当前激活路线 ID={route_id},模式={self.db.get_route_mode(route_id)}"
  793. )
  794. else:
  795. parts.append("当前还没有路线,请先开始记录")
  796. return ";".join(parts)
  797. def get_service(conn) -> NavCopilotService:
  798. if not hasattr(conn, "nav_copilot_service") or conn.nav_copilot_service is None:
  799. conn.nav_copilot_service = NavCopilotService(conn, NavDB.default())
  800. return conn.nav_copilot_service