升级协议到v3
authorsnickersun <snickersun@163.com>
Wed, 27 May 2026 08:30:13 +0000 (16:30 +0800)
committersnickersun <snickersun@163.com>
Wed, 27 May 2026 08:30:13 +0000 (16:30 +0800)
录音音量降低
开机震动提前

18 files changed:
1.doc/K8_diaozhui/design/V2.5_to_V3_协议变更对照.md
2.sdk/Helios_SDK/services/microPython/ports/quectel/core/uwebsocket.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/Makefile
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_config.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/default_srv.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/main_init.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/motor.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8/default_config.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py [new file with mode: 0644]
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini [new file with mode: 0644]
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py [new file with mode: 0644]
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_common.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py [new file with mode: 0644]
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_imports.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_io.py
2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_session_media.py
2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip

index 98ee433..8376a29 100644 (file)
@@ -114,10 +114,17 @@ V3 start 负载结构强调“模式”和“音频配置”:
 | V2.5 字段路径 | V3 字段路径 | 备注 |\r
 |---|---|---|\r
 | `payload.device_id` | `data.device_id` | 字段名一致,位置不同 |\r
-| `payload.outbound_attributes.cfg.*` | `data.audio_config.output.*` | 输出音频参数归一 |\r
-| `payload.inbound_attributes.cfg.*` | `data.audio_config.input.*` | 输入音频参数归一 |\r
-| `payload.inbound_attributes.disable_vad` | `data.session_mode` | 语义变化:V3 用会话模式显式表达 |\r
-| `payload.simple_response` | `data.output_mode` + LLM 事件处理 | V3 不再用该开关表达“增量/完整”同一概念 |\r
+| `payload.outbound_attributes.cfg.*` | `data.audio_config.output.*` | 仅 `format/sample_rate/bit_rate/channel/speech_rate` |\r
+| `payload.outbound_attributes.cfg.voice` | `data.metadata.voice`(或 agent 绑定) | **V3 audio_config 无 voice** |\r
+| `payload.outbound_attributes.cfg.chunk_interval_ms` | `data.metadata.tts_cfg_extra` | **V3 audio_config 无此字段** |\r
+| `payload.outbound_attributes.cfg.emotion` | (V3 不传) | V3 无 emotion;端侧 V2.5 仍可在 outbound 使用 |\r
+| `payload.outbound_attributes.cfg.volume/chunk_interval_ms` | (V3 不传) | 同上 |\r
+| `payload.inbound_attributes.cfg.*` | `data.audio_config.input.*` | 仅 `format/sample_rate/bit_rate/channel` |\r
+| `payload.inbound_attributes.cfg.keywords` 等 | `data.metadata.asr_cfg_extra` | V3 input 无热词等字段 |\r
+| `payload.inbound_attributes.disable_vad=false` | `session_mode: "realtime"` | 长对话/VAD 自动断句;无需 audio.create |\r
+| `payload.inbound_attributes.disable_vad=true` | `session_mode: "push_to_talk"` | 按键说话;须 audio.create → 帧 → audio.completed |\r
+| `payload.simple_response: true` | `output_mode: "audio"` | 不订阅 LLM 文本流;与 `both`/`text` 区分 |\r
+| `payload.dialog_attributes` / `bot_option` | `agent_id` + `metadata` | V3 无 `dialog_attributes` 顶层;须服务端约定映射 |\r
 \r
 ---\r
 \r
@@ -343,5 +350,173 @@ V3 增加 `audio_encryption`:
 2) **别再等 `asr.completed` 才 cancel**:V3 cancel 面向打断 LLM/TTS,但要看策略。  \r
 3) **TTS 结束事件名不同**:V2.5 写 `tts.completed`;V3 是 `tts.done`。  \r
 4) **ASR 字段名不同**:V2.5 `content`;V3 `text`,并有 `is_final`。  \r
-5) **V2.5 的某些扩展事件 V3 文档没写**:先确认再迁移,避免上线缺功能。\r
+5) **V2.5 的某些扩展事件 V3 文档没写**:先确认再迁移,避免上线缺功能。  \r
+6) **本工程 Start 出站形态与 V2.5 文档示例不完全一致**:代码用 `header.event="Start"` + `payload`,recv 侧按顶层 `event` 解析;切 V3 时须统一走适配层,勿只改 start 不改 cancel/synthesis。\r
+\r
+---\r
+\r
+## 14. 本工程(7PIN_1_5 / k8)现状与代码锚点\r
+\r
+当前固件实现的是 **金三 V2.5 语义**(`event`/`payload` 或 `header.event` + `payload`),与 `vc_fsm` / 按键 UX 已深度绑定。\r
+\r
+| 职责 | 文件 | 关键符号 |\r
+|------|------|----------|\r
+| WS 建链 URL | `vc_session_io.py` | `websocket_connect_VC()`:`VC_WS_PATH` + `?is_auth=&dt={token}` |\r
+| 会话 start | `vc_session_io.py` | `AI_client_VC_start_requset()`:`header.event="Start"` + `inbound_attributes` / `outbound_attributes` / `dialog_attributes` |\r
+| 下行分发 | `vc_session_io.py` | `AI_client_VC_recv()`:`jsload_data["event"]`、`payload` |\r
+| PTT 音频控制 | `vc_session_media.py` | `AI_client_VC_send_speech()` / `end_speech()`:`audio.create` / `audio.completed` |\r
+| TTS 合成 / 播完上报 | `vc_session_media.py` | `AI_client_VC_synthesis()`、`AI_client_VC_audio_played()` |\r
+| 状态机 | `vc_fsm.py` | `VC_mode` 0=长对话(VAD on) ↔ realtime;1=PTT ↔ push_to_talk |\r
+| 产品配置 | `products/configs/k8/default_config.py` | `VC_WS_PATH = "vc/agent"` |\r
+| 环境主机 | `default_srv.py` / `srv_configs/*` | `AMP_SERVER`(`ws://{AMP_SERVER}/...`) |\r
+\r
+**与 V2.5 文档的差异(实现层,非阻塞迁移):**\r
+\r
+- 出站 **Start** 使用 `header.event: "Start"`(大写),文档示例为顶层 `"event": "start"`。\r
+- 出站 **audio.create / audio.completed** 放在 `header.event` 下;**cancel / synthesis** 为顶层 `event` + `payload`(`_vc_out_json_event_meta` 已兼容两种形态)。\r
+- **长对话**(`VC_mode==0`)在 start 里设 `disable_vad=False`;**PTT** 默认 `disable_vad=True`,与 V3 的 `session_mode` 一一对应。\r
+- **`audio.played`** 在 `vc_common.py` 注释中明确为 V2.5 服务端门控;`vc_fsm` 的 `await_played` 依赖本地播完 + 上报。\r
+\r
+---\r
+\r
+## 15. 最简单可配置迁移方案(推荐)\r
+\r
+原则:**配置切换协议版本 + 单一适配层归一化事件名/字段**;`vc_fsm`、按键、`vc_session_media` 上行门控 **尽量不改**,避免拆东墙补西墙。\r
+\r
+### 15.1 仅增配置项(`default_config.py`,k8 量产可覆盖)\r
+\r
+```python\r
+# 协议版本:v2.5 | v3\r
+VC_PROTOCOL_VERSION = "v2.5"\r
+\r
+# WebSocket 路径段(无首尾斜杠)\r
+# v2.5: vc/agent | vc/chat\r
+# v3:   rvc-service/api/v3/ws\r
+VC_WS_PATH = "vc/agent"\r
+\r
+# 鉴权 Query 风格(与 get_dev_token / DMP 链路配合)\r
+# dmp_dt  → ...?dt={token}(现状)\r
+# api_key → ...?api_key={key}&device_id={imei}(V3 ApiKey 模式,需服务端发 key)\r
+VC_WS_AUTH_MODE = "dmp_dt"\r
+\r
+# V2.5 专有:播完上报 audio.played;V3 文档无此事件,应置 0,改由 tts.done + 本地播完驱动\r
+VC_AUDIO_PLAYED_ACK = 1\r
+\r
+# V3 start:agent_id(对应现网 bot);workflow 等放进 metadata(须与 RVC 服务端约定)\r
+VC_AGENT_ID = None   # None 时用 VC_BOT_ID\r
+\r
+# 可选:首版不启用 V3 音频帧加密\r
+VC_AUDIO_ENCRYPTION = 0\r
+```\r
+\r
+`default_srv.py` 仍只负责 `AMP_SERVER`;**路径与协议版本放在 `default_config`**,与现有 `VC_WS_PATH` 分工一致。\r
+\r
+### 15.2 新增薄适配层(建议 `vc_proto.py`,约 120 行)\r
+\r
+只暴露 4 个函数,所有 JSON 进出 WS 经此层:\r
+\r
+| 函数 | 作用 |\r
+|------|------|\r
+| `vc_proto_build_ws_url(amp_server, path, auth_mode, token, device_id)` | 统一拼 `websocket_connect_VC` 的 URL |\r
+| `vc_proto_pack_client(event, payload)` | 出站:`v2.5` → `event/payload` 或 `header`;`v3` → `{type, data}` |\r
+| `vc_proto_unpack_server(raw_dict)` | 入站:解析后 **归一化为 V2.5 内部事件名 + payload 字典** |\r
+| `vc_proto_build_start(vc_session)` | 按版本生成 start/Start 整包 |\r
+\r
+**入站别名表(V3 → 内部仍用 V2.5 名,FSM/recv 零改动):**\r
+\r
+| V3 `type` | 内部 `event`(现有代码) |\r
+|-----------|-------------------------|\r
+| `error` | `failed` |\r
+| `tts.done` | `tts.completed` |\r
+| `llm.started` | `llm.created` |\r
+| 其余同名 | 不变(`started`、`asr.*`、`vad.*`、`cancel` 等) |\r
+\r
+**payload 字段归一(在 `unpack` 内完成):**\r
+\r
+- `asr.result`:`data.text` → `payload.content`(保留现有 `asr.result` 已尝试 `text` 的兼容逻辑)。\r
+- `llm.delta`:`data.delta` → 若业务读 `payload.content` 则同步写入。\r
+- `failed`/`error`:`data.code/message/details` → `payload.error` + `payload.message`(供 `websocket_error` 使用)。\r
+\r
+### 15.3 改动面最小化(仅 6 个 choke point)\r
+\r
+| # | 位置 | 改动 |\r
+|---|------|------|\r
+| 1 | `websocket_connect_VC` | URL 改为 `vc_proto_build_ws_url(...)` |\r
+| 2 | `AI_client_VC_start_requset` | 仍构建 V2.5 JSON;`AI_client_VC_send` 内 `vc_proto_pack_wire` 转 V3 start |\r
+| 3 | `AI_client_VC_send` | 统一 `vc_proto_pack_wire`(所有 JSON 出站,含 cancel/audio.*/synthesis) |\r
+| 4 | `AI_client_VC_recv` 解析后 | `ev, payload, env = vc_proto_unpack_server(jsload_data)`,后续仍用 `jsload_data["event"]` 可写回归一结果 |\r
+| 5 | `AI_client_VC_audio_played` | `if VC_AUDIO_PLAYED_ACK: ...`;V3 直接走现有 `_vc_fire_local_play_end` 链 |\r
+| 6 | `vc_common._vc_out_json_event_meta` | 同时识别 `type`(V3 出站日志) |\r
+\r
+**明确不先改:** `vc_fsm.py` 事件名、`vc_key_router`、录音上行门控(`VC_PROTO_GUARDS`)、二进制 PCM 发送逻辑。\r
+\r
+### 15.4 K8 产品:start 字段映射(V2.5 → V3)\r
+\r
+| 现网 `AI_client_VC_start_requset` | V3 `start.data` |\r
+|----------------------------------|-----------------|\r
+| `VC_mode==0` → `disable_vad=False` | `session_mode: "realtime"` |\r
+| `VC_mode==1` → `disable_vad=True` | `session_mode: "push_to_talk"` |\r
+| (固定音频对话) | `input_mode: "audio"`, `output_mode: "audio"` |\r
+| `simple_response: True` | `output_mode: "audio"`(不订阅 LLM 文本流;无需 `both`) |\r
+| `outbound_attributes.cfg.format/sr/...` | `audio_config.output.*`(仅 format/sample_rate/bit_rate/channel/speech_rate) |\r
+| `outbound_attributes.cfg.voice` | `metadata.voice` | 仅音色进 metadata |\r
+| `outbound_attributes.cfg.emotion/volume/chunk_interval_ms` | (不传) | V3 无这些字段 |\r
+| `inbound_attributes.cfg.*` + `vad_silence` | `audio_config.input.*`;静音阈值若 V3 无独立字段 → `metadata.vad_silence` **须服务端确认** |\r
+| `interrupt_options.thinking_time_only` | `interrupt_options.strategy: "thinking_time_only"`(V3 文档支持) |\r
+| `dialog_attributes.cfg.bot_option` | `agent_id` + `metadata`(`bot_id`/`workflow_id`/`custom_variables`) |\r
+| `payload.device_id` | `data.device_id` |\r
+| DMP `devToken` in `custom_variables` | `metadata` 或继续 DMP Query `dt` |\r
+\r
+### 15.5 分阶段上线建议\r
+\r
+| 阶段 | 配置 | 验证点 |\r
+|------|------|--------|\r
+| P0 联调 | `VC_PROTOCOL_VERSION=v3`,`VC_AUDIO_ENCRYPTION=0`,`VC_AUDIO_PLAYED_ACK=0` | 建链、start、started、长对话连续 3 轮、PTT 短按/长按 |\r
+| P1 行为对齐 | 同上 + 打断/cancel 策略 | thinking 期打断、PTT 过短取消、Power Close |\r
+| P2 可选 | `VC_AUDIO_ENCRYPTION=1` | 仅二进制帧加解密,JSON 仍明文 |\r
+| 保持 V2.5 | 默认不改配置 | 回归现有金三网关 |\r
+\r
+**上线前必须向 RVC 服务端确认:**\r
+\r
+1. K8 使用的 **Dify bot_id / workflow_id** 在 V3 下是 `agent_id` 还是 `metadata.inputs`。\r
+2. **`audio.played` 无等价事件** 时,关键词打断 / 连续对话是否仅依赖 `tts.done` + 本地播完。\r
+3. **`command` / `audio.convert.*`** 是否仍走旧 `/vc/agent` 或另有 HTTP/扩展 type。\r
+\r
+---\r
+\r
+## 16. 迁移改造 Checklist(端侧,含本仓库)\r
+\r
+在 §12 通用项基础上,按本工程勾选:\r
+\r
+- [x] `default_config.py` 增加 `VC_PROTOCOL_VERSION` / `VC_WS_AUTH_MODE` / `VC_AUDIO_PLAYED_ACK`(`configs/k8/default_config.py`)\r
+- [x] 新增 `vc_proto.py`(pack/unpack/url;start 经 `pack_wire` 转换)\r
+- [x] `vc_session_io.websocket_connect_VC` 使用 `vc_proto_build_ws_url`\r
+- [x] `AI_client_VC_start_requset`:仍构建 V2.5 结构,经 `AI_client_VC_send` → `vc_proto_pack_wire` 发 V3\r
+- [x] 所有 JSON 出站经 `AI_client_VC_send` → `vc_proto_pack_wire`(含 cancel/audio.*/synthesis)\r
+- [x] `AI_client_VC_recv` 入站 `vc_proto_normalize_server`\r
+- [x] `VC_AUDIO_PLAYED_ACK=0` 时 `play_end` 自动开麦(长对话):V3 无 `dialog_id` 时用 `_turn_has_asr_completed` / `_vc_expect_dialog_tts` 标 `kind=dialog`(`vc_session_io._vc_classify_llm_tts_kind`)\r
+- [ ] PTT:`session_mode=push_to_talk` 下 `audio.create`/`audio.completed` 行为不变 — **需实机 V3 联调**\r
+- [ ] 留言 / `audio.convert` / `command`:确认 V3 不支持则 **保持 v2.5 路径或单独开关**\r
+- [ ] 联调环境:`AMP_SERVER` + `VC_WS_PATH=rvc-service/api/v3/ws`(`default_srv` + k8 `default_config`)\r
+\r
+---\r
+\r
+## 17. 配置示例(k8 切 V3 联调)\r
+\r
+```python\r
+# products/configs/k8/default_config.py(或 usr 覆盖)\r
+VC_PROTOCOL_VERSION = "v3"\r
+VC_WS_PATH = "rvc-service/api/v3/ws"\r
+VC_WS_AUTH_MODE = "dmp_dt"          # 仍用设备 token 时\r
+VC_AUDIO_PLAYED_ACK = 0\r
+VC_AGENT_ID = VC_BOT_ID             # 或服务端分配的 agent_id\r
+VC_AUDIO_ENCRYPTION = 0\r
+```\r
+\r
+```python\r
+# products/srv_configs/beike_dev/default_srv.py(示例,按实际 RVC 主机修改)\r
+AMP_SERVER = "115.190.94.208:8087"  # 文档示例 ws://host:8087/rvc-service/api/v3/ws\r
+```\r
+\r
+编译:`new_clean.bat k8 1`(与 `.cursor/rules/project.mdc` 一致)。\r
 \r
index 258d5f1..cbf3d9c 100644 (file)
@@ -11,6 +11,16 @@ from ucollections import namedtuple
 import websocket_mask
 LOGGER = log.getLogger(__name__)
 
+
+def _vc_ws_native_ping_tx_enabled():
+    """1=客户端主动发 RFC6455 OP_PING;0=不发。入站 OP_PING 始终回 OP_PONG(与配置无关)。"""
+    try:
+        import vc_config
+        return int(vc_config.get_int("VC_WS_NATIVE_PING_ENABLE", 0)) != 0
+    except Exception:
+        return False
+
+
 # Opcodes
 OP_CONT = 0x0
 OP_TEXT = 0x1
@@ -215,10 +225,9 @@ class Websocket(object):
                 # Ignore this frame, keep waiting for a data frame
                 continue
             elif opcode == OP_PING:
-                # We need to send a pong frame
-                if self.debug: LOGGER.info("Sending PONG")
+                if self.debug:
+                    LOGGER.info("Sending PONG")
                 self.write_frame(OP_PONG, data)
-                # And then wait to receive
                 continue
             elif opcode == OP_CONT:
                 # This is a continuation of a previous frame
@@ -242,7 +251,8 @@ class Websocket(object):
         self.write_frame(opcode, buf)
 
     def ping(self):
-        self.write_frame(OP_PING,b'ping')
+        if _vc_ws_native_ping_tx_enabled():
+            self.write_frame(OP_PING, b'ping')
 
     def close(self, code=CLOSE_OK, reason=''):
         """Close the websocket."""
index 1c8fa20..16eb706 100644 (file)
@@ -969,6 +969,7 @@ frozen_mpy_list =   $(BUILD)/_boot.mpy \
                                        $(BUILD)/version_util.mpy \
                                        $(BUILD)/motor.mpy \
                                        $(BUILD)/vc_common.mpy \
+                                       $(BUILD)/vc_proto.mpy \
                                        $(BUILD)/vc_utils.mpy \
                                        $(BUILD)/vc_led.mpy \
                                        $(BUILD)/vc_hw.mpy \
index d7723d3..5bacccb 100644 (file)
@@ -1,9 +1,9 @@
 # -*- coding: utf-8 -*-
-# k8 产品 — 设备侧默认配置(编译期 freeze 进固件)
+# k8v3 产品 — 设备侧默认配置(RVC WebSocket V3;编译期 freeze 进固件)
 #
 # 加载优先级(vc_config.py):
 #   1) usr/default_config.py(现场覆盖)
-#   2) 本文件(由 new_clean.bat 按 product_k8.ini 的 DEV_TYPE 拷贝到 core/default_config.py)
+#   2) 本文件(由 new_clean.bat 按 product_k8v3.ini 的 DEV_TYPE 拷贝到 core/default_config.py)
 #
 # 约定:仅放「机型/产品差异」常量,勿放运行态变量。
 
@@ -106,6 +106,11 @@ BATTERY_OCV_MAP = {
     3634: 20, 3577: 15, 3522: 10, 3468: 5, 3400: 0,
 }
 
+# 录音麦克增益:record.gain_set(codec_gain, dsp_gain)
+# codec_gain 0~4(编解码模拟增益 默认4);dsp_gain -36~12(数字增益,原工程常用 10)
+RECORD_CODEC_GAIN = 2
+RECORD_DSP_GAIN = 6
+
 # =============================================================================
 # 5. 可选外设与协议
 # =============================================================================
@@ -114,6 +119,7 @@ GNSS_ENABLED = False           # 无 GNSS 硬件时 False,避免 import quecgn
 UART_SUPPORTED = False
 BT_OTA_ENABLED = False
 UPLOAD_START_TONE_ENABLED = False  # 开始上传语音时是否播成功提示音
+AI_ALARM_ENABLED = False         # K8 吊坠无闹钟/日程硬件
 
 # =============================================================================
 # 6. 按键逻辑(AI_dev / btn_device)
@@ -127,11 +133,28 @@ SOS_ENABLED = False                      # AI 长按 3s SOS、板载 SOS 入口
 # 7. VC 会话 / WebSocket / 空闲与休眠
 # =============================================================================
 
-# 语音 WS 路径段(无首尾斜杠);完整 URL 见 default_srv.AMP_SERVER + 文首拼接说明
-VC_WS_PATH = "vc/agent"
-VC_WS_PING_INTERVAL_SEC = 55  # 文本 ping 间隔,过大影响保活,过小费电
+# 语音 WS 路径段(无首尾斜杠);主机见 srv_configs/beike_dev_v3/default_srv.py AMP_SERVER
+VC_WS_PATH = "rvc-service/api/v3/ws"
+VC_WS_NATIVE_PING_ENABLE = 0   # 0=不发标准 PING(入站 PING 仍回 PONG);1=客户端也发标准 PING
+VC_WS_PING_INTERVAL_SEC = 0    # V3 无自定义文本心跳;仅 V2.5 使用(>0 时发 "ping")
+
+# VC 协议:v3(RVC);环境 SRV_TYPE=beike_dev_v3
+VC_PROTOCOL_VERSION = "v3"
+VC_WS_AUTH_MODE = "dmp_dt"       # dmp_dt | api_key
+VC_AUDIO_PLAYED_ACK = 0          # V2.5 播完上报 audio.played;V3 联调建议 0
+VC_AGENT_ID = None               # V3 start.agent_id;None 时用 VC_BOT_ID
+VC_AUDIO_ENCRYPTION = 0          # V3 二进制帧加密(首版保持 0)
+# VC_API_KEY = ""                # VC_WS_AUTH_MODE=api_key 时使用
+VC_PROTO_TX_LOG = 1              # 1=打印出站摘要 + 原始 JSON(过长分 json_partN 输出)
+
+# V3 start.welcome_options:0=关闭服务端自动欢迎 TTS(由设备 synthesis/notify 播报)
+VC_WELCOME_SERVER_ENABLED = 0
+# VC_WELCOME_SERVER_MESSAGE = ""  # VC_WELCOME_SERVER_ENABLED=1 时可选文案
+
+# 协议 failed / websocket_error 是否 synthesis 播报错误原文(联调=1,量产=0)
+VC_ERROR_TTS_ENABLED = 1
 
-# 长对话:无有效业务下行累计达此秒数结束会话(代码最小 5)
+# 长对话 L1:轮次间隙无 TTS 下行累计达此秒 Close(听/想/播/录、保活帧不计;最小 5)
 VC_IDLE_AUTO_CLOSE_SEC = 20
 VC_IDLE_AUTO_CLOSE_SEC_PTT = 0  # 单对话预留,当前与 idle 统一处理
 
@@ -139,7 +162,8 @@ VC_IDLE_AUTO_CLOSE_SEC_PTT = 0  # 单对话预留,当前与 idle 统一处理
 VC_IDLE_DEEP_SLEEP_SEC = 60
 VC_IDLE_DEEP_SLEEP_ENABLE = 1
 
-VAD_SILENCE_DEFAULT = 300  # VAD 静音阈值(ms)
+# 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
+VAD_SILENCE_DEFAULT = 300
 
 # =============================================================================
 # 8. 语音对话 / TTS / 智能体
@@ -148,8 +172,27 @@ VAD_SILENCE_DEFAULT = 300  # VAD 静音阈值(ms)
 POWER_ON_TONE_ENABLED = True
 POWER_ON_TONE_DEFAULT = "你好,我来了"
 
-VC_TTS_VOICE_TYPE_DEFAULT = None   # dict 或 None
-VC_TTS_VOICE_KEY_DEFAULT = None    # 如 "男" / "童"
+# 默认音色(无 storage / usr 覆盖时生效)
+# - voice:start → audio_config.output.voice,须与 RVC V3 服务端已配置的 speaker 一致
+# - 联调若报 resource ID mismatched with speaker,改 voice 或让服务端绑定该音色
+VC_TTS_VOICE_KEY_DEFAULT = "男"
+VC_TTS_VOICE_TYPE_DEFAULT = {
+    "男": {
+        "bot_name": "柒先生",
+        "voice": "zh_male_wennuanahu_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "女": {
+        "bot_name": "柒妹妹",
+        "voice": "zh_female_wanwanxiaohe_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "童": {
+        "bot_name": "柒乐乐",
+        "voice": "ICL_zh_female_huoponvhai_tob",
+        "lang": "zh-cn",
+    },
+}
 
 DIALOG_BOT_OPTION = None           # {"bot_id", "workflow_id", "use_workflow"}
 VC_BOT_ID = "7462617881076351002"
@@ -173,13 +216,20 @@ POWEROFF_CMD_CLOSE_ONLY = False     # 「关机」指令仅 Close,不真断电
 KWS_PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "
 PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "
 
+ASR_EMPTY_HINT_TTS = "我没有听清,请再说一遍"   # asr.completed 无有效识别时 Notify 播报
+
 # =============================================================================
-# 10. 开录音 / PTT 时序(k8 量产:仅 start.mp3 开录提醒,无按键确认音)
+# 10. 开录音 / PTT 时序(对齐「录音上传与播放」设计稿)
 # =============================================================================
+# 无按键确认音:不按 key_prompt;仅按键/PTT 开录时播 start.mp3(自动续聊不播)
+# PTT idle 短按:notify 话术 PTT_HINT;长按开录:start.mp3
 
-RECORD_START_TONE_FILE = "U:/static/start.mp3"
+RECORD_START_TONE_FILE = "U:/static/start.mp3"    # 按键/PTT 开录提示音(非 server.started 自动开麦)
 RECORD_START_TONE_GAP_MS = 180
 
+PTT_MIN_RECORD_MS = 400
+PTT_RECORD_TOO_SHORT_TTS = "录音时间太短,请按住圆形按钮说话"  # notify
+
 PTT_RELEASE_END_SPEECH_DELAY_MS = 200   # 松手后发 audio.completed 前延迟
 PTT_CANCEL_WAIT_MS = 2500               # 打断后等 canceled 最长时间
 PTT_POST_MUTE_MS = 30                   # 本地硬静音后 codec 收尾
index d282d90..e0e1e16 100644 (file)
@@ -5,7 +5,7 @@
 # =============================================================================\r
 #\r
 # 编译:\r
-#   product_k8.ini 中 SRV_TYPE=beike_dev\r
+#   product_k8v3.ini 中 SRV_TYPE=beike_dev_v3\r
 #     → new_clean.bat 拷贝本文件 → core/default_srv.py → 打进固件\r
 #\r
 # 运行时优先级(srv_config.py):\r
 # 【语音对话 WebSocket — AI_VC.websocket_connect_VC】\r
 #   主机:constants.AMP_SERVER(本文件 AMP_SERVER)\r
 #         → ai_VC 赋给 self.server_ip(可被存储 server_ip / 云端下发覆盖)\r
-#   路径:default_config.VC_WS_PATH(vc_config,如 k8 为 "vc/agent")\r
-#   查询:is_auth、设备 token(get_dev_token)\r
+#   路径:default_config.VC_WS_PATH(k8v3 为 "rvc-service/api/v3/ws")\r
+#   查询:DMP dt(get_dev_token)或 api_key(见 VC_WS_AUTH_MODE)\r
 #\r
-#   拼接结果:\r
-#     req_str = "{VC_WS_PATH}?is_auth={is_auth}"\r
-#     有 token:  ws://{AMP_SERVER}/{req_str}&dt={token}\r
-#     无 token:  ws://{AMP_SERVER}/{req_str}\r
-#\r
-#   示例(beike_dev + k8):\r
-#     ws://118.145.158.207:22080/vc/agent?is_auth=...&dt=...\r
+#   示例(beike_dev_v3 + k8v3,RVC V3 API 文档):\r
+#     ws://115.190.94.208:8087/rvc-service/api/v3/ws?is_auth=...&dt=...\r
 #\r
 # 【IoT 设备云 WebSocket — protocol.py】\r
 #   完整 URL 已在本文件写死为 IOT_WS_URL,仅追加 token:\r
 # =============================================================================\r
 \r
 # ---- 环境标识(仅注释/运维用;constants.SERVER_ENV 会读取)----\r
-SERVER_ENV = "beike_dev"\r
+SERVER_ENV = "beike_dev_v3"\r
 \r
 # ---- 三套「根地址」(constants.py 启动时加载,改环境必改)----\r
 \r
 # DMP 业务 HTTP 根路径(无尾斜杠)。用于 DMP_BASE + "/dev/..." 、"/ota/result?..."\r
 DMP_BASE = "http://dmp-dev.raisound.com/dmp-api"\r
 \r
-# 语音对话 AMP 主机 host:port(无 ws://)。用于 ws://{AMP_SERVER}/{VC_WS_PATH}...\r
-# 须与 default_config.VC_WS_PATH 联调;勿漏改 VC_WS_PATH(在 configs/k8/default_config.py)\r
-AMP_SERVER = "118.145.158.207:22080"\r
+# RVC V3 语音对话主机 host:port(无 ws://)。完整 WS:\r
+#   ws://{AMP_SERVER}/{VC_WS_PATH}  例见 configs/k8v3/default_config.py\r
+AMP_SERVER = "115.190.94.208:8087"\r
 \r
 # IoT 长连接完整 WebSocket URL(含 ws://)。protocol 仅追加 ?bz-dt=token\r
 IOT_WS_URL = "ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1"\r
index e43dcaa..57122db 100644 (file)
@@ -85,8 +85,19 @@ def main_init():
     res = list(res)
     EventMesh.debug_log('总usr空间:{} MB,剩余空间:{} MB'.format((((res[0] * res[2]) / 1024 / 1024)),((res[0] * res[3]) / 1024 / 1024)))
     EventMesh.debug_log("init1:{}".format(utime.localtime()))
-    
-    
+    try:
+        try:
+            import vc_config  # noqa: F401 — 尽早加载产品 GPIO/时长
+        except Exception:
+            pass
+        try:
+            from usr.motor import motor_boot_vibrate_kick_early
+        except Exception:
+            from motor import motor_boot_vibrate_kick_early
+        motor_boot_vibrate_kick_early()
+    except Exception as e:
+        usys.print_exception(e)
+
     EventMesh.debug_log("init2:{}".format(utime.localtime()))
     EventMesh.debug_log("init3:{}".format(utime.localtime()))
 
index c155c5c..275b53f 100644 (file)
@@ -20,6 +20,8 @@ except Exception:
 \r
 motor_pin = None\r
 _motor_active_low = 0\r
+_boot_vibrate_kicked = 0\r
+_boot_vibrate_timer = None\r
 \r
 \r
 def _cfg_motor_gpio_name():\r
@@ -96,6 +98,34 @@ def close_motor():
     motor_pin.write(1 if _motor_active_low else 0)\r
 \r
 \r
+def motor_boot_vibrate_kick_early():\r
+    """开机尽早震动(main_init 早期调用);与 motorEventManager 注册幂等。"""\r
+    global _boot_vibrate_kicked, _boot_vibrate_timer\r
+    ms = 0 if _boot_vibrate_kicked else _cfg_boot_vibrate_ms()\r
+    if ms <= 0:\r
+        return\r
+    try:\r
+        _ensure_motor_pin()\r
+        open_motor()\r
+        _boot_vibrate_kicked = 1\r
+\r
+        def _boot_vibe_stop(*args):\r
+            try:\r
+                close_motor()\r
+            except Exception:\r
+                pass\r
+\r
+        try:\r
+            _boot_vibrate_timer.stop()\r
+        except Exception:\r
+            pass\r
+        _boot_vibrate_timer = osTimer()\r
+        _boot_vibrate_timer.start(ms, 0, _boot_vibe_stop)\r
+        print("[motor] boot vibrate early {}ms".format(ms))\r
+    except Exception as e:\r
+        print("[motor] boot vibrate early fail:{}".format(e))\r
+\r
+\r
 class motorEventManager(Abstract):\r
     def __init__(self):\r
         self.motor_timer = osTimer()\r
@@ -133,12 +163,7 @@ class motorEventManager(Abstract):
         EventMesh.publish("publish_message_event_motorInfo",{"func":self.motor_fun_cb,"data":0})  \r
         \r
     def post_processor_after_instantiation(self):\r
-        ms = _cfg_boot_vibrate_ms()\r
-        if ms <= 0:\r
-            return\r
-        ticks = _motor_vibrate_ticks(ms)\r
-        self.motor_count_tmp = 0\r
-        self.set_motor_time(None, [1, 1, ticks, 0, 0])\r
+        motor_boot_vibrate_kick_early()\r
 \r
     # msg 0马达使能 1 马达振动时间100ms单位 2 震动次数 3 间隔时间振动 4 是否需要长振 5 长振时间  6间隔时间长振 7长振次数1一次 要为200ms的倍数\r
     def set_motor_time(self,event,msg):\r
index bb24b9a..06facae 100644 (file)
@@ -132,6 +132,18 @@ SOS_ENABLED = False                      # AI 长按 3s SOS、板载 SOS 入口
 VC_WS_PATH = "vc/agent"
 VC_WS_PING_INTERVAL_SEC = 55  # 文本 ping 间隔,过大影响保活,过小费电
 
+# VC 协议:v2.5(金三,默认)| v3(RVC);切 v3 时改 VC_WS_PATH 与 AMP_SERVER 联调
+VC_PROTOCOL_VERSION = "v2.5"
+VC_WS_AUTH_MODE = "dmp_dt"       # dmp_dt | api_key
+VC_AUDIO_PLAYED_ACK = 1          # V2.5 播完上报 audio.played;V3 联调建议 0
+VC_AGENT_ID = None               # V3 start.agent_id;None 时用 VC_BOT_ID
+VC_AUDIO_ENCRYPTION = 0          # V3 二进制帧加密(首版保持 0)
+# VC_API_KEY = ""                # VC_WS_AUTH_MODE=api_key 时使用
+VC_PROTO_TX_LOG = 0              # 1=打印 start/synthesis 等出站 TTS 参数摘要
+
+# 协议 failed 是否 synthesis 播报错误原文(联调=1,量产=0)
+VC_ERROR_TTS_ENABLED = 0
+
 # 长对话:无有效业务下行累计达此秒数结束会话(代码最小 5)
 VC_IDLE_AUTO_CLOSE_SEC = 20
 VC_IDLE_AUTO_CLOSE_SEC_PTT = 0  # 单对话预留,当前与 idle 统一处理
@@ -141,7 +153,7 @@ VC_IDLE_DEEP_SLEEP_SEC = 60
 VC_IDLE_DEEP_SLEEP_ENABLE = 1
 
 # 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
-VAD_SILENCE_DEFAULT = 1500
+VAD_SILENCE_DEFAULT = 300
 
 # =============================================================================
 # 8. 语音对话 / TTS / 智能体
@@ -150,8 +162,26 @@ VAD_SILENCE_DEFAULT = 1500
 POWER_ON_TONE_ENABLED = True
 POWER_ON_TONE_DEFAULT = "你好,我来了"
 
-VC_TTS_VOICE_TYPE_DEFAULT = None   # dict 或 None
-VC_TTS_VOICE_KEY_DEFAULT = None    # 如 "男" / "童"
+# 默认音色(无 storage / usr 覆盖时生效)
+# - voice:start → outbound_attributes.cfg.voice(金三 V2.5)
+VC_TTS_VOICE_KEY_DEFAULT = "男"
+VC_TTS_VOICE_TYPE_DEFAULT = {
+    "男": {
+        "bot_name": "柒先生",
+        "voice": "zh_male_wennuanahu_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "女": {
+        "bot_name": "柒妹妹",
+        "voice": "zh_female_wanwanxiaohe_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "童": {
+        "bot_name": "柒乐乐",
+        "voice": "ICL_zh_female_huoponvhai_tob",
+        "lang": "zh-cn",
+    },
+}
 
 DIALOG_BOT_OPTION = None           # {"bot_id", "workflow_id", "use_workflow"}
 VC_BOT_ID = "7462617881076351002"
diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/configs/k8v3/default_config.py
new file mode 100644 (file)
index 0000000..5bacccb
--- /dev/null
@@ -0,0 +1,244 @@
+# -*- coding: utf-8 -*-
+# k8v3 产品 — 设备侧默认配置(RVC WebSocket V3;编译期 freeze 进固件)
+#
+# 加载优先级(vc_config.py):
+#   1) usr/default_config.py(现场覆盖)
+#   2) 本文件(由 new_clean.bat 按 product_k8v3.ini 的 DEV_TYPE 拷贝到 core/default_config.py)
+#
+# 约定:仅放「机型/产品差异」常量,勿放运行态变量。
+
+# =============================================================================
+# 1. 产品与区域
+# =============================================================================
+
+# 硬件平台类型(与 products/configs/<DEV_TYPE>/ 目录名一致)
+DEV_TYPE = "7PIN"
+
+# 文案/逻辑用语言(如 zh / en)
+LANG = "zh"
+
+# 设备鉴权(device_res_author:HMAC 签名与 AES 加解密)产品名“小量博士” 、“浪漫挂坠”
+PRODUCT_PID = "cqxJZi5CNgzjS3mX21Zw"
+PRODUCT_KEY = "uKLrChwrxA1LklgBc3VwawYjUkbT3BgX"  # 32 字节,运行时转为 bytes
+
+# =============================================================================
+# 2. GPIO 引脚
+# =============================================================================
+
+# --- 2.1 三色 LED(Pin 属性名字符串,vc_config 转为 Pin.GPIOxx)---
+LED_GPIO = ("GPIO36", "GPIO44", "GPIO43")  # (R, G, B)
+
+# 点亮电平:False=高电平亮;True=低电平亮;仅某路反相用 LED_ACTIVE_LOW_RGB
+LED_ACTIVE_LOW = False
+LED_ACTIVE_LOW_RGB = None  # 例 (True, False, False) 仅红灯反相
+
+# --- 2.2 按键 ExtInt ---
+EXTINT_AI_GPIO = "GPIO22"   # AI 键
+PWK_KEY_GPIO = "GPIO26"     # 电源键(短按/双击切模式等)
+
+# btn_device_ec800m:扩展按键(本机型未用)
+BTN_WECHAT_GPIO = None
+BTN_SOS_GPIO = None
+
+# --- 2.3 马达(motor.py / motorEventManager)---
+MOTOR_PIN_GPIO = "GPIO20"   # None 表示无马达
+MOTOR_TICK_MS = 100
+MOTOR_ACTIVE_LOW = False
+MOTOR_BOOT_VIBRATE_MS = 200  # 开机震动时长(ms)
+
+# --- 2.4 蓝牙电源 / 音频 IIS(本机型关闭)---
+BT_POWER_MOTOR_ENABLED = False
+PIN_BT_ON_GPIO = None
+PIN_MOT_GPIO = None
+AUDIO_QDAI_CFG = None  # 非 None 时调用 audio.set_qdai_cfg(...)
+
+# --- 2.5 蓝牙↔4G 唤醒(本机型关闭)---
+BT_WAKEUP_4G_ENABLED = False
+EXTINT_WAKEUP_CTA1_GPIO = None
+PIN_WAKEUP_BT_GPIO = None
+
+# =============================================================================
+# 3. LED 灯效策略(AI_led / ai_VC,对齐交互规范)
+# =============================================================================
+
+# 正常待机 idle:off=灭灯(REQ-BAT-01);green_blink=旧工程绿灯慢闪
+LED_IDLE_MODE = "off"
+
+# green_blink 模式下的亮/灭时长(ms);LED_IDLE_MODE=off 时不生效
+LED_IDLE_GREEN_BLINK_MS = 1000
+LED_IDLE_GREEN_BLINK_OFF_MS = 2000
+
+# LLM 思考阶段:flicker_blue_green | open_red_green
+THINKING_LED_MODE = "flicker_blue_green"
+
+# 收到 vad.active 是否强制回蓝灯(长对话)
+VAD_ACTIVE_BLUE_LED = True
+
+# 收音 listening:长对话蓝慢闪(1000ms)、单对话蓝快闪(250ms) 由 VC_mode 在 AI_led 内切换,无需本文件单独配置
+
+# =============================================================================
+# 4. 电池与硬件状态(AI_hw_status / AI_dev)
+# =============================================================================
+
+HW_STATUS_PERIOD_MS = 10000
+# 电池电压:全机约每 10s 采样 1 次 getVbatt,10 点滑动平均(见 vc_hw._BatteryCfg)
+VBATT_AVG_LEN = 10
+VBATT_PUSH_MIN_MS = 9000   # 同周期内 AI_hw / protocol 去重,避免重复读电压
+SIGNAL_WEAK_CSQ = 10  # 0≤CSQ<此值 → signal=weak(REQ-ERR-02)
+
+# SoC 门限:仅低电提示/LED 空电闪(REQ-BAT-02/03);关机见 VBATT_EMPTY_MV
+BATTERY_LOW_SOC_PERCENT = 25       # <25% 低电语音
+BATTERY_EMPTY_SOC_PERCENT = 5      # <5% LED 空电态;不单独触发关机
+BATTERY_LOW_WARN_INTERVAL_MS = 300000  # 低电语音间隔 5min
+BATTERY_EMPTY_FLASH_COUNT = 3
+BATTERY_EMPTY_FLASH_PERIOD_MS = 500    # 空电红快闪约 2 次/秒
+
+# 电压门限(mV):充满 / 低电 / 空电(与 get_battery、simple 模式共用)
+USE_SIMPLE_VBATT_THRESH = False  # True 时忽略 BATTERY_OCV_MAP,仅用三档电压
+VBATT_CHARGE_FULL_MV = 4320   # 502631 ZCV 100% OCV≈4.322V;CV 4.35V
+VBATT_LOW_MV = 3630           # 约对应 ZCV 20%(4G 带载下不宜再抬高)
+VBATT_EMPTY_MV = 3400         # 未充电:滑动平均电压 < 此值 → 低电关机(唯一关机条件)
+
+# 502631-510 4.35V ZCV 放电 OCV 表(mV→SoC);0% 锚定 3400 而非实验室 3262
+BATTERY_OCV_MAP = {
+    4322: 100, 4239: 95, 4177: 90, 4117: 85, 4059: 80, 4006: 75, 3942: 70, 3898: 65,
+    3853: 60, 3814: 55, 3781: 50, 3755: 45, 3738: 40, 3724: 35, 3706: 30, 3678: 25,
+    3634: 20, 3577: 15, 3522: 10, 3468: 5, 3400: 0,
+}
+
+# 录音麦克增益:record.gain_set(codec_gain, dsp_gain)
+# codec_gain 0~4(编解码模拟增益 默认4);dsp_gain -36~12(数字增益,原工程常用 10)
+RECORD_CODEC_GAIN = 2
+RECORD_DSP_GAIN = 6
+
+# =============================================================================
+# 5. 可选外设与协议
+# =============================================================================
+
+GNSS_ENABLED = False           # 无 GNSS 硬件时 False,避免 import quecgnss
+UART_SUPPORTED = False
+BT_OTA_ENABLED = False
+UPLOAD_START_TONE_ENABLED = False  # 开始上传语音时是否播成功提示音
+AI_ALARM_ENABLED = False         # K8 吊坠无闹钟/日程硬件
+
+# =============================================================================
+# 6. 按键逻辑(AI_dev / btn_device)
+# =============================================================================
+
+KEY_RELEASE_DEBOUNCE_COUNT = 5
+AI_LONG_PRESS_POWER_OFF_ENABLED = False  # AI 键长按 8s 关机
+SOS_ENABLED = False                      # AI 长按 3s SOS、板载 SOS 入口
+
+# =============================================================================
+# 7. VC 会话 / WebSocket / 空闲与休眠
+# =============================================================================
+
+# 语音 WS 路径段(无首尾斜杠);主机见 srv_configs/beike_dev_v3/default_srv.py AMP_SERVER
+VC_WS_PATH = "rvc-service/api/v3/ws"
+VC_WS_NATIVE_PING_ENABLE = 0   # 0=不发标准 PING(入站 PING 仍回 PONG);1=客户端也发标准 PING
+VC_WS_PING_INTERVAL_SEC = 0    # V3 无自定义文本心跳;仅 V2.5 使用(>0 时发 "ping")
+
+# VC 协议:v3(RVC);环境 SRV_TYPE=beike_dev_v3
+VC_PROTOCOL_VERSION = "v3"
+VC_WS_AUTH_MODE = "dmp_dt"       # dmp_dt | api_key
+VC_AUDIO_PLAYED_ACK = 0          # V2.5 播完上报 audio.played;V3 联调建议 0
+VC_AGENT_ID = None               # V3 start.agent_id;None 时用 VC_BOT_ID
+VC_AUDIO_ENCRYPTION = 0          # V3 二进制帧加密(首版保持 0)
+# VC_API_KEY = ""                # VC_WS_AUTH_MODE=api_key 时使用
+VC_PROTO_TX_LOG = 1              # 1=打印出站摘要 + 原始 JSON(过长分 json_partN 输出)
+
+# V3 start.welcome_options:0=关闭服务端自动欢迎 TTS(由设备 synthesis/notify 播报)
+VC_WELCOME_SERVER_ENABLED = 0
+# VC_WELCOME_SERVER_MESSAGE = ""  # VC_WELCOME_SERVER_ENABLED=1 时可选文案
+
+# 协议 failed / websocket_error 是否 synthesis 播报错误原文(联调=1,量产=0)
+VC_ERROR_TTS_ENABLED = 1
+
+# 长对话 L1:轮次间隙无 TTS 下行累计达此秒 Close(听/想/播/录、保活帧不计;最小 5)
+VC_IDLE_AUTO_CLOSE_SEC = 20
+VC_IDLE_AUTO_CLOSE_SEC_PTT = 0  # 单对话预留,当前与 idle 统一处理
+
+# 业务 idle 达此秒数触发深睡断开(最小 10;ENABLE=0 关闭)
+VC_IDLE_DEEP_SLEEP_SEC = 60
+VC_IDLE_DEEP_SLEEP_ENABLE = 1
+
+# 服务端断句静音(ms);句中短暂停顿建议 ≥1200;可被 storage vad_silence 覆盖
+VAD_SILENCE_DEFAULT = 300
+
+# =============================================================================
+# 8. 语音对话 / TTS / 智能体
+# =============================================================================
+
+POWER_ON_TONE_ENABLED = True
+POWER_ON_TONE_DEFAULT = "你好,我来了"
+
+# 默认音色(无 storage / usr 覆盖时生效)
+# - voice:start → audio_config.output.voice,须与 RVC V3 服务端已配置的 speaker 一致
+# - 联调若报 resource ID mismatched with speaker,改 voice 或让服务端绑定该音色
+VC_TTS_VOICE_KEY_DEFAULT = "男"
+VC_TTS_VOICE_TYPE_DEFAULT = {
+    "男": {
+        "bot_name": "柒先生",
+        "voice": "zh_male_wennuanahu_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "女": {
+        "bot_name": "柒妹妹",
+        "voice": "zh_female_wanwanxiaohe_moon_bigtts",
+        "lang": "zh-cn",
+    },
+    "童": {
+        "bot_name": "柒乐乐",
+        "voice": "ICL_zh_female_huoponvhai_tob",
+        "lang": "zh-cn",
+    },
+}
+
+DIALOG_BOT_OPTION = None           # {"bot_id", "workflow_id", "use_workflow"}
+VC_BOT_ID = "7462617881076351002"
+VC_WORKFLOW_ID = "7462621757327196187"
+DIALOG_PROVIDER_DEFAULT = None
+KEYWORDS_FROM_VOICE_TYPE = False
+
+BT_VOICE_FEATURES_ENABLED = False
+
+# =============================================================================
+# 9. 本地指令与固定话术
+# =============================================================================
+
+# protocol.ai_cmd_handle:云端 NLP 扩展(会议纪要 / 实时翻译 / 历史留言)
+NLP_EXTENDED_CMDS = False
+
+CMD_TOGGLE_MODE_TO_PTT_TTS = "已切换到单对话模式"
+CMD_TOGGLE_MODE_TO_LONG_TTS = "已切换到长对话模式"
+POWEROFF_CMD_CLOSE_ONLY = False     # 「关机」指令仅 Close,不真断电
+
+KWS_PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "
+PTT_HINT = "请您按住圆形的按钮开始讲话,讲完松开即可 "
+
+ASR_EMPTY_HINT_TTS = "我没有听清,请再说一遍"   # asr.completed 无有效识别时 Notify 播报
+
+# =============================================================================
+# 10. 开录音 / PTT 时序(对齐「录音上传与播放」设计稿)
+# =============================================================================
+# 无按键确认音:不按 key_prompt;仅按键/PTT 开录时播 start.mp3(自动续聊不播)
+# PTT idle 短按:notify 话术 PTT_HINT;长按开录:start.mp3
+
+RECORD_START_TONE_FILE = "U:/static/start.mp3"    # 按键/PTT 开录提示音(非 server.started 自动开麦)
+RECORD_START_TONE_GAP_MS = 180
+
+PTT_MIN_RECORD_MS = 400
+PTT_RECORD_TOO_SHORT_TTS = "录音时间太短,请按住圆形按钮说话"  # notify
+
+PTT_RELEASE_END_SPEECH_DELAY_MS = 200   # 松手后发 audio.completed 前延迟
+PTT_CANCEL_WAIT_MS = 2500               # 打断后等 canceled 最长时间
+PTT_POST_MUTE_MS = 30                   # 本地硬静音后 codec 收尾
+
+# =============================================================================
+# 11. 调试开关(量产可全 False)
+# =============================================================================
+
+LED_DEBUG = True
+LED_STATE_DEBUG = True
+KEY_LED_DEBUG = True
+HW_STATUS_DEBUG = True
\ No newline at end of file
diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/product_k8v3.ini
new file mode 100644 (file)
index 0000000..1612493
--- /dev/null
@@ -0,0 +1,3 @@
+DEV_TYPE=k8v3
+SRV_TYPE=beike_dev_v3
+VERSION=V1.3
diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/products/srv_configs/beike_dev_v3/default_srv.py
new file mode 100644 (file)
index 0000000..e0e1e16
--- /dev/null
@@ -0,0 +1,93 @@
+# 设备侧 Server 配置(编译期由 product_*.ini 的 SRV_TYPE 拷贝为 core/default_srv.py 并 freeze)。\r
+#\r
+# =============================================================================\r
+# 加载与修改方式(改环境时按此检查,避免漏改)\r
+# =============================================================================\r
+#\r
+# 编译:\r
+#   product_k8v3.ini 中 SRV_TYPE=beike_dev_v3\r
+#     → new_clean.bat 拷贝本文件 → core/default_srv.py → 打进固件\r
+#\r
+# 运行时优先级(srv_config.py):\r
+#   1) usr/default_srv.py(现场覆盖,无需重编)\r
+#   2) freeze 内置 core/default_srv.py\r
+#\r
+# 启动时注入 constants(constants.py 仅读取下列 3 项到模块常量):\r
+#   DMP_BASE, AMP_SERVER, IOT_WS_URL  →  constants.DMP_BASE / AMP_SERVER / IOT_WS_URL\r
+#\r
+# 与 default_config.py 的分工(勿混在同一文件改):\r
+#   - 本文件:环境 / 域名 / 主机(随 SRV_TYPE 变,如 beike_dev ↔ beike)\r
+#   - default_config:产品能力(随 DEV_TYPE/k8 变),含 VC_WS_PATH、VC_BOT_ID 等\r
+#\r
+# =============================================================================\r
+# URL 拼接总览(改 VC 联调时两项都要对)\r
+# =============================================================================\r
+#\r
+# 【语音对话 WebSocket — AI_VC.websocket_connect_VC】\r
+#   主机:constants.AMP_SERVER(本文件 AMP_SERVER)\r
+#         → ai_VC 赋给 self.server_ip(可被存储 server_ip / 云端下发覆盖)\r
+#   路径:default_config.VC_WS_PATH(k8v3 为 "rvc-service/api/v3/ws")\r
+#   查询:DMP dt(get_dev_token)或 api_key(见 VC_WS_AUTH_MODE)\r
+#\r
+#   示例(beike_dev_v3 + k8v3,RVC V3 API 文档):\r
+#     ws://115.190.94.208:8087/rvc-service/api/v3/ws?is_auth=...&dt=...\r
+#\r
+# 【IoT 设备云 WebSocket — protocol.py】\r
+#   完整 URL 已在本文件写死为 IOT_WS_URL,仅追加 token:\r
+#     {IOT_WS_URL}?bz-dt={token}\r
+#   示例:ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1?bz-dt=...\r
+#\r
+# 【DMP HTTP — ai_VC 内认证/校时/ICCID/OTA 结果等】\r
+#   前缀:constants.DMP_BASE(本文件 DMP_BASE,无尾部斜杠)\r
+#   路径:ai_VC 内目前为字面量 "/dev/..."(与下方 DMP_*_PATH 取值应对齐)\r
+#   示例:{DMP_BASE}/dev/time 、{DMP_BASE}/dev/auth/active\r
+#\r
+# 【OTA 检查任务 — vc_ota.min_sys_ota / bt_ota】\r
+#   constants.OTA_TASK_URL_FMT.format(imei, sourceVersion)\r
+#   OTA 结果上报:{DMP_BASE} + OTA_RESULT_PATH_FMT(vc_ota.ota_result)\r
+#\r
+# 【AMP 健康检查 — ai_VC 错误上报】\r
+#   使用 self.server_ip(同 AMP_SERVER)+ 硬编码路径:\r
+#     http://{server_ip}/health?task_id={...}\r
+#   与 AMP_HEALTH_URL_FMT 形式一致(第一参数为 host:port,无 http 前缀)\r
+#\r
+# 注意:下方 DMP_*_PATH / OTA_*_FMT / AMP_HEALTH_URL_FMT 为**集中文档与目标值**;\r
+# constants 启动时尚未自动读取这些路径常量;修改后若 ai_VC 仍写死字符串,需同步改代码或接 srv_config.get_attr。\r
+#\r
+# =============================================================================\r
+\r
+# ---- 环境标识(仅注释/运维用;constants.SERVER_ENV 会读取)----\r
+SERVER_ENV = "beike_dev_v3"\r
+\r
+# ---- 三套「根地址」(constants.py 启动时加载,改环境必改)----\r
+\r
+# DMP 业务 HTTP 根路径(无尾斜杠)。用于 DMP_BASE + "/dev/..." 、"/ota/result?..."\r
+DMP_BASE = "http://dmp-dev.raisound.com/dmp-api"\r
+\r
+# RVC V3 语音对话主机 host:port(无 ws://)。完整 WS:\r
+#   ws://{AMP_SERVER}/{VC_WS_PATH}  例见 configs/k8v3/default_config.py\r
+AMP_SERVER = "115.190.94.208:8087"\r
+\r
+# IoT 长连接完整 WebSocket URL(含 ws://)。protocol 仅追加 ?bz-dt=token\r
+IOT_WS_URL = "ws://dmp-dev.raisound.com/iot-api/ws-hub/dev/v1"\r
+\r
+# ---- DMP 子路径(相对 DMP_BASE;与 ai_VC 内硬编码路径保持一致)----\r
+\r
+DMP_TIME_PATH = "/dev/time"                    # 校时 GET  → dev_NTP\r
+DMP_ACTIVE_PATH = "/dev/auth/active"           # 激活 POST\r
+DMP_SIGN_IN_PATH = "/dev/auth/sign_in"        # 登录\r
+DMP_TOKEN_PATH = "/dev/auth/token"             # 取 token → get_dev_token 链路\r
+DMP_ICCID_PATH = "/dev/iccid"                 # ICCID 上报(带 query)\r
+DMP_DEV_CONFIG_PATH_FMT = "/dev/config/{}"     # 设备配置,format(deviceId)\r
+\r
+# ---- OTA ----\r
+\r
+# 完整 URL 模板;占位符 {} 依次为 deviceId(IMEI)、sourceVersion\r
+OTA_TASK_URL_FMT = "http://dmp-dev.raisound.com/dmp-api/ota/task?deviceId={}&sourceVersion={}"\r
+\r
+# 相对 DMP_BASE 的路径模板(上报 OTA 结果)\r
+OTA_RESULT_PATH_FMT = "/ota/result?deviceId={}&currentVersion={}"\r
+\r
+# ---- AMP 健康检查(host 用 AMP_SERVER / server_ip,非 DMP_BASE)----\r
+\r
+AMP_HEALTH_URL_FMT = "http://{}/health?task_id={}"  # .format(server_ip, task_id)\r
index 728d240..c04daf4 100644 (file)
@@ -89,15 +89,19 @@ VC_HEARTBEAT_LOG = 0
 #       若误关会导致问候结束后首轮用户说话无 vad/asr。
 VC_PROTO_GUARDS = 0
 # 1=打印 VC WebSocket 每条收发的完整文本(流量大、易刷屏);0 仅一行 event 摘要(llm.delta 省略)
-VC_WS_IO_LOG = 0
-# 长对话 20s 无 TTS idle:仅 tts.* / started / TTS 音频字节重置 close_timerout;asr/vad/llm 不重置。
-# 帧级 WebSocket PING/PONG 在 uwebsocket.recv() 内已处理,不会进入 AI_client_VC_recv。
-# 金三 V2.5 文档示例:WebSocket 文本帧 "ping" 保活;对端可能回 pong / heartbeat 类事件。
+VC_WS_IO_LOG = 1
+# 1=打印出站 JSON 摘要(start 音色/TTS 参数、synthesis 文本等);联调 TTS 建议开启
+VC_PROTO_TX_LOG = 1
+# 长对话 L1(VC_IDLE_AUTO_CLOSE_SEC):仅 tts.* / started / TTS 音频下行重置 close_timerout。
+# 保活不计入 L1:V2.5 文本 ping/pong、JSON heartbeat;RFC6455 OP_PING/OP_PONG(uwebsocket 内处理)。
+# V2.5:默认不发标准 PING,发文本 "ping"(VC_WS_PING_INTERVAL_SEC>0);须响应入站标准 PING→PONG。
+# V3:无自定义心跳;默认不发标准 PING;须响应入站标准 PING→PONG。
+# VC_WS_NATIVE_PING_ENABLE=1 时客户端才主动发标准 PING(两协议均默认 0)。
 _VC_KEEPALIVE_RX_EVENTS = frozenset(("pong", "ping", "heartbeat", "keepalive"))
 
 
 def _vc_out_json_event_meta(s):
-    """解析 WS 出站 JSON:event 可能在 header 或顶层(Start/audio.create vs synthesis)。"""
+    """解析 WS 出站 JSON:event 可能在 header、顶层 event 或 V3 type。"""
     try:
         jd = ujson.loads(s)
         h = jd.get("header")
@@ -105,8 +109,11 @@ def _vc_out_json_event_meta(s):
             ev = h.get("event") or ""
             if ev:
                 return ev, h.get("trace_id") or ""
-        ev = jd.get("event") or ""
-        return ev, ""
+        ev = jd.get("event") or jd.get("type") or ""
+        tid = jd.get("trace_id") or ""
+        if not tid and isinstance(h, dict):
+            tid = h.get("trace_id") or ""
+        return ev, tid
     except Exception:
         return "", ""
 
diff --git a/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py b/2.sdk/project/7PIN_1_5/sdk/services/microPython/ports/quectel/core/vc_proto.py
new file mode 100644 (file)
index 0000000..cf9e666
--- /dev/null
@@ -0,0 +1,536 @@
+# -*- coding: utf-8 -*-
+"""VC WebSocket 协议适配:V2.5(金三)与 V3(RVC)可配置切换。"""
+import ujson
+
+PROTO_V25 = "v2.5"
+PROTO_V3 = "v3"
+
+# V3 下行 type → 内部仍使用的 V2.5 事件名(FSM/recv 无需改)
+_SERVER_TYPE_TO_EVENT = {
+    "error": "failed",
+    "tts.done": "tts.completed",
+    "llm.started": "llm.created",
+}
+
+
+def _cfg(name, default=None):
+    try:
+        import vc_config
+        return vc_config.get_attr(name, default)
+    except Exception:
+        return default
+
+
+def get_protocol_version():
+    v = _cfg("VC_PROTOCOL_VERSION", PROTO_V25)
+    if v is None:
+        return PROTO_V25
+    try:
+        v = str(v).lower().strip()
+    except Exception:
+        return PROTO_V25
+    if v in ("v3", "3", "rvc", "rvc_v3"):
+        return PROTO_V3
+    return PROTO_V25
+
+
+def is_v3():
+    return get_protocol_version() == PROTO_V3
+
+
+def vc_ws_custom_ping_enabled():
+    """V2.5:允许设备发 WebSocket 文本帧 \"ping\";V3:无自定义心跳。"""
+    if is_v3():
+        return False
+    try:
+        return int(_cfg("VC_WS_PING_INTERVAL_SEC", 55)) > 0
+    except Exception:
+        return True
+
+
+def vc_proto_audio_played_enabled():
+    default = 0 if is_v3() else 1
+    try:
+        return bool(_cfg("VC_AUDIO_PLAYED_ACK", default))
+    except Exception:
+        return bool(default)
+
+
+def vc_proto_build_ws_url(amp_server, path, is_auth, token=None, device_id=None):
+    """拼 VC WebSocket URL(无 ws:// 前缀)。"""
+    path = path or "vc/chat"
+    auth_mode = _cfg("VC_WS_AUTH_MODE", "dmp_dt")
+    try:
+        auth_mode = str(auth_mode or "dmp_dt").lower().strip()
+    except Exception:
+        auth_mode = "dmp_dt"
+
+    if is_v3():
+        if auth_mode == "api_key":
+            key = token or _cfg("VC_API_KEY", "") or ""
+            did = device_id or ""
+            if key and did:
+                return "ws://{}/{}?api_key={}&device_id={}".format(
+                    amp_server, path, key, did)
+            if key:
+                return "ws://{}/{}?api_key={}".format(amp_server, path, key)
+            return "ws://{}/{}".format(amp_server, path)
+        # DMP:V3 文档 dt/ut;保留 is_auth 便于联调旧网关
+        if token:
+            return "ws://{}/{}?is_auth={}&dt={}".format(
+                amp_server, path, is_auth, token)
+        return "ws://{}/{}?is_auth={}".format(amp_server, path, is_auth)
+
+    # V2.5(现状)
+    req = "{}?is_auth={}".format(path, is_auth)
+    if token:
+        return "ws://{}/{}&dt={}".format(amp_server, req, token)
+    return "ws://{}/{}".format(amp_server, req)
+
+
+def _extract_client_event_payload(obj):
+    """从出站 JSON 对象解析 event 与 payload(兼容 header 与顶层 event)。"""
+    if not isinstance(obj, dict):
+        return None, {}
+    h = obj.get("header")
+    if isinstance(h, dict):
+        ev = h.get("event") or ""
+        if ev:
+            return ev, obj.get("payload") or {}
+    ev = obj.get("event") or obj.get("type") or ""
+    if ev:
+        pl = obj.get("payload")
+        if pl is None:
+            pl = obj.get("data")
+        if pl is None:
+            pl = {}
+        return ev, pl
+    return None, {}
+
+
+# V3 StartData.audio_config 文档字段(不含 V2.5 专有项)
+_V3_AUDIO_CFG_INPUT_KEYS = ("format", "sample_rate", "bit_rate", "channel")
+_V3_AUDIO_CFG_OUTPUT_KEYS = ("format", "sample_rate", "bit_rate", "channel", "speech_rate")
+# V2.5 outbound 专有:V3 不识别,不写入 start.data(仅 voice 放 metadata 供服务端选用)
+_V3_DROP_TTS_EXTRA_KEYS = frozenset(("emotion", "volume", "chunk_interval_ms", "codec"))
+
+
+def _pick_v3_audio_cfg(src, is_output=False):
+    """仅保留 V3 支持的 audio_config 字段;其余进 metadata 扩展。"""
+    allowed = _V3_AUDIO_CFG_OUTPUT_KEYS if is_output else _V3_AUDIO_CFG_INPUT_KEYS
+    cfg = {}
+    extras = {}
+    if not isinstance(src, dict):
+        return cfg, extras
+    for k, v in src.items():
+        if k in allowed:
+            cfg[k] = v
+        else:
+            extras[k] = v
+    return cfg, extras
+
+
+def _build_v3_welcome_options(dlg_cfg):
+    """V3 start.welcome_options:与 V2.5 dialog_attributes.cfg.greeting 及配置对齐。
+
+    设备侧 notify/local/boot_prompt 等走 synthesis,默认关闭服务端自动欢迎 TTS。
+    """
+    greeting = {}
+    if isinstance(dlg_cfg, dict):
+        g = dlg_cfg.get("greeting")
+        if isinstance(g, dict):
+            greeting = g
+    enabled = False
+    message = ""
+    try:
+        if greeting.get("enabled"):
+            enabled = True
+            message = greeting.get("content") or ""
+    except Exception:
+        pass
+    cfg_val = _cfg("VC_WELCOME_SERVER_ENABLED", None)
+    if cfg_val is not None:
+        try:
+            enabled = bool(int(cfg_val))
+        except Exception:
+            enabled = bool(cfg_val)
+        if enabled:
+            message = _cfg("VC_WELCOME_SERVER_MESSAGE", message) or message
+    en = bool(enabled)
+    msg = message if en else ""
+    # 同时带 enabled/message(与 audio_encryption.enabled 命名一致)及 started 回包字段名
+    return {
+        "enabled": en,
+        "message": msg,
+        "welcome_enabled": en,
+        "welcome_message": msg,
+    }
+
+
+def _convert_start_payload_v25_to_v3(payload):
+    """V2.5 start payload → V3 start.data。"""
+    if not isinstance(payload, dict):
+        payload = {}
+    inb = payload.get("inbound_attributes") or {}
+    outb = payload.get("outbound_attributes") or {}
+    dlg = payload.get("dialog_attributes") or {}
+    dlg_cfg = dlg.get("cfg") if isinstance(dlg.get("cfg"), dict) else {}
+    bot = dlg_cfg.get("bot_option") if isinstance(dlg_cfg.get("bot_option"), dict) else {}
+
+    disable_vad = inb.get("disable_vad", True)
+    session_mode = "push_to_talk" if disable_vad else "realtime"
+
+    in_cfg, in_extra = _pick_v3_audio_cfg(inb.get("cfg") or {}, False)
+    out_cfg, out_extra = _pick_v3_audio_cfg(outb.get("cfg") or {}, True)
+
+    data = {
+        "device_id": payload.get("device_id") or "",
+        "session_mode": session_mode,
+        "input_mode": "audio",
+        "output_mode": "audio",
+        "audio_config": {
+            "input": in_cfg,
+            "output": out_cfg,
+        },
+    }
+
+    agent_id = _cfg("VC_AGENT_ID", None)
+    if agent_id is None or agent_id == "":
+        agent_id = bot.get("bot_id") or _cfg("VC_BOT_ID", "")
+    if agent_id:
+        data["agent_id"] = agent_id
+
+    io = dict(inb.get("interrupt_options") or {})
+    if io.get("thinking_time_only"):
+        io["strategy"] = "thinking_time_only"
+    elif io.get("keyword_interrupt"):
+        io["strategy"] = "keyword"
+        if io.get("interrupt_keywords"):
+            io["interrupt_keywords"] = io["interrupt_keywords"]
+    if io:
+        data["interrupt_options"] = io
+
+    data["welcome_options"] = _build_v3_welcome_options(dlg_cfg)
+
+    meta = {}
+    if bot:
+        meta["bot_option"] = bot
+    cv = dlg_cfg.get("custom_variables")
+    if isinstance(cv, dict) and cv:
+        meta["custom_variables"] = cv
+    vs = inb.get("vad_silence")
+    if vs is not None:
+        meta["vad_silence"] = vs
+    prov = dlg.get("provider")
+    if prov:
+        meta["dialog_provider"] = prov
+    # V2.5 专有字段勿进 audio_config;V3 仅保留文档认可的 metadata
+    if in_extra:
+        meta["asr_cfg_extra"] = in_extra
+    if out_extra:
+        voice = out_extra.get("voice")
+        if voice:
+            meta["voice"] = voice
+        # emotion/volume/chunk_interval_ms 等 V3 无对应项,不随 start 上报
+        tts_other = {}
+        for k, v in out_extra.items():
+            if k in _V3_DROP_TTS_EXTRA_KEYS or k == "voice":
+                continue
+            tts_other[k] = v
+        if tts_other:
+            meta["tts_cfg_extra"] = tts_other
+    if outb.get("provider"):
+        meta["tts_provider"] = outb.get("provider")
+    if inb.get("provider"):
+        meta["asr_provider"] = inb.get("provider")
+    if meta:
+        data["metadata"] = meta
+
+    enc = _cfg("VC_AUDIO_ENCRYPTION", 0)
+    try:
+        if int(enc):
+            data["audio_encryption"] = {"enabled": True}
+    except Exception:
+        pass
+
+    return data
+
+
+def vc_proto_pack_obj(obj):
+    """V3:将 V2.5 形态 dict 转为 Envelope dict;V2.5:原样返回。"""
+    if not is_v3():
+        return obj
+    if not isinstance(obj, dict):
+        return obj
+
+    ev, payload = _extract_client_event_payload(obj)
+    if not ev:
+        return obj
+
+    type_name = ev
+    if type_name == "Start":
+        type_name = "start"
+
+    if type_name == "start":
+        data = _convert_start_payload_v25_to_v3(payload)
+    elif type_name in ("cancel", "audio.create", "audio.completed"):
+        data = payload if isinstance(payload, dict) else {}
+    else:
+        data = payload if isinstance(payload, dict) else {}
+
+    return {"type": type_name, "data": data}
+
+
+def vc_proto_pack_wire(s):
+    """出站 JSON 字符串:V3 时改写为 type/data。"""
+    if not is_v3():
+        return s
+    if not isinstance(s, str) or not s or s == "ping":
+        return s
+    try:
+        obj = ujson.loads(s)
+    except Exception:
+        return s
+    try:
+        packed = vc_proto_pack_obj(obj)
+        return ujson.dumps(packed)
+    except Exception:
+        return s
+
+
+def _normalize_payload(event, data):
+    if not isinstance(data, dict):
+        data = {}
+    p = data
+    if event == "asr.result":
+        if "text" in p and "content" not in p:
+            p["content"] = p["text"]
+    elif event == "failed":
+        if "message" not in p:
+            if isinstance(p.get("error"), str):
+                p["message"] = p.get("error")
+            elif isinstance(p.get("msg"), str):
+                p["message"] = p.get("msg")
+        if "code" in p and "error" not in p:
+            p["error"] = p["code"]
+    elif event == "llm.delta":
+        if "delta" in p and "content" not in p:
+            p["content"] = p["delta"]
+    return p
+
+
+def _proto_tx_log_enabled():
+    try:
+        import vc_config
+        if vc_config:
+            v = vc_config.get_attr("VC_PROTO_TX_LOG", None)
+            if v is not None:
+                return bool(v)
+    except Exception:
+        pass
+    try:
+        from vc_common import VC_PROTO_TX_LOG
+        return bool(VC_PROTO_TX_LOG)
+    except Exception:
+        return True
+
+
+def _text_preview(s, n=48):
+    try:
+        t = (s or "").strip().replace("\n", " ")
+    except Exception:
+        return ""
+    if len(t) <= n:
+        return t
+    return t[:n] + "..."
+
+
+def _log_wire_json(tag, wire_str):
+    """打印 WebSocket 出站原始 JSON(过长时分段,避免串口截断)。"""
+    if not _proto_tx_log_enabled():
+        return
+    try:
+        s = wire_str if isinstance(wire_str, str) else ""
+        n = len(s)
+        chunk = 480
+        print("[VC][proto] {} json_len={}".format(tag, n))
+        if n <= chunk:
+            print("[VC][proto] {} json={}".format(tag, s))
+            return
+        pos = 0
+        part = 0
+        while pos < n:
+            print("[VC][proto] {} json_part{}={}".format(tag, part, s[pos:pos + chunk]))
+            pos += chunk
+            part += 1
+    except Exception:
+        pass
+
+
+def vc_proto_log_started_server(payload):
+    """入站 started:打印服务端 welcome 配置(联调 welcome_options 是否生效)。"""
+    if not is_v3() or not _proto_tx_log_enabled():
+        return
+    try:
+        p = payload if isinstance(payload, dict) else {}
+        we = p.get("welcome_enabled")
+        wm = p.get("welcome_message") or ""
+        agent = p.get("agent") or {}
+        if isinstance(agent, dict) and we is None:
+            we = agent.get("welcome_enabled")
+            if not wm:
+                wm = agent.get("welcome_message") or ""
+        print("[VC][proto] rx_started ver=v3 welcome_enabled={} welcome_msg={!r}".format(
+            we, _text_preview(str(wm), 48)))
+    except Exception:
+        pass
+
+
+def vc_proto_log_start_from_v25(json_data, vc_mode=-1):
+    """start/Start 发出前:打印 TTS/会话关键参数(不依赖大包 JSON 解析)。"""
+    if not _proto_tx_log_enabled():
+        return
+    try:
+        p = json_data.get("payload") or {}
+        inb = p.get("inbound_attributes") or {}
+        outb = p.get("outbound_attributes") or {}
+        ocfg = outb.get("cfg") or {}
+        icfg = inb.get("cfg") or {}
+        dlg = p.get("dialog_attributes") or {}
+        bot = (dlg.get("cfg") or {}).get("bot_option") or {}
+        disable_vad = inb.get("disable_vad", True)
+        sess = "push_to_talk" if disable_vad else "realtime"
+        tid = ""
+        try:
+            tid = (json_data.get("header") or {}).get("trace_id") or ""
+        except Exception:
+            pass
+        print("[VC][proto] start_tx ver={} trace={} vc_mode={} sess={} vad_silence={} "
+              "voice={} out_prov={} in_prov={} fmt={} sr={} speech_rate={} bot={} wf={}".format(
+                  get_protocol_version(),
+                  tid or "-",
+                  vc_mode,
+                  sess,
+                  inb.get("vad_silence", "-"),
+                  ocfg.get("voice", "-"),
+                  outb.get("provider", "-"),
+                  inb.get("provider", "-"),
+                  ocfg.get("format", "-"),
+                  ocfg.get("sample_rate", "-"),
+                  ocfg.get("speech_rate", "-"),
+                  bot.get("bot_id", "-"),
+                  bot.get("workflow_id", "-"),
+              ))
+        try:
+            # 内部 V2.5 草稿,不经 pack_wire,不会上 WebSocket
+            _log_wire_json("start_draft_v25", ujson.dumps(json_data))
+        except Exception:
+            pass
+        if is_v3():
+            try:
+                d = _convert_start_payload_v25_to_v3(p)
+                ac_out = (d.get("audio_config") or {}).get("output") or {}
+                meta = d.get("metadata") or {}
+                wo = d.get("welcome_options") or {}
+                print("[VC][proto] start_preview_v3 agent={} session_mode={} voice_meta={} "
+                      "welcome_enabled={} audio_out_keys={}".format(
+                          d.get("agent_id", "-"),
+                          d.get("session_mode", "-"),
+                          meta.get("voice", "-"),
+                          wo.get("welcome_enabled", "-"),
+                          list(ac_out.keys()),
+                      ))
+                # V3 pack 预览,未上链;链路上仅 start_wire_SENT 一条
+                _log_wire_json("start_preview_v3", ujson.dumps({"type": "start", "data": d}))
+                print("[VC][proto] start_log_note 链路上仅发 start_wire_SENT(type:start)")
+            except Exception:
+                pass
+    except Exception:
+        pass
+
+
+def vc_proto_log_synthesis(text, pending_kind=""):
+    """synthesis 发出前:打印待合成文本摘要。"""
+    if not _proto_tx_log_enabled():
+        return
+    try:
+        t = text or ""
+        print("[VC][proto] synthesis_tx ver={} text_len={} kind={} text={!r}".format(
+            get_protocol_version(),
+            len(t),
+            pending_kind or "-",
+            _text_preview(t, 64),
+        ))
+        try:
+            if is_v3():
+                wire = ujson.dumps({"type": "synthesis", "data": {"text": t}})
+            else:
+                wire = ujson.dumps({"event": "synthesis", "payload": {"text": t}})
+            _log_wire_json("tx_synthesis", wire)
+        except Exception:
+            pass
+    except Exception:
+        pass
+
+
+def vc_proto_log_tx_wire(wire_str, ws_conn=0):
+    """发送线程:对已 pack 的 JSON 打一行摘要(synthesis 等小报文)。"""
+    if not _proto_tx_log_enabled():
+        return
+    if not isinstance(wire_str, str) or not wire_str or wire_str == "ping":
+        return
+    ev = ""
+    try:
+        jd = ujson.loads(wire_str)
+        ev, pl = _extract_client_event_payload(jd)
+    except Exception:
+        print("[VC][proto] tx parse_fail len={} ws={}".format(len(wire_str), ws_conn))
+        _log_wire_json("tx_wire_raw", wire_str)
+        return
+    if not ev:
+        print("[VC][proto] tx unknown_json len={} ws={}".format(len(wire_str), ws_conn))
+        _log_wire_json("tx_wire_raw", wire_str)
+        return
+    el = str(ev).lower()
+    tag = "tx_" + el.replace(".", "_")
+    _log_wire_json(tag, wire_str)
+    if el == "synthesis":
+        txt = ""
+        try:
+            txt = pl.get("text") or ""
+        except Exception:
+            pass
+        print("[VC][proto] tx synthesis ver={} len={} ws={} text={!r}".format(
+            get_protocol_version(), len(wire_str), ws_conn, _text_preview(txt, 64)))
+        return
+    if el in ("start",):
+        print("[VC][proto] start_wire_SENT ver={} wire_len={} ws={}".format(
+            get_protocol_version(), len(wire_str), ws_conn))
+        return
+    if el in ("audio.create", "audio.completed", "cancel", "audio.played", "audio.convert"):
+        print("[VC][proto] tx event={} ver={} len={} ws={}".format(
+            ev, get_protocol_version(), len(wire_str), ws_conn))
+        return
+    print("[VC][proto] tx event={} ver={} len={} ws={}".format(
+        ev, get_protocol_version(), len(wire_str), ws_conn))
+
+
+def vc_proto_normalize_server(js):
+    """入站:V3 Envelope 归一为顶层 event + payload(就地修改并返回)。"""
+    if not isinstance(js, dict):
+        return js
+    if not is_v3():
+        return js
+
+    if js.get("type"):
+        ev = js.get("type") or ""
+        ev = _SERVER_TYPE_TO_EVENT.get(ev, ev)
+        data = js.get("data")
+        if data is None:
+            data = {}
+        js["event"] = ev
+        js["payload"] = _normalize_payload(ev, data)
+        sid = js.get("session_id") or js.get("sid")
+        if sid and not js.get("sid"):
+            js["sid"] = sid
+    return js
index 96c6759..469b383 100644 (file)
@@ -208,6 +208,22 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
         return 8000
 
+    def _vc_ignore_late_tts_completed_event(self):
+        """打断后已停播/开麦:迟到的 tts.completed 不参与 await_played / play_end。"""
+        try:
+            return (
+                self._vc_is_long_dialogue_mode()
+                and not self._vc_tts_playback_active()
+                and (
+                    getattr(self, "VC_record_state_flag", 0)
+                    or getattr(self, "_vc_pending_interrupt_uplink", 0)
+                    or getattr(self, "is_ruppt_tts", 0)
+                    or getattr(self, "_drop_tts_audio", 0)
+                )
+            )
+        except Exception:
+            return False
+
     def _long_dialogue_should_drop_inbound_tts(self):
         """长对话打断窗口:丢弃迟到 TTS 帧 / 禁止起播。"""
         if not self._vc_is_long_dialogue_mode():
@@ -483,7 +499,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             pass
     def _vc_rx_resets_long_idle_timer(self, recv_data):
-        """æ\98¯å\90¦æ¸\85é\9b¶ close_timeroutï¼\9aä»\85 TTS å\8f\8dé¦\88ï¼\88tts.* / started / é\9f³é¢\91å­\97è\8a\82ï¼\89ï¼\8casr/vad/llm 不计。"""
+        """æ\98¯å\90¦æ¸\85é\9b¶ close_timeroutï¼\9aä»\85 TTS å\8f\8dé¦\88ï¼\9bä¿\9dæ´»ï¼\88æ\96\87æ\9c¬ ping/pongã\80\81heartbeatï¼\89不计。"""
         try:
             if recv_data is None:
                 return False
@@ -515,7 +531,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         except Exception:
             return False
     def _vc_long_idle_timer_armed(self):
-        """长对话 L1:会话已 started 且已连接时才开始计 20s 无 TTS。"""
+        """长对话 L1:轮次间隙无 TTS 下行才累计;听/想/播/录与 JSON 保活均不计。"""
         try:
             if getattr(self, "VC_mode", -1) != 0:
                 return False
@@ -525,6 +541,17 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 return False
             if getattr(self, "_vc_session_started", 0) != 1:
                 return False
+            if getattr(self, "VC_record_state_flag", 0) != 0:
+                return False
+            t = getattr(self.fsm, "turn", VCTurnState.Idle)
+            if t in (
+                VCTurnState.Listening,
+                VCTurnState.Thinking,
+                VCTurnState.TtsReceiving,
+                VCTurnState.Playing,
+                VCTurnState.AwaitPlayedAck,
+            ):
+                return False
             return True
         except Exception:
             return False
@@ -2038,6 +2065,7 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         self._vc_boot_standby = 0
         self._last_tts_kind = ""
         self._pending_tts_kind = ""
+        self._vc_expect_dialog_tts = 0
         # Push-To-Talk(VAD关闭/单对话)是否已发送 audio.create
         # 重要:协议要求 disable_vad=true 时必须 audio.create→音频帧→audio.completed
         self._ptt_audio_created = 0
@@ -2073,6 +2101,9 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         self._uplink_active = 0
         self._uplink_bytes = 0
         self._uplink_start_ms = 0
+        self._uplink_tx_fail_cnt = 0
+        self._uplink_tx_fail_bytes = 0
+        self._uplink_tx_fail_log_ms = 0
 
         # 状态机骨架(后续逐步把旧逻辑迁移进来)
         self.fsm = VCStateMachine(self)
@@ -2325,8 +2356,26 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
         self.Audio_buf_size = 20*1024
         self.record = audio.Record()
         #self.record.end_callback(self.record_callback)
-        self.record.gain_set(4,10)
-        #self.record.gain_set(4,12)
+        try:
+            _cg, _dg = 4, 10
+            if "vc_config" in globals() and vc_config:
+                _cg = int(vc_config.get_int("RECORD_CODEC_GAIN", 4))
+                _dg = int(vc_config.get_int("RECORD_DSP_GAIN", 10))
+            if _cg < 0:
+                _cg = 0
+            elif _cg > 4:
+                _cg = 4
+            if _dg < -36:
+                _dg = -36
+            elif _dg > 12:
+                _dg = 12
+            self.record.gain_set(_cg, _dg)
+        except Exception as e:
+            usys.print_exception(e)
+            try:
+                self.record.gain_set(4, 10)
+            except Exception:
+                pass
         self.aud = audio.Audio(0)
         self.aud.setCallback(self.audio_cb)
         # 确保 record/aud 已创建后再打印硬件/音频诊断信息(需要 gain_get/type(record))
@@ -3123,8 +3172,8 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
             pass
         if _bg_active:
             EventMesh.publish("system_sleep_rest")
-        # --- Level 1:仅长对话,idle_sec 内无 TTS 下行 → Close 进 idle(需再按 AI 进入)
-        # close_timerout:仅 tts.* / started / TTS 音频重置;见 _vc_rx_resets_long_idle_timer
+        # --- Level 1:长对话轮次间隙 idle_sec 内无 TTS 下行 → Close(JSON ping/pong 不重置、不暂停累计)
+        # close_timerout:仅 tts.* / started / TTS 音频重置;听/想/播/录期 _vc_long_idle_timer_armed=False
         idle_sec = 20
         try:
             if "vc_config" in globals() and vc_config:
@@ -3216,16 +3265,17 @@ class AI_VC(VCSessionIoMixin, VCSessionMediaMixin, Abstract):
                 if self.VC_satrt_kws_flag == 1 and self.VC_satrt_flag == 0:
                     pass
 
-            # VC WebSocket 文本 ping
-            try:
-                _ping_iv = int(vc_config.get_int("VC_WS_PING_INTERVAL_SEC", 4))
-            except Exception:
-                _ping_iv = 4
-            if _ping_iv > 0 and getattr(self, "AI_client_VC_isconnect", 0) == 1:
-                self.VC_pingping_timerout += 1
-                if self.VC_pingping_timerout >= _ping_iv:
-                    self.VC_pingping_timerout = 0
-                    self.AI_client_VC_ping()
+            # V2.5 自定义文本 ping;V3 无自定义心跳(入站标准 PING 由 uwebsocket 回 PONG)
+            if vc_ws_custom_ping_enabled() and getattr(self, "AI_client_VC_isconnect", 0) == 1:
+                try:
+                    _ping_iv = int(vc_config.get_int("VC_WS_PING_INTERVAL_SEC", 55))
+                except Exception:
+                    _ping_iv = 55
+                if _ping_iv > 0:
+                    self.VC_pingping_timerout += 1
+                    if self.VC_pingping_timerout >= _ping_iv:
+                        self.VC_pingping_timerout = 0
+                        self.AI_client_VC_ping()
         # self.asr_pingping_timerout += 1
         # if self.asr_pingping_timerout>3:
         #     self.asr_pingping_timerout=0
index 7a97060..ee7a4ed 100644 (file)
@@ -59,6 +59,18 @@ from vc_common import (
     _vc_out_json_event_meta,\r
     PRESS_AUDIO_MODE,\r
 )\r
+from vc_proto import (\r
+    vc_proto_build_ws_url,\r
+    vc_proto_pack_wire,\r
+    vc_proto_normalize_server,\r
+    vc_proto_audio_played_enabled,\r
+    vc_proto_log_start_from_v25,\r
+    vc_proto_log_started_server,\r
+    vc_proto_log_synthesis,\r
+    vc_proto_log_tx_wire,\r
+    is_v3,\r
+    vc_ws_custom_ping_enabled,\r
+)\r
 from vc_fsm import (\r
     VCSessionState,\r
     VCTurnState,\r
index 8585aa2..7bc733d 100644 (file)
@@ -21,6 +21,64 @@ class VCSessionIoMixin:
             pass
         return False
 
+    def _vc_error_tts_enabled(self):
+        """VC_ERROR_TTS_ENABLED=1:协议 failed 原文 synthesis 播报(联调用)。"""
+        try:
+            if "vc_config" in globals() and vc_config:
+                return vc_config.get_bool("VC_ERROR_TTS_ENABLED", False)
+        except Exception:
+            pass
+        return False
+
+    def _vc_error_tts_speakable(self, msg):
+        """协议 failed 是否走 synthesis;默认过滤技术错误,VC_ERROR_TTS_ENABLED=1 时全播。"""
+        try:
+            if msg is None:
+                return None
+            s = str(msg).strip()
+            if not s:
+                return None
+            if self._vc_error_tts_enabled():
+                return s
+            low = s.lower()
+            for pat in (
+                "failed to parse",
+                "parse message",
+                "json decode",
+                "invalid json",
+                "internal server",
+                "concurrency_limit",
+            ):
+                if pat in low:
+                    return None
+        except Exception:
+            return None
+        return s
+
+    def _vc_classify_llm_tts_kind(self, payload_dialog_id=""):
+        """设计稿 llm tts:V3 下行常无 dialog_id,用轮次状态补判为 dialog。"""
+        try:
+            if payload_dialog_id:
+                return "dialog"
+            k = getattr(self, "_pending_tts_kind", "") or ""
+            if k:
+                return k
+            cur = getattr(self, "_last_tts_kind", "") or ""
+            if cur in ("mode_switch_long", "mode_switch_ptt", "boot_prompt"):
+                return cur
+            if getattr(self, "_vc_expect_dialog_tts", 0) == 1:
+                return "dialog"
+            if (
+                getattr(self, "VC_mode", -1) == 0
+                and getattr(self, "_turn_has_asr_completed", 0) == 1
+            ):
+                return "dialog"
+            if not cur:
+                return "prompt"
+            return cur
+        except Exception:
+            return "prompt"
+
     def _vc_orchestrator_server_event_cb(self, data):
         """btn 编排线程:服务端协议事件唯一入口(长对话/PTT 统一)。"""
         try:
@@ -64,6 +122,9 @@ class VCSessionIoMixin:
                 self._vc_stop_downlink_for_interrupt("canceled")
             except Exception:
                 pass
+        if ev == "tts.completed" and self._vc_ignore_late_tts_completed_event():
+            print("[ORCH] ignore late tts.completed (interrupt/recording)")
+            return
         try:
             actions = self.fsm.on_server_event(ev, jsload)
             if ev == "canceled" and self._vc_is_long_dialogue_mode():
@@ -155,17 +216,18 @@ class VCSessionIoMixin:
             token = EventMesh.publish("get_dev_token")
             print("token:{}".format(token))
             
-            req_str = "vc/chat?is_auth={}".format(self.is_auth)
+            base = "vc/chat"
             try:
                 if "vc_config" in globals() and vc_config:
                     base = vc_config.get_attr("VC_WS_PATH", "vc/chat") or "vc/chat"
-                    req_str = "{}?is_auth={}".format(base, self.is_auth)
             except Exception:
                 pass
-            if(token):
-                url = 'ws://{}/{}&dt={}'.format(self.server_ip,req_str,token)
-            else:
-                url = 'ws://{}/{}'.format(self.server_ip,req_str)
+            try:
+                did = modem.getDevImei()
+            except Exception:
+                did = ""
+            url = vc_proto_build_ws_url(
+                self.server_ip, base, self.is_auth, token=token, device_id=did)
             #url = 'ws://115.190.94.208:22080/vc/coze?dt={}'.format(token)
             
             if VC_WS_IO_LOG:
@@ -274,10 +336,17 @@ class VCSessionIoMixin:
                 utime.sleep_ms(3000)
                 EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start})
         else:
-            print("websocket_error:tts:{}".format(TTS_str))
-            self.AI_TTS_str = TTS_str
-            EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start})
-            self.Close_flag = 1
+            speak = self._vc_error_tts_speakable(TTS_str)
+            print("websocket_error:tts:{} speakable={}".format(TTS_str, speak))
+            if speak:
+                self.AI_TTS_str = speak
+                EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start})
+                self.Close_flag = 1
+            else:
+                self.websocket_error_count += 1
+                if self.websocket_error_count < 3:
+                    utime.sleep_ms(3000)
+                    EventMesh.publish("publish_AI_btnInfo", {"func": self.AI_VC_start})
         print("websocket_error2{}".format(self.websocket_error_count))
        
         
@@ -351,23 +420,29 @@ class VCSessionIoMixin:
                 usys.print_exception(e)
         print("websocket_deconnect_VC {}!!!!!!!".format(self.AI_client_VC_isconnect))
     def AI_client_VC_ping(self):
-        pass
+        """V2.5:WebSocket 文本帧 \"ping\";V3 不调用。不发 RFC6455 OP_PING(默认)。"""
+        if not vc_ws_custom_ping_enabled():
+            return
         if self.AI_client_VC_isconnect == 1:
-            #self.AI_client_VC.write_frame(0x09,b'ping ping')
             self.AI_client_VC_send("ping")
             if VC_HEARTBEAT_LOG:
                 print("VC: ping ping")
     def AI_client_VC_send(self,data_str):
-        # if isinstance(data_str, str):#是字符串
-        #     print("AI_client_VC_send:{}".format(data_str))
-        #     pass
-        # else:
-        #     print("AI_client_VC_send:len{}".format(len(data_str)))
+        # V3:统一在发送队列前将 event/payload 转为 type/data
+        try:
+            if isinstance(data_str, str):
+                data_str = vc_proto_pack_wire(data_str)
+        except Exception:
+            pass
         # 统计“语音上行”真实开始/结束:这里只统计二进制音频(非 ping/字符串)
+        is_pcm = not isinstance(data_str, str)
         try:
-            if not isinstance(data_str, str):
+            if is_pcm:
                 l = len(data_str)
                 if l > 0:
+                    if self.AI_client_VC_isconnect != 1 or self.AI_client_VC is None:
+                        self._uplink_note_tx_fail(l, "enqueue_ws_down")
+                        return
                     self._uplink_mark_start(l)
                     self._uplink_bytes += l
         except Exception:
@@ -460,15 +535,9 @@ class VCSessionIoMixin:
                                         pass
                                     else:
                                         try:
-                                            ev, tid = _vc_out_json_event_meta(s)
-                                            if ev:
-                                                print("[VC][proto] tx event={} trace_id={} len={} ws_conn={}".format(
-                                                    ev, tid or "-", len(s), self.AI_client_VC_isconnect))
-                                            else:
-                                                print("[VC][proto] tx unknown_json len={} ws_conn={}".format(
-                                                    len(s), self.AI_client_VC_isconnect))
+                                            vc_proto_log_tx_wire(s, self.AI_client_VC_isconnect)
                                         except Exception:
-                                            print("[VC][proto] tx parse_fail len={}".format(len(s)))
+                                            print("[VC][proto] tx log_fail len={}".format(len(s)))
                             else:
                                 pass
                                 # if VC_AUDIO_FRAME_LOG:
@@ -483,8 +552,15 @@ class VCSessionIoMixin:
                                         print("VC VC: send_data:ERROR len={} event={} trace_id={}".format(
                                             len(data[1]), _ev or "?", _tid or "-"))
                                     else:
+                                        if not isinstance(data[1], str):
+                                            self._uplink_note_tx_fail(len(data[1]), "ws_down")
                                         print("VC VC: send_data:ERROR:{}".format(len(data[1])))
                                 except Exception:
+                                    if not isinstance(data[1], str):
+                                        try:
+                                            self._uplink_note_tx_fail(len(data[1]), "ws_down")
+                                        except Exception:
+                                            pass
                                     print("VC VC: send_data:ERROR:{}".format(len(data[1])))
                             #print("VC: send_data end")
     
@@ -493,10 +569,11 @@ class VCSessionIoMixin:
                         print("DeviceInfo_queue_thread:ERROR:{}".format(e))
                         usys.print_exception(e)
                         if data[0] == AI_client_VC_num:
-                            #self.websocket_error()
-                            pass
-
-                            
+                            try:
+                                if data[1] is not None and not isinstance(data[1], str):
+                                    self._uplink_note_tx_fail(len(data[1]), "send_exc")
+                            except Exception:
+                                pass
                             try:
                                 if self.AI_client_VC_isconnect != 0:
                                     print("VC发送数据错误,退出")
@@ -568,7 +645,14 @@ class VCSessionIoMixin:
                 try:
                     self.websocket_error_count = 0
                     if isinstance(recv_data, str):#返回的是字符串
+                        _tl = recv_data.strip().lower()
+                        if _tl in ("ping", "pong"):
+                            continue
                         jsload_data = ujson.loads(recv_data)
+                        try:
+                            jsload_data = vc_proto_normalize_server(jsload_data)
+                        except Exception:
+                            pass
                         if VC_WS_IO_LOG:
                             print("[VC] recv raw:{}".format(recv_data))
                         else:
@@ -608,6 +692,7 @@ class VCSessionIoMixin:
                             self._turn_has_vad_silence = 0
                             self._turn_has_tts_started = 0
                             self._llm_completed_ms = 0
+                            self._vc_expect_dialog_tts = 0
                             _skip_long_up = 0
                             if getattr(self, "_skip_started_uplink_once", 0) == 1:
                                 try:
@@ -617,6 +702,10 @@ class VCSessionIoMixin:
                                 except Exception:
                                     pass
                             print("VC:Started!!!!!!!!\r\n")
+                            try:
+                                vc_proto_log_started_server(jsload_data.get("payload") or {})
+                            except Exception:
+                                pass
                             print("TTS音频为:{},语音下发地址为:{}".format(
                                 self.AI_TTS_str, self.AI_new_voice_url))
                             try:
@@ -813,7 +902,14 @@ class VCSessionIoMixin:
                             except Exception:
                                 pass
                             self._llm_completed_ms = 0
-                            # EventMesh.publish("publish_AI_btnInfo",{"func":self.VC_Play_Start})
+                            try:
+                                if (
+                                    getattr(self, "VC_mode", 0) == 0
+                                    and getattr(self, "_turn_has_asr_completed", 0) == 1
+                                ):
+                                    self._vc_expect_dialog_tts = 1
+                            except Exception:
+                                pass
                             pass
                         elif jsload_data["event"] == "llm.completed" :#llm完成(可能无 tts)
                             try:
@@ -850,28 +946,14 @@ class VCSessionIoMixin:
                             # - 无 dialog_id:认为是“提示/提醒 TTS”(模式切换可通过 _pending_tts_kind 标记)
                             try:
                                 did = jsload_data.get("payload", {}).get("dialog_id", "") or ""
+                                if not did:
+                                    did = getattr(self, "_cur_dialog_id", "") or ""
                             except Exception:
                                 did = ""
-                            # 注意:无 dialog_id 的 tts.started 很可能是提示音/提醒音;
-                            # 但“模式切换提示音”的 kind 可能已在 synthesis 时写入 _last_tts_kind,
-                            # 这里不应无条件覆盖为 prompt,否则播完无法触发下一轮开麦。
                             try:
-                                if did:
-                                    self._last_tts_kind = "dialog"
-                                else:
-                                    k = getattr(self, "_pending_tts_kind", "") or ""
-                                    if k:
-                                        self._last_tts_kind = k
-                                    else:
-                                        cur = getattr(self, "_last_tts_kind", "") or ""
-                                        if cur in (
-                                            "mode_switch_long",
-                                            "mode_switch_ptt",
-                                            "boot_prompt",
-                                        ):
-                                            pass
-                                        elif not cur:
-                                            self._last_tts_kind = "prompt"
+                                self._last_tts_kind = self._vc_classify_llm_tts_kind(did)
+                                if self._last_tts_kind == "dialog":
+                                    self._vc_expect_dialog_tts = 0
                             except Exception:
                                 pass
                             try:
@@ -1014,13 +1096,22 @@ class VCSessionIoMixin:
                             EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_deconnect_VC}) 
                             EventMesh.publish("publish_AI_btnInfo",{"func":self.AI_VC_start}) 
                         elif jsload_data["event"] == "failed":#接口返回错误
-                            if jsload_data["payload"]["error"] == "concurrency_limit_exceeded":
+                            pl = jsload_data.get("payload") or {}
+                            err = pl.get("error") or ""
+                            msg = pl.get("message") or pl.get("msg") or err or ""
+                            try:
+                                print("[VC] rx failed sid={} err={} msg={}".format(
+                                    jsload_data.get("sid", "") or "-", err, msg))
+                            except Exception:
+                                pass
+                            if err == "concurrency_limit_exceeded":
                                 EventMesh.publish("publish_AI_btnInfo",{"func":self.concurrency_limit_exceeded_tone}) 
-                                
-                                #utime.sleep_ms(4000)
-                                
                             else:
-                                EventMesh.publish("publish_AI_btnInfo",{"func":self.websocket_error,"data":jsload_data["payload"]["message"]}) 
+                                speak = self._vc_error_tts_speakable(msg)
+                                EventMesh.publish(
+                                    "publish_AI_btnInfo",
+                                    {"func": self.websocket_error, "data": speak},
+                                )
                             pass
                            
                         elif jsload_data["event"] == "audio.convert.completed":#音频转成完成没有出错 接口 
@@ -1774,17 +1865,11 @@ class VCSessionIoMixin:
             self.fsm.on_session_start_request()
         except Exception:
             pass
-        self.AI_client_VC_send(ujson.dumps(json_data))
         try:
-            _tid = json_data.get("header", {}).get("trace_id", "")
-            print("[VC][proto] Start enqueued trace_id={} VC_mode={} disable_vad={} vad_silence={}ms ws_conn={}".format(
-                _tid or "-",
-                self.VC_mode,
-                json_data.get("payload", {}).get("inbound_attributes", {}).get("disable_vad", "?"),
-                getattr(self, "vad_silence", "?"),
-                self.AI_client_VC_isconnect))
+            vc_proto_log_start_from_v25(json_data, getattr(self, "VC_mode", -1))
         except Exception:
             pass
+        self.AI_client_VC_send(ujson.dumps(json_data))
     def AI_client_VC_RequestToSpeak(self):
         """兼容旧入口:长对话短按打断(勿 VC_init,避免灯态/fsm 进 thinking)。"""
         print("AI_client_VC_RequestToSpeak!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
index adba4f7..ef5a26b 100644 (file)
@@ -744,22 +744,54 @@ class VCSessionMediaMixin:
             self._uplink_active = 1
             self._uplink_bytes = 0
             self._uplink_start_ms = utime.ticks_ms()
+            self._uplink_tx_fail_cnt = 0
+            self._uplink_tx_fail_bytes = 0
+            self._uplink_tx_fail_log_ms = 0
             print("[VC][uplink] START len={} {} VC_record_state_flag={}".format(
                 first_len, self._vc_fsm_log_snip(), self.VC_record_state_flag))
         except Exception:
             pass
+
+    def _uplink_note_tx_fail(self, nbytes, reason=""):
+        """上行 PCM 未成功发出(WS 未连、send 异常、协议门控丢弃等)。"""
+        try:
+            n = int(nbytes or 0)
+            self._uplink_tx_fail_cnt = getattr(self, "_uplink_tx_fail_cnt", 0) + 1
+            self._uplink_tx_fail_bytes = getattr(self, "_uplink_tx_fail_bytes", 0) + n
+            now = utime.ticks_ms()
+            last = getattr(self, "_uplink_tx_fail_log_ms", 0)
+            if not last or utime.ticks_diff(now, last) >= 800:
+                self._uplink_tx_fail_log_ms = now
+                print(
+                    "[VC][uplink] TX_FAIL reason={} len={} fail_cnt={} fail_bytes={} {}".format(
+                        reason or "?",
+                        n,
+                        self._uplink_tx_fail_cnt,
+                        self._uplink_tx_fail_bytes,
+                        self._vc_fsm_log_snip(),
+                    )
+                )
+        except Exception:
+            pass
+
     def _uplink_mark_end(self, reason=""):
         try:
             if self._uplink_active != 1:
                 return
             dur = utime.ticks_diff(utime.ticks_ms(), self._uplink_start_ms) if self._uplink_start_ms else -1
-            print("[VC][uplink] END reason={} bytes={} dur_ms={} {} VC_record_state_flag={}".format(
-                reason, self._uplink_bytes, dur, self._vc_fsm_log_snip(), self.VC_record_state_flag))
+            fc = getattr(self, "_uplink_tx_fail_cnt", 0)
+            fb = getattr(self, "_uplink_tx_fail_bytes", 0)
+            print("[VC][uplink] END reason={} bytes={} dur_ms={} tx_fail_cnt={} tx_fail_bytes={} {} VC_record_state_flag={}".format(
+                reason, self._uplink_bytes, dur, fc, fb,
+                self._vc_fsm_log_snip(), self.VC_record_state_flag))
         except Exception:
             pass
         self._uplink_active = 0
         self._uplink_bytes = 0
         self._uplink_start_ms = 0
+        self._uplink_tx_fail_cnt = 0
+        self._uplink_tx_fail_bytes = 0
+        self._uplink_tx_fail_log_ms = 0
 
         # 最近一次 TTS 的语义来源(dialog/prompt/mode_switch_long/mode_switch_ptt/...)
         self._last_tts_kind = ""
@@ -1191,6 +1223,10 @@ class VCSessionMediaMixin:
                                     # 协议门控:会话未 started 前禁止上行音频(否则 session not initialized)
                                     if VC_PROTO_GUARDS and self._vc_session_started != 1 and self.record_data_save_flag == 0:
                                         _ = self.AI_record_tmp_queue.get()
+                                        try:
+                                            self._uplink_note_tx_fail(len(_), "not_started_drop")
+                                        except Exception:
+                                            pass
                                         AI_record_tmp_queue_len = max(0, AI_record_tmp_queue_len - len(_))
                                         continue
                                     if not (self._server_vad_silence == 1 and self.record_data_save_flag == 0):
@@ -1211,6 +1247,10 @@ class VCSessionMediaMixin:
                                     AI_record_tmp_queue_len += len(read_buf)
                                     while AI_record_tmp_queue_len > 128*1024 and self.AI_record_tmp_queue.empty() != True:
                                         d = self.AI_record_tmp_queue.get()
+                                        try:
+                                            self._uplink_note_tx_fail(len(d), "prestart_buf_overflow")
+                                        except Exception:
+                                            pass
                                         AI_record_tmp_queue_len = max(0, AI_record_tmp_queue_len - len(d))
                                 except Exception:
                                     pass
@@ -1619,6 +1659,16 @@ class VCSessionMediaMixin:
             return 0
         return self._PCM_STREAM_RING_BYTES - pcm_free
 
+    def _vc_pcm_pending_for_play_end(self):
+        """play_pcm 已关闭时勿用 stream_get_buf_len(0) 误判整环待播。"""
+        pcm = getattr(self, "play_pcm", None)
+        if not pcm:
+            return 0
+        try:
+            return max(0, self._vc_pcm_stream_pending_bytes(pcm.stream_get_buf_len()))
+        except Exception:
+            return 0
+
     def _vc_finish_play_end_close_if_needed(self):
         """旧版 LocalRespondingEnded:Close_flag==1 时提示音/模式切换播完应 Close 会话。"""
         try:
@@ -1695,13 +1745,7 @@ class VCSessionMediaMixin:
                 is_pcm_empty_z = (self.play_pcm is None) or (
                     self.play_pcm is not None and self.play_pcm.stream_is_empty() == 1
                 )
-                pcm_pending_z = 0
-                if self.play_pcm is not None:
-                    try:
-                        pcm_pending_z = self._vc_pcm_stream_pending_bytes(
-                            self.play_pcm.stream_get_buf_len())
-                    except Exception:
-                        pcm_pending_z = 0
+                pcm_pending_z = self._vc_pcm_pending_for_play_end()
                 if (not is_pcm_empty_z) or pcm_pending_z > 0:
                     if since_done < 5000:
                         return False
@@ -1717,14 +1761,7 @@ class VCSessionMediaMixin:
                     slack = max(slack, in_len // 25)
             except Exception:
                 pass
-            try:
-                if self.play_pcm is not None:
-                    pcm_free_pre = self.play_pcm.stream_get_buf_len()
-                else:
-                    pcm_free_pre = 0
-            except Exception:
-                pcm_free_pre = 0
-            pcm_pending_pre = self._vc_pcm_stream_pending_bytes(pcm_free_pre)
+            pcm_pending_pre = self._vc_pcm_pending_for_play_end()
             if pcm_pending_pre < 0:
                 pcm_pending_pre = max(0, in_len - out_len)
             played_ok = (out_len >= in_len) or (
@@ -1746,13 +1783,13 @@ class VCSessionMediaMixin:
             is_pcm_empty = (self.play_pcm is None) or (
                 self.play_pcm is not None and self.play_pcm.stream_is_empty() == 1
             )
-            pcm_free = 0
+            pcm_free = -1
             if self.play_pcm is not None:
                 try:
                     pcm_free = self.play_pcm.stream_get_buf_len()
                 except Exception:
                     pcm_free = -1
-            pcm_pending = self._vc_pcm_stream_pending_bytes(pcm_free)
+            pcm_pending = self._vc_pcm_pending_for_play_end()
             if pcm_pending < 0:
                 pcm_pending = max(0, in_len - out_len)
 
@@ -1859,11 +1896,20 @@ class VCSessionMediaMixin:
                 return
         except Exception:
             pass
+        _syn_text = self.AI_TTS_str or ""
+        try:
+            _syn_kind = getattr(self, "_pending_tts_kind", "") or getattr(self, "_last_tts_kind", "") or ""
+        except Exception:
+            _syn_kind = ""
+        try:
+            vc_proto_log_synthesis(_syn_text, _syn_kind)
+        except Exception:
+            pass
         json_data = \
         {
             "event": "synthesis",
             "payload": {
-                "text": self.AI_TTS_str
+                "text": _syn_text
             }
         }
         # 语义标记只用于“是否自动开麦”的策略。
@@ -1884,8 +1930,10 @@ class VCSessionMediaMixin:
         #     print("AI_client_VC_RequestToRespond 重复 tts_str:{}\nasr_str:{}".format(self.AI_TTS_str,self.AI_ASR_str))
         self.AI_client_VC_send(ujson.dumps(json_data))
     def AI_client_VC_audio_played(self):
-        # V2.5:播完上报;与 VC_PROTO_GUARDS 无关(见文件头说明)
+        # V2.5:播完上报;V3 默认关闭(VC_AUDIO_PLAYED_ACK=0)
         try:
+            if not vc_proto_audio_played_enabled():
+                return
             if self.AI_client_VC_isconnect != 1 or self.AI_client_VC == None:
                 return
             json_data = {"event": "audio.played", "payload": {}}
index a066ee2..f79de5c 100644 (file)
Binary files a/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip and b/2.sdk/project/7PIN_1_5/sdk/system/platform/ASR_1606/boards/EG810MCN_GA_VOLTE/images/firmware_base_release_pack.zip differ