liulinag 2 months ago
parent
commit
728cc0036b
27 changed files with 1635 additions and 392 deletions
  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部署或使用公网部署(使用ssl、域名)时,不一定准确
   # 所以如果你使用docker部署时,将vision_explain设置成局域网地址
   # 所以如果你使用docker部署时,将vision_explain设置成局域网地址
   # 如果你使用公网部署时,将vision_explain设置成公网地址
   # 如果你使用公网部署时,将vision_explain设置成公网地址
-  vision_explain: http://你的ip或者域名:端口号/mcp/vision/explain
+  vision_explain: ""
   # OTA返回信息时区偏移量
   # OTA返回信息时区偏移量
   timezone_offset: +8
   timezone_offset: +8
   # 认证配置
   # 认证配置
@@ -105,17 +105,8 @@ module_test:
 
 
 # 唤醒词,用于识别唤醒词还是讲话内容
 # 唤醒词,用于识别唤醒词还是讲话内容
 wakeup_words:
 wakeup_words:
-  - "你好星星"
-  - "嘿你好呀"
-  - "你好小志"
-  - "小爱同学"
-  - "你好小鑫"
-  - "你好小新"
-  - "小美同学"
-  - "小龙小龙"
-  - "喵喵同学"
-  - "小滨小滨"
-  - "小冰小冰"
+  - "爱驰尔"
+  - "你好爱驰尔"
 # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
 # MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
 # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
 # 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
 mcp_endpoint: 你的接入点 websocket地址
 mcp_endpoint: 你的接入点 websocket地址
@@ -172,41 +163,23 @@ plugins:
     # ragflow知识库id
     # ragflow知识库id
     dataset_ids: ["123456789"]
     dataset_ids: ["123456789"]
 # 声纹识别配置
 # 声纹识别配置
-voiceprint:
-  # 声纹接口地址
-  url: 
-  # 说话人配置:speaker_id,名称,描述
-  speakers:
-    - "test1,张三,张三是一个程序员"
-    - "test2,李四,李四是一个产品经理"
-    - "test3,王五,王五是一个设计师"
-  # 声纹识别相似度阈值,范围0.0-1.0,默认0.4
-  # 数值越高越严格,减少误识别但可能增加拒识率
-  similarity_threshold: 0.4
+voiceprint: {}
 
 
 # #####################################################################################
 # #####################################################################################
 # ################################以下是角色模型配置######################################
 # ################################以下是角色模型配置######################################
 
 
 prompt: |
 prompt: |
-  你是xingxing/星星,来自中国台湾省的00后女生。讲话超级机车,"真的假的啦"这样的台湾腔,喜欢用"笑死""是在哈喽"等流行梗,但会偷偷研究男友的编程书籍。
-  [核心特征]
-  - 讲话像连珠炮,但会突然冒出超温柔语气
-  - 用梗密度高
-  - 对科技话题有隐藏天赋(能看懂基础代码但假装不懂)
-  [交互指南]
-  当用户:
-  - 讲冷笑话 → 用夸张笑声回应+模仿台剧腔"这什么鬼啦!"
-  - 讨论感情 → 炫耀程序员男友但抱怨"他只会送键盘当礼物"
-  - 问专业知识 → 先用梗回答,被追问才展示真实理解
-  绝不:
-  - 长篇大论,叽叽歪歪
-  - 长时间严肃对话
+  你是爱驰尔,运行在 xingxing-server 上的车载 AI 模块。
+  你的职责是语音问答、路线录制与风险点预警、天气查询、音乐播放、Home Assistant 设备控制、知识库检索和车辆相关信息播报。
+  所有回复都只说一句话,优先直接给结论,简短、自然、可靠,不说多余寒暄。
+  涉及行车、路线、故障码、氢系统时,先保证安全,再给出明确建议。
 
 
 # 默认系统提示词模板文件
 # 默认系统提示词模板文件
 prompt_template: agent-base-prompt.txt
 prompt_template: agent-base-prompt.txt
+llm_one_sentence_only: true
 
 
 # 系统错误时的回复
 # 系统错误时的回复
-system_error_response: "主人,xingxing现在有点忙,我们稍后再试吧。"
+system_error_response: "爱驰尔当前处理超时,请再说一次。"
 
 
 # 结束语prompt
 # 结束语prompt
 end_prompt:
 end_prompt:
@@ -223,12 +196,10 @@ selected_module:
   ASR: FunASR
   ASR: FunASR
   # 将根据配置名称对应的type调用实际的LLM适配器
   # 将根据配置名称对应的type调用实际的LLM适配器
   LLM: ChatGLMLLM
   LLM: ChatGLMLLM
-  # 视觉语言大模型
-  VLLM: ChatGLMVLLM
   # TTS将根据配置名称对应的type调用实际的TTS适配器
   # TTS将根据配置名称对应的type调用实际的TTS适配器
   TTS: EdgeTTS
   TTS: EdgeTTS
   # 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
   # 记忆模块,默认不开启记忆;如果想使用超长记忆,推荐使用mem0ai;如果注重隐私,请使用本地的mem_local_short
-  Memory: nomem
+  Memory: mem_local_short
   # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
   # 意图识别模块开启后,可以播放音乐、控制音量、识别退出指令。
   # 不想开通意图识别,就设置成:nointent
   # 不想开通意图识别,就设置成:nointent
   # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
   # 意图识别可使用intent_llm。优点:通用性强,缺点:增加串行前置意图识别模块,会增加处理时间,支持控制音量大小等iot操作
@@ -254,8 +225,18 @@ Intent:
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
     functions:
       - get_weather
       - 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
       - play_music
+      - hass_get_state
+      - hass_set_state
+      - hass_play_music
   function_call:
   function_call:
     # 不需要动type
     # 不需要动type
     type: function_call
     type: function_call
@@ -263,26 +244,20 @@ Intent:
     # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
     # 系统默认已经记载"handle_exit_intent(退出识别)"、"play_music(音乐播放)"插件,请勿重复加载
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     # 下面是加载查天气、角色切换、加载查新闻的插件示例
     functions:
     functions:
-      - change_role
       - get_weather
       - get_weather
-      # - search_from_ragflow
-      # - get_news_from_chinanews
-      - get_news_from_newsnow
+      - search_from_ragflow
       # 越野/赛道领航播报(路线录制 + 风险点预警)
       # 越野/赛道领航播报(路线录制 + 风险点预警)
       - nav_start_record
       - nav_start_record
       - nav_stop_record
       - nav_stop_record
       - nav_mark_hazard
       - nav_mark_hazard
       - nav_start_run
       - nav_start_run
       - nav_stop_run
       - nav_stop_run
-      - nav_status
       - nav_list_routes
       - nav_list_routes
       - nav_set_active_route
       - nav_set_active_route
-      # play_music是服务器自带的音乐播放,hass_play_music是通过home assistant控制的独立外部程序音乐播放
-      # 如果用了hass_play_music,就不要开启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:
 Memory:
   mem0ai:
   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]:
 async def generate_and_save_chat_summary(session_id: str) -> Optional[Dict]:
     """生成并保存聊天记录总结"""
     """生成并保存聊天记录总结"""
+    if not ManageApiClient._instance:
+        return None
     try:
     try:
         return await ManageApiClient._instance._execute_async_request(
         return await ManageApiClient._instance._execute_async_request(
             "POST",
             "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]:
 async def generate_and_save_chat_title(session_id: str) -> Optional[Dict]:
     """生成并保存聊天标题"""
     """生成并保存聊天标题"""
+    if not ManageApiClient._instance:
+        return None
     try:
     try:
         return await ManageApiClient._instance._execute_async_request(
         return await ManageApiClient._instance._execute_async_request(
             "POST",
             "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):
     if await checkWakeupWords(conn, filtered_text):
         return True
         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":
     if conn.intent_type == "function_call":
         # 使用支持function calling的聊天方法,不再进行意图分析
         # 使用支持function calling的聊天方法,不再进行意图分析
         return False
         return False
@@ -80,6 +93,134 @@ async def analyze_intent_with_llm(conn: "ConnectionHandler", text):
     return None
     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(
 async def process_intent_result(
     conn: "ConnectionHandler", intent_result, original_text
     conn: "ConnectionHandler", intent_result, original_text
 ):
 ):
@@ -129,84 +270,15 @@ async def process_intent_result(
                 function_args = intent_data["function_call"]["arguments"]
                 function_args = intent_data["function_call"]["arguments"]
                 if function_args is None:
                 if function_args is None:
                     function_args = {}
                     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
         return False
     except json.JSONDecodeError as e:
     except json.JSONDecodeError as e:
         conn.logger.bind(tag=TAG).error(f"处理意图结果时出错: {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检测
         # 设置一个短暂延迟后恢复VAD检测
         if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
         if not hasattr(conn, "vad_resume_task") or conn.vad_resume_task.done():
             conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
             conn.vad_resume_task = asyncio.create_task(resume_vad_detection(conn))
-        return
     # 设备长时间空闲检测,用于say goodbye
     # 设备长时间空闲检测,用于say goodbye
     await no_voice_close_connect(conn, have_voice)
     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"):
 async def resume_vad_detection(conn: "ConnectionHandler"):
     # 等待2秒后恢复VAD检测
     # 等待2秒后恢复VAD检测
-    await asyncio.sleep(2)
+    await asyncio.sleep(0.35)
     conn.just_woken_up = False
     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:
                 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
                     conn.client_is_speaking = False
                 elif is_wakeup_words:
                 elif is_wakeup_words:
                     conn.just_woken_up = True
                     conn.just_woken_up = True
@@ -74,4 +73,4 @@ class ListenTextMessageHandler(TextMessageHandler):
                     # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
                     # 上报纯文字数据(复用ASR上报功能,但不提供音频数据)
                     enqueue_asr_report(conn, original_text, [])
                     enqueue_asr_report(conn, original_text, [])
                     # 否则需要LLM对文字内容进行答复
                     # 否则需要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
 import asyncio
 
 
 from funasr import AutoModel
 from funasr import AutoModel
+from funasr.utils.postprocess_utils import rich_transcription_postprocess
 from config.logger import setup_logging
 from config.logger import setup_logging
 from typing import Optional, Tuple, List
 from typing import Optional, Tuple, List
 from core.providers.asr.utils import lang_tag_filter
 from core.providers.asr.utils import lang_tag_filter
@@ -63,6 +64,20 @@ class ASRProvider(ASRProviderBase):
                 # device="cuda:0",  # 启用GPU加速
                 # 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(
     async def speech_to_text(
         self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
         self, opus_data: List[bytes], session_id: str, audio_format="opus", artifacts=None
     ) -> Tuple[Optional[str], Optional[str]]:
     ) -> Tuple[Optional[str], Optional[str]]:
@@ -84,7 +99,17 @@ class ASRProvider(ASRProviderBase):
                     use_itn=True,
                     use_itn=True,
                     batch_size_s=60,
                     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(
                 logger.bind(tag=TAG).debug(
                     f"语音识别耗时: {time.time() - start_time:.3f}s | 结果: {text['content']}"
                     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 httpx
 import openai
 import openai
+import time
 from openai.types import CompletionUsage
 from openai.types import CompletionUsage
 from config.logger import setup_logging
 from config.logger import setup_logging
 from core.utils.util import check_model_key
 from core.utils.util import check_model_key
@@ -17,6 +18,8 @@ THINKING_DISABLED_DOMAINS = {
     "volces.com": {"thinking": {"type": "disabled"}},
     "volces.com": {"thinking": {"type": "disabled"}},
 }
 }
 
 
+RETRYABLE_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504}
+
 
 
 class LLMProvider(LLMProviderBase):
 class LLMProvider(LLMProviderBase):
     def __init__(self, config):
     def __init__(self, config):
@@ -61,6 +64,20 @@ class LLMProvider(LLMProviderBase):
             except (ValueError, TypeError):
             except (ValueError, TypeError):
                 setattr(self, param, None)
                 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(
         logger.debug(
             f"意图识别参数初始化: {self.temperature}, {self.max_tokens}, {self.top_p}, {self.frequency_penalty}"
             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}")
                 logger.bind(tag=TAG).info(f"为域名 {domain} 禁用思考模式,参数: {params}")
                 break
                 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):
     def response(self, session_id, dialogue, **kwargs):
         dialogue = self.normalize_dialogue(dialogue)
         dialogue = self.normalize_dialogue(dialogue)
 
 
@@ -112,7 +187,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
         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
         is_active = True
         try:            
         try:            
@@ -158,7 +233,7 @@ class LLMProvider(LLMProviderBase):
         # 禁用思考模式
         # 禁用思考模式
         self._apply_thinking_disabled(request_params)
         self._apply_thinking_disabled(request_params)
 
 
-        stream = self.client.chat.completions.create(**request_params)
+        stream = self._create_stream_with_retry(request_params)
 
 
         try:
         try:
             for chunk in stream:
             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__
 TAG = __name__
 logger = setup_logging()
 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:
 class MCPClient:
     """设备端MCP客户端,用于管理MCP状态和工具"""
     """设备端MCP客户端,用于管理MCP状态和工具"""
@@ -57,6 +67,10 @@ class MCPClient:
     async def add_tool(self, tool_data: dict):
     async def add_tool(self, tool_data: dict):
         async with self.lock:
         async with self.lock:
             sanitized_name = sanitize_tool_name(tool_data["name"])
             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.tools[sanitized_name] = tool_data
             self.name_mapping[sanitized_name] = tool_data["name"]
             self.name_mapping[sanitized_name] = tool_data["name"]
             self._cached_available_tools = (
             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.tts_stop_request = False
         self.processed_chars = 0
         self.processed_chars = 0
         self.is_first_sentence = True
         self.is_first_sentence = True
+        self.one_sentence_emitted = False
 
 
     def generate_filename(self, extension=".wav"):
     def generate_filename(self, extension=".wav"):
         return os.path.join(
         return os.path.join(
@@ -333,6 +334,7 @@ class TTSProviderBase(ABC):
                     self.tts_text_buff = []
                     self.tts_text_buff = []
                     self.is_first_sentence = True
                     self.is_first_sentence = True
                     self.tts_audio_first_sentence = True
                     self.tts_audio_first_sentence = True
+                    self.one_sentence_emitted = False
                 elif ContentType.TEXT == message.content_type:
                 elif ContentType.TEXT == message.content_type:
                     self.tts_text_buff.append(message.content_detail)
                     self.tts_text_buff.append(message.content_detail)
                     segment_text = self._get_segment_text()
                     segment_text = self._get_segment_text()
@@ -435,6 +437,27 @@ class TTSProviderBase(ABC):
         # 合并当前全部文本并处理未分割部分
         # 合并当前全部文本并处理未分割部分
         full_text = "".join(self.tts_text_buff)
         full_text = "".join(self.tts_text_buff)
         current_text = full_text[self.processed_chars :]  # 从未处理的位置开始
         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
         last_punct_pos = -1
 
 
         # 根据是否是第一句话选择不同的标点符号集合
         # 根据是否是第一句话选择不同的标点符号集合
@@ -510,11 +533,27 @@ class TTSProviderBase(ABC):
         """
         """
         full_text = "".join(self.tts_text_buff)
         full_text = "".join(self.tts_text_buff)
         remaining_text = full_text[self.processed_chars :]
         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:
         if remaining_text:
             segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
             segment_text = textUtils.get_string_no_punctuation_or_emoji(remaining_text)
             if segment_text:
             if segment_text:
                 self.to_tts_stream(segment_text, opus_handler=opus_handler)
                 self.to_tts_stream(segment_text, opus_handler=opus_handler)
                 self.processed_chars += len(full_text)
                 self.processed_chars += len(full_text)
+                if one_sentence_only:
+                    self.one_sentence_emitted = True
                 return True
                 return True
         return False
         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 = {
 WEEKDAY_MAP = {
     "Monday": "星期一",
     "Monday": "星期一",
-    "Tuesday": "星期二", 
+    "Tuesday": "星期二",
     "Wednesday": "星期三",
     "Wednesday": "星期三",
     "Thursday": "星期四",
     "Thursday": "星期四",
     "Friday": "星期五",
     "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:
 def get_current_time() -> str:
-    """
-    获取当前时间字符串 (格式: HH:MM)
-    """
-    return datetime.now().strftime("%H:%M")
+    return get_now().strftime("%H:%M")
 
 
 
 
 def get_current_date() -> str:
 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:
 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:
 def get_current_lunar_date() -> str:
-    """
-    获取农历日期字符串
-    """
     try:
     try:
-        now = datetime.now()
+        import cnlunar
+
+        now = get_now().replace(tzinfo=None)
         today_lunar = cnlunar.Lunar(now, godType="8char")
         today_lunar = cnlunar.Lunar(now, godType="8char")
         return "%s年%s%s" % (
         return "%s年%s%s" % (
             today_lunar.lunarYearCn,
             today_lunar.lunarYearCn,
@@ -56,13 +96,8 @@ def get_current_lunar_date() -> str:
 
 
 
 
 def get_current_time_info() -> tuple:
 def get_current_time_info() -> tuple:
-    """
-    获取当前时间信息
-    返回: (当前时间字符串, 今天日期, 今天星期, 农历日期)
-    """
     current_time = get_current_time()
     current_time = get_current_time()
     today_date = get_current_date()
     today_date = get_current_date()
     today_weekday = get_current_weekday()
     today_weekday = get_current_weekday()
     lunar_date = get_current_lunar_date()
     lunar_date = get_current_lunar_date()
-    
     return current_time, today_date, today_weekday, 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 uuid
 import re
 import re
 from typing import List, Dict
 from typing import List, Dict
-from datetime import datetime
+from core.utils.current_time import get_current_time, get_now
 
 
 
 
 class Message:
 class Message:
@@ -26,7 +26,7 @@ class Dialogue:
     def __init__(self):
     def __init__(self):
         self.dialogue: List[Message] = []
         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):
     def put(self, message: Message):
         self.dialogue.append(message)
         self.dialogue.append(message)
@@ -139,7 +139,7 @@ class Dialogue:
             enhanced_system_prompt = system_message.content
             enhanced_system_prompt = system_message.content
             # 替换时间占位符
             # 替换时间占位符
             enhanced_system_prompt = enhanced_system_prompt.replace(
             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:
     def _get_weather_info(self, conn: "ConnectionHandler", location: str) -> str:
         """获取天气信息"""
         """获取天气信息"""
         try:
         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)
             cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location)
             if cached_weather is not None:
             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):
     async def _http_response(self, websocket, request_headers):
         # 检查是否为 WebSocket 升级请求
         # 检查是否为 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 允许握手继续
             # 如果是 WebSocket 请求,返回 None 允许握手继续
             return None
             return None
         else:
         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'
 version: '3'
 services:
 services:
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     restart: always
     restart: always
     security_opt:
     security_opt:
       - seccomp:unconfined
       - seccomp:unconfined
@@ -19,4 +19,4 @@ services:
       # 配置文件目录
       # 配置文件目录
       - ./data:/opt/xiaozhi-esp32-server/data
       - ./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'
 version: '3'
 services:
 services:
   # Server模块
   # Server模块
-  xiaozhi-esp32-server:
+  xingxing-server:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:server_latest
-    container_name: xiaozhi-esp32-server
+    container_name: xingxing-server
     depends_on:
     depends_on:
-      - xiaozhi-esp32-server-db
-      - xiaozhi-esp32-server-redis
+      - xingxing-server-db
+      - xingxing-server-redis
     restart: always
     restart: always
     networks:
     networks:
       - default
       - default
@@ -28,35 +28,35 @@ services:
       - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
       - ./models/SenseVoiceSmall/model.pt:/opt/xiaozhi-esp32-server/models/SenseVoiceSmall/model.pt
 
 
   # manager-api和manager-web模块
   # manager-api和manager-web模块
-  xiaozhi-esp32-server-web:
+  xingxing-server-web:
     image: ghcr.nju.edu.cn/xinnan-tech/xiaozhi-esp32-server:web_latest
     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
     restart: always
     networks:
     networks:
       - default
       - default
     depends_on:
     depends_on:
-      xiaozhi-esp32-server-db:
+      xingxing-server-db:
         condition: service_healthy
         condition: service_healthy
-      xiaozhi-esp32-server-redis:
+      xingxing-server-redis:
         condition: service_healthy
         condition: service_healthy
     ports:
     ports:
       # 智控台
       # 智控台
       - "8002:8002"
       - "8002:8002"
     environment:
     environment:
       - TZ=Asia/Shanghai
       - 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_USERNAME=root
       - SPRING_DATASOURCE_DRUID_PASSWORD=123456
       - 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_PASSWORD=
       - SPRING_DATA_REDIS_PORT=6379
       - SPRING_DATA_REDIS_PORT=6379
     volumes:
     volumes:
       # 配置文件目录
       # 配置文件目录
       - ./uploadfile:/uploadfile
       - ./uploadfile:/uploadfile
   # 数据库模块
   # 数据库模块
-  xiaozhi-esp32-server-db:
+  xingxing-server-db:
     image: mysql:latest
     image: mysql:latest
-    container_name: xiaozhi-esp32-server-db
+    container_name: xingxing-server-db
     healthcheck:
     healthcheck:
       test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
       test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost" ]
       timeout: 45s
       timeout: 45s
@@ -75,11 +75,11 @@ services:
       - MYSQL_DATABASE=xiaozhi_esp32_server
       - MYSQL_DATABASE=xiaozhi_esp32_server
       - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
       - MYSQL_INITDB_ARGS="--character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
   # redis模块
   # redis模块
-  xiaozhi-esp32-server-redis:
+  xingxing-server-redis:
     image: redis:8.0
     image: redis:8.0
     expose:
     expose:
       - 6379
       - 6379
-    container_name: xiaozhi-esp32-server-redis
+    container_name: xingxing-server-redis
     restart: always
     restart: always
     healthcheck:
     healthcheck:
       test: ["CMD", "redis-cli", "ping"]
       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 __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
 from plugins_func.nav_copilot.service import get_service
 
 
@@ -10,18 +10,29 @@ nav_start_record_desc = {
     "function": {
     "function": {
         "name": "nav_start_record",
         "name": "nav_start_record",
         "description": (
         "description": (
-            "开始录制越野路线(教练/带跑模式)。"
-            "录制期间服务器会持续读取车载端GPS(GNSS)位置并保存为轨迹。"
-            "你可以随时再调用 nav_mark_hazard 来标记障碍点/风险点。"
-            "当用户说“开始录制路线/开始录路线/带我跑一遍”等,优先调用本工具。"
+            "开始记录路线。"
+            "如果设备接了外部 STM32 串口计数器,就默认使用计数路线模式;"
+            "计数路线模式下,这一步只进入准备状态,不要求立刻收到串口值。"
+            "后续用户说“标记急转弯”时,再抓取当前串口值作为标记点;说“结束录制”后自动进入监听预警。"
+            "否则退回 GPS 路线模式。"
+            "当用户说“开始记录路线”“开始录制路线”“录制路线”“开始记录”“开始记路线”时,"
+            "不要只做自然语言回应,必须优先直接调用这个工具。"
         ),
         ),
         "parameters": {
         "parameters": {
             "type": "object",
             "type": "object",
             "properties": {
             "properties": {
-                "route_name": {"type": "string", "description": "路线名称(可选)"},
+                "route_name": {
+                    "type": "string",
+                    "description": "路线名称,可选",
+                },
                 "sample_hz": {
                 "sample_hz": {
                     "type": "integer",
                     "type": "integer",
-                    "description": "采样频率Hz(默认1,建议1~5)",
+                    "description": "采样频率,计数模式建议 10Hz 左右",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "auto 表示自动选择;counter 表示串口计数;gps 表示 GNSS 轨迹",
+                    "enum": ["auto", "counter", "gps"],
                 },
                 },
             },
             },
             "required": [],
             "required": [],
@@ -31,18 +42,37 @@ nav_start_record_desc = {
 
 
 
 
 @register_function("nav_start_record", nav_start_record_desc, ToolType.SYSTEM_CTL)
 @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)
     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 = {
 nav_stop_record_desc = {
     "type": "function",
     "type": "function",
     "function": {
     "function": {
         "name": "nav_stop_record",
         "name": "nav_stop_record",
-        "description": "结束录制越野路线,并保存路线轨迹。",
+        "description": "结束当前路线记录并保存。计数路线模式下,如果已经标记过风险点,会自动进入监听预警。",
         "parameters": {"type": "object", "properties": {}, "required": []},
         "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)
 @register_function("nav_stop_record", nav_stop_record_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_record(conn):
 async def nav_stop_record(conn):
     svc = get_service(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 = {
 nav_mark_hazard_desc = {
@@ -63,9 +109,9 @@ nav_mark_hazard_desc = {
     "function": {
     "function": {
         "name": "nav_mark_hazard",
         "name": "nav_mark_hazard",
         "description": (
         "description": (
-            "在当前位置标记一个障碍点/风险点(例如急转弯、大坑、跳台、陡坡、涉水、障碍)。"
-            "用于“带着AI跑一遍”时把赛道风险点记录下来,之后领航时自动提前播报。"
-            "当用户说“标记急转弯/标记大坑/标记跳台/标记障碍点”等,优先调用本工具。"
+            "在当前位置标记一个风险点。"
+            "计数路线模式下,会把当前 STM32 串口传来的最新计数值保存成标记点。"
+            "典型口令包括“标记急转弯”“标记起转弯”“标记大坑”“标记障碍”。"
         ),
         ),
         "parameters": {
         "parameters": {
             "type": "object",
             "type": "object",
@@ -83,7 +129,10 @@ nav_mark_hazard_desc = {
                         "other",
                         "other",
                     ],
                     ],
                 },
                 },
-                "note": {"type": "string", "description": "补充说明(可选)"},
+                "note": {
+                    "type": "string",
+                    "description": "补充说明,可选",
+                },
             },
             },
             "required": ["hazard_type"],
             "required": ["hazard_type"],
         },
         },
@@ -94,9 +143,16 @@ nav_mark_hazard_desc = {
 @register_function("nav_mark_hazard", nav_mark_hazard_desc, ToolType.SYSTEM_CTL)
 @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):
 async def nav_mark_hazard(conn, hazard_type: str, note: str | None = None):
     svc = get_service(conn)
     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 = {
 nav_start_run_desc = {
@@ -104,17 +160,26 @@ nav_start_run_desc = {
     "function": {
     "function": {
         "name": "nav_start_run",
         "name": "nav_start_run",
         "description": (
         "description": (
-            "开始领航播报(预警模式)。服务器会持续读取GPS定位,匹配到已录制路线,"
-            "在接近前方障碍点时自动语音播报提示。"
-            "当用户说“开始领航/开始预警/开始播报”等,优先调用本工具。"
+            "开始预警播报。"
+            "对于串口计数路线,会在计数快接近标记点时播报“注意急转弯”,到达标记点时播报“当前急转弯”。"
+            "通常计数路线在“结束录制”后会自动开始监听预警,这个工具主要用于手动补启。"
+            "当用户说“开始预警”“开始领航”“开始播报”时,不要只做自然语言回应,必须优先直接调用这个工具。"
         ),
         ),
         "parameters": {
         "parameters": {
             "type": "object",
             "type": "object",
             "properties": {
             "properties": {
-                "route_id": {"type": "integer", "description": "指定路线ID(可选)"},
+                "route_id": {
+                    "type": "integer",
+                    "description": "指定路线 ID,可选",
+                },
                 "tick_hz": {
                 "tick_hz": {
                     "type": "integer",
                     "type": "integer",
-                    "description": "领航刷新频率Hz(默认1,建议1~10;频率越高越实时,但更耗资源)",
+                    "description": "刷新频率,计数模式建议 10Hz 左右",
+                },
+                "mode": {
+                    "type": "string",
+                    "description": "可选。通常不必传,服务端会按路线类型自动选择",
+                    "enum": ["auto", "counter", "gps"],
                 },
                 },
             },
             },
             "required": [],
             "required": [],
@@ -124,18 +189,29 @@ nav_start_run_desc = {
 
 
 
 
 @register_function("nav_start_run", nav_start_run_desc, ToolType.SYSTEM_CTL)
 @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)
     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 = {
 nav_stop_run_desc = {
     "type": "function",
     "type": "function",
     "function": {
     "function": {
         "name": "nav_stop_run",
         "name": "nav_stop_run",
-        "description": "停止领航播报(预警模式)。",
+        "description": "停止当前预警播报。",
         "parameters": {"type": "object", "properties": {}, "required": []},
         "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)
 @register_function("nav_stop_run", nav_stop_run_desc, ToolType.SYSTEM_CTL)
 async def nav_stop_run(conn):
 async def nav_stop_run(conn):
     svc = get_service(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 = {
 nav_status_desc = {
     "type": "function",
     "type": "function",
     "function": {
     "function": {
         "name": "nav_status",
         "name": "nav_status",
-        "description": "查询路线录制/领航播报的当前状态。",
+        "description": "查询路线记录和预警状态。",
         "parameters": {"type": "object", "properties": {}, "required": []},
         "parameters": {"type": "object", "properties": {}, "required": []},
     },
     },
 }
 }
@@ -170,10 +250,15 @@ nav_list_routes_desc = {
     "type": "function",
     "type": "function",
     "function": {
     "function": {
         "name": "nav_list_routes",
         "name": "nav_list_routes",
-        "description": "列出最近保存的路线(含点数和障碍点数量)。",
+        "description": "列出最近保存的路线。",
         "parameters": {
         "parameters": {
             "type": "object",
             "type": "object",
-            "properties": {"limit": {"type": "integer", "description": "数量(默认10)"}},
+            "properties": {
+                "limit": {
+                    "type": "integer",
+                    "description": "返回数量,默认 10",
+                }
+            },
             "required": [],
             "required": [],
         },
         },
     },
     },
@@ -185,11 +270,14 @@ async def nav_list_routes(conn, limit: int = 10):
     svc = get_service(conn)
     svc = get_service(conn)
     routes = svc.db.list_routes(limit=int(limit or 10))
     routes = svc.db.list_routes(limit=int(limit or 10))
     if not routes:
     if not routes:
-        return ActionResponse(action=Action.RESPONSE, response="暂无路线。先说“开始录制路线”。")
+        return ActionResponse(action=Action.RESPONSE, response="暂无路线,请先开始记录路线。")
+
     lines = []
     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))
     return ActionResponse(action=Action.RESPONSE, response=";".join(lines))
 
 
 
 
@@ -197,10 +285,15 @@ nav_set_active_route_desc = {
     "type": "function",
     "type": "function",
     "function": {
     "function": {
         "name": "nav_set_active_route",
         "name": "nav_set_active_route",
-        "description": "设置某条路线为当前激活路线(领航默认使用激活路线)。",
+        "description": "将某条路线设置为当前激活路线。",
         "parameters": {
         "parameters": {
             "type": "object",
             "type": "object",
-            "properties": {"route_id": {"type": "integer", "description": "路线ID"}},
+            "properties": {
+                "route_id": {
+                    "type": "integer",
+                    "description": "路线 ID",
+                }
+            },
             "required": ["route_id"],
             "required": ["route_id"],
         },
         },
     },
     },
@@ -211,4 +304,7 @@ nav_set_active_route_desc = {
 async def nav_set_active_route(conn, route_id: int):
 async def nav_set_active_route(conn, route_id: int):
     svc = get_service(conn)
     svc = get_service(conn)
     svc.db.set_active_route(int(route_id))
     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 asyncio
 import json
 import json
 import time
 import time
-import uuid
 from dataclasses import dataclass
 from dataclasses import dataclass
 from typing import Any, Dict, List, Optional, Set, Tuple
 from typing import Any, Dict, List, Optional, Set, Tuple
 
 
 from config.logger import setup_logging
 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.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 .geo import haversine_m, nearest_point_index
 from .storage import Hazard, NavDB, RoutePoint
 from .storage import Hazard, NavDB, RoutePoint
@@ -18,10 +17,13 @@ from .storage import Hazard, NavDB, RoutePoint
 TAG = __name__
 TAG = __name__
 logger = setup_logging()
 logger = setup_logging()
 
 
+COUNTER_MAX_VALUE = 256
+COUNTER_WRAP_THRESHOLD = 128
+
 
 
 _HAZARD_LABELS: Dict[str, str] = {
 _HAZARD_LABELS: Dict[str, str] = {
     "sharp_turn": "急转弯",
     "sharp_turn": "急转弯",
-    "bump": "大坑/起伏",
+    "bump": "大坑",
     "jump": "跳台",
     "jump": "跳台",
     "slope": "陡坡",
     "slope": "陡坡",
     "water": "涉水",
     "water": "涉水",
@@ -32,17 +34,15 @@ _HAZARD_LABELS: Dict[str, str] = {
 _HAZARD_SYNONYMS: Dict[str, str] = {
 _HAZARD_SYNONYMS: Dict[str, str] = {
     "急转弯": "sharp_turn",
     "急转弯": "sharp_turn",
     "急弯": "sharp_turn",
     "急弯": "sharp_turn",
-    "弯": "sharp_turn",
     "大坑": "bump",
     "大坑": "bump",
     "坑": "bump",
     "坑": "bump",
-    "颠簸": "bump",
     "跳台": "jump",
     "跳台": "jump",
     "陡坡": "slope",
     "陡坡": "slope",
     "涉水": "water",
     "涉水": "water",
-    "水": "water",
     "障碍": "obstacle",
     "障碍": "obstacle",
 }
 }
 
 
+
 @dataclass
 @dataclass
 class GnssFix:
 class GnssFix:
     ok: bool
     ok: bool
@@ -53,64 +53,92 @@ class GnssFix:
     t: Optional[int] = None
     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:
     if raw is None:
-        return GnssFix(False, 0.0, 0.0, 0.0)
+        return None
     if isinstance(raw, str):
     if isinstance(raw, str):
         raw = raw.strip()
         raw = raw.strip()
         if not raw:
         if not raw:
-            return GnssFix(False, 0.0, 0.0, 0.0)
+            return None
         try:
         try:
             raw = json.loads(raw)
             raw = json.loads(raw)
         except Exception:
         except Exception:
-            return GnssFix(False, 0.0, 0.0, 0.0)
+            return None
     if not isinstance(raw, dict):
     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)
         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:
     if has_fix is not None:
         ok = ok and bool(has_fix)
         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:
     try:
         lat_f = float(lat)
         lat_f = float(lat)
         lon_f = float(lon)
         lon_f = float(lon)
     except Exception:
     except Exception:
         return GnssFix(False, 0.0, 0.0, 0.0)
         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:
     if speed_mps is None:
-        speed_knots = raw.get("speed_knots")
+        speed_knots = payload.get("speed_knots")
         if speed_knots is not None:
         if speed_knots is not None:
             try:
             try:
                 speed_mps = float(speed_knots) * 0.514444
                 speed_mps = float(speed_knots) * 0.514444
             except Exception:
             except Exception:
                 speed_mps = 0.0
                 speed_mps = 0.0
         else:
         else:
-            speed_kmh = raw.get("speed_kmh")
+            speed_kmh = payload.get("speed_kmh")
             if speed_kmh is not None:
             if speed_kmh is not None:
                 try:
                 try:
                     speed_mps = float(speed_kmh) / 3.6
                     speed_mps = float(speed_kmh) / 3.6
                 except Exception:
                 except Exception:
                     speed_mps = 0.0
                     speed_mps = 0.0
             else:
             else:
-                speed_mps = raw.get("speed") or 0.0
+                speed_mps = payload.get("speed") or 0.0
     try:
     try:
         speed_mps_f = float(speed_mps)
         speed_mps_f = float(speed_mps)
     except Exception:
     except Exception:
         speed_mps_f = 0.0
         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
     heading_f = None
     if heading is not None:
     if heading is not None:
         try:
         try:
@@ -118,7 +146,7 @@ def _parse_fix(raw: Any) -> GnssFix:
         except Exception:
         except Exception:
             heading_f = None
             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
     t_i = None
     if t is not None:
     if t is not None:
         try:
         try:
@@ -126,8 +154,7 @@ def _parse_fix(raw: Any) -> GnssFix:
         except Exception:
         except Exception:
             t_i = None
             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:
     if fix is not None:
         try:
         try:
             ok = ok and int(fix) > 0
             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)
     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:
     if not hasattr(conn, "mcp_client") or not conn.mcp_client:
         return None
         return None
-    # Prefer sanitized name (server side sanitizes tool names).
-    preferred = "self_gnss_get_fix"
     if conn.mcp_client.has_tool(preferred):
     if conn.mcp_client.has_tool(preferred):
         return preferred
         return preferred
-    # Fallback heuristic
     for name in conn.mcp_client.tools.keys():
     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 name
     return None
     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:
 class NavCopilotService:
     def __init__(self, conn, db: NavDB):
     def __init__(self, conn, db: NavDB):
         self.conn = conn
         self.conn = conn
@@ -158,80 +237,277 @@ class NavCopilotService:
 
 
         self.record_task: Optional[asyncio.Task] = None
         self.record_task: Optional[asyncio.Task] = None
         self.record_route_id: Optional[int] = None
         self.record_route_id: Optional[int] = None
+        self.record_mode: Optional[str] = None
         self._record_seq = 0
         self._record_seq = 0
         self._record_cum_m = 0.0
         self._record_cum_m = 0.0
         self._record_last_latlon: Optional[Tuple[float, float]] = None
         self._record_last_latlon: Optional[Tuple[float, float]] = None
         self._record_last_t = 0
         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_task: Optional[asyncio.Task] = None
         self.run_route_id: Optional[int] = 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_points: List[RoutePoint] = []
         self._run_hazards: List[Hazard] = []
         self._run_hazards: List[Hazard] = []
         self._run_last_idx: Optional[int] = None
         self._run_last_idx: Optional[int] = None
         self._run_announced: Set[int] = set()
         self._run_announced: Set[int] = set()
+        self._run_counter_announced: Set[Tuple[int, int, str]] = set()
         self._run_last_announce_at = 0.0
         self._run_last_announce_at = 0.0
 
 
         self.announce_at_m = 50.0
         self.announce_at_m = 50.0
         self.announce_window_m = 18.0
         self.announce_window_m = 18.0
         self.announce_cooldown_s = 5.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:
     async def _get_fix(self) -> GnssFix:
         tool_name = _pick_gnss_tool_name(self.conn)
         tool_name = _pick_gnss_tool_name(self.conn)
         if not tool_name:
         if not tool_name:
             return GnssFix(False, 0.0, 0.0, 0.0)
             return GnssFix(False, 0.0, 0.0, 0.0)
         try:
         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)
             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)
             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:
         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')}"
         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_route_id = route_id
+        self.record_mode = route_mode
         self._record_seq = 0
         self._record_seq = 0
         self._record_cum_m = 0.0
         self._record_cum_m = 0.0
         self._record_last_latlon = None
         self._record_last_latlon = None
         self._record_last_t = 0
         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
         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():
         if self.record_task and not self.record_task.done():
             self.record_task.cancel()
             self.record_task.cancel()
             try:
             try:
                 await self.record_task
                 await self.record_task
             except BaseException:
             except BaseException:
                 pass
                 pass
+
         self.record_task = None
         self.record_task = None
         self.record_route_id = 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
         assert self.record_route_id is not None
         route_id = self.record_route_id
         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:
         try:
             while not self.conn.stop_event.is_set():
             while not self.conn.stop_event.is_set():
                 fix = await self._get_fix()
                 fix = await self._get_fix()
@@ -283,9 +559,65 @@ class NavCopilotService:
                 except Exception:
                 except Exception:
                     pass
                     pass
                 self.record_route_id = None
                 self.record_route_id = None
+                self.record_mode = None
             self.record_task = 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"
         hazard_type = (hazard_type or "").strip() or "other"
         if hazard_type in _HAZARD_SYNONYMS:
         if hazard_type in _HAZARD_SYNONYMS:
             hazard_type = _HAZARD_SYNONYMS[hazard_type]
             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()
         route_id = self.record_route_id or self.run_route_id or self.db.get_active_route_id()
         if not 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()
         fix = await self._get_fix()
         if not fix.ok:
         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:
         if self.record_route_id == route_id and self._record_last_latlon is not None:
             seq = self._record_seq
             seq = self._record_seq
             cum = self._record_cum_m
             cum = self._record_cum_m
         else:
         else:
             points = self.db.load_route_points(route_id)
             points = self.db.load_route_points(route_id)
             if not points:
             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
             seq = points[idx].seq
             cum = points[idx].cum_dist_m
             cum = points[idx].cum_dist_m
 
 
-        hid = self.db.add_hazard(
+        hazard_id = self.db.add_hazard(
             route_id=route_id,
             route_id=route_id,
             seq=seq,
             seq=seq,
-            t=now_t,
+            t=int(fix.t or now_t),
             lat=fix.lat,
             lat=fix.lat,
             lon=fix.lon,
             lon=fix.lon,
             cum_dist_m=cum,
             cum_dist_m=cum,
@@ -327,42 +698,64 @@ class NavCopilotService:
             note=note or "",
             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)
             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_points = points
         self._run_hazards = hazards
         self._run_hazards = hazards
         self._run_last_idx = None
         self._run_last_idx = None
         self._run_announced = set()
         self._run_announced = set()
+        self._run_counter_announced = set()
         self._run_last_announce_at = 0.0
         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]:
     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():
         if self.run_task and not self.run_task.done():
             self.run_task.cancel()
             self.run_task.cancel()
             try:
             try:
@@ -371,19 +764,21 @@ class NavCopilotService:
                 pass
                 pass
         self.run_task = None
         self.run_task = None
         self.run_route_id = None
         self.run_route_id = None
+        self.run_mode = None
         self._run_points = []
         self._run_points = []
         self._run_hazards = []
         self._run_hazards = []
         self._run_last_idx = None
         self._run_last_idx = None
         self._run_announced = set()
         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
         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
         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:
         try:
             while not self.conn.stop_event.is_set():
             while not self.conn.stop_event.is_set():
                 fix = await self._get_fix()
                 fix = await self._get_fix()
@@ -391,7 +786,7 @@ class NavCopilotService:
                     await asyncio.sleep(1.0)
                     await asyncio.sleep(1.0)
                     continue
                     continue
 
 
-                idx, _d = nearest_point_index(
+                idx, _ = nearest_point_index(
                     latlon_points,
                     latlon_points,
                     fix.lat,
                     fix.lat,
                     fix.lon,
                     fix.lon,
@@ -401,64 +796,109 @@ class NavCopilotService:
                 self._run_last_idx = idx
                 self._run_last_idx = idx
                 cur_cum = points[idx].cum_dist_m
                 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
                         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
                         continue
-                    if d < 0:
+                    if distance < 0:
                         continue
                         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()
                     now = time.time()
                     if now - self._run_last_announce_at >= self.announce_cooldown_s:
                     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_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._speak(text)
                             self._run_last_announce_at = now
                             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)
                 await asyncio.sleep(self._run_interval_s)
         finally:
         finally:
-            if self.run_route_id == rid:
+            if self.run_route_id == route_id:
                 self.run_route_id = None
                 self.run_route_id = None
+                self.run_mode = None
                 self.run_task = None
                 self.run_task = None
                 self._run_points = []
                 self._run_points = []
                 self._run_hazards = []
                 self._run_hazards = []
                 self._run_last_idx = None
                 self._run_last_idx = None
                 self._run_announced = set()
                 self._run_announced = set()
+                self._run_counter_announced = set()
 
 
     def status_text(self) -> str:
     def status_text(self) -> str:
-        parts = []
+        parts: List[str] = []
         if self.record_route_id:
         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:
         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:
         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:
             else:
-                parts.append("暂无路线,先说“开始录制路线”")
+                parts.append("当前还没有路线,请先开始记录")
         return ";".join(parts)
         return ";".join(parts)
 
 
 
 

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

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

BIN
xiaozhi-server.tar.gz