service.py 35 KB

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