Procházet zdrojové kódy

Fix GPS hazard warning flow

liulinag před 2 měsíci
rodič
revize
966c124462

+ 7 - 0
main/xiaozhi-server/core/providers/tools/device_mcp/mcp_handler.py

@@ -230,6 +230,13 @@ async def handle_mcp_message(
                 if hasattr(conn, "func_handler") and conn.func_handler:
                     conn.func_handler.tool_manager.refresh_tools()
                     conn.func_handler.current_support_functions()
+                if hasattr(conn, "nav_copilot_service") and conn.nav_copilot_service:
+                    try:
+                        await conn.nav_copilot_service.recover_monitoring_if_needed()
+                    except Exception as exc:
+                        logger.bind(tag=TAG).warning(
+                            f"Failed to recover nav monitoring after MCP ready: {exc}"
+                        )
             return
 
     elif "method" in payload:

+ 35 - 32
main/xiaozhi-server/plugins_func/functions/nav_copilot.py

@@ -10,13 +10,9 @@ nav_start_record_desc = {
     "function": {
         "name": "nav_start_record",
         "description": (
-            "开始记录路线。"
-            "如果设备接了外部 STM32 串口计数器,就默认使用计数路线模式;"
-            "计数路线模式下,这一步只进入准备状态,不要求立刻收到串口值。"
-            "后续用户说“标记急转弯”时,再抓取当前串口值作为标记点;说“结束录制”后自动进入监听预警。"
-            "否则退回 GPS 路线模式。"
-            "当用户说“开始记录路线”“开始录制路线”“录制路线”“开始记录”“开始记路线”时,"
-            "不要只做自然语言回应,必须优先直接调用这个工具。"
+            "开始记录路线。GPS 模式下,标记风险点会保存当前 GPS 位置;"
+            "结束录制后会进入预警状态。"
+            "当用户说“开始记录路线”“开始录制路线”“开始记录”时,优先调用这个工具。"
         ),
         "parameters": {
             "type": "object",
@@ -27,11 +23,11 @@ nav_start_record_desc = {
                 },
                 "sample_hz": {
                     "type": "integer",
-                    "description": "采样频率,计数模式建议 10Hz 左右",
+                    "description": "采样频率,默认 10Hz",
                 },
                 "mode": {
                     "type": "string",
-                    "description": "auto 表示自动选择;counter 表示串口计数;gps 表示 GNSS 轨迹",
+                    "description": "路线模式。通常使用 gps",
                     "enum": ["auto", "counter", "gps"],
                 },
             },
@@ -46,7 +42,7 @@ async def nav_start_record(
     conn,
     route_name: str | None = None,
     sample_hz: int = 10,
-    mode: str = "auto",
+    mode: str = "gps",
 ):
     svc = get_service(conn)
     route_id = await svc.start_recording(
@@ -58,12 +54,12 @@ async def nav_start_record(
     if route_mode == "counter":
         text = (
             f"已经进入计数路线录制准备状态,路线 ID 是 {route_id}。"
-            "你到目标点时直接说“标记急转弯”或“标记障碍”,说“结束录制”后会自动开始监听预警。"
+            "到风险点时直接说“标记急转弯”或“标记障碍”,说“结束录制”后会进入预警。"
         )
     else:
         text = (
             f"已经开始记录 GPS 路线,路线 ID 是 {route_id}。"
-            "你到目标点时可以直接说“标记急转弯”或“标记障碍”。"
+            "到风险点时可以直接说“标记水坑”“标记急转弯”或“标记障碍”。"
         )
     return ActionResponse(action=Action.RESPONSE, result=str(route_id), response=text)
 
@@ -72,7 +68,7 @@ nav_stop_record_desc = {
     "type": "function",
     "function": {
         "name": "nav_stop_record",
-        "description": "结束当前路线记录并保存。计数路线模式下,如果已经标记过风险点,会自动进入监听预警。",
+        "description": "结束当前路线记录并保存。如果已经标记过风险点,则自动进入预警状态。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -84,19 +80,24 @@ async def nav_stop_record(conn):
     result = await svc.stop_recording()
     if result is None:
         return ActionResponse(action=Action.RESPONSE, response="当前没有正在记录的路线。")
-    if result.mode == "counter":
-        if result.monitor_started:
+
+    if result.monitor_started:
+        if result.mode == "counter":
             response = (
                 f"路线录制已结束,路线 ID 是 {result.route_id}。"
-                f"已记录 {result.hazard_count} 个风险点,并且已经自动开始监听预警。"
+                f"已记录 {result.hazard_count} 个风险点,并且已经开始监听预警。"
             )
         else:
             response = (
-                f"路线录已结束,路线 ID 是 {result.route_id}。"
-                "但你还没有标记风险点,所以还没有启动监听预警。"
+                f"路线录已结束,路线 ID 是 {result.route_id}。"
+                f"已记录 {result.hazard_count} 个风险点,并且已经进入预警状态。"
             )
     else:
-        response = f"路线记录已结束,路线 ID 是 {result.route_id}。"
+        response = (
+            f"路线记录已结束,路线 ID 是 {result.route_id}。"
+            "但你还没有标记风险点,所以还没有进入预警状态。"
+        )
+
     return ActionResponse(
         action=Action.RESPONSE,
         result=str(result.route_id),
@@ -110,8 +111,8 @@ nav_mark_hazard_desc = {
         "name": "nav_mark_hazard",
         "description": (
             "在当前位置标记一个风险点。"
-            "计数路线模式下,会把当前 STM32 串口传来的最新计数值保存成标记点。"
-            "典型口令包括“标记急转弯”“标记起转弯”“标记大坑”“标记障碍”。"
+            "GPS 模式下会保存当前 GPS 数据,后续进入该风险点范围内时触发预警。"
+            "典型口令包括“标记水坑”“标记急转弯”“标记障碍”。"
         ),
         "parameters": {
             "type": "object",
@@ -146,6 +147,8 @@ async def nav_mark_hazard(conn, hazard_type: str, note: str | None = None):
     result = await svc.mark_hazard(hazard_type=hazard_type, note=note or "")
     if result.mode == "counter" and result.counter_value is not None:
         response = f"已标记风险点,当前串口值是 {result.counter_value}。"
+    elif result.mode == "gps" and result.lat is not None and result.lon is not None:
+        response = f"已标记风险点,GPS 数据是 {result.lat:.6f},{result.lon:.6f}。"
     else:
         response = "已标记风险点。"
     return ActionResponse(
@@ -159,7 +162,7 @@ nav_mark_hazard_reject_desc = {
     "type": "function",
     "function": {
         "name": "nav_mark_hazard_reject",
-        "description": "当用户进入风险点录制口令但没有说出有效风险类型时,提示其保持安静并等待重新录入。",
+        "description": "当用户只说标记风险点但没有说具体类型时,提示其重新说清楚。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -169,7 +172,7 @@ nav_mark_hazard_reject_desc = {
 async def nav_mark_hazard_reject(conn):
     return ActionResponse(
         action=Action.RESPONSE,
-        response="请保持安静,等待录入风险点。",
+        response="请直接说要标记的风险类型,例如:标记水坑、标记急转弯、标记障碍。",
     )
 
 
@@ -178,10 +181,8 @@ nav_start_run_desc = {
     "function": {
         "name": "nav_start_run",
         "description": (
-            "开始预警播报。"
-            "对于串口计数路线,会在计数快接近标记点时播报“注意急转弯”,到达标记点时播报“当前急转弯”。"
-            "通常计数路线在“结束录制”后会自动开始监听预警,这个工具主要用于手动补启。"
-            "当用户说“开始预警”“开始领航”“开始播报”时,不要只做自然语言回应,必须优先直接调用这个工具。"
+            "开始预警播报。通常结束录制后会自动进入预警;"
+            "这个工具用于手动补启。"
         ),
         "parameters": {
             "type": "object",
@@ -192,11 +193,11 @@ nav_start_run_desc = {
                 },
                 "tick_hz": {
                     "type": "integer",
-                    "description": "刷新频率,计数模式建议 10Hz 左右",
+                    "description": "刷新频率,默认 10Hz",
                 },
                 "mode": {
                     "type": "string",
-                    "description": "可选。通常不必传,服务端会按路线类型自动选择",
+                    "description": "路线模式,通常不需要手动指定",
                     "enum": ["auto", "counter", "gps"],
                 },
             },
@@ -215,9 +216,10 @@ async def nav_start_run(
 ):
     svc = get_service(conn)
     active_route_id = await svc.start_run(route_id=route_id, tick_hz=tick_hz, mode=mode)
-    response = f"已经开始预警,使用路线 ID={active_route_id}。"
     if svc.run_mode == "counter":
         response = f"已经开始监听预警,使用路线 ID={active_route_id}。"
+    else:
+        response = f"已经开始预警,使用路线 ID={active_route_id}。"
     return ActionResponse(
         action=Action.RESPONSE,
         result=str(active_route_id),
@@ -293,10 +295,11 @@ async def nav_list_routes(conn, limit: int = 10):
     lines = []
     for route in routes:
         active = "(激活)" if route.active else ""
+        monitoring = "(预警中)" if route.monitoring else ""
         lines.append(
-            f"路线 {route.id}{active}:{route.name},模式 {route.mode},点数 {route.points},风险点 {route.hazards}"
+            f"路线 {route.id}{active}{monitoring}:{route.name},模式 {route.mode},点数 {route.points},风险点 {route.hazards}"
         )
-    return ActionResponse(action=Action.RESPONSE, response="".join(lines))
+    return ActionResponse(action=Action.RESPONSE, response="\n".join(lines))
 
 
 nav_set_active_route_desc = {

+ 182 - 99
main/xiaozhi-server/plugins_func/nav_copilot/service.py

@@ -69,6 +69,8 @@ class HazardMarkResult:
     mode: str
     counter_value: Optional[int] = None
     counter_absolute: Optional[int] = None
+    lat: Optional[float] = None
+    lon: Optional[float] = None
 
 
 @dataclass(frozen=True)
@@ -230,6 +232,10 @@ def _pick_counter_tool_name(conn) -> Optional[str]:
     return _pick_tool_name(conn, "self_nav_counter_get_state", ("counter", "state"))
 
 
+def _pick_nav_alert_tool_name(conn) -> Optional[str]:
+    return _pick_tool_name(conn, "self_nav_alert_play", ("nav", "alert", "play"))
+
+
 class NavCopilotService:
     def __init__(self, conn, db: NavDB):
         self.conn = conn
@@ -257,15 +263,57 @@ class NavCopilotService:
         self._run_hazards: List[Hazard] = []
         self._run_last_idx: Optional[int] = None
         self._run_announced: Set[int] = set()
+        self._run_gps_last_announce_at: Dict[int, float] = {}
         self._run_counter_announced: Set[Tuple[int, int, str]] = set()
         self._run_last_announce_at = 0.0
 
+        self.gps_alert_radius_m = 3.0
+        self.gps_alert_repeat_cooldown_s = 2.0
         self.announce_at_m = 50.0
         self.announce_window_m = 18.0
         self.announce_cooldown_s = 5.0
         self.counter_announce_ahead = 6
         self.counter_announce_cooldown_s = 1.5
 
+    async def recover_monitoring_if_needed(self) -> Optional[int]:
+        if self.run_route_id is not None:
+            return self.run_route_id
+        if not _pick_gnss_tool_name(self.conn):
+            return None
+
+        route_id = self.db.get_monitoring_route_id()
+        if not route_id:
+            return None
+
+        hazards = self.db.load_hazards(route_id)
+        if not hazards:
+            logger.bind(tag=TAG).warning(
+                f"Route {route_id} was marked as monitoring but has no hazards; clearing monitoring state"
+            )
+            self.db.set_monitoring_route(None)
+            return None
+
+        route_mode = self.db.get_route_mode(route_id)
+        if route_mode != "gps":
+            logger.bind(tag=TAG).warning(
+                f"Route {route_id} monitoring recovery only supports gps mode, current mode={route_mode}; clearing monitoring state"
+            )
+            self.db.set_monitoring_route(None)
+            return None
+
+        try:
+            await self.start_run(route_id=route_id, tick_hz=10, mode=route_mode)
+        except RuntimeError as exc:
+            logger.bind(tag=TAG).warning(
+                f"Failed to recover monitoring route {route_id}: {exc}"
+            )
+            return None
+
+        logger.bind(tag=TAG).info(
+            f"Recovered GPS monitoring after reconnect, route_id={route_id}, hazards={len(hazards)}"
+        )
+        return route_id
+
     def _counter_input_hint(self) -> str:
         return (
             "请确认 STM32 已接到 RXD1/GND,波特率 115200,"
@@ -282,10 +330,42 @@ class NavCopilotService:
         except Exception as exc:
             logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
 
+    async def _play_local_hazard_alert(self, hazard: Hazard) -> bool:
+        tool_name = _pick_nav_alert_tool_name(self.conn)
+        if not tool_name:
+            return False
+        try:
+            await call_mcp_tool(
+                self.conn,
+                self.conn.mcp_client,
+                tool_name,
+                json.dumps(
+                    {
+                        "hazard_type": hazard.type,
+                        "note": hazard.note or "",
+                    },
+                    ensure_ascii=False,
+                ),
+                timeout=5,
+            )
+            return True
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"Local hazard alert failed: {exc}")
+            return False
+
+    async def _announce_gps_hazard(self, hazard: Hazard) -> None:
+        if await self._play_local_hazard_alert(hazard):
+            return
+        label = _HAZARD_LABELS.get(hazard.type, _HAZARD_LABELS["other"])
+        text = f"注意{label}"
+        if hazard.note:
+            text = f"{text},{hazard.note}"
+        self._speak(text)
+
     def _normalize_mode(self, mode: str | None) -> str:
-        normalized = (mode or "auto").strip().lower()
+        normalized = (mode or "gps").strip().lower()
         if normalized not in {"auto", "gps", "counter"}:
-            return "auto"
+            return "gps"
         return normalized
 
     def _resolve_mode(self, requested_mode: str | None, route_id: int | None = None) -> str:
@@ -298,15 +378,9 @@ class NavCopilotService:
             if not _pick_gnss_tool_name(self.conn):
                 raise RuntimeError("设备端没有可用的 GNSS 定位工具")
             return "gps"
-        if mode == "counter":
-            if not _pick_counter_tool_name(self.conn):
-                raise RuntimeError("设备端没有可用的串口计数工具")
-            return "counter"
-        if _pick_counter_tool_name(self.conn):
-            return "counter"
         if _pick_gnss_tool_name(self.conn):
             return "gps"
-        raise RuntimeError("设备端既没有 GNSS 工具,也没有串口计数工具")
+        raise RuntimeError("设备端没有可用的 GNSS 定位工具")
 
     async def _get_fix(self) -> GnssFix:
         tool_name = _pick_gnss_tool_name(self.conn)
@@ -493,8 +567,11 @@ class NavCopilotService:
 
         hazard_count = len(self.db.load_hazards(route_id))
         monitor_started = False
-        if route_mode == "counter" and hazard_count > 0:
-            await self.start_run(route_id=route_id, tick_hz=10, mode="counter")
+        logger.bind(tag=TAG).info(
+            f"Stop recording route_id={route_id}, mode={route_mode}, hazards={hazard_count}"
+        )
+        if hazard_count > 0:
+            await self.start_run(route_id=route_id, tick_hz=10, mode=route_mode)
             monitor_started = True
 
         return StopRecordingResult(
@@ -627,50 +704,69 @@ class NavCopilotService:
         route_id = self.record_route_id or self.run_route_id or self.db.get_active_route_id()
         if not route_id:
             raise RuntimeError("没有可用路线,请先开始记录")
-        route_mode = self.db.get_route_mode(route_id)
+        route_mode = "gps"
         now_t = int(time.time())
 
-        if route_mode == "counter":
+        if False and route_mode == "counter":
             sample = await self._get_counter_state()
             if not sample.ok:
+                fix = await self._get_fix()
+                if fix.ok:
+                    logger.bind(tag=TAG).warning(
+                        f"Counter route {route_id} has no live counter sample but GPS is available; switching route to gps mode"
+                    )
+                    self.db.update_route_mode(route_id, "gps")
+                    route_mode = "gps"
+                    if self.record_route_id == route_id:
+                        self.record_mode = "gps"
+                        if self.record_task is None or self.record_task.done():
+                            self._record_interval_s = 0.1
+                            self.record_task = asyncio.create_task(
+                                self._record_gps_loop(),
+                                name=f"NavRecordGps:{route_id}",
+                            )
+                    if self.run_route_id == route_id:
+                        self.run_mode = "gps"
+            if route_mode == "counter" and not sample.ok:
                 raise RuntimeError(
                     "当前没有可用的串口计数值,无法标记风险点。"
                     + self._counter_input_hint()
                 )
 
-            points = self.db.load_route_points(route_id)
-            if points:
-                point = self._pick_counter_point(points, sample)
-                seq = point.seq
-                cum = point.cum_dist_m
-            else:
-                seq = len(self.db.load_hazards(route_id)) + 1
-                cum = float(max(0, seq - 1))
-
-            hazard_id = self.db.add_hazard(
-                route_id=route_id,
-                seq=seq,
-                t=int(sample.t or now_t),
-                lat=0.0,
-                lon=0.0,
-                cum_dist_m=cum,
-                hazard_type=hazard_type,
-                note=note or "",
-                counter_value=sample.value,
-                counter_absolute=sample.absolute,
-            )
+            if route_mode == "counter":
+                points = self.db.load_route_points(route_id)
+                if points:
+                    point = self._pick_counter_point(points, sample)
+                    seq = point.seq
+                    cum = point.cum_dist_m
+                else:
+                    seq = len(self.db.load_hazards(route_id)) + 1
+                    cum = float(max(0, seq - 1))
+
+                hazard_id = self.db.add_hazard(
+                    route_id=route_id,
+                    seq=seq,
+                    t=int(sample.t or now_t),
+                    lat=0.0,
+                    lon=0.0,
+                    cum_dist_m=cum,
+                    hazard_type=hazard_type,
+                    note=note or "",
+                    counter_value=sample.value,
+                    counter_absolute=sample.absolute,
+                )
 
-            if self.record_route_id == route_id and self.record_mode == "counter":
-                self._record_counter_hazards = self.db.load_hazards(route_id)
-            if self.run_route_id == route_id and self.run_mode == "counter":
-                self._run_hazards = self.db.load_hazards(route_id)
-            return HazardMarkResult(
-                hazard_id=hazard_id,
-                route_id=route_id,
-                mode="counter",
-                counter_value=sample.value,
-                counter_absolute=sample.absolute,
-            )
+                if self.record_route_id == route_id and self.record_mode == "counter":
+                    self._record_counter_hazards = self.db.load_hazards(route_id)
+                if self.run_route_id == route_id and self.run_mode == "counter":
+                    self._run_hazards = self.db.load_hazards(route_id)
+                return HazardMarkResult(
+                    hazard_id=hazard_id,
+                    route_id=route_id,
+                    mode="counter",
+                    counter_value=sample.value,
+                    counter_absolute=sample.absolute,
+                )
 
         fix = await self._get_fix()
         if not fix.ok:
@@ -681,11 +777,13 @@ class NavCopilotService:
             cum = self._record_cum_m
         else:
             points = self.db.load_route_points(route_id)
-            if not points:
-                raise RuntimeError("路线里还没有轨迹点")
-            idx, _ = nearest_point_index([(point.lat, point.lon) for point in points], fix.lat, fix.lon)
-            seq = points[idx].seq
-            cum = points[idx].cum_dist_m
+            if points:
+                idx, _ = nearest_point_index([(point.lat, point.lon) for point in points], fix.lat, fix.lon)
+                seq = points[idx].seq
+                cum = points[idx].cum_dist_m
+            else:
+                seq = self._record_seq if self._record_seq > 0 else len(self.db.load_hazards(route_id)) + 1
+                cum = self._record_cum_m
 
         hazard_id = self.db.add_hazard(
             route_id=route_id,
@@ -704,6 +802,8 @@ class NavCopilotService:
             hazard_id=hazard_id,
             route_id=route_id,
             mode="gps",
+            lat=fix.lat,
+            lon=fix.lon,
         )
 
     async def start_run(
@@ -724,17 +824,23 @@ class NavCopilotService:
         route_mode = self._resolve_mode(mode, route_id=route_id)
         points = self.db.load_route_points(route_id)
         hazards = self.db.load_hazards(route_id)
-        if route_mode == "gps" and len(points) < 2:
-            raise RuntimeError("GPS 路线点太少,无法开始预警")
+        if route_mode == "gps" and not hazards:
+            raise RuntimeError("这条 GPS 路线还没有任何标记点")
         if route_mode == "counter" and not hazards:
             raise RuntimeError("这条计数路线还没有任何标记点")
 
+        logger.bind(tag=TAG).info(
+            f"Start run requested, route_id={route_id}, mode={route_mode}, hazards={len(hazards)}, points={len(points)}"
+        )
         self.run_route_id = route_id
         self.run_mode = route_mode
+        self.db.set_active_route(route_id)
+        self.db.set_monitoring_route(route_id)
         self._run_points = points
         self._run_hazards = hazards
         self._run_last_idx = None
         self._run_announced = set()
+        self._run_gps_last_announce_at = {}
         self._run_counter_announced = set()
         self._run_last_announce_at = 0.0
         hz = max(1, int(tick_hz or 10))
@@ -765,20 +871,22 @@ class NavCopilotService:
         self.run_task = None
         self.run_route_id = None
         self.run_mode = None
+        self.db.set_monitoring_route(None)
         self._run_points = []
         self._run_hazards = []
         self._run_last_idx = None
         self._run_announced = set()
+        self._run_gps_last_announce_at = {}
         self._run_counter_announced = set()
         return route_id
 
     async def _run_gps_loop(self) -> None:
         assert self.run_route_id is not None
         route_id = self.run_route_id
-        logger.bind(tag=TAG).info(f"GPS run loop started, route_id={route_id}")
+        logger.bind(tag=TAG).info(
+            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}"
+        )
 
-        points = self._run_points
-        latlon_points = [(point.lat, point.lon) for point in points]
         try:
             while not self.conn.stop_event.is_set():
                 fix = await self._get_fix()
@@ -786,52 +894,20 @@ class NavCopilotService:
                     await asyncio.sleep(1.0)
                     continue
 
-                idx, _ = nearest_point_index(
-                    latlon_points,
-                    fix.lat,
-                    fix.lon,
-                    start_idx=self._run_last_idx,
-                    window=120,
-                )
-                self._run_last_idx = idx
-                cur_cum = points[idx].cum_dist_m
-
-                next_hazard: Optional[Hazard] = None
-                next_distance: Optional[float] = None
                 for hazard in self._run_hazards:
-                    if hazard.id in self._run_announced:
-                        continue
-                    distance = hazard.cum_dist_m - cur_cum
-                    if distance < -15:
-                        self._run_announced.add(hazard.id)
+                    if hazard.lat == 0.0 and hazard.lon == 0.0:
                         continue
-                    if distance < 0:
-                        continue
-                    if next_distance is None or distance < next_distance:
-                        next_hazard = hazard
-                        next_distance = distance
-
-                if next_hazard and next_distance is not None:
-                    now = time.time()
-                    if now - self._run_last_announce_at >= self.announce_cooldown_s:
-                        if (
-                            self.announce_at_m - self.announce_window_m
-                            <= next_distance
-                            <= self.announce_at_m
-                        ):
-                            speed_mps = max(0.5, float(fix.speed_mps or 0.0))
-                            label = _HAZARD_LABELS.get(
-                                next_hazard.type,
-                                _HAZARD_LABELS["other"],
-                            )
-                            eta_s = int(round(next_distance / speed_mps))
-                            dist_i = int(round(next_distance))
-                            text = f"注意前方 {dist_i} 米{label},预计 {eta_s} 秒到达"
-                            if next_hazard.note:
-                                text = f"{text},{next_hazard.note}"
-                            self._speak(text)
-                            self._run_last_announce_at = now
-                            self._run_announced.add(next_hazard.id)
+                    distance = haversine_m(fix.lat, fix.lon, hazard.lat, hazard.lon)
+                    if distance <= self.gps_alert_radius_m:
+                        now = time.monotonic()
+                        last_announce_at = self._run_gps_last_announce_at.get(hazard.id, 0.0)
+                        if now - last_announce_at < self.gps_alert_repeat_cooldown_s:
+                            continue
+                        logger.bind(tag=TAG).info(
+                            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})"
+                        )
+                        await self._announce_gps_hazard(hazard)
+                        self._run_gps_last_announce_at[hazard.id] = now
 
                 await asyncio.sleep(self._run_interval_s)
         finally:
@@ -843,6 +919,7 @@ class NavCopilotService:
                 self._run_hazards = []
                 self._run_last_idx = None
                 self._run_announced = set()
+                self._run_gps_last_announce_at = {}
                 self._run_counter_announced = set()
 
     async def _run_counter_loop(self) -> None:
@@ -869,6 +946,7 @@ class NavCopilotService:
                 self._run_hazards = []
                 self._run_last_idx = None
                 self._run_announced = set()
+                self._run_gps_last_announce_at = {}
                 self._run_counter_announced = set()
 
     def status_text(self) -> str:
@@ -892,6 +970,11 @@ class NavCopilotService:
                     f"正在预警路线 ID={self.run_route_id},模式={self.run_mode or 'unknown'},风险点={len(self._run_hazards)}"
                 )
         if not parts:
+            route_id = self.db.get_monitoring_route_id()
+            if route_id:
+                parts.append(
+                    f"数据库显示正在预警路线 ID={route_id},模式={self.db.get_route_mode(route_id)},等待本次连接恢复执行"
+                )
             route_id = self.db.get_active_route_id()
             if route_id:
                 parts.append(

+ 46 - 4
main/xiaozhi-server/plugins_func/nav_copilot/storage.py

@@ -19,6 +19,7 @@ class RouteInfo:
     points: int
     hazards: int
     active: bool
+    monitoring: bool
 
 
 @dataclass(frozen=True)
@@ -73,7 +74,8 @@ class NavDB:
                     mode TEXT NOT NULL DEFAULT 'gps',
                     created_at INTEGER NOT NULL,
                     finished_at INTEGER,
-                    active INTEGER NOT NULL DEFAULT 0
+                    active INTEGER NOT NULL DEFAULT 0,
+                    monitoring INTEGER NOT NULL DEFAULT 0
                 )
                 """
             )
@@ -112,6 +114,12 @@ class NavDB:
             )
 
             self._ensure_column(cur, "routes", "mode", "TEXT NOT NULL DEFAULT 'gps'")
+            self._ensure_column(
+                cur,
+                "routes",
+                "monitoring",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
             self._ensure_column(
                 cur,
                 "route_points",
@@ -158,9 +166,9 @@ class NavDB:
         now = int(time.time())
         route_mode = (mode or "gps").strip() or "gps"
         with self._lock:
-            self._conn.execute("UPDATE routes SET active=0")
+            self._conn.execute("UPDATE routes SET active=0, monitoring=0")
             cur = self._conn.execute(
-                "INSERT INTO routes(name, mode, created_at, active) VALUES(?, ?, ?, 1)",
+                "INSERT INTO routes(name, mode, created_at, active, monitoring) VALUES(?, ?, ?, 1, 0)",
                 (name, route_mode, now),
             )
             self._conn.commit()
@@ -188,6 +196,30 @@ class NavDB:
             ).fetchone()
             return int(row["id"]) if row else None
 
+    def set_monitoring_route(self, route_id: Optional[int]) -> None:
+        with self._lock:
+            self._conn.execute("UPDATE routes SET monitoring=0")
+            if route_id is not None:
+                self._conn.execute(
+                    "UPDATE routes SET monitoring=1 WHERE id=?",
+                    (int(route_id),),
+                )
+            self._conn.commit()
+
+    def get_monitoring_route_id(self) -> Optional[int]:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT id FROM routes WHERE monitoring=1 ORDER BY id DESC LIMIT 1"
+            ).fetchone()
+            return int(row["id"]) if row else None
+
+    # Backward-compatible aliases for older call sites that used sanitized names.
+    def setmonitoringroute(self, route_id: Optional[int]) -> None:
+        self.set_monitoring_route(route_id)
+
+    def getmonitoringrouteid(self) -> Optional[int]:
+        return self.get_monitoring_route_id()
+
     def get_route_mode(self, route_id: int) -> str:
         with self._lock:
             row = self._conn.execute(
@@ -198,6 +230,15 @@ class NavDB:
             return "gps"
         return str(row["mode"] or "gps")
 
+    def update_route_mode(self, route_id: int, mode: str) -> None:
+        route_mode = (mode or "gps").strip() or "gps"
+        with self._lock:
+            self._conn.execute(
+                "UPDATE routes SET mode=? WHERE id=?",
+                (route_mode, route_id),
+            )
+            self._conn.commit()
+
     def add_point(
         self,
         *,
@@ -276,7 +317,7 @@ class NavDB:
             rows = self._conn.execute(
                 """
                 SELECT
-                    r.id, r.name, r.mode, r.created_at, r.finished_at, r.active,
+                    r.id, r.name, r.mode, r.created_at, r.finished_at, r.active, r.monitoring,
                     (SELECT COUNT(1) FROM route_points p WHERE p.route_id=r.id) AS points,
                     (SELECT COUNT(1) FROM hazards h WHERE h.route_id=r.id) AS hazards
                 FROM routes r
@@ -295,6 +336,7 @@ class NavDB:
                 points=int(row["points"]),
                 hazards=int(row["hazards"]),
                 active=bool(int(row["active"])),
+                monitoring=bool(int(row["monitoring"] or 0)),
             )
             for row in rows
         ]