跳到主要内容

dsh-aiui

已验证

dsh-aiui · v0.7.0 · MIT

讯飞语音背包 AIUI 控制插件:TCP 19199 协议客户端、语音监听/播报、机器人运动控制、设备运维、USB 自动发现背包 IP

安装

dsh plugin add dsh-aiui

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

发布到 npm 但没有公开仓库。安装前请检查包内容。

标签

说明文档

dsh-aiui

讯飞语音背包(iFlytek AIUI 语音背包 / ZC-H358S)DSH 插件:把背包的语音识别、语义理解与机器人运动控制封装为 8 个 agent 工具。

协议实现依据 TcpDemo_1.3.0 内 UARTKit 源码逐字节还原,并经真机联调验证(完整过程见工作区《分析报告-语音控制机器人.md》)。

背包 IP 自动发现(默认开启)

背包重启后 DHCP 可能换 IP(实测 .153 → .172)。插件默认在每次(重)连之前经 USB 线 + adb 读背包当前网卡地址,与本机网卡同网段才采用,否则回落配置的 host:

USB 线 + adb(python/adbutils)→ ip addr show → wlan0/eth0 的 IPv4
  ├─ 与本机同一网段 → 用发现的 IP 连 19199;adb 工具用 <发现的IP>:5555
  └─ 否则 / 发现失败 → 回落配置 host(旧行为),日志说明原因
  • 需要USB 线连接背包;关掉它(discover: false)则完全按配置的 host / adbSerial 走,不依赖 python
  • 5555 未监听时(设备重启后 adb tcpip 状态丢失)会经 USB 自动重发 adb tcpip 5555 自愈
  • 分辨率 30s 缓存;发现失败同样缓存,避免重连风暴里反复拉 python
  • aiui_status 会显示来源:usb-discover / fallback / config
# 手动验证(配置 host 故意写错也能连上)
$env:AIUI_HOST="192.168.0.199"; node mcp\server.mjs --self-test
# → [aiui] host 192.168.0.199 → 192.168.0.172,via=usb-discover

接口一览(6 层 11 工具)

层 工具 作用
连接层 aiui_status 连接背包,返回设备信息、speech/mmsp 参数、通道状态、动作词典、健康度
连接层 aiui_config 查询/下发 speech 运行参数(固件可能强制覆盖,工具内已说明)
连接层 aiui_channel 19199 通道管理:status 看争抢情况、release 让给常驻服务/演示程序、acquire 抢回(仅 ZCode 侧)
诊断层 aiui_diagnose 一次跑完现场诊断:通道争抢、线上引擎、演示程序、引擎与桥服务、外网、WiFi 省电、TTS 播放器、唤醒后秒睡,并给下一步建议
语音层 aiui_listen 监听 N 秒:唤醒、识别文本、AiChain 回答;可 playTts 出声、可 raw 取原始报文
语音层 aiui_say TTS 播报一句话(经典云 TTS 通道)
语音层 aiui_control 远程唤醒 / 休眠 / 停止播报 / 查询状态机
控制层 aiui_actions 听一轮 → 线上抽槽 → 返回 JSON 动作序列;也可 actions 直接下发、text 离线规划
控制层 aiui_motion 单条机器人运动:forward/backward/left/right/stop
运维层 aiui_device info / check_net / engine_log / wifi_powersave(查/关/开 WiFi 省电,需 root) / disable_demo / enable_demo
运维层 aiui_screen 背包屏幕:截屏 PNG、scrcpy 投屏、录屏 mp4

典型用法

用户:你听一下背包说什么
agent:→ aiui_listen { seconds: 20, prompt: "请说指令" }
       ← 识别:「向前走」 / 语义答「2 times 6 equals 12.」
agent:→ aiui_motion { action: "forward", seconds: 2 }

唤醒词 「小飞小飞」,休眠词 「休眠一下」。唤醒后背包进入"工作中"才接受指令。

连不上 / 没反应?先跑诊断

agent:→ aiui_diagnose {}
       ← 诊断:发现 2 项异常:通道争抢、背包外网
          ✅ 19199 连接:192.168.0.172:19199 ready
          ❌ 通道争抢:已让出通道(连续被踢 2 次)
          ✅ 线上引擎:intent_engine_type=cloud
          ✅ 经典 AIUI 已关:close_aiui=true,work_mode=rec_only
          ➖ 厂商演示程序:(未取到,需网络 ADB)
          ...
          建议:
            · 19199 是单会话。同一时刻只能有一个客户端…

aiui_diagnose 就是把 2026-09-17 那次人工排查的每一步(netstat 看谁占通道、adb 看演示程序、 看引擎服务、看外网丢包、看是否秒睡)固化成一张检查表。

让 agent 听得见、说得出

19199 单会话:我们的客户端占着通道时,背包自己不出声(TTS 音频块是推给上位机的)。 所以:

  • aiui_listen { playTts: true } / aiui_actions { playTts: true } —— 把设备推来的 PCM 播出来(需 ffplay), 也可以把 userConfig.play_tts 设成 true 作为默认
  • aiui_channel { action: "release" } —— 干脆把通道还给厂商演示程序,让背包自己亮屏出声; 下次调用任一 aiui_* 工具会自动抢回

语音 → JSON 动作序列 → 机器人(0.4.0)

人说话,线上模型理解,产出动作序列驱动机器人。链路:

人:「小飞小飞」→「握个手吧」
  ↓ 背包本地唤醒(mic4)
  ↓ AiChain 云管线(线上)  云端 STT → 大模型命中意图插件 robot_action → 抽取槽位
  ↓ TCP 19199:iat_aichain(转写)→ nlp_aichain(data.slots 带上插件槽位)→ tts
  ↓ 上位机:校验线上证据 → 槽位展开成动作序列 → 整份 POST 给机器人
  ↓ 机器人:按 action → 动作端口,逐个执行

前置条件:先在 AIUI 控制台配好意图插件。照 docs/aiui-plugin-robot-action.md 逐步填(插件别名 robot_action、 5 个参数、28 条示例说法、粘一份 Jinja2 后处理模板),发布插件 → 关联到背包所用的应用 → 发布应用。

常驻上位机服务(推荐)

跑之前先做两件事,否则会看到每秒「重连中」(19199 单会话,多方互抢):

# ① 停掉厂商演示程序:它会周期性地抢回 19199,导致常驻服务被反复踢
G:\讯飞语音背包\scrcpy-win64-v1.20\scrcpy-win64-v1.20\adb.exe -s f3d87c45528dc7e8 \
  shell pm disable-user --user 0 com.iflytek.aiint.app.speechassistant
#   撤销:把 disable-user 换成 enable

# ② 本会话/别的终端里不要再调 aiui_* 工具(MCP 客户端也会抢这条单会话通道)

npm run host                 # 或 node mcp/robot-host.mjs
# 机器人收据端:http://127.0.0.1:8266/robot/actions (PC 扮演机器人,按动作端口模拟执行)
# 控制面:      http://127.0.0.1:8267  GET /status /last /sequences
#                                     POST /control/release /acquire

停掉演示程序不影响语音链路:唤醒、云端识别、意图插件都在引擎与云端,退掉的只是背包那块屏幕界面。 测量:停掉后 32 秒窗口内 已连接 1 次、重连 0 次(停之前是 30 秒内重连 2 次)。

2026-09-17 复核:曾一度怀疑「演示程序是音频来源、停掉就没声音」——不成立。 20:08 那次完整跑通(握手 → 端口 9101 → 回执)就是在演示程序已禁用状态下发生的。

背包换 IP / 重启后直接跑就行。 常驻服务在两条路径上都会自己跟上:

  1. 发现优先:每次(重)连前经 USB 读背包实际 IP,同网段才用——配置里的 host 只是回落值。 实测把 AIUI_HOST 故意写成错的 192.168.0.199,host 仍连到 192.168.0.172(via=usb-discover)。
  2. 首连重试:以前首次拨号失败只打一行日志就闲置了(AiuiClient 的自动重连只覆盖"连上后掉线"), 于是背包还没联上网时启动 host 会永远连不上,看起来"已就绪"其实是死的。 现在失败会按 3s→4.8s→…→15s 封顶重试,背包一上线自动连上,并打印当前目标与发现情况。

排查用 curl http://127.0.0.1:8267/status 看 resolve(via / 发现的 IP)。 若日志出现「与本机不在同一网段」,说明本机和背包不在一个网——把本机网段调到与背包一致即可 (本次实测就是本机跑在手机热点 192.168.43.x、背包在 192.168.0.x,怎么重试都连不上,这不是 bug)。

唤醒提示音(默认开):通道被我们占着时背包自己没有任何反馈,人不知道该何时开口 (实测过一次「唤醒了但没说话」,云端只收到 713ms 静音)。所以主机在收到唤醒事件的瞬间 用 ffplay 本地合成一声短音(sine via lavfi,不走云端,弱网也稳):听到就立刻说指令。 关掉用 --no-wake-cue。也可以 curl -X POST http://127.0.0.1:8267/control/wake 远程唤醒并试听这一声。

对背包说「小飞小飞」→「握个手吧」,终端会打出:

══ 19:52:03 收到语音「握 个 手 吧 。」
   线上证据:✅ 已确认走云端  appid=jh7BMBRi  插件=robot_action  形态=cloud-slots
   动作序列:握手→端口9101
   JSON: { "schema": "aiui.robot.actions/1", … }
   机器人回执 rcpt-…:端口 9101 ← 握手 {"times":1} 预计 3000ms [simulated]

常用开关:--robot-url <url>(指到真机底盘;给了就不起本机收据端)、--require-cloud(证据不满足就拒绝出序列)、 --release-after 60(空闲 60s 把 19199 还给演示程序)、--no-tts、--once(处理一轮就退出,脚本化验证用)。

会话内触发

agent:→ aiui_actions { seconds: 30, prompt: "请说,握个手吧" }   ← 听一轮,返回动作序列 JSON
agent:→ aiui_actions { text: "握个手吧" }                        ← 离线自测:不连设备,只跑规划
agent:→ aiui_actions { forward: true }                           ← 顺便转发到 robotUrl

⚠️ 19199 是单会话:常驻服务与 aiui_* 工具不要同时用,否则每秒互相踢一次 (现象是响应变慢、背包更没反应)。二选一即可。

Ubuntu 安装与运行(换系统后从这里开始)

系统依赖

sudo apt update
sudo apt install -y nodejs npm ffmpeg android-tools-adb git   # nodejs 需 ≥20,旧源请用 NodeSource
pip3 install adbutils jinja2                                   # adbutils=USB 发现/设备运维;jinja2=模板自检

方式一:npm 安装(推荐,最省事)

mkdir -p ~/voice-robot && cd ~/voice-robot
npm init -y
npm install dsh-aiui                     # ≥0.7.0
# 常驻上位机:连背包 → 语音 → JSON 动作序列 → 转发给机器人适配器
node node_modules/dsh-aiui/mcp/robot-host.mjs --robot-url http://127.0.0.1:8266/robot/actions

方式二:拷贝源码(要跑测试/改代码时用这个)

把 Windows 上的 dsh-aiui/ 整个目录拷到 Ubuntu(如 /opt/dsh-aiui),然后:

cd /opt/dsh-aiui && npm install
npm test                 # 全部自检(需要 pip3 install jinja2)
npm run host -- --robot-url http://127.0.0.1:8266/robot/actions

两种方式的差异

方式一(npm 包) 方式二(源码)
运行 host / 连背包 / 转发动作 ✅ ✅
aiui_diagnose 等 11 个 MCP 工具 ✅(配 ZCode MCP 时) ✅
npm test 自检 ❌(包里不含 test/) ✅
改代码 ❌ ✅

Windows 特有项在 Ubuntu 上不存在

  • D:\Anaconda3\python.exe 这类兜底不需要——设 PYTHON=python3 或什么都不设(会自动用 python3)
  • ffplay 直接 apt install ffmpeg 即可(声道参数会自动探测 -ch_layout mono / -ac 1)
  • USB 自动发现需要当前用户有 USB 权限(Ubuntu 通常已在 plugdev 组;adb devices 能看到设备即可)

人脸唤醒(真机实测:已开启,无需唤醒词)

背包的人脸/视觉唤醒是开着的,引擎日志可证:

MMSPProcess            onWakeUpChange wakeUp=true, wakeUpType=FACE
AiChain_CustomHandler  enterWakeUpState: type=FACE → 连 AiChain
AIUIService            onAIUIEvent EVENT_WAKEUP

aiui.cfg 里的相关配置:mmsp.wakeup_mode="auto"、identity_enable=true、cae_mode="mmsp"、 min_face_w/h=100、face_out_ms=800(即人脸唤醒由 mmsp 引擎负责,与厂商演示程序无关)。

判别唤醒来源:下发给 19199 的 wakeup 报文不带类型字段(人脸与语音的报文一模一样), 唯一可用的差异是语音唤醒会配对出现 sub=keywords 事件。上位机据此推断并直接报出来:

[host] 唤醒词命中(语音唤醒)
[host] 唤醒(语音):已响提示音——听到就立刻说指令
[host] 唤醒(人脸/视觉(未伴随唤醒词)):已响提示音——直接说指令即可,不需要唤醒词

curl .../status 的 lastWakeSource 会给出 voice / face。

实测注意(10:36 那次):人脸唤醒后云端会话可能很快被丢弃 (日志 Drop active cid for sleep,建起来约 0.4 秒就结束)。 所以听到提示音后要立刻说指令;人脸唤醒不是"一直待命"。 另外:厂商演示程序那个「图像理解迎宾」会主动打招呼,我们现在禁用了它, 所以提示音就是唯一的开口信号——这也是为什么要开着 --no-wake-cue 之外不要动它的原因。

「有时能触发、有时不触发」是怎么回事(真机实测)

先看日志里云端听成了什么 —— 上位机现在会把每轮的识别文本打出来:

[host] 识别:握 个 手 吧 。
[host] 动作序列 act-…-0001(cloud-slots):握手          ← 命中

[host] 云端走闲聊,未命中意图插件(识别为「播 个 守 班 。」)   ← 没听懂
[host]   云端回答:好的,小飞为您播放一首欢快的歌。

实测:说「握个手吧」,云端有时转写成**「播个守班」/「播个歌曲」**——握/播、手/守、吧/班 全是近音, 于是走闲聊、答"小飞为您播放一首欢快的歌",当然没有动作。这是识别准确率问题,不是链路故障。

两类失败要分清(日志里的 判定 字段直接给出):

判定 含义 怎么办
chat 云端走闲聊(没命中插件) 把该说法与近音变体补进插件的示例说法(手册 3.1.1),或改用歧义更小的说法(「握手」)
ignore 无原话且无云端结构(超时帧/尾帧) 那一次没说出话或开口太晚;听到提示音后立刻说
empty 命中插件但没抽到动作 补示例说法覆盖该表述
dispatch 正常下发 —

aiui_diagnose 的「最近识别记录」会列最近几轮 「识别文本」 → 命中插件 / 走闲聊, 不用翻设备日志就能判断。详见 docs/aiui-plugin-robot-action.md 第 7.5 节。

「背包没反应」是怎么回事(真机实测)

对背包说唤醒词,背包屏幕不亮、也不出声——这不是识别失败。抓包证明唤醒 6 次、云端转写、 插件命中、answer 全部到位。真正原因是 19199 单会话:

  • 背包自己的屏幕界面 + 本地播报由厂商演示程序 com.iflytek.aiint.app.speechassistant 负责
  • 上位机一连上 19199,演示程序就被踢 → 背包既不亮屏也不出声
  • 同时设备把 TTS 音频块推给上位机,等上位机播放 —— 所以由本机音箱出声(dist/tts.js 走 ffplay)

想让背包自己亮屏出声:curl -X POST http://127.0.0.1:8267/control/release 把通道还回去, 或启动时用 --release-after <秒> 空闲自动还;/control/acquire 抢回。

屏幕查看(aiui_screen)

用户:看看背包屏幕上现在显示什么
agent:→ aiui_screen { action: "screenshot" }   ← 存 PNG 并返回路径/分辨率,agent 读图即可"看到"屏幕

用户:把背包画面投到电脑上
agent:→ aiui_screen { action: "mirror" }        ← 本机桌面弹出 scrcpy 窗口(自动 -s 指定网络 ADB serial)
agent:→ aiui_screen { action: "stop" }          ← 关闭投屏
  • mirror 需要 本机有 scrcpy:配置项 scrcpyPath 指向 scrcpy.exe,或已加入 PATH;投屏进程以 detached 方式启动,独立于 DSH 存活
  • 背包同时插着 USB 线时 adb 会出现两条记录(scrcpy 不带参数会报 Multiple devices connected),本工具始终用 -s <adbSerial> 指定,不受影响
  • record 走设备端 screenrecord(无需 scrcpy),5~180 秒,保存 mp4

配置项

键 默认 说明
host 192.168.0.172 背包 IP(自动发现关闭或失败时的回落值)
port 19199 AIUI 控制通道(单会话,后被连者踢前者)
adbSerial 192.168.0.172:5555 网络 ADB(aiui_device 用;discover 关闭时使用)
discover true USB 自动发现背包 IP(换 IP 自动跟上;需 USB 线 + python/adbutils)
usbSerial 空 指定 USB 设备序列号;留空取第一台 USB 设备
adbPath 空 adb.exe 路径(5555 端口自愈用);留空自动探测插件目录 / 工作区 scrcpy / PATH
motionWebhookUrl 空 运动 HTTP 转发(POST JSON {action,seconds,vx,wz,…});留空=模拟
robotUrl 空 动作序列端点(POST 整份 aiui.robot.actions/1);aiui_actions.forward 用它;留空=只模拟
requireCloud false 要求线上证据全部通过才出动作序列;默认只警告并在 warnings 里逐条标注
playTts false aiui_listen/aiui_actions 默认是否把设备推来的 TTS 播出来(需 ffplay)
ffplayPath 空 ffplay 绝对路径;留空自动探测 PATH 与 ffmpeg 同目录
yieldOnKicks true 工具客户端连上即被踢 2 次后让出 19199(避免与常驻服务互踢);下次工具调用自动重试
autoMotion true 语音说"向前走/停"等自动转发运动指令
maxListenSeconds 120 aiui_listen 上限
adbTimeoutMs 30000 aiui_device 单次 ADB 操作超时(超时杀整棵进程树)
scrcpyPath 空 scrcpy.exe 路径(aiui_screen mirror 用);留空自动探测 PATH 与插件 tools 目录
screenshotDir 空 aiui_screen 截屏/录屏保存目录;留空用系统临时目录 dsh-aiui/
autoReconnect true 断线自动重连(含 5s 心跳、12s 静默重连)

底盘接入

把底盘控制做成一个 HTTP 服务,配置 motionWebhookUrl 即可:

// 示例(Node):接收 dsh-aiui 的运动指令 → 转成你的底盘协议
http.createServer((req, res) => {
  let body = ''
  req.on('data', c => body += c)
  req.on('end', () => {
    const { action, seconds, vx, wz } = JSON.parse(body)
    // ROS: 发布 geometry_msgs/Twist(linear.x=vx, angular.z=wz) 到 /cmd_vel
    // 串口底盘: 发运动帧;持续 seconds 后自动停
    res.end('ok')
  })
}).listen(8266)

已知固件行为(真机实测)

  • 本批固件走 AiChain 全双工云管线(云端 STT + 大模型 NLU + TTS),work_mode 被引擎强制 rec_only,经典 iat/nlp 事件基本不再出现;上位机收到的是 iat_aichain / nlp_aichain / tts 事件
  • 唤醒后"秒睡" = 背包外网不通/弱网(云端 WebSocket 建不起来),可用 aiui_device check_net 诊断;建议给背包插网线
  • 19199 单会话互斥:厂商演示程序会抢占连接并回滚配置,可 aiui_device disable_demo 禁用(enable_demo 恢复)
  • 引擎 com.iflytek.aiuiservice 与协议桥 com.iflytek.aiui.devboard.uartservice 勿停

依赖

  • 宿主:@deepseek-ai/dsh-tools、@deepseek-ai/schemastery(peer,随宿主/profile 安装)
  • aiui_device:本机 python + adbutils(pip install adbutils),无需 adb 可执行文件

离线/断连时的行为

背包未连接(关机、换网段、19199 被演示程序占用)时,语音类工具都会快速返回 ok:false 与可读提示,不会挂起:

  • aiui_status / aiui_listen / aiui_say / aiui_control / aiui_config:连接或握手失败立即返回,提示检查 IP、网段与会话占用
  • aiui_device:先做 ADB 端口预检(1.5s 超时),不可达直接返回,不拉起 python/adb 服务
  • 断线自动重连为退避重试:1.5s → 2.4s → 3.8s → … → 15s 封顶,连上即复位
  • aiui_motion 与背包无关:未配置 motionWebhookUrl 时只做模拟执行
  • IP 自动发现失败(没插 USB 线 / 没装 adbutils / 背包不在本机网段)不影响使用:回落配置 host,日志与 aiui_status 会写明原因

自检与测试

npm test                 # 协议单测 + IP 发现单测 + 宿主契约/离线测试(不需要背包)
npm run test:protocol    # 帧编解码与事件解析(以真机抓包字节为基准向量)
npm run test:discover    # 网段比对、候选挑选、adb 探测、resolver 分支
npm run test:contract    # 用宿主真实 defineTool 加载插件 + 离线降级 + webhook 转发
npm run smoke            # 真机烟雾测试(需背包在线,可传 IP 参数)

已知环境坑(本机 Windows 实测)

  • Node fs.cpSync 与中文路径:目标路径含中文时静默不复制;源路径含中文时直接崩进程(exit 127)。测试脚手架因此改用手写 copyTree,插件运行时不受影响
  • webhook 用 node:http 而非全局 fetch:避免 undici 保活 socket 在进程退出时触发 libuv 断言崩溃
  • 本机 CLI 直接 dsh web 起不来(应用内置 node shim 在应用外无法执行),插件加载验证请以 DSH Desktop 实际启动为准

ZCode 接入

本插件同时可挂到 ZCode:清单在 .zcode-plugin/plugin.json,工具由 mcp/server.mjs 以 MCP 协议发布 (复用 dist/ 的同一批模块),工具名为 mcp__plugin_dsh-aiui_aiui__<tool>(8 个)。差异与装卸步骤见 ZCODE.md。

License

MIT