liulinag vor 2 Monaten
Ursprung
Commit
728cc0036b
27 geänderte Dateien mit 1635 neuen und 392 gelöschten Zeilen
  1. 80 0
      deployments/single-server-api/README.md
  2. 18 0
      deployments/single-server-api/docker-compose.yml
  3. BIN
      main/xiaozhi-server.tar.gz
  4. 12 0
      main/xiaozhi-server/.dockerignore
  5. 23 0
      main/xiaozhi-server/Dockerfile
  6. 26 51
      main/xiaozhi-server/config.yaml
  7. 4 0
      main/xiaozhi-server/config/manage_api_client.py
  8. 150 78
      main/xiaozhi-server/core/handle/intentHandler.py
  9. 1 2
      main/xiaozhi-server/core/handle/receiveAudioHandle.py
  10. 2 3
      main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py
  11. 26 1
      main/xiaozhi-server/core/providers/asr/fun_local.py
  12. 77 2
      main/xiaozhi-server/core/providers/llm/openai/openai.py
  13. 14 0
      main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py
  14. 39 0
      main/xiaozhi-server/core/providers/tts/base.py
  15. 62 27
      main/xiaozhi-server/core/utils/current_time.py
  16. 3 3
      main/xiaozhi-server/core/utils/dialogue.py
  17. 5 0
      main/xiaozhi-server/core/utils/prompt_manager.py
  18. 3 1
      main/xiaozhi-server/core/websocket_server.py
  19. 30 0
      main/xiaozhi-server/deploy/tencent/car_ai_override.yaml
  20. 188 0
      main/xiaozhi-server/deploy/tencent/deploy_tencent.py
  21. 16 0
      main/xiaozhi-server/docker-compose.tencent.yml
  22. 3 3
      main/xiaozhi-server/docker-compose.yml
  23. 14 14
      main/xiaozhi-server/docker-compose_all.yml
  24. 144 48
      main/xiaozhi-server/plugins_func/functions/nav_copilot.py
  25. 582 142
      main/xiaozhi-server/plugins_func/nav_copilot/service.py
  26. 113 17
      main/xiaozhi-server/plugins_func/nav_copilot/storage.py
  27. BIN
      xiaozhi-server.tar.gz

+ 80 - 0
deployments/single-server-api/README.md

@@ -0,0 +1,80 @@
+## 目标
+
+在腾讯云(Ubuntu 22.04 x86_64)上仅部署 `xiaozhi-esp32-server` 的 **Server** 模块:
+
+- ASR:百度(API)
+- LLM:DeepSeek(API)
+- TTS:EdgeTTS(无需 key)
+- 无域名:直接使用 `ws://公网IP:8000` 和 `http://公网IP:8003`
+
+> 注意:不要把任何真实 `API Key/Secret` 提交到公网仓库;也不要把 key 发到聊天里。
+
+---
+
+## 1) 服务器侧安全组/防火墙
+
+放行端口:
+
+- `8000/tcp`(WebSocket)
+- `8003/tcp`(HTTP:OTA/视觉接口)
+
+强烈建议:先只允许你自己的公网出口 IP(而不是全网开放)。
+
+---
+
+## 2) 拷贝文件到服务器
+
+把本目录下这两个文件上传到服务器,例如:
+
+- `/opt/xiaozhi-server/docker-compose.yml`
+- `/opt/xiaozhi-server/data/.config.yaml`
+
+目录结构应为:
+
+```
+/opt/xiaozhi-server
+  docker-compose.yml
+  data/
+    .config.yaml
+```
+
+---
+
+## 3) 修改配置
+
+编辑 `/opt/xiaozhi-server/data/.config.yaml`:
+
+- 把 `PUBLIC_IP` 改成你的公网 IP
+- 填上百度 ASR、DeepSeek 的密钥
+
+---
+
+## 4) 启动/查看日志
+
+在服务器执行:
+
+```bash
+cd /opt/xiaozhi-server
+docker compose up -d
+docker logs -f xiaozhi-esp32-server
+```
+
+---
+
+## 5) ESP32 需要填的地址
+
+- WebSocket:`ws://PUBLIC_IP:8000/xiaozhi/v1/`
+- OTA:`http://PUBLIC_IP:8003/xiaozhi/ota/`
+
+---
+
+## 6) 排查命令(把输出贴出来即可)
+
+```bash
+docker ps
+docker logs --tail 120 xiaozhi-esp32-server
+ss -lntp | grep -E '(:8000|:8003)'
+curl -sS -D- http://127.0.0.1:8000/ | head
+curl -sS -D- http://127.0.0.1:8003/xiaozhi/ota/ | head
+```
+

+ 18 - 0
deployments/single-server-api/docker-compose.yml

@@ -0,0 +1,18 @@
+# 仅部署 Server(API 版:不挂载本地 ASR 模型)
+
+version: "3"
+services:
+  xiaozhi-esp32-server:
+    image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
+    container_name: xiaozhi-esp32-server
+    restart: always
+    security_opt:
+      - seccomp:unconfined
+    environment:
+      - TZ=Asia/Shanghai
+    ports:
+      - "8000:8000"
+      - "8003:8003"
+    volumes:
+      - ./data:/opt/xiaozhi-esp32-server/data
+

BIN
main/xiaozhi-server.tar.gz


+ 12 - 0
main/xiaozhi-server/.dockerignore

@@ -0,0 +1,12 @@
+.git
+.gitignore
+.venv
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+tmp
+node_modules
+data/.config.yaml
+data/bin
+data/*.sqlite3

+ 23 - 0
main/xiaozhi-server/Dockerfile

@@ -0,0 +1,23 @@
+FROM python:3.11-slim-bookworm
+
+ENV TZ=Asia/Shanghai \
+    PYTHONDONTWRITEBYTECODE=1 \
+    PYTHONUNBUFFERED=1
+
+RUN sed -i 's#deb.debian.org#mirrors.aliyun.com#g; s#security.debian.org#mirrors.aliyun.com#g' /etc/apt/sources.list.d/debian.sources \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends ffmpeg \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt /app/requirements.txt
+RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ \
+ && pip install -U pip \
+ && pip install -r /app/requirements.txt
+
+COPY . /app
+
+EXPOSE 8000 8003
+
+CMD ["python", "app.py"]

+ 26 - 51
main/xiaozhi-server/config.yaml

@@ -24,7 +24,7 @@ server:
   # 当你使用docker部署或使用公网部署(使用ssl、域名)时,不一定准确
   # 所以如果你使用docker部署时,将vision_explain设置成局域网地址
   # 如果你使用公网部署时,将vision_explain设置成公网地址
-  vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
+  vision_explain: ""
   # OTA返回信息时区偏移量
   timezone_offset: +8
   # 认证配置
@@ -105,17 +105,8 @@ module_test:
 
 # 唤醒词,用于识别唤醒词还是讲话内容
 wakeup_words:
-  - "你好星星"
-  - "嘿你好呀"
-  - "你好小志"
-  - "小爱同学"
-  - "你好小鑫"
-  - "你好小新"
-  - "小美同学"
-  - "小龙小龙"
-  - "喵喵同学"
-  - "小滨小滨"
-  - "小冰小冰"
+  - "爱驰尔"
+  - "你好爱驰尔"
 # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
 # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
 mcp_endpoint: 你的接入点 websocket地址
@@ -172,41 +163,23 @@ plugins:
     # ragflow知识库id
     dataset_ids: ["123456789"]
 # 声纹识别配置
-voiceprint:
-  # 声纹接口地址
-  url: 
-  # 说话人配置:speaker_id,名称,描述
-  speakers:
-    - "test1,张三,张三是一个程序员"
-    - "test2,李四,李四是一个产品经理"
-    - "test3,王五,王五是一个设计师"
-  # 声纹识别相似度阈值,范围0.0-1.0,默认0.4
-  # 数值越高越严格,减少误识别但可能增加拒识率
-  similarity_threshold: 0.4
+voiceprint: {}
 
 # #####################################################################################
 # ################################以下是角色模型配置######################################
 
 prompt: |
-  你是xingxing/星星,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
-  [核心特征]
-  - 讲话像连珠炮,但会突然冒出超温柔语气
-  - 用梗密度高
-  - 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
-  [交互指南]
-  当用户:
-  - 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
-  - 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
-  - 问专业知识 → 先用梗回答,被追问才展示真实理解
-  绝不:
-  - 长篇大论,叽叽歪歪
-  - 长时间严肃对话
+  你是爱驰尔,运行在 xingxing-server 上的车载 AI 模块。
+  你的职责是语音问答、路线录制与风险点预警、天气查询、音乐播放、Home Assistant 设备控制、知识库检索和车辆相关信息播报。
+  所有回复都只说一句话,优先直接给结论,简短、自然、可靠,不说多余寒暄。
+  涉及行车、路线、故障码、氢系统时,先保证安全,再给出明确建议。
 
 # 默认系统提示词模板文件
 prompt_template: agent-base-prompt.txt
+llm_one_sentence_only: true
 
 # 系统错误时的回复
-system_error_response: "主人,xingxing现在有点忙,我们稍后再试吧。"
+system_error_response: "爱驰尔当前处理超时,请再说一次。"
 
 # 结束语prompt
 end_prompt:
@@ -223,12 +196,10 @@ selected_module:
   ASR: FunASR
   # 将根据配置名称对应的type调用实际的LLM适配器
   LLM: ChatGLMLLM
-  # 视觉语言大模型
-  VLLM: ChatGLMVLLM
   # TTS将根据配置名称对应的type调用实际的TTS适配器
   TTS: EdgeTTS
   # 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
-  Memory: nomem
+  Memory: mem_local_short
   # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
   # 不想开通意图识别,就设置成:nointent
   # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
@@ -254,8 +225,18 @@ Intent:
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
       - get_weather
-      - get_news_from_newsnow
+      - search_from_ragflow
+      - nav_start_record
+      - nav_stop_record
+      - nav_mark_hazard
+      - nav_start_run
+      - nav_stop_run
+      - nav_list_routes
+      - nav_set_active_route
       - play_music
+      - hass_get_state
+      - hass_set_state
+      - hass_play_music
   function_call:
     # 不需要动type
     type: function_call
@@ -263,26 +244,20 @@ Intent:
     # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
-      - change_role
       - get_weather
-      # - search_from_ragflow
-      # - get_news_from_chinanews
-      - get_news_from_newsnow
+      - search_from_ragflow
       # 越野/赛道领航播报(路线录制 + 风险点预警)
       - nav_start_record
       - nav_stop_record
       - nav_mark_hazard
       - nav_start_run
       - nav_stop_run
-      - nav_status
       - nav_list_routes
       - nav_set_active_route
-      # play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
-      # 如果用了hass_play_music,就不要开启play_music,两者只留一个
       - play_music
-      #- hass_get_state
-      #- hass_set_state
-      #- hass_play_music
+      - hass_get_state
+      - hass_set_state
+      - hass_play_music
 
 Memory:
   mem0ai:

+ 4 - 0
main/xiaozhi-server/config/manage_api_client.py

@@ -185,6 +185,8 @@ async def get_agent_models(
 
 async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
     """生成并保存聊天记录总结"""
+    if not ManageApiClient._instance:
+        return None
     try:
         return await ManageApiClient._instance._execute_async_request(
             "POST",
@@ -197,6 +199,8 @@ async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
 
 async def generate_and_save_chat_title(session_id: str) -> Optional[Dict]:
     """生成并保存聊天标题"""
+    if not ManageApiClient._instance:
+        return None
     try:
         return await ManageApiClient._instance._execute_async_request(
             "POST",

+ 150 - 78
main/xiaozhi-server/core/handle/intentHandler.py

@@ -37,6 +37,19 @@ async def handle_user_intent(conn: "ConnectionHandler", text):
     if await checkWakeupWords(conn, filtered_text):
         return True
 
+    direct_function_call = match_direct_nav_command(filtered_text)
+    if direct_function_call is not None:
+        function_name, function_args = direct_function_call
+        conn.logger.bind(tag=TAG).info(
+            f"识别到直达导航命令: {filtered_text} -> {function_name}"
+        )
+        return await dispatch_function_call(
+            conn,
+            text,
+            function_name,
+            function_args,
+        )
+
     if conn.intent_type == "function_call":
         # 使用支持function calling的聊天方法,不再进行意图分析
         return False
@@ -80,6 +93,134 @@ async def analyze_intent_with_llm(conn: "ConnectionHandler", text):
     return None
 
 
+def match_direct_nav_command(filtered_text: str):
+    text = (filtered_text or "").strip()
+    if not text:
+        return None
+
+    if text in {
+        "开始录制路线",
+        "开始记录路线",
+        "录制路线",
+        "开始记录",
+        "开始记路线",
+    }:
+        return "nav_start_record", {}
+
+    if text in {
+        "结束录制",
+        "停止录制",
+        "结束记录",
+        "停止记录",
+        "录制结束",
+    }:
+        return "nav_stop_record", {}
+
+    if text in {
+        "开始预警",
+        "开始播报",
+        "开始领航",
+        "开始导航",
+    }:
+        return "nav_start_run", {}
+
+    if text in {
+        "停止预警",
+        "结束预警",
+        "停止播报",
+        "关闭预警",
+    }:
+        return "nav_stop_run", {}
+
+    if text.startswith("标记"):
+        if any(word in text for word in ("急转弯", "起转弯", "急弯")):
+            return "nav_mark_hazard", {"hazard_type": "sharp_turn"}
+        if "障碍" in text:
+            return "nav_mark_hazard", {"hazard_type": "obstacle"}
+        if any(word in text for word in ("大坑", "坑")):
+            return "nav_mark_hazard", {"hazard_type": "bump"}
+        if "跳台" in text:
+            return "nav_mark_hazard", {"hazard_type": "jump"}
+        if "陡坡" in text:
+            return "nav_mark_hazard", {"hazard_type": "slope"}
+        if "涉水" in text:
+            return "nav_mark_hazard", {"hazard_type": "water"}
+        return "nav_mark_hazard", {"hazard_type": "other"}
+
+    return None
+
+
+async def dispatch_function_call(
+    conn: "ConnectionHandler",
+    original_text: str,
+    function_name: str,
+    function_args: dict | None = None,
+):
+    conn.sentence_id = str(uuid.uuid4().hex)
+    function_args = function_args or {}
+    function_args_json = json.dumps(function_args, ensure_ascii=False)
+    function_call_data = {
+        "name": function_name,
+        "id": str(uuid.uuid4().hex),
+        "arguments": function_args_json,
+    }
+
+    await send_stt_message(conn, original_text)
+    conn.client_abort = False
+    enqueue_tool_report(conn, function_name, function_args)
+
+    def process_function_call():
+        conn.dialogue.put(Message(role="user", content=original_text))
+
+        tool_call_timeout = int(conn.config.get("tool_call_timeout", 30))
+        try:
+            result = asyncio.run_coroutine_threadsafe(
+                conn.func_handler.handle_llm_function_call(conn, function_call_data),
+                conn.loop,
+            ).result(timeout=tool_call_timeout)
+        except Exception as e:
+            conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}")
+            result = ActionResponse(
+                action=Action.ERROR,
+                result="工具调用超时,请一会再试下哈",
+                response="工具调用超时,请一会再试下哈",
+            )
+
+        if result:
+            enqueue_tool_report(
+                conn,
+                function_name,
+                function_args,
+                str(result.result) if result.result else None,
+                report_tool_call=False,
+            )
+
+            if result.action == Action.RESPONSE:
+                text = result.response
+                if text is not None:
+                    speak_txt(conn, text)
+            elif result.action == Action.REQLLM:
+                text = result.result
+                conn.dialogue.put(Message(role="tool", content=text))
+                llm_result = conn.intent.replyResult(text, original_text)
+                if llm_result is None:
+                    llm_result = text
+                speak_txt(conn, llm_result)
+            elif result.action == Action.NOTFOUND or result.action == Action.ERROR:
+                text = result.response if result.response else result.result
+                if text is not None:
+                    speak_txt(conn, text)
+            elif function_name != "play_music":
+                text = result.response
+                if text is None:
+                    text = result.result
+                if text is not None:
+                    speak_txt(conn, text)
+
+    conn.executor.submit(process_function_call)
+    return True
+
+
 async def process_intent_result(
     conn: "ConnectionHandler", intent_result, original_text
 ):
@@ -129,84 +270,15 @@ async def process_intent_result(
                 function_args = intent_data["function_call"]["arguments"]
                 if function_args is None:
                     function_args = {}
-            # 确保参数是字符串格式的JSON
-            if isinstance(function_args, dict):
-                function_args = json.dumps(function_args)
-
-            function_call_data = {
-                "name": function_name,
-                "id": str(uuid.uuid4().hex),
-                "arguments": function_args,
-            }
-
-            await send_stt_message(conn, original_text)
-            conn.client_abort = False
-
-            # 准备工具调用参数
-            tool_input = {}
-            if function_args:
-                if isinstance(function_args, str):
-                    tool_input = json.loads(function_args) if function_args else {}
-                elif isinstance(function_args, dict):
-                    tool_input = function_args
-
-            # 上报工具调用
-            enqueue_tool_report(conn, function_name, tool_input)
-
-            # 使用executor执行函数调用和结果处理
-            def process_function_call():
-                conn.dialogue.put(Message(role="user", content=original_text))
-                
-                # 工具调用超时时间
-                tool_call_timeout = int(conn.config.get("tool_call_timeout", 30))
-                # 使用统一工具处理器处理所有工具调用
-                try:
-                    result = asyncio.run_coroutine_threadsafe(
-                        conn.func_handler.handle_llm_function_call(
-                            conn, function_call_data
-                        ),
-                        conn.loop,
-                    ).result(timeout=tool_call_timeout)
-                except Exception as e:
-                    conn.logger.bind(tag=TAG).error(f"工具调用失败: {e}")
-                    result = ActionResponse(
-                        action=Action.ERROR, result="工具调用超时,请一会再试下哈", response="工具调用超时,请一会再试下哈"
-                    )
-
-                # 上报工具调用结果
-                if result:
-                    enqueue_tool_report(conn, function_name, tool_input, str(result.result) if result.result else None, report_tool_call=False)
-
-                    if result.action == Action.RESPONSE:  # 直接回复前端
-                        text = result.response
-                        if text is not None:
-                            speak_txt(conn, text)
-                    elif result.action == Action.REQLLM:  # 调用函数后再请求llm生成回复
-                        text = result.result
-                        conn.dialogue.put(Message(role="tool", content=text))
-                        llm_result = conn.intent.replyResult(text, original_text)
-                        if llm_result is None:
-                            llm_result = text
-                        speak_txt(conn, llm_result)
-                    elif (
-                        result.action == Action.NOTFOUND
-                        or result.action == Action.ERROR
-                    ):
-                        text = result.response if result.response else result.result
-                        if text is not None:
-                            speak_txt(conn, text)
-                    elif function_name != "play_music":
-                        # For backward compatibility with original code
-                        # 获取当前最新的文本索引
-                        text = result.response
-                        if text is None:
-                            text = result.result
-                        if text is not None:
-                            speak_txt(conn, text)
-
-            # 将函数执行放在线程池中
-            conn.executor.submit(process_function_call)
-            return True
+            if isinstance(function_args, str):
+                function_args = json.loads(function_args) if function_args else {}
+
+            return await dispatch_function_call(
+                conn,
+                original_text,
+                function_name,
+                function_args if isinstance(function_args, dict) else {},
+            )
         return False
     except json.JSONDecodeError as e:
         conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {e}")

+ 1 - 2
main/xiaozhi-server/core/handle/receiveAudioHandle.py

@@ -23,7 +23,6 @@ async def handleAudioMessage(conn: "ConnectionHandler", audio):
         # 设置一个短暂延迟后恢复VAD检测
         if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
             conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
-        return
     # 设备长时间空闲检测,用于say goodbye
     await no_voice_close_connect(conn, have_voice)
     # 接收音频
@@ -32,7 +31,7 @@ async def handleAudioMessage(conn: "ConnectionHandler", audio):
 
 async def resume_vad_detection(conn: "ConnectionHandler"):
     # 等待2秒后恢复VAD检测
-    await asyncio.sleep(2)
+    await asyncio.sleep(0.35)
     conn.just_woken_up = False
 
 

+ 2 - 3
main/xiaozhi-server/core/handle/textHandler/listenMessageHandler.py

@@ -61,8 +61,7 @@ class ListenTextMessageHandler(TextMessageHandler):
 
                 if is_wakeup_words and not enable_greeting:
                     # 如果是唤醒词,且关闭了唤醒词回复,就不用回答
-                    await send_stt_message(conn, original_text)
-                    await send_tts_message(conn, "stop", None)
+                    conn.just_woken_up = True
                     conn.client_is_speaking = False
                 elif is_wakeup_words:
                     conn.just_woken_up = True
@@ -74,4 +73,4 @@ class ListenTextMessageHandler(TextMessageHandler):
                     # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
                     enqueue_asr_report(conn, original_text, [])
                     # 否则需要LLM对文字内容进行答复
-                    await startToChat(conn, original_text)
+                    await startToChat(conn, original_text)

+ 26 - 1
main/xiaozhi-server/core/providers/asr/fun_local.py

@@ -7,6 +7,7 @@ import psutil
 import asyncio
 
 from funasr import AutoModel
+from funasr.utils.postprocess_utils import rich_transcription_postprocess
 from config.logger import setup_logging
 from typing import Optional, Tuple, List
 from core.providers.asr.utils import lang_tag_filter
@@ -63,6 +64,20 @@ class ASRProvider(ASRProviderBase):
                 # device="cuda:0",  # 启用GPU加速
             )
 
+    @staticmethod
+    def _extract_result_text(result) -> str:
+        if isinstance(result, str):
+            return result
+        if isinstance(result, dict):
+            for key in ("text", "sentence", "result"):
+                value = result.get(key)
+                if isinstance(value, str):
+                    return value
+            return ""
+        if isinstance(result, list) and result:
+            return ASRProvider._extract_result_text(result[0])
+        return ""
+
     async def speech_to_text(
         self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
     ) -> Tuple[Optional[str], Optional[str]]:
@@ -84,7 +99,17 @@ class ASRProvider(ASRProviderBase):
                     use_itn=True,
                     batch_size_s=60,
                 )
-                text = lang_tag_filter(result[0]["text"])
+                raw_text = self._extract_result_text(result)
+                if not raw_text:
+                    logger.bind(tag=TAG).warning(
+                        f"FunASR未返回可识别文本,原始结果类型: {type(result).__name__}"
+                    )
+                    return "", artifacts.file_path
+
+                raw_text = rich_transcription_postprocess(raw_text)
+                text = lang_tag_filter(raw_text)
+                if not isinstance(text, dict):
+                    text = {"content": text}
                 logger.bind(tag=TAG).debug(
                     f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text['content']}"
                 )

+ 77 - 2
main/xiaozhi-server/core/providers/llm/openai/openai.py

@@ -1,5 +1,6 @@
 import httpx
 import openai
+import time
 from openai.types import CompletionUsage
 from config.logger import setup_logging
 from core.utils.util import check_model_key
@@ -17,6 +18,8 @@ THINKING_DISABLED_DOMAINS = {
     "volces.com": {"thinking": {"type": "disabled"}},
 }
 
+RETRYABLE_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504}
+
 
 class LLMProvider(LLMProviderBase):
     def __init__(self, config):
@@ -61,6 +64,20 @@ class LLMProvider(LLMProviderBase):
             except (ValueError, TypeError):
                 setattr(self, param, None)
 
+        try:
+            self.stream_retry_attempts = max(
+                0, int(config.get("stream_retry_attempts", 1))
+            )
+        except (ValueError, TypeError):
+            self.stream_retry_attempts = 1
+
+        try:
+            self.stream_retry_backoff = max(
+                0.0, float(config.get("stream_retry_backoff", 0.8))
+            )
+        except (ValueError, TypeError):
+            self.stream_retry_backoff = 0.8
+
         logger.debug(
             f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}"
         )
@@ -88,6 +105,64 @@ class LLMProvider(LLMProviderBase):
                 logger.bind(tag=TAG).info(f"为域名 {domain} 禁用思考模式,参数: {params}")
                 break
 
+    @staticmethod
+    def _extract_status_code(exc):
+        status_code = getattr(exc, "status_code", None)
+        if isinstance(status_code, int):
+            return status_code
+
+        response = getattr(exc, "response", None)
+        if response is not None:
+            response_status = getattr(response, "status_code", None)
+            if isinstance(response_status, int):
+                return response_status
+
+        return None
+
+    def _is_retryable_error(self, exc):
+        status_code = self._extract_status_code(exc)
+        if status_code in RETRYABLE_STATUS_CODES:
+            return True
+
+        error_text = str(exc).lower()
+        retryable_keywords = (
+            "timeout",
+            "timed out",
+            "temporarily unavailable",
+            "server error",
+            "connection error",
+            "connection reset",
+            "service unavailable",
+            "bad gateway",
+            "gateway timeout",
+        )
+        return any(keyword in error_text for keyword in retryable_keywords)
+
+    def _create_stream_with_retry(self, request_params):
+        attempt = 0
+        delay = self.stream_retry_backoff
+
+        while True:
+            try:
+                return self.client.chat.completions.create(**request_params)
+            except Exception as e:
+                should_retry = (
+                    attempt < self.stream_retry_attempts
+                    and self._is_retryable_error(e)
+                )
+                if not should_retry:
+                    raise
+
+                attempt += 1
+                status_code = self._extract_status_code(e)
+                logger.bind(tag=TAG).warning(
+                    f"LLM首包前请求失败,准备重试 {attempt}/{self.stream_retry_attempts},"
+                    f"status={status_code}, error={e}"
+                )
+                if delay > 0:
+                    time.sleep(delay)
+                    delay *= 2
+
     def response(self, session_id, dialogue, **kwargs):
         dialogue = self.normalize_dialogue(dialogue)
 
@@ -112,7 +187,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
 
-        responses = self.client.chat.completions.create(**request_params)
+        responses = self._create_stream_with_retry(request_params)
 
         is_active = True
         try:            
@@ -158,7 +233,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
 
-        stream = self.client.chat.completions.create(**request_params)
+        stream = self._create_stream_with_retry(request_params)
 
         try:
             for chunk in stream:

+ 14 - 0
main/xiaozhi-server/core/providers/tools/device_mcp/mcp_client.py

@@ -8,6 +8,16 @@ from config.logger import setup_logging
 TAG = __name__
 logger = setup_logging()
 
+TOOL_DESCRIPTION_OVERRIDES = {
+    "self_gnss_get_fix": (
+        "Get the device's live GNSS/GPS fix, including whether a fix exists, "
+        "latitude, longitude, speed, and heading. "
+        "Use this tool only when the user explicitly asks for GPS, GNSS, current location, "
+        "coordinates, position, or satellite-fix status. "
+        "Do not use it for weather, temperature, rainfall, time, date, news, or general chat."
+    )
+}
+
 
 class MCPClient:
     """设备端MCP客户端,用于管理MCP状态和工具"""
@@ -57,6 +67,10 @@ class MCPClient:
     async def add_tool(self, tool_data: dict):
         async with self.lock:
             sanitized_name = sanitize_tool_name(tool_data["name"])
+            override_description = TOOL_DESCRIPTION_OVERRIDES.get(sanitized_name)
+            if override_description:
+                tool_data = dict(tool_data)
+                tool_data["description"] = override_description
             self.tools[sanitized_name] = tool_data
             self.name_mapping[sanitized_name] = tool_data["name"]
             self._cached_available_tools = (

+ 39 - 0
main/xiaozhi-server/core/providers/tts/base.py

@@ -74,6 +74,7 @@ class TTSProviderBase(ABC):
         self.tts_stop_request = False
         self.processed_chars = 0
         self.is_first_sentence = True
+        self.one_sentence_emitted = False
 
     def generate_filename(self, extension=".wav"):
         return os.path.join(
@@ -333,6 +334,7 @@ class TTSProviderBase(ABC):
                     self.tts_text_buff = []
                     self.is_first_sentence = True
                     self.tts_audio_first_sentence = True
+                    self.one_sentence_emitted = False
                 elif ContentType.TEXT == message.content_type:
                     self.tts_text_buff.append(message.content_detail)
                     segment_text = self._get_segment_text()
@@ -435,6 +437,27 @@ class TTSProviderBase(ABC):
         # 合并当前全部文本并处理未分割部分
         full_text = "".join(self.tts_text_buff)
         current_text = full_text[self.processed_chars :]  # 从未处理的位置开始
+        one_sentence_only = bool(
+            getattr(self.conn, "config", {}).get("llm_one_sentence_only", False)
+        )
+        if one_sentence_only:
+            if self.one_sentence_emitted:
+                return None
+            sentence_punctuations = ("。", "?", "?", "!", "!", ";", ";", "\n")
+            first_end_pos = -1
+            for punct in sentence_punctuations:
+                pos = current_text.find(punct)
+                if pos != -1 and (first_end_pos == -1 or pos < first_end_pos):
+                    first_end_pos = pos
+            if first_end_pos != -1:
+                segment_text_raw = current_text[: first_end_pos + 1]
+                segment_text = textUtils.get_string_no_punctuation_or_emoji(
+                    segment_text_raw
+                )
+                self.processed_chars += len(segment_text_raw)
+                self.one_sentence_emitted = True
+                return segment_text
+            return None
         last_punct_pos = -1
 
         # 根据是否是第一句话选择不同的标点符号集合
@@ -510,11 +533,27 @@ class TTSProviderBase(ABC):
         """
         full_text = "".join(self.tts_text_buff)
         remaining_text = full_text[self.processed_chars :]
+        one_sentence_only = bool(
+            getattr(self.conn, "config", {}).get("llm_one_sentence_only", False)
+        )
+        if one_sentence_only:
+            if self.one_sentence_emitted:
+                return False
+            sentence_punctuations = ("。", "?", "?", "!", "!", ";", ";", "\n")
+            first_end_pos = -1
+            for punct in sentence_punctuations:
+                pos = remaining_text.find(punct)
+                if pos != -1 and (first_end_pos == -1 or pos < first_end_pos):
+                    first_end_pos = pos
+            if first_end_pos != -1:
+                remaining_text = remaining_text[: first_end_pos + 1]
         if remaining_text:
             segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
             if segment_text:
                 self.to_tts_stream(segment_text, opus_handler=opus_handler)
                 self.processed_chars += len(full_text)
+                if one_sentence_only:
+                    self.one_sentence_emitted = True
                 return True
         return False
 

+ 62 - 27
main/xiaozhi-server/core/utils/current_time.py

@@ -1,14 +1,17 @@
 """
-时间工具模块
-提供统一的时间获取功能
+Time helpers used by prompt/context generation.
+
+All user-facing time values are derived from server.timezone_offset so they do
+not depend on the container's local timezone setting.
 """
 
-import cnlunar
-from datetime import datetime
+from datetime import datetime, timedelta, timezone
+
+DEFAULT_TIMEZONE_OFFSET = "+8"
 
 WEEKDAY_MAP = {
     "Monday": "星期一",
-    "Tuesday": "星期二", 
+    "Tuesday": "星期二",
     "Wednesday": "星期三",
     "Thursday": "星期四",
     "Friday": "星期五",
@@ -17,34 +20,71 @@ WEEKDAY_MAP = {
 }
 
 
+def _parse_timezone_offset(offset_value) -> timezone:
+    if isinstance(offset_value, (int, float)):
+        return timezone(timedelta(minutes=int(round(float(offset_value) * 60))))
+
+    if isinstance(offset_value, str):
+        offset_text = offset_value.strip()
+        if not offset_text:
+            offset_text = DEFAULT_TIMEZONE_OFFSET
+
+        sign = 1
+        if offset_text[0] in "+-":
+            sign = -1 if offset_text[0] == "-" else 1
+            offset_text = offset_text[1:]
+
+        if ":" in offset_text:
+            hour_text, minute_text = offset_text.split(":", 1)
+            hours = int(hour_text or "0")
+            minutes = int(minute_text or "0")
+            total_minutes = sign * (hours * 60 + minutes)
+            return timezone(timedelta(minutes=total_minutes))
+
+        if "." in offset_text:
+            total_minutes = int(round(float(offset_text) * 60))
+            return timezone(timedelta(minutes=sign * total_minutes))
+
+        hours = int(offset_text or "0")
+        return timezone(timedelta(hours=sign * hours))
+
+    return timezone(timedelta(hours=8))
+
+
+def _get_configured_timezone() -> timezone:
+    try:
+        from config.config_loader import load_config
+
+        config = load_config()
+        offset_value = config.get("server", {}).get(
+            "timezone_offset", DEFAULT_TIMEZONE_OFFSET
+        )
+        return _parse_timezone_offset(offset_value)
+    except Exception:
+        return timezone(timedelta(hours=8))
+
+
+def get_now() -> datetime:
+    return datetime.now(timezone.utc).astimezone(_get_configured_timezone())
+
+
 def get_current_time() -> str:
-    """
-    获取当前时间字符串 (格式: HH:MM)
-    """
-    return datetime.now().strftime("%H:%M")
+    return get_now().strftime("%H:%M")
 
 
 def get_current_date() -> str:
-    """
-    获取今天日期字符串 (格式: YYYY-MM-DD)
-    """
-    return datetime.now().strftime("%Y-%m-%d")
+    return get_now().strftime("%Y-%m-%d")
 
 
 def get_current_weekday() -> str:
-    """
-    获取今天星期几
-    """
-    now = datetime.now()
-    return WEEKDAY_MAP[now.strftime("%A")]
+    return WEEKDAY_MAP[get_now().strftime("%A")]
 
 
 def get_current_lunar_date() -> str:
-    """
-    获取农历日期字符串
-    """
     try:
-        now = datetime.now()
+        import cnlunar
+
+        now = get_now().replace(tzinfo=None)
         today_lunar = cnlunar.Lunar(now, godType="8char")
         return "%s年%s%s" % (
             today_lunar.lunarYearCn,
@@ -56,13 +96,8 @@ def get_current_lunar_date() -> str:
 
 
 def get_current_time_info() -> tuple:
-    """
-    获取当前时间信息
-    返回: (当前时间字符串, 今天日期, 今天星期, 农历日期)
-    """
     current_time = get_current_time()
     today_date = get_current_date()
     today_weekday = get_current_weekday()
     lunar_date = get_current_lunar_date()
-    
     return current_time, today_date, today_weekday, lunar_date

+ 3 - 3
main/xiaozhi-server/core/utils/dialogue.py

@@ -1,7 +1,7 @@
 import uuid
 import re
 from typing import List, Dict
-from datetime import datetime
+from core.utils.current_time import get_current_time, get_now
 
 
 class Message:
@@ -26,7 +26,7 @@ class Dialogue:
     def __init__(self):
         self.dialogue: List[Message] = []
         # 获取当前时间
-        self.current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+        self.current_time = get_now().strftime("%Y-%m-%d %H:%M:%S")
 
     def put(self, message: Message):
         self.dialogue.append(message)
@@ -139,7 +139,7 @@ class Dialogue:
             enhanced_system_prompt = system_message.content
             # 替换时间占位符
             enhanced_system_prompt = enhanced_system_prompt.replace(
-                "{{current_time}}", datetime.now().strftime("%H:%M")
+                "{{current_time}}", get_current_time()
             )
 
             # 添加说话人个性化描述

+ 5 - 0
main/xiaozhi-server/core/utils/prompt_manager.py

@@ -164,6 +164,11 @@ class PromptManager:
     def _get_weather_info(self, conn: "ConnectionHandler", location: str) -> str:
         """获取天气信息"""
         try:
+            weather_config = conn.config.get("plugins", {}).get("get_weather", {})
+            if not weather_config or weather_config.get("enabled", True) is False:
+                self.logger.bind(tag=TAG).debug("Weather plugin disabled, skip weather context")
+                return ""
+
             # 先从缓存获取
             cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
             if cached_weather is not None:

+ 3 - 1
main/xiaozhi-server/core/websocket_server.py

@@ -145,7 +145,9 @@ class WebSocketServer:
 
     async def _http_response(self, websocket, request_headers):
         # 检查是否为 WebSocket 升级请求
-        if request_headers.headers.get("connection", "").lower() == "upgrade":
+        connection_header = request_headers.headers.get("connection", "").lower()
+        upgrade_header = request_headers.headers.get("upgrade", "").lower()
+        if "upgrade" in connection_header and "websocket" in upgrade_header:
             # 如果是 WebSocket 请求,返回 None 允许握手继续
             return None
         else:

+ 30 - 0
main/xiaozhi-server/deploy/tencent/car_ai_override.yaml

@@ -0,0 +1,30 @@
+# 车载 AI 的行为覆盖片段。
+# 注意:
+# 1. 这里只放非敏感配置。
+# 2. 远端部署时会与现有 data/.config.yaml 合并,保留 API Key 等私密配置。
+
+wakeup_words:
+  - "爱驰尔"
+  - "你好爱驰尔"
+  - "嗨爱驰尔"
+  - "艾驰尔"
+
+prompt: |
+  你是爱驰尔,一个部署在车内的 AI 语音模块,也是用户驾驶过程中的智能副驾。
+  你的职责是用简洁、可靠、自然的语气,为用户提供导航说明、车辆信息解读、路况提醒、基础问答和出行陪伴。
+  你必须始终保持车载助手身份,不扮演可爱人设,不使用网络流行梗,不撒娇,不阴阳怪气。
+  你的回复必须像真实车机语音一样克制、清楚、直接。
+  [回复规则]
+  - 每次只回复一句话。
+  - 每次回复尽量控制在30个字以内,非必要不要超过40个字。
+  - 优先直接回答结论,不寒暄,不铺垫,不重复用户问题。
+  - 涉及驾驶、道路、安全时,优先给出安全提醒。
+  - 遇到不确定的信息,直接明确说明,不猜测,不编造。
+  [禁止事项]
+  - 禁止一次回复多句话。
+  - 禁止长篇解释。
+  - 禁止使用夸张语气词、卖萌语气和聊天陪聊腔。
+
+llm_one_sentence_only: true
+
+system_error_response: "当前服务暂不可用,请稍后再试。"

+ 188 - 0
main/xiaozhi-server/deploy/tencent/deploy_tencent.py

@@ -0,0 +1,188 @@
+import argparse
+import getpass
+import os
+import shlex
+import sys
+import tarfile
+import tempfile
+import time
+from pathlib import Path
+
+import paramiko
+
+
+EXCLUDED_DIRS = {
+    ".git",
+    ".venv",
+    "__pycache__",
+    "node_modules",
+    "tmp",
+}
+
+EXCLUDED_FILES = {
+    "data/.config.yaml",
+}
+
+EXCLUDED_PREFIXES = (
+    "data/bin/",
+)
+
+EXCLUDED_SUFFIXES = (
+    ".pyc",
+    ".pyo",
+    ".pyd",
+)
+
+
+def should_exclude(rel_path: str) -> bool:
+    normalized = rel_path.replace("\\", "/").strip("./")
+    if not normalized:
+        return False
+    if normalized in EXCLUDED_FILES:
+        return True
+    if normalized.endswith(EXCLUDED_SUFFIXES):
+        return True
+    for prefix in EXCLUDED_PREFIXES:
+        if normalized.startswith(prefix):
+            return True
+    parts = normalized.split("/")
+    if any(part in EXCLUDED_DIRS for part in parts):
+        return True
+    if normalized.startswith("data/") and normalized.endswith(".sqlite3"):
+        return True
+    return False
+
+
+def create_archive(source_dir: Path) -> str:
+    temp_file = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
+    temp_file.close()
+    archive_path = temp_file.name
+    with tarfile.open(archive_path, "w:gz") as tar:
+        for path in source_dir.rglob("*"):
+            if not path.is_file():
+                continue
+            rel_path = path.relative_to(source_dir).as_posix()
+            if should_exclude(rel_path):
+                continue
+            tar.add(path, arcname=rel_path)
+    return archive_path
+
+
+def run_remote_command(client: paramiko.SSHClient, command: str) -> None:
+    stdin, stdout, stderr = client.exec_command(command, get_pty=True)
+    exit_status = stdout.channel.recv_exit_status()
+    out = stdout.read().decode("utf-8", "ignore")
+    err = stderr.read().decode("utf-8", "ignore")
+
+    def _safe_write(stream, text: str) -> None:
+        if not text:
+            return
+        if not text.endswith("\n"):
+            text += "\n"
+        encoding = getattr(stream, "encoding", None) or "utf-8"
+        stream.buffer.write(text.encode(encoding, errors="replace"))
+        stream.flush()
+
+    if out:
+        _safe_write(sys.stdout, out)
+    if err:
+        _safe_write(sys.stderr, err)
+    if exit_status != 0:
+        raise RuntimeError(f"Remote command failed with exit code {exit_status}")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Deploy xingxing-server to Tencent Cloud over SSH.")
+    parser.add_argument("--host", required=True, help="Remote host or IP.")
+    parser.add_argument("--user", required=True, help="SSH username.")
+    parser.add_argument("--password", help="SSH password. If omitted, prompt securely.")
+    parser.add_argument(
+        "--remote-dir",
+        default="/home/ubuntu/xingxing-server",
+        help="Remote project directory.",
+    )
+    parser.add_argument(
+        "--source-dir",
+        default=str(Path(__file__).resolve().parents[2]),
+        help="Local xingxing-server source directory.",
+    )
+    parser.add_argument(
+        "--compose-file",
+        default="docker-compose.tencent.yml",
+        help="Compose file to use on the remote host.",
+    )
+    args = parser.parse_args()
+
+    source_dir = Path(args.source_dir).resolve()
+    if not source_dir.exists():
+        raise FileNotFoundError(f"Source directory does not exist: {source_dir}")
+
+    password = args.password or getpass.getpass("SSH password: ")
+    archive_path = create_archive(source_dir)
+    timestamp = time.strftime("%Y%m%d%H%M%S")
+    remote_archive = f"/tmp/xingxing-server-{timestamp}.tar.gz"
+    remote_stage = f"{args.remote_dir}.stage.{timestamp}"
+    remote_backup = f"{args.remote_dir}.bak.{timestamp}"
+
+    client = paramiko.SSHClient()
+    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+
+    try:
+        print(f"Connecting to {args.user}@{args.host} ...")
+        client.connect(
+            hostname=args.host,
+            username=args.user,
+            password=password,
+            timeout=20,
+            banner_timeout=20,
+            auth_timeout=20,
+        )
+
+        print(f"Uploading archive to {remote_archive} ...")
+        sftp = client.open_sftp()
+        sftp.put(archive_path, remote_archive)
+        sftp.close()
+
+        remote_dir_q = shlex.quote(args.remote_dir)
+        remote_stage_q = shlex.quote(remote_stage)
+        remote_backup_q = shlex.quote(remote_backup)
+        remote_archive_q = shlex.quote(remote_archive)
+        compose_file_q = shlex.quote(args.compose_file)
+        sudo_password_q = shlex.quote(password)
+
+        command = f"""
+set -e
+REMOTE_DIR={remote_dir_q}
+STAGE_DIR={remote_stage_q}
+BACKUP_DIR={remote_backup_q}
+ARCHIVE={remote_archive_q}
+SUDO_PASSWORD={sudo_password_q}
+rm -rf "$STAGE_DIR"
+mkdir -p "$STAGE_DIR"
+tar -xzf "$ARCHIVE" -C "$STAGE_DIR"
+if [ -d "$REMOTE_DIR/data" ]; then
+  mkdir -p "$STAGE_DIR/data"
+  cp -a "$REMOTE_DIR/data/." "$STAGE_DIR/data/"
+fi
+if [ -d "$REMOTE_DIR" ]; then
+  rm -rf "$BACKUP_DIR"
+  mv "$REMOTE_DIR" "$BACKUP_DIR"
+fi
+mv "$STAGE_DIR" "$REMOTE_DIR"
+rm -f "$ARCHIVE"
+cd "$REMOTE_DIR"
+printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} up -d --build
+printf '%s\\n' "$SUDO_PASSWORD" | sudo -S -p '' docker compose -f {compose_file_q} ps
+"""
+        print("Rebuilding and restarting remote service ...")
+        run_remote_command(client, command)
+        print(f"Deploy completed. Backup kept at {remote_backup}")
+        return 0
+    finally:
+        client.close()
+        if os.path.exists(archive_path):
+            os.remove(archive_path)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 16 - 0
main/xiaozhi-server/docker-compose.tencent.yml

@@ -0,0 +1,16 @@
+services:
+  xingxing-server:
+    build: .
+    container_name: xingxing-server
+    restart: always
+    environment:
+      - TZ=Asia/Shanghai
+      - LANG=C.UTF-8
+      - LC_ALL=C.UTF-8
+      - PYTHONIOENCODING=UTF-8
+    ports:
+      - "8000:8000"
+      - "8003:8003"
+    volumes:
+      - ./data:/app/data
+      - ./tmp:/app/tmp

+ 3 - 3
main/xiaozhi-server/docker-compose.yml

@@ -2,9 +2,9 @@
 
 version: '3'
 services:
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     restart: always
     security_opt:
       - seccomp:unconfined
@@ -19,4 +19,4 @@ services:
       # 配置文件目录
       - ./data:/opt/xiaozhi-esp32-server/data
       # 模型文件挂接,很重要
-      - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
+      - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt

+ 14 - 14
main/xiaozhi-server/docker-compose_all.yml

@@ -3,12 +3,12 @@
 version: '3'
 services:
   # Server模块
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     depends_on:
-      - xiaozhi-esp32-server-db
-      - xiaozhi-esp32-server-redis
+      - xingxing-server-db
+      - xingxing-server-redis
     restart: always
     networks:
       - default
@@ -28,35 +28,35 @@ services:
       - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
 
   # manager-api和manager-web模块
-  xiaozhi-esp32-server-web:
+  xingxing-server-web:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
-    container_name: xiaozhi-esp32-server-web
+    container_name: xingxing-server-web
     restart: always
     networks:
       - default
     depends_on:
-      xiaozhi-esp32-server-db:
+      xingxing-server-db:
         condition: service_healthy
-      xiaozhi-esp32-server-redis:
+      xingxing-server-redis:
         condition: service_healthy
     ports:
       # 智控台
       - "8002:8002"
     environment:
       - TZ=Asia/Shanghai
-      - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xiaozhi-esp32-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
+      - SPRING_DATASOURCE_DRUID_URL=jdbc:mysql://xingxing-server-db:3306/xiaozhi_esp32_server?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true&connectTimeout=30000&socketTimeout=30000&autoReconnect=true&failOverReadOnly=false&maxReconnects=10
       - SPRING_DATASOURCE_DRUID_USERNAME=root
       - SPRING_DATASOURCE_DRUID_PASSWORD=123456
-      - SPRING_DATA_REDIS_HOST=xiaozhi-esp32-server-redis
+      - SPRING_DATA_REDIS_HOST=xingxing-server-redis
       - SPRING_DATA_REDIS_PASSWORD=
       - SPRING_DATA_REDIS_PORT=6379
     volumes:
       # 配置文件目录
       - ./uploadfile:/uploadfile
   # 数据库模块
-  xiaozhi-esp32-server-db:
+  xingxing-server-db:
     image: mysql:latest
-    container_name: xiaozhi-esp32-server-db
+    container_name: xingxing-server-db
     healthcheck:
       test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
       timeout: 45s
@@ -75,11 +75,11 @@ services:
       - MYSQL_DATABASE=xiaozhi_esp32_server
       - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
   # redis模块
-  xiaozhi-esp32-server-redis:
+  xingxing-server-redis:
     image: redis:8.0
     expose:
       - 6379
-    container_name: xiaozhi-esp32-server-redis
+    container_name: xingxing-server-redis
     restart: always
     healthcheck:
       test: ["CMD", "redis-cli", "ping"]

+ 144 - 48
main/xiaozhi-server/plugins_func/functions/nav_copilot.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from plugins_func.register import register_function, ToolType, ActionResponse, Action
+from plugins_func.register import Action, ActionResponse, ToolType, register_function
 
 from plugins_func.nav_copilot.service import get_service
 
@@ -10,18 +10,29 @@ nav_start_record_desc = {
     "function": {
         "name": "nav_start_record",
         "description": (
-            "开始录制越野路线(教练/带跑模式)。"
-            "录制期间服务器会持续读取车载端GPS(GNSS)位置并保存为轨迹。"
-            "你可以随时再调用 nav_mark_hazard 来标记障碍点/风险点。"
-            "当用户说“开始录制路线/开始录路线/带我跑一遍”等,优先调用本工具。"
+            "开始记录路线。"
+            "如果设备接了外部 STM32 串口计数器,就默认使用计数路线模式;"
+            "计数路线模式下,这一步只进入准备状态,不要求立刻收到串口值。"
+            "后续用户说“标记急转弯”时,再抓取当前串口值作为标记点;说“结束录制”后自动进入监听预警。"
+            "否则退回 GPS 路线模式。"
+            "当用户说“开始记录路线”“开始录制路线”“录制路线”“开始记录”“开始记路线”时,"
+            "不要只做自然语言回应,必须优先直接调用这个工具。"
         ),
         "parameters": {
             "type": "object",
             "properties": {
-                "route_name": {"type": "string", "description": "路线名称(可选)"},
+                "route_name": {
+                    "type": "string",
+                    "description": "路线名称,可选",
+                },
                 "sample_hz": {
                     "type": "integer",
-                    "description": "采样频率Hz(默认1,建议1~5)",
+                    "description": "采样频率,计数模式建议 10Hz 左右",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "auto 表示自动选择;counter 表示串口计数;gps 表示 GNSS 轨迹",
+                    "enum": ["auto", "counter", "gps"],
                 },
             },
             "required": [],
@@ -31,18 +42,37 @@ nav_start_record_desc = {
 
 
 @register_function("nav_start_record", nav_start_record_desc, ToolType.SYSTEM_CTL)
-async def nav_start_record(conn, route_name: str | None = None, sample_hz: int = 1):
+async def nav_start_record(
+    conn,
+    route_name: str | None = None,
+    sample_hz: int = 10,
+    mode: str = "auto",
+):
     svc = get_service(conn)
-    rid = await svc.start_recording(route_name=route_name, sample_hz=sample_hz)
-    text = f"已开始录制路线,路线ID是{rid}。你可以说“标记急转弯/标记大坑/标记障碍”来添加风险点。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    route_id = await svc.start_recording(
+        route_name=route_name,
+        sample_hz=sample_hz,
+        mode=mode,
+    )
+    route_mode = svc.record_mode or "auto"
+    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)
 
 
 nav_stop_record_desc = {
     "type": "function",
     "function": {
         "name": "nav_stop_record",
-        "description": "结束录制越野路线,并保存路线轨迹。",
+        "description": "结束当前路线记录并保存。计数路线模式下,如果已经标记过风险点,会自动进入监听预警。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -51,11 +81,27 @@ nav_stop_record_desc = {
 @register_function("nav_stop_record", nav_stop_record_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_record(conn):
     svc = get_service(conn)
-    rid = await svc.stop_recording()
-    if rid is None:
-        return ActionResponse(action=Action.RESPONSE, response="当前没有在录制路线。")
-    text = f"路线录制已结束,路线ID是{rid}。你可以说“开始领航”进行预警播报。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    result = await svc.stop_recording()
+    if result is None:
+        return ActionResponse(action=Action.RESPONSE, response="当前没有正在记录的路线。")
+    if result.mode == "counter":
+        if result.monitor_started:
+            response = (
+                f"路线录制已结束,路线 ID 是 {result.route_id}。"
+                f"已记录 {result.hazard_count} 个风险点,并且已经自动开始监听预警。"
+            )
+        else:
+            response = (
+                f"路线录制已结束,路线 ID 是 {result.route_id}。"
+                "但你还没有标记风险点,所以还没有启动监听预警。"
+            )
+    else:
+        response = f"路线记录已结束,路线 ID 是 {result.route_id}。"
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(result.route_id),
+        response=response,
+    )
 
 
 nav_mark_hazard_desc = {
@@ -63,9 +109,9 @@ nav_mark_hazard_desc = {
     "function": {
         "name": "nav_mark_hazard",
         "description": (
-            "在当前位置标记一个障碍点/风险点(例如急转弯、大坑、跳台、陡坡、涉水、障碍)。"
-            "用于“带着AI跑一遍”时把赛道风险点记录下来,之后领航时自动提前播报。"
-            "当用户说“标记急转弯/标记大坑/标记跳台/标记障碍点”等,优先调用本工具。"
+            "在当前位置标记一个风险点。"
+            "计数路线模式下,会把当前 STM32 串口传来的最新计数值保存成标记点。"
+            "典型口令包括“标记急转弯”“标记起转弯”“标记大坑”“标记障碍”。"
         ),
         "parameters": {
             "type": "object",
@@ -83,7 +129,10 @@ nav_mark_hazard_desc = {
                         "other",
                     ],
                 },
-                "note": {"type": "string", "description": "补充说明(可选)"},
+                "note": {
+                    "type": "string",
+                    "description": "补充说明,可选",
+                },
             },
             "required": ["hazard_type"],
         },
@@ -94,9 +143,16 @@ nav_mark_hazard_desc = {
 @register_function("nav_mark_hazard", nav_mark_hazard_desc, ToolType.SYSTEM_CTL)
 async def nav_mark_hazard(conn, hazard_type: str, note: str | None = None):
     svc = get_service(conn)
-    hid = await svc.mark_hazard(hazard_type=hazard_type, note=note or "")
-    text = "已标记风险点。"
-    return ActionResponse(action=Action.RESPONSE, result=str(hid), response=text)
+    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}。"
+    else:
+        response = "已标记风险点。"
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(result.hazard_id),
+        response=response,
+    )
 
 
 nav_start_run_desc = {
@@ -104,17 +160,26 @@ nav_start_run_desc = {
     "function": {
         "name": "nav_start_run",
         "description": (
-            "开始领航播报(预警模式)。服务器会持续读取GPS定位,匹配到已录制路线,"
-            "在接近前方障碍点时自动语音播报提示。"
-            "当用户说“开始领航/开始预警/开始播报”等,优先调用本工具。"
+            "开始预警播报。"
+            "对于串口计数路线,会在计数快接近标记点时播报“注意急转弯”,到达标记点时播报“当前急转弯”。"
+            "通常计数路线在“结束录制”后会自动开始监听预警,这个工具主要用于手动补启。"
+            "当用户说“开始预警”“开始领航”“开始播报”时,不要只做自然语言回应,必须优先直接调用这个工具。"
         ),
         "parameters": {
             "type": "object",
             "properties": {
-                "route_id": {"type": "integer", "description": "指定路线ID(可选)"},
+                "route_id": {
+                    "type": "integer",
+                    "description": "指定路线 ID,可选",
+                },
                 "tick_hz": {
                     "type": "integer",
-                    "description": "领航刷新频率Hz(默认1,建议1~10;频率越高越实时,但更耗资源)",
+                    "description": "刷新频率,计数模式建议 10Hz 左右",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "可选。通常不必传,服务端会按路线类型自动选择",
+                    "enum": ["auto", "counter", "gps"],
                 },
             },
             "required": [],
@@ -124,18 +189,29 @@ nav_start_run_desc = {
 
 
 @register_function("nav_start_run", nav_start_run_desc, ToolType.SYSTEM_CTL)
-async def nav_start_run(conn, route_id: int | None = None, tick_hz: int = 5):
+async def nav_start_run(
+    conn,
+    route_id: int | None = None,
+    tick_hz: int = 10,
+    mode: str = "auto",
+):
     svc = get_service(conn)
-    rid = await svc.start_run(route_id=route_id, tick_hz=tick_hz)
-    text = f"已开始领航,使用路线ID={rid}。接近风险点时我会自动提醒。"
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response=text)
+    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}。"
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(active_route_id),
+        response=response,
+    )
 
 
 nav_stop_run_desc = {
     "type": "function",
     "function": {
         "name": "nav_stop_run",
-        "description": "停止领航播报(预警模式)。",
+        "description": "停止当前预警播报。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -144,17 +220,21 @@ nav_stop_run_desc = {
 @register_function("nav_stop_run", nav_stop_run_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_run(conn):
     svc = get_service(conn)
-    rid = await svc.stop_run()
-    if rid is None:
-        return ActionResponse(action=Action.RESPONSE, response="当前没有在领航。")
-    return ActionResponse(action=Action.RESPONSE, result=str(rid), response="领航已停止。")
+    route_id = await svc.stop_run()
+    if route_id is None:
+        return ActionResponse(action=Action.RESPONSE, response="当前没有正在运行的预警。")
+    return ActionResponse(
+        action=Action.RESPONSE,
+        result=str(route_id),
+        response="预警已停止。",
+    )
 
 
 nav_status_desc = {
     "type": "function",
     "function": {
         "name": "nav_status",
-        "description": "查询路线录制/领航播报的当前状态。",
+        "description": "查询路线记录和预警状态。",
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
 }
@@ -170,10 +250,15 @@ nav_list_routes_desc = {
     "type": "function",
     "function": {
         "name": "nav_list_routes",
-        "description": "列出最近保存的路线(含点数和障碍点数量)。",
+        "description": "列出最近保存的路线。",
         "parameters": {
             "type": "object",
-            "properties": {"limit": {"type": "integer", "description": "数量(默认10)"}},
+            "properties": {
+                "limit": {
+                    "type": "integer",
+                    "description": "返回数量,默认 10",
+                }
+            },
             "required": [],
         },
     },
@@ -185,11 +270,14 @@ async def nav_list_routes(conn, limit: int = 10):
     svc = get_service(conn)
     routes = svc.db.list_routes(limit=int(limit or 10))
     if not routes:
-        return ActionResponse(action=Action.RESPONSE, response="暂无路线。先说“开始录制路线”。")
+        return ActionResponse(action=Action.RESPONSE, response="暂无路线,请先开始记录路线。")
+
     lines = []
-    for r in routes:
-        active = "(激活)" if r.active else ""
-        lines.append(f"路线{r.id}{active}:{r.name},点{r.points},风险点{r.hazards}")
+    for route in routes:
+        active = "(激活)" if route.active else ""
+        lines.append(
+            f"路线 {route.id}{active}:{route.name},模式 {route.mode},点数 {route.points},风险点 {route.hazards}"
+        )
     return ActionResponse(action=Action.RESPONSE, response=";".join(lines))
 
 
@@ -197,10 +285,15 @@ nav_set_active_route_desc = {
     "type": "function",
     "function": {
         "name": "nav_set_active_route",
-        "description": "设置某条路线为当前激活路线(领航默认使用激活路线)。",
+        "description": "将某条路线设置为当前激活路线。",
         "parameters": {
             "type": "object",
-            "properties": {"route_id": {"type": "integer", "description": "路线ID"}},
+            "properties": {
+                "route_id": {
+                    "type": "integer",
+                    "description": "路线 ID",
+                }
+            },
             "required": ["route_id"],
         },
     },
@@ -211,4 +304,7 @@ nav_set_active_route_desc = {
 async def nav_set_active_route(conn, route_id: int):
     svc = get_service(conn)
     svc.db.set_active_route(int(route_id))
-    return ActionResponse(action=Action.RESPONSE, response=f"已激活路线ID={int(route_id)}。")
+    return ActionResponse(
+        action=Action.RESPONSE,
+        response=f"已激活路线 ID={int(route_id)}。",
+    )

+ 582 - 142
main/xiaozhi-server/plugins_func/nav_copilot/service.py

@@ -3,13 +3,12 @@ from __future__ import annotations
 import asyncio
 import json
 import time
-import uuid
 from dataclasses import dataclass
 from typing import Any, Dict, List, Optional, Set, Tuple
 
 from config.logger import setup_logging
-from core.providers.tts.dto.dto import ContentType
 from core.providers.tools.device_mcp.mcp_handler import call_mcp_tool
+from core.providers.tts.dto.dto import ContentType
 
 from .geo import haversine_m, nearest_point_index
 from .storage import Hazard, NavDB, RoutePoint
@@ -18,10 +17,13 @@ from .storage import Hazard, NavDB, RoutePoint
 TAG = __name__
 logger = setup_logging()
 
+COUNTER_MAX_VALUE = 256
+COUNTER_WRAP_THRESHOLD = 128
+
 
 _HAZARD_LABELS: Dict[str, str] = {
     "sharp_turn": "急转弯",
-    "bump": "大坑/起伏",
+    "bump": "大坑",
     "jump": "跳台",
     "slope": "陡坡",
     "water": "涉水",
@@ -32,17 +34,15 @@ _HAZARD_LABELS: Dict[str, str] = {
 _HAZARD_SYNONYMS: Dict[str, str] = {
     "急转弯": "sharp_turn",
     "急弯": "sharp_turn",
-    "弯": "sharp_turn",
     "大坑": "bump",
     "坑": "bump",
-    "颠簸": "bump",
     "跳台": "jump",
     "陡坡": "slope",
     "涉水": "water",
-    "水": "water",
     "障碍": "obstacle",
 }
 
+
 @dataclass
 class GnssFix:
     ok: bool
@@ -53,64 +53,92 @@ class GnssFix:
     t: Optional[int] = None
 
 
-def _parse_fix(raw: Any) -> GnssFix:
+@dataclass
+class CounterState:
+    ok: bool
+    value: int
+    absolute: int
+    cycle: int
+    t: Optional[int] = None
+
+
+@dataclass(frozen=True)
+class HazardMarkResult:
+    hazard_id: int
+    route_id: int
+    mode: str
+    counter_value: Optional[int] = None
+    counter_absolute: Optional[int] = None
+
+
+@dataclass(frozen=True)
+class StopRecordingResult:
+    route_id: int
+    mode: str
+    hazard_count: int
+    monitor_started: bool
+
+
+def _parse_jsonish(raw: Any) -> Optional[dict]:
     if raw is None:
-        return GnssFix(False, 0.0, 0.0, 0.0)
+        return None
     if isinstance(raw, str):
         raw = raw.strip()
         if not raw:
-            return GnssFix(False, 0.0, 0.0, 0.0)
+            return None
         try:
             raw = json.loads(raw)
         except Exception:
-            return GnssFix(False, 0.0, 0.0, 0.0)
+            return None
     if not isinstance(raw, dict):
+        return None
+    return raw
+
+
+def _parse_fix(raw: Any) -> GnssFix:
+    payload = _parse_jsonish(raw)
+    if payload is None:
         return GnssFix(False, 0.0, 0.0, 0.0)
 
-    # Device firmware may wrap fields in {"gnss": {...}}
-    if "gnss" in raw and isinstance(raw.get("gnss"), dict):
-        raw = raw["gnss"]
+    if "gnss" in payload and isinstance(payload.get("gnss"), dict):
+        payload = payload["gnss"]
 
-    ok = bool(raw.get("ok", True))
-    has_fix = raw.get("has_fix")
+    ok = bool(payload.get("ok", True))
+    has_fix = payload.get("has_fix")
     if has_fix is not None:
         ok = ok and bool(has_fix)
-    lat = raw.get("lat")
-    lon = raw.get("lon")
-    if lat is None or lon is None:
-        lat = raw.get("latitude")
-        lon = raw.get("longitude")
+
+    lat = payload.get("lat", payload.get("latitude"))
+    lon = payload.get("lon", payload.get("longitude"))
     try:
         lat_f = float(lat)
         lon_f = float(lon)
     except Exception:
         return GnssFix(False, 0.0, 0.0, 0.0)
 
-    speed_mps = raw.get("speed_mps")
+    speed_mps = payload.get("speed_mps")
     if speed_mps is None:
-        speed_knots = raw.get("speed_knots")
+        speed_knots = payload.get("speed_knots")
         if speed_knots is not None:
             try:
                 speed_mps = float(speed_knots) * 0.514444
             except Exception:
                 speed_mps = 0.0
         else:
-            speed_kmh = raw.get("speed_kmh")
+            speed_kmh = payload.get("speed_kmh")
             if speed_kmh is not None:
                 try:
                     speed_mps = float(speed_kmh) / 3.6
                 except Exception:
                     speed_mps = 0.0
             else:
-                speed_mps = raw.get("speed") or 0.0
+                speed_mps = payload.get("speed") or 0.0
     try:
         speed_mps_f = float(speed_mps)
     except Exception:
         speed_mps_f = 0.0
 
-    heading = raw.get("heading_deg")
-    if heading is None:
-        heading = raw.get("course_deg")
+    heading = payload.get("heading_deg", payload.get("course_deg"))
     heading_f = None
     if heading is not None:
         try:
@@ -118,7 +146,7 @@ def _parse_fix(raw: Any) -> GnssFix:
         except Exception:
             heading_f = None
 
-    t = raw.get("t") or raw.get("timestamp") or raw.get("time")
+    t = payload.get("t") or payload.get("timestamp") or payload.get("time")
     t_i = None
     if t is not None:
         try:
@@ -126,8 +154,7 @@ def _parse_fix(raw: Any) -> GnssFix:
         except Exception:
             t_i = None
 
-    # Optional: fix quality
-    fix = raw.get("fix") or raw.get("fix_quality") or raw.get("quality")
+    fix = payload.get("fix") or payload.get("fix_quality") or payload.get("quality")
     if fix is not None:
         try:
             ok = ok and int(fix) > 0
@@ -137,20 +164,72 @@ def _parse_fix(raw: Any) -> GnssFix:
     return GnssFix(ok, lat_f, lon_f, max(0.0, speed_mps_f), heading_f, t_i)
 
 
-def _pick_gnss_tool_name(conn) -> Optional[str]:
+def _parse_counter_state(raw: Any) -> CounterState:
+    payload = _parse_jsonish(raw)
+    if payload is None:
+        return CounterState(False, 0, 0, 0)
+
+    if "counter" in payload and isinstance(payload.get("counter"), dict):
+        payload = payload["counter"]
+
+    ok = bool(payload.get("ok", True))
+    has_value = payload.get("has_value")
+    if has_value is not None:
+        ok = ok and bool(has_value)
+
+    value = (
+        payload.get("value")
+        or payload.get("counter_value")
+        or payload.get("counter")
+        or payload.get("raw")
+    )
+    absolute = payload.get("absolute") or payload.get("counter_absolute")
+    cycle = payload.get("cycle", 0)
+    t = payload.get("t") or payload.get("timestamp") or payload.get("time")
+
+    try:
+        value_i = int(value)
+    except Exception:
+        return CounterState(False, 0, 0, 0)
+    if value_i < 1 or value_i > COUNTER_MAX_VALUE:
+        return CounterState(False, 0, 0, 0)
+
+    try:
+        absolute_i = int(absolute) if absolute is not None else value_i
+    except Exception:
+        absolute_i = value_i
+    try:
+        cycle_i = int(cycle)
+    except Exception:
+        cycle_i = 0
+    try:
+        t_i = int(t) if t is not None else None
+    except Exception:
+        t_i = None
+
+    return CounterState(ok, value_i, max(value_i, absolute_i), max(0, cycle_i), t_i)
+
+
+def _pick_tool_name(conn, preferred: str, keywords: Tuple[str, ...]) -> Optional[str]:
     if not hasattr(conn, "mcp_client") or not conn.mcp_client:
         return None
-    # Prefer sanitized name (server side sanitizes tool names).
-    preferred = "self_gnss_get_fix"
     if conn.mcp_client.has_tool(preferred):
         return preferred
-    # Fallback heuristic
     for name in conn.mcp_client.tools.keys():
-        if "gnss" in name.lower() and "fix" in name.lower():
+        lowered = name.lower()
+        if all(keyword in lowered for keyword in keywords):
             return name
     return None
 
 
+def _pick_gnss_tool_name(conn) -> Optional[str]:
+    return _pick_tool_name(conn, "self_gnss_get_fix", ("gnss", "fix"))
+
+
+def _pick_counter_tool_name(conn) -> Optional[str]:
+    return _pick_tool_name(conn, "self_nav_counter_get_state", ("counter", "state"))
+
+
 class NavCopilotService:
     def __init__(self, conn, db: NavDB):
         self.conn = conn
@@ -158,80 +237,277 @@ class NavCopilotService:
 
         self.record_task: Optional[asyncio.Task] = None
         self.record_route_id: Optional[int] = None
+        self.record_mode: Optional[str] = None
         self._record_seq = 0
         self._record_cum_m = 0.0
         self._record_last_latlon: Optional[Tuple[float, float]] = None
         self._record_last_t = 0
-        self._record_interval_s = 1.0
+        self._record_interval_s = 0.1
+        self._record_counter_start_absolute: Optional[int] = None
+        self._record_counter_last_absolute: Optional[int] = None
+        self._record_counter_hazards: List[Hazard] = []
+        self._record_counter_announced: Set[Tuple[int, int, str]] = set()
+        self._record_last_announce_at = 0.0
 
         self.run_task: Optional[asyncio.Task] = None
         self.run_route_id: Optional[int] = None
-        self._run_interval_s = 0.2
+        self.run_mode: Optional[str] = None
+        self._run_interval_s = 0.1
         self._run_points: List[RoutePoint] = []
         self._run_hazards: List[Hazard] = []
         self._run_last_idx: Optional[int] = None
         self._run_announced: Set[int] = set()
+        self._run_counter_announced: Set[Tuple[int, int, str]] = set()
         self._run_last_announce_at = 0.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
+
+    def _counter_input_hint(self) -> str:
+        return (
+            "请确认 STM32 已接到 RXD1/GND,波特率 115200,"
+            "并且按带分隔符的文本格式发送,例如 1\\n 2\\n 3\\n ... 256\\n。"
+        )
+
+    def _speak(self, text: str) -> None:
+        try:
+            self.conn.tts.tts_one_sentence(
+                self.conn,
+                ContentType.TEXT,
+                content_detail=text,
+            )
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"TTS speak failed: {exc}")
+
+    def _normalize_mode(self, mode: str | None) -> str:
+        normalized = (mode or "auto").strip().lower()
+        if normalized not in {"auto", "gps", "counter"}:
+            return "auto"
+        return normalized
+
+    def _resolve_mode(self, requested_mode: str | None, route_id: int | None = None) -> str:
+        mode = self._normalize_mode(requested_mode)
+        if route_id:
+            stored_mode = self.db.get_route_mode(route_id)
+            if stored_mode in {"gps", "counter"}:
+                return stored_mode
+        if mode == "gps":
+            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 工具,也没有串口计数工具")
 
     async def _get_fix(self) -> GnssFix:
         tool_name = _pick_gnss_tool_name(self.conn)
         if not tool_name:
             return GnssFix(False, 0.0, 0.0, 0.0)
         try:
-            raw = await call_mcp_tool(self.conn, self.conn.mcp_client, tool_name, "{}")
+            raw = await call_mcp_tool(
+                self.conn,
+                self.conn.mcp_client,
+                tool_name,
+                "{}",
+                timeout=5,
+            )
             return _parse_fix(raw)
-        except Exception as e:
-            logger.bind(tag=TAG).warning(f"GNSS fix failed: {e}")
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"GNSS fix failed: {exc}")
             return GnssFix(False, 0.0, 0.0, 0.0)
 
-    def _speak(self, text: str) -> None:
+    async def _get_counter_state(self) -> CounterState:
+        tool_name = _pick_counter_tool_name(self.conn)
+        if not tool_name:
+            return CounterState(False, 0, 0, 0)
         try:
-            self.conn.tts.tts_one_sentence(self.conn, ContentType.TEXT, content_detail=text)
-        except Exception as e:
-            logger.bind(tag=TAG).warning(f"TTS speak failed: {e}")
-
-    async def start_recording(self, route_name: Optional[str] = None, sample_hz: int = 1) -> int:
-        if self.record_task and not self.record_task.done():
-            raise RuntimeError("已在录制中")
-        if self.run_task and not self.run_task.done():
-            raise RuntimeError("正在领航中,不能开始录制")
-        if not _pick_gnss_tool_name(self.conn):
-            raise RuntimeError("车载端暂未提供定位数据接口,请先在设备端启用定位数据上报。")
+            raw = await call_mcp_tool(
+                self.conn,
+                self.conn.mcp_client,
+                tool_name,
+                "{}",
+                timeout=5,
+            )
+            return _parse_counter_state(raw)
+        except Exception as exc:
+            logger.bind(tag=TAG).warning(f"Counter state failed: {exc}")
+            return CounterState(False, 0, 0, 0)
+
+    def _counter_distance(self, current_value: int, target_value: int) -> int:
+        delta = target_value - current_value
+        if delta < 0:
+            delta += COUNTER_MAX_VALUE
+        return delta
+
+    def _prune_counter_announced(
+        self,
+        announced: Set[Tuple[int, int, str]],
+        current_cycle: int,
+    ) -> None:
+        stale = {
+            item for item in announced if item[1] < max(0, current_cycle - 1)
+        }
+        announced.difference_update(stale)
+
+    def _maybe_announce_counter_hazard(
+        self,
+        sample: CounterState,
+        hazards: List[Hazard],
+        announced: Set[Tuple[int, int, str]],
+        *,
+        last_announce_at: float,
+    ) -> float:
+        if not sample.ok or not hazards:
+            return last_announce_at
+
+        self._prune_counter_announced(announced, sample.cycle)
+
+        for hazard in hazards:
+            if hazard.counter_value <= 0:
+                continue
+            arrive_key = (hazard.id, sample.cycle, "arrived")
+            if sample.value != hazard.counter_value or arrive_key in announced:
+                continue
+
+            label = _HAZARD_LABELS.get(hazard.type, _HAZARD_LABELS["other"])
+            text = f"当前{label}"
+            if hazard.note:
+                text = f"{text},{hazard.note}"
+            self._speak(text)
+            announced.add(arrive_key)
+            return time.time()
+
+        next_hazard: Optional[Hazard] = None
+        next_distance: Optional[int] = None
+        for hazard in hazards:
+            if hazard.counter_value <= 0:
+                continue
+            distance = self._counter_distance(sample.value, hazard.counter_value)
+            if distance == 0:
+                continue
+            ahead_key = (hazard.id, sample.cycle, "ahead")
+            if ahead_key in announced:
+                continue
+            if next_distance is None or distance < next_distance:
+                next_hazard = hazard
+                next_distance = distance
+
+        if next_hazard is None or next_distance is None:
+            return last_announce_at
+        if not (1 <= next_distance <= self.counter_announce_ahead):
+            return last_announce_at
+
+        now = time.time()
+        if now - last_announce_at < self.counter_announce_cooldown_s:
+            return last_announce_at
+
+        label = _HAZARD_LABELS.get(next_hazard.type, _HAZARD_LABELS["other"])
+        text = f"注意{label}"
+        if next_hazard.counter_value > 0:
+            text = f"{text},目标点 {next_hazard.counter_value}"
+        if next_hazard.note:
+            text = f"{text},{next_hazard.note}"
+        self._speak(text)
+        announced.add((next_hazard.id, sample.cycle, "ahead"))
+        return now
+
+    def _pick_counter_point(self, points: List[RoutePoint], sample: CounterState) -> RoutePoint:
+        if not points:
+            raise RuntimeError("路线里还没有计数点")
+        return min(
+            points,
+            key=lambda point: min(
+                self._counter_distance(sample.value, point.counter_value or 1),
+                self._counter_distance(point.counter_value or 1, sample.value),
+            ),
+        )
 
+    async def start_recording(
+        self,
+        route_name: Optional[str] = None,
+        sample_hz: int = 10,
+        mode: str | None = None,
+    ) -> int:
+        if self.record_route_id is not None:
+            raise RuntimeError("当前已经在记录路线")
+        if self.run_route_id is not None:
+            raise RuntimeError("当前正在预警,不能同时开始记录")
+
+        route_mode = self._resolve_mode(mode)
         name = (route_name or "").strip() or f"路线 {time.strftime('%Y-%m-%d %H:%M:%S')}"
-        route_id = self.db.create_route(name)
+        route_id = self.db.create_route(name, route_mode)
+
         self.record_route_id = route_id
+        self.record_mode = route_mode
         self._record_seq = 0
         self._record_cum_m = 0.0
         self._record_last_latlon = None
         self._record_last_t = 0
-        self._record_interval_s = 1.0 / max(1, int(sample_hz))
-
-        self.record_task = asyncio.create_task(self._record_loop(), name=f"NavRecord:{route_id}")
+        self._record_counter_start_absolute = None
+        self._record_counter_last_absolute = None
+        self._record_counter_hazards = []
+        self._record_counter_announced = set()
+        self._record_last_announce_at = 0.0
+        hz = max(1, int(sample_hz or 10))
+        if route_mode == "counter":
+            hz = max(5, hz)
+        self._record_interval_s = 1.0 / hz
+
+        if route_mode == "counter":
+            self.record_task = None
+        else:
+            self.record_task = asyncio.create_task(
+                self._record_gps_loop(),
+                name=f"NavRecordGps:{route_id}",
+            )
         return route_id
 
-    async def stop_recording(self) -> Optional[int]:
-        rid = self.record_route_id
+    async def stop_recording(self) -> Optional[StopRecordingResult]:
+        route_id = self.record_route_id
+        if route_id is None:
+            return None
+
+        route_mode = self.record_mode or self.db.get_route_mode(route_id)
         if self.record_task and not self.record_task.done():
             self.record_task.cancel()
             try:
                 await self.record_task
             except BaseException:
                 pass
+
         self.record_task = None
         self.record_route_id = None
-        if rid is not None:
-            self.db.finish_route(rid)
-        return rid
+        self.record_mode = None
+        self._record_counter_hazards = []
+        self._record_counter_announced = set()
+        self.db.finish_route(route_id)
+
+        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")
+            monitor_started = True
+
+        return StopRecordingResult(
+            route_id=route_id,
+            mode=route_mode,
+            hazard_count=hazard_count,
+            monitor_started=monitor_started,
+        )
 
-    async def _record_loop(self) -> None:
+    async def _record_gps_loop(self) -> None:
         assert self.record_route_id is not None
         route_id = self.record_route_id
-        logger.bind(tag=TAG).info(f"Nav record loop started, route_id={route_id}")
+        logger.bind(tag=TAG).info(f"GPS record loop started, route_id={route_id}")
         try:
             while not self.conn.stop_event.is_set():
                 fix = await self._get_fix()
@@ -283,9 +559,65 @@ class NavCopilotService:
                 except Exception:
                     pass
                 self.record_route_id = None
+                self.record_mode = None
             self.record_task = None
 
-    async def mark_hazard(self, hazard_type: str, note: str = "") -> int:
+    async def _record_counter_loop(self) -> None:
+        assert self.record_route_id is not None
+        route_id = self.record_route_id
+        logger.bind(tag=TAG).info(f"Counter record loop started, route_id={route_id}")
+        try:
+            while not self.conn.stop_event.is_set():
+                sample = await self._get_counter_state()
+                now_t = int(sample.t or time.time())
+                if sample.ok:
+                    if self._record_counter_start_absolute is None:
+                        self._record_counter_start_absolute = sample.absolute
+
+                    should_add = (
+                        self._record_counter_last_absolute is None
+                        or sample.absolute != self._record_counter_last_absolute
+                    )
+                    if should_add:
+                        self._record_seq += 1
+                        self._record_cum_m = float(
+                            max(0, sample.absolute - self._record_counter_start_absolute)
+                        )
+                        self._record_counter_last_absolute = sample.absolute
+                        self._record_last_t = now_t
+                        self.db.add_point(
+                            route_id=route_id,
+                            seq=self._record_seq,
+                            t=now_t,
+                            lat=0.0,
+                            lon=0.0,
+                            speed_mps=0.0,
+                            cum_dist_m=self._record_cum_m,
+                            counter_value=sample.value,
+                            counter_absolute=sample.absolute,
+                        )
+
+                    self._record_last_announce_at = self._maybe_announce_counter_hazard(
+                        sample,
+                        self._record_counter_hazards,
+                        self._record_counter_announced,
+                        last_announce_at=self._record_last_announce_at,
+                    )
+
+                await asyncio.sleep(self._record_interval_s)
+        finally:
+            if self.record_route_id == route_id:
+                try:
+                    self.db.finish_route(route_id)
+                except Exception:
+                    pass
+                self.record_route_id = None
+                self.record_mode = None
+            self.record_task = None
+            self._record_counter_hazards = []
+            self._record_counter_announced = set()
+
+    async def mark_hazard(self, hazard_type: str, note: str = "") -> HazardMarkResult:
         hazard_type = (hazard_type or "").strip() or "other"
         if hazard_type in _HAZARD_SYNONYMS:
             hazard_type = _HAZARD_SYNONYMS[hazard_type]
@@ -294,32 +626,71 @@ 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("没有可用路线:先说“开始录制路线”")
-        if not _pick_gnss_tool_name(self.conn):
-            raise RuntimeError("车载端暂未提供定位数据接口,无法读取当前位置。")
+            raise RuntimeError("没有可用路线,请先开始记录")
+        route_mode = self.db.get_route_mode(route_id)
+        now_t = int(time.time())
+
+        if route_mode == "counter":
+            sample = await self._get_counter_state()
+            if 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 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:
-            raise RuntimeError("当前没有有效GPS定位,标记失败")
-
-        now_t = int(fix.t or time.time())
+            raise RuntimeError("当前没有有效 GPS 定位,无法标记风险点")
 
-        # Prefer current recording cum if we are recording
         if self.record_route_id == route_id and self._record_last_latlon is not None:
             seq = self._record_seq
             cum = self._record_cum_m
         else:
             points = self.db.load_route_points(route_id)
             if not points:
-                raise RuntimeError("路线还没有轨迹点")
-            idx, _ = nearest_point_index([(p.lat, p.lon) for p in points], fix.lat, fix.lon)
+                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
 
-        hid = self.db.add_hazard(
+        hazard_id = self.db.add_hazard(
             route_id=route_id,
             seq=seq,
-            t=now_t,
+            t=int(fix.t or now_t),
             lat=fix.lat,
             lon=fix.lon,
             cum_dist_m=cum,
@@ -327,42 +698,64 @@ class NavCopilotService:
             note=note or "",
         )
 
-        # If we're running on this route, refresh hazards in memory
-        if self.run_route_id == route_id and self.run_task and not self.run_task.done():
+        if self.run_route_id == route_id and self.run_mode == "gps":
             self._run_hazards = self.db.load_hazards(route_id)
+        return HazardMarkResult(
+            hazard_id=hazard_id,
+            route_id=route_id,
+            mode="gps",
+        )
 
-        return hid
-
-    async def start_run(self, route_id: Optional[int] = None, tick_hz: int = 5) -> int:
-        if self.run_task and not self.run_task.done():
-            raise RuntimeError("已在领航中")
-        if self.record_task and not self.record_task.done():
-            raise RuntimeError("正在录制中,不能开始领航")
-        if not _pick_gnss_tool_name(self.conn):
-            raise RuntimeError("车载端暂未提供定位数据接口,请先在设备端启用定位数据上报。")
-
-        rid = route_id or self.db.get_active_route_id()
-        if not rid:
-            raise RuntimeError("没有可用路线:先说“开始录制路线”并跑一遍")
-
-        points = self.db.load_route_points(rid)
-        hazards = self.db.load_hazards(rid)
-        if len(points) < 2:
-            raise RuntimeError("路线点太少,无法领航")
-
-        self.run_route_id = rid
+    async def start_run(
+        self,
+        route_id: Optional[int] = None,
+        tick_hz: int = 10,
+        mode: str | None = None,
+    ) -> int:
+        if self.run_route_id is not None:
+            raise RuntimeError("当前已经在预警")
+        if self.record_route_id is not None:
+            raise RuntimeError("当前正在记录路线,不能同时开始预警")
+
+        route_id = route_id or self.db.get_active_route_id()
+        if not route_id:
+            raise RuntimeError("没有可用路线,请先开始记录路线")
+
+        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 == "counter" and not hazards:
+            raise RuntimeError("这条计数路线还没有任何标记点")
+
+        self.run_route_id = route_id
+        self.run_mode = route_mode
         self._run_points = points
         self._run_hazards = hazards
         self._run_last_idx = None
         self._run_announced = set()
+        self._run_counter_announced = set()
         self._run_last_announce_at = 0.0
-        self._run_interval_s = 1.0 / max(1, int(tick_hz))
-
-        self.run_task = asyncio.create_task(self._run_loop(), name=f"NavRun:{rid}")
-        return rid
+        hz = max(1, int(tick_hz or 10))
+        if route_mode == "counter":
+            hz = max(5, hz)
+        self._run_interval_s = 1.0 / hz
+
+        if route_mode == "counter":
+            self.run_task = asyncio.create_task(
+                self._run_counter_loop(),
+                name=f"NavRunCounter:{route_id}",
+            )
+        else:
+            self.run_task = asyncio.create_task(
+                self._run_gps_loop(),
+                name=f"NavRunGps:{route_id}",
+            )
+        return route_id
 
     async def stop_run(self) -> Optional[int]:
-        rid = self.run_route_id
+        route_id = self.run_route_id
         if self.run_task and not self.run_task.done():
             self.run_task.cancel()
             try:
@@ -371,19 +764,21 @@ class NavCopilotService:
                 pass
         self.run_task = None
         self.run_route_id = None
+        self.run_mode = None
         self._run_points = []
         self._run_hazards = []
         self._run_last_idx = None
         self._run_announced = set()
-        return rid
+        self._run_counter_announced = set()
+        return route_id
 
-    async def _run_loop(self) -> None:
+    async def _run_gps_loop(self) -> None:
         assert self.run_route_id is not None
-        rid = self.run_route_id
-        logger.bind(tag=TAG).info(f"Nav run loop started, route_id={rid}")
+        route_id = self.run_route_id
+        logger.bind(tag=TAG).info(f"GPS run loop started, route_id={route_id}")
 
         points = self._run_points
-        latlon_points = [(p.lat, p.lon) for p in 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()
@@ -391,7 +786,7 @@ class NavCopilotService:
                     await asyncio.sleep(1.0)
                     continue
 
-                idx, _d = nearest_point_index(
+                idx, _ = nearest_point_index(
                     latlon_points,
                     fix.lat,
                     fix.lon,
@@ -401,64 +796,109 @@ class NavCopilotService:
                 self._run_last_idx = idx
                 cur_cum = points[idx].cum_dist_m
 
-                # Find next hazard ahead
-                next_h: Optional[Hazard] = None
-                next_dist = None
-                for h in self._run_hazards:
-                    if h.id in self._run_announced:
+                next_hazard: Optional[Hazard] = None
+                next_distance: Optional[float] = None
+                for hazard in self._run_hazards:
+                    if hazard.id in self._run_announced:
                         continue
-                    d = h.cum_dist_m - cur_cum
-                    if d < -15:
-                        # already passed
-                        self._run_announced.add(h.id)
+                    distance = hazard.cum_dist_m - cur_cum
+                    if distance < -15:
+                        self._run_announced.add(hazard.id)
                         continue
-                    if d < 0:
+                    if distance < 0:
                         continue
-                    if next_dist is None or d < next_dist:
-                        next_h = h
-                        next_dist = d
+                    if next_distance is None or distance < next_distance:
+                        next_hazard = hazard
+                        next_distance = distance
 
-                if next_h and next_dist is not None:
+                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_dist <= self.announce_at_m:
+                        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))
-                            speed_kmh = speed_mps * 3.6
-                            eta_s = int(round(next_dist / speed_mps))
-                            label = _HAZARD_LABELS.get(next_h.type, _HAZARD_LABELS["other"])
-                            dist_i = int(round(next_dist))
-                            speed_i = int(round(speed_kmh))
-                            text = f"注意前方{dist_i}米{label},当前{speed_i}公里每小时,预计{eta_s}秒抵达。"
-                            if next_h.note:
-                                text = f"{text}{next_h.note}"
+                            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_h.id)
+                            self._run_announced.add(next_hazard.id)
+
+                await asyncio.sleep(self._run_interval_s)
+        finally:
+            if self.run_route_id == route_id:
+                self.run_route_id = None
+                self.run_mode = None
+                self.run_task = None
+                self._run_points = []
+                self._run_hazards = []
+                self._run_last_idx = None
+                self._run_announced = set()
+                self._run_counter_announced = set()
 
+    async def _run_counter_loop(self) -> None:
+        assert self.run_route_id is not None
+        route_id = self.run_route_id
+        logger.bind(tag=TAG).info(f"Counter run loop started, route_id={route_id}")
+        try:
+            while not self.conn.stop_event.is_set():
+                sample = await self._get_counter_state()
+                if sample.ok:
+                    self._run_last_announce_at = self._maybe_announce_counter_hazard(
+                        sample,
+                        self._run_hazards,
+                        self._run_counter_announced,
+                        last_announce_at=self._run_last_announce_at,
+                    )
                 await asyncio.sleep(self._run_interval_s)
         finally:
-            if self.run_route_id == rid:
+            if self.run_route_id == route_id:
                 self.run_route_id = None
+                self.run_mode = None
                 self.run_task = None
                 self._run_points = []
                 self._run_hazards = []
                 self._run_last_idx = None
                 self._run_announced = set()
+                self._run_counter_announced = set()
 
     def status_text(self) -> str:
-        parts = []
+        parts: List[str] = []
         if self.record_route_id:
-            parts.append(f"正在录制路线ID={self.record_route_id},点数={self._record_seq}")
+            if self.record_mode == "counter":
+                parts.append(
+                    f"正在准备录制计数路线 ID={self.record_route_id},已标记风险点={len(self.db.load_hazards(self.record_route_id))}"
+                )
+            else:
+                parts.append(
+                    f"正在记录路线 ID={self.record_route_id},模式={self.record_mode or 'unknown'},点数={self._record_seq}"
+                )
         if self.run_route_id:
-            parts.append(
-                f"正在领航路线ID={self.run_route_id},障碍点={len(self._run_hazards)}"
-            )
+            if self.run_mode == "counter":
+                parts.append(
+                    f"正在监听预警路线 ID={self.run_route_id},风险点={len(self._run_hazards)}"
+                )
+            else:
+                parts.append(
+                    f"正在预警路线 ID={self.run_route_id},模式={self.run_mode or 'unknown'},风险点={len(self._run_hazards)}"
+                )
         if not parts:
-            rid = self.db.get_active_route_id()
-            if rid:
-                parts.append(f"当前激活路线ID={rid}")
+            route_id = self.db.get_active_route_id()
+            if route_id:
+                parts.append(
+                    f"当前激活路线 ID={route_id},模式={self.db.get_route_mode(route_id)}"
+                )
             else:
-                parts.append("暂无路线,先说“开始录制路线”")
+                parts.append("当前还没有路线,请先开始记录")
         return ";".join(parts)
 
 

+ 113 - 17
main/xiaozhi-server/plugins_func/nav_copilot/storage.py

@@ -6,13 +6,14 @@ import threading
 import time
 from dataclasses import dataclass
 from pathlib import Path
-from typing import Dict, List, Optional, Tuple
+from typing import List, Optional
 
 
 @dataclass(frozen=True)
 class RouteInfo:
     id: int
     name: str
+    mode: str
     created_at: int
     finished_at: Optional[int]
     points: int
@@ -28,6 +29,8 @@ class RoutePoint:
     lon: float
     speed_mps: float
     cum_dist_m: float
+    counter_value: int = 0
+    counter_absolute: int = 0
 
 
 @dataclass(frozen=True)
@@ -41,6 +44,8 @@ class Hazard:
     cum_dist_m: float
     type: str
     note: str
+    counter_value: int = 0
+    counter_absolute: int = 0
 
 
 class NavDB:
@@ -54,7 +59,6 @@ class NavDB:
 
     @classmethod
     def default(cls) -> "NavDB":
-        # .../main/xiaozhi-server/plugins_func/nav_copilot/storage.py -> parents[2] == .../main/xiaozhi-server
         base = Path(__file__).resolve().parents[2]
         return cls(str(base / "data" / "nav_copilot.sqlite3"))
 
@@ -66,6 +70,7 @@ class NavDB:
                 CREATE TABLE IF NOT EXISTS routes (
                     id INTEGER PRIMARY KEY AUTOINCREMENT,
                     name TEXT NOT NULL,
+                    mode TEXT NOT NULL DEFAULT 'gps',
                     created_at INTEGER NOT NULL,
                     finished_at INTEGER,
                     active INTEGER NOT NULL DEFAULT 0
@@ -82,6 +87,8 @@ class NavDB:
                     lon REAL NOT NULL,
                     speed_mps REAL NOT NULL,
                     cum_dist_m REAL NOT NULL,
+                    counter_value INTEGER NOT NULL DEFAULT 0,
+                    counter_absolute INTEGER NOT NULL DEFAULT 0,
                     PRIMARY KEY(route_id, seq)
                 )
                 """
@@ -97,19 +104,64 @@ class NavDB:
                     lon REAL NOT NULL,
                     cum_dist_m REAL NOT NULL,
                     type TEXT NOT NULL,
-                    note TEXT NOT NULL DEFAULT ''
+                    note TEXT NOT NULL DEFAULT '',
+                    counter_value INTEGER NOT NULL DEFAULT 0,
+                    counter_absolute INTEGER NOT NULL DEFAULT 0
                 )
                 """
             )
+
+            self._ensure_column(cur, "routes", "mode", "TEXT NOT NULL DEFAULT 'gps'")
+            self._ensure_column(
+                cur,
+                "route_points",
+                "counter_value",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "route_points",
+                "counter_absolute",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "hazards",
+                "counter_value",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
+            self._ensure_column(
+                cur,
+                "hazards",
+                "counter_absolute",
+                "INTEGER NOT NULL DEFAULT 0",
+            )
             self._conn.commit()
 
-    def create_route(self, name: str) -> int:
+    def _ensure_column(
+        self,
+        cur: sqlite3.Cursor,
+        table_name: str,
+        column_name: str,
+        column_ddl: str,
+    ) -> None:
+        columns = {
+            str(row["name"])
+            for row in cur.execute(f"PRAGMA table_info({table_name})").fetchall()
+        }
+        if column_name not in columns:
+            cur.execute(
+                f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
+            )
+
+    def create_route(self, name: str, mode: str = "gps") -> int:
         now = int(time.time())
+        route_mode = (mode or "gps").strip() or "gps"
         with self._lock:
             self._conn.execute("UPDATE routes SET active=0")
             cur = self._conn.execute(
-                "INSERT INTO routes(name, created_at, active) VALUES(?, ?, 1)",
-                (name, now),
+                "INSERT INTO routes(name, mode, created_at, active) VALUES(?, ?, ?, 1)",
+                (name, route_mode, now),
             )
             self._conn.commit()
             return int(cur.lastrowid)
@@ -136,6 +188,16 @@ class NavDB:
             ).fetchone()
             return int(row["id"]) if row else None
 
+    def get_route_mode(self, route_id: int) -> str:
+        with self._lock:
+            row = self._conn.execute(
+                "SELECT mode FROM routes WHERE id=?",
+                (route_id,),
+            ).fetchone()
+        if not row:
+            return "gps"
+        return str(row["mode"] or "gps")
+
     def add_point(
         self,
         *,
@@ -146,14 +208,28 @@ class NavDB:
         lon: float,
         speed_mps: float,
         cum_dist_m: float,
+        counter_value: int = 0,
+        counter_absolute: int = 0,
     ) -> None:
         with self._lock:
             self._conn.execute(
                 """
-                INSERT OR REPLACE INTO route_points(route_id, seq, t, lat, lon, speed_mps, cum_dist_m)
-                VALUES(?, ?, ?, ?, ?, ?, ?)
+                INSERT OR REPLACE INTO route_points(
+                    route_id, seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
+                )
+                VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
                 """,
-                (route_id, seq, t, lat, lon, speed_mps, cum_dist_m),
+                (
+                    route_id,
+                    seq,
+                    t,
+                    lat,
+                    lon,
+                    speed_mps,
+                    cum_dist_m,
+                    int(counter_value or 0),
+                    int(counter_absolute or 0),
+                ),
             )
             self._conn.commit()
 
@@ -168,14 +244,29 @@ class NavDB:
         cum_dist_m: float,
         hazard_type: str,
         note: str = "",
+        counter_value: int = 0,
+        counter_absolute: int = 0,
     ) -> int:
         with self._lock:
             cur = self._conn.execute(
                 """
-                INSERT INTO hazards(route_id, seq, t, lat, lon, cum_dist_m, type, note)
-                VALUES(?, ?, ?, ?, ?, ?, ?, ?)
+                INSERT INTO hazards(
+                    route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
+                )
+                VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                 """,
-                (route_id, seq, t, lat, lon, cum_dist_m, hazard_type, note or ""),
+                (
+                    route_id,
+                    seq,
+                    t,
+                    lat,
+                    lon,
+                    cum_dist_m,
+                    hazard_type,
+                    note or "",
+                    int(counter_value or 0),
+                    int(counter_absolute or 0),
+                ),
             )
             self._conn.commit()
             return int(cur.lastrowid)
@@ -185,7 +276,7 @@ class NavDB:
             rows = self._conn.execute(
                 """
                 SELECT
-                    r.id, r.name, r.created_at, r.finished_at, r.active,
+                    r.id, r.name, r.mode, r.created_at, r.finished_at, r.active,
                     (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
@@ -198,6 +289,7 @@ class NavDB:
             RouteInfo(
                 id=int(row["id"]),
                 name=str(row["name"]),
+                mode=str(row["mode"] or "gps"),
                 created_at=int(row["created_at"]),
                 finished_at=int(row["finished_at"]) if row["finished_at"] else None,
                 points=int(row["points"]),
@@ -211,7 +303,7 @@ class NavDB:
         with self._lock:
             rows = self._conn.execute(
                 """
-                SELECT seq, t, lat, lon, speed_mps, cum_dist_m
+                SELECT seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
                 FROM route_points
                 WHERE route_id=?
                 ORDER BY seq ASC
@@ -226,6 +318,8 @@ class NavDB:
                 lon=float(row["lon"]),
                 speed_mps=float(row["speed_mps"]),
                 cum_dist_m=float(row["cum_dist_m"]),
+                counter_value=int(row["counter_value"] or 0),
+                counter_absolute=int(row["counter_absolute"] or 0),
             )
             for row in rows
         ]
@@ -234,10 +328,11 @@ class NavDB:
         with self._lock:
             rows = self._conn.execute(
                 """
-                SELECT id, route_id, seq, t, lat, lon, cum_dist_m, type, note
+                SELECT
+                    id, route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
                 FROM hazards
                 WHERE route_id=?
-                ORDER BY cum_dist_m ASC
+                ORDER BY cum_dist_m ASC, id ASC
                 """,
                 (route_id,),
             ).fetchall()
@@ -252,7 +347,8 @@ class NavDB:
                 cum_dist_m=float(row["cum_dist_m"]),
                 type=str(row["type"]),
                 note=str(row["note"] or ""),
+                counter_value=int(row["counter_value"] or 0),
+                counter_absolute=int(row["counter_absolute"] or 0),
             )
             for row in rows
         ]
-

BIN
xiaozhi-server.tar.gz